diff --git a/.github/ISSUE_TEMPLATE/release-qa.md b/.github/ISSUE_TEMPLATE/release-qa.md
index 5d77f255fc..0e31bb1b5a 100644
--- a/.github/ISSUE_TEMPLATE/release-qa.md
+++ b/.github/ISSUE_TEMPLATE/release-qa.md
@@ -3,7 +3,7 @@ name: Release QA
about: Checklist of required tests prior to release
title: 'Release QA:'
labels: '#g-mdm,#g-endpoint-ops,:release'
-assignees: 'xpkoala,pezhub'
+assignees: 'xpkoala,pezhub,jmwatts'
---
@@ -111,8 +111,8 @@ Smoke tests are limited to core functionality and serve as a pre-release final r
1. Verify able to run a script on all host types from CLI.
2. Verify scripts library upload/download/delete.
-3. From Host details (Windows and macOS) run a script that should PASS, verify.
-4. From Host details (Windows and macOS) run a script that should FAIL, verify.
+3. From Host details (macOS, Windows, & Linux) run a script that should PASS, verify.
+4. From Host details (macOS, Windows, & Linux) run a script that should FAIL, verify.
5. Verify UI loading state and statuses for scripts.
6. Disable scripts globally and verify unable to run.
7. Verify scripts display correctly in Activity feed.
@@ -121,40 +121,51 @@ Smoke tests are limited to core functionality and serve as a pre-release final r
Migration Test Verify Fleet can migrate to the next version with no issues.
diff --git a/.github/ISSUE_TEMPLATE/story.md b/.github/ISSUE_TEMPLATE/story.md
index dd3c1190c9..8c708a6020 100644
--- a/.github/ISSUE_TEMPLATE/story.md
+++ b/.github/ISSUE_TEMPLATE/story.md
@@ -41,8 +41,8 @@ What else should contributors [keep in mind](https://fleetdm.com/handbook/compan
### Product
- [ ] UI changes: TODO
- [ ] CLI (fleetctl) usage changes: TODO
-- [ ] YAML changes: TODO
-- [ ] REST API changes: TODO
+- [ ] YAML changes: TODO
+- [ ] REST API changes: TODO
- [ ] Fleet's agent (fleetd) changes: TODO
- [ ] Activity changes: TODO
- [ ] Permissions changes: TODO
diff --git a/.github/workflows/dogfood-gitops.yml b/.github/workflows/dogfood-gitops.yml
index 0d3220cea7..df03e86818 100644
--- a/.github/workflows/dogfood-gitops.yml
+++ b/.github/workflows/dogfood-gitops.yml
@@ -76,6 +76,7 @@ jobs:
DOGFOOD_COMPANY_OWNED_IPHONES_ENROLL_SECRET: ${{ secrets.DOGFOOD_COMPANY_OWNED_IPHONES_ENROLL_SECRET }}
DOGFOOD_COMPANY_OWNED_IPADS_ENROLL_SECRET: ${{ secrets.DOGFOOD_COMPANY_OWNED_IPADS_ENROLL_SECRET }}
MANAGED_CHROME_ENROLLMENT_TOKEN: ${{ secrets.CLOUD_MANAGEMENT_ENROLLMENT_TOKEN }}
+ DOGFOOD_PERSONALLY_OWNED_IPHONES_ENROLL_SECRET: ${{ secrets.DOGFOOD_PERSONALLY_OWNED_IPHONES_ENROLL_SECRET }}
- name: Notify on Gitops failure
if: failure() && github.ref_name == 'main'
diff --git a/CODEOWNERS b/CODEOWNERS
index c8512f309c..14a7406a05 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -65,9 +65,9 @@ go.mod @fleetdm/go
#
# (see website/config/custom.js for DRIs of other paths not listed here)
##############################################################################################
-/docs @rachaelshaw
-/docs/REST\ API/rest-api.md @rachaelshaw # « REST API reference documentation
-/docs/Contributing/API-for-contributors.md @rachaelshaw # « Advanced / contributors-only API reference documentation
+/docs @marko-lisica
+/docs/REST\ API/rest-api.md @iansltx # « REST API reference documentation
+/docs/Contributing/API-for-contributors.md @iansltx # « Advanced / contributors-only API reference documentation
/schema @eashaw # « Data tables (osquery/fleetd schema) documentation
/render.yaml @edwardsb
diff --git a/articles/deploying-cloudflare-warp-with-fleet.md b/articles/deploying-cloudflare-warp-with-fleet.md
new file mode 100644
index 0000000000..cae8d1c16b
--- /dev/null
+++ b/articles/deploying-cloudflare-warp-with-fleet.md
@@ -0,0 +1,103 @@
+# Deploying Cloudflare WARP with Fleet
+
+Cloudflare WARP is a secure VPN-like service that encrypts internet traffic and routes it through Cloudflare's network, improving privacy and security without compromising speed.
+
+## MacOS
+
+1. Create custom MDM Config
+ a. Download the example `.mobileconfig` file
+ b. Tailor the payload with the [desired parameters](https://developers.cloudflare.com/cloudflare-one/connections/connect-devices/warp/deployment/mdm-deployment/parameters/) to satisfy your deployment
+
+2. Upload `.mobileconfig` to Fleet
+ a. In the Fleet admin console, navigate to **Controls**
+ b. Select the **Team** that requires Cloudflare WARP
+ c. Select **OS settings** > **Custom settings**
+ d. Select **Add profile** and upload the `.mobileconfig` from step 1
+ e. Select the hosts which require Cloudflare WARP:
+ - **All hosts:** Deploys WARP to all hosts in selected Team
+ - **Custom:** Deploys WARP to a subset of the hosts in the Team using [labels](https://fleetdm.com/guides/managing-labels-in-fleet)
+
+> Note that the payload will be installed on all targeted hosts, but the WARP agent is not yet installed. Proceed to step 3 to complete the process.
+
+3. Install WARP on hosts
+ a. In the Fleet admin console, navigate to **Software**
+ b. Select the **Team** that requires Cloudflare WARP
+ c. Select **Add software**
+ - Either add Cloudflare WARP from the **Fleet-maintained** library or
+ - Upload a custom `.pkg` obtained from [Cloudflare.](https://developers.cloudflare.com/cloudflare-one/connections/connect-devices/warp/download-warp/#macos) If deploying with this approach, WARP will still need to be installed on select hosts via the UI, API or GitOps. Learn more about deploying software from this [article.](https://fleetdm.com/guides/deploy-software-packages)
+
+> If using Fleet-maintained app, you can choose to install on hosts automatically or manually. To allow users to install WARP from Fleet Desktop, check the box for Self-service.
+
+## Windows
+
+1. Download the WARP installer for Windows
+ a. Visit the [Download](https://developers.cloudflare.com/cloudflare-one/connections/connect-devices/warp/download-warp/#windows) page to review system requirements and download the installer for your OS.
+
+2. Upload WARP installer to Fleet
+ a. In the Fleet admin console, navigate to **Software**
+ b. Select the **Team** that requires Cloudflare WARP
+ c. Select **Add software** > **Custom Package** and upload the `.msi` file downloaded from step 1
+ - To allow users to install WARP from Fleet Desktop, select Self-service. (Optional)
+ d. Select **Advanced options**
+ e. In **Install script**, replace the default script:
+
+ ```
+ $logFile = "${env:TEMP}/fleet-install-software.log"
+
+ try {
+
+ $installProcess = Start-Process msiexec.exe `
+ -ArgumentList "/quiet /norestart ORGANIZATION=your-team-name SUPPORT_URL=https://example.com /lv ${logFile} /i `"${env:INSTALLER_PATH}`"" `
+ -PassThru -Verb RunAs -Wait
+
+ Get-Content $logFile -Tail 500
+
+ Exit $installProcess.ExitCode
+
+ } catch {
+ Write-Host "Error: $_"
+ Exit 1
+ }
+ ```
+
+> Refer to Cloudflare's [deployment parameters](https://developers.cloudflare.com/cloudflare-one/connections/connect-devices/warp/deployment/mdm-deployment/parameters/) for a description of each argument and adjust your script as needed.
+
+4. Install WARP on hosts
+ a. In the Fleet admin console, navigate to **Hosts**
+ b. Select the host that requires the WARP client
+ c. Go to **Software** and search for **Cloudflare WARP**
+ d. Select **Actions** > **Install**
+
+> Learn more about ways to deploy software via the UI, API or GitOps from this [article.](https://fleetdm.com/guides/deploy-software-packages)
+
+## Linux
+
+Fleet allows admins to execute custom scripts on Linux hosts. The following example script creates an [MDM file](https://developers.cloudflare.com/cloudflare-one/connections/connect-devices/warp/deployment/mdm-deployment/#linux) and installs WARP on an Ubuntu host:
+
+```
+#!/bin/sh
+
+# Write the mdm.xml file
+touch /var/lib/cloudflare-warp/mdm.xml
+echo -e "\n organization \n your-team-name \n
+" > /var/lib/cloudflare-warp/mdm.xml
+
+# Add cloudflare gpg key
+curl -fsSL https://pkg.cloudflareclient.com/pubkey.gpg | sudo gpg --yes --dearmor --output /usr/share/keyrings/cloudflare-warp-archive-keyring.gpg
+
+# Add this repo to your apt repositories
+echo "deb [signed-by=/usr/share/keyrings/cloudflare-warp-archive-keyring.gpg] https://pkg.cloudflareclient.com/ $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/cloudflare-client.list
+
+# Install
+sudo apt-get -y update && sudo apt-get -y install cloudflare-warp
+```
+
+> To learn about deploying scripts across multiple hosts, check out this [article.](https://fleetdm.com/guides/policy-automation-run-script)
+
+To install WARP on other Linux distributions, refer to the [package repository](https://pkg.cloudflareclient.com/)
+
+
+
+
+
+
diff --git a/articles/fintech-company-migrates-to-fleet.md b/articles/fintech-company-migrates-to-fleet.md
new file mode 100644
index 0000000000..9e656dee2c
--- /dev/null
+++ b/articles/fintech-company-migrates-to-fleet.md
@@ -0,0 +1,73 @@
+# American financial services company migrates to Fleet for MDM and next-gen change management
+
+
+
+“I don't want one bad actor to brick my fleet, I want them to make a pull request first.”
+
+**— Client Platform Engineering Manager**
+
+
+## Challenge
+
+A prominent financial services company encountered substantial challenges with its existing device management solution. The platform demanded excessive resources and time for maintenance while limiting its ability to implement automated GitOps workflows, a focus of its operational strategy. The previous migration experience had been arduous, needing to support a fleet comprising 2,700 devices across macOS, Windows, and iOS devices. They required a scalable and secure platform that could support its configuration-as-code philosophy and reallocate resources to more strategic initiatives.
+
+## Solution
+
+They selected Fleet as its new device management platform to unify its device ecosystem. Fleet's next-gen GitOps capabilities aligned with their commitment to configuration as code, enabling seamless integration with their existing automation workflows. Fleet's support and direct database schema [migration tool](https://github.com/fleetdm/fleet/blob/d563d09baca642d5e4f910759b71619333b500b9/tools/mdm/migration/micromdm/touchless/README.md) facilitated the migration process, ensuring a smooth transition. Additionally, [Fleet's open API](https://fleetdm.com/docs/rest-api/rest-api) and advanced features, such as live query execution, real-time insights, and configurable logging pipelines, provide their teams with real-time visibility and control over their endpoints.
+
+## Results
+
+
+
+Unified device management platform across macOS, Windows, and iOS.
+
+Introduced new infrastructure as code workflows.
+
+Fewer resources are spent configuring device management.
+
+Smooth and seamless migration.
+
+
+
+## Their story
+
+The leading financial services company dedicated to democratizing finance for all. By leveraging cutting-edge technology, they empower millions of users to invest and manage their finances with ease and confidence. The company sought a new device management solution that wouldn’t require strenuous time or resources to manage. They were looking for a more full-featured MDM that enabled customization through configuration as code.
+
+Specifically, they were looking for:
+
+- Next-gen change management and open-source flexibility
+- Increased efficiency
+- An easy migration path
+- Improved support and feature access
+
+### Next-gen change management and open-source flexibility
+
+Fleet is [open-source](https://fleetdm.com/handbook/company/why-this-way#why-open-source), allowing engineering teams to audit, customize, and extend the platform as needed alongside [infrastructure-as-code](https://github.com/fleetdm/fleet-gitops) workflows. This makes device management more agile and automated while reducing errors through peer review.
+
+### Eliminate tool overlap and increase efficiency
+
+They were able to replace multiple legacy tools and consolidate the management of thousands of macOS, Windows, and iOS devices into a single platform. This led to a significant reduction in resources and time spent maintaining their previous tools, allowing efforts to be reallocated towards innovation and development.
+
+
+### Easy migration path
+
+Fleet ensures a smooth transition with minimal disruption. Migrations are directly assisted by Fleet’s [best-in-class support](https://fleetdm.com/support) teams and built-in [migration tools](https://github.com/fleetdm/fleet/tree/main/tools/mdm/migration).
+
+### Improved support and feature access
+
+Fleet has a three-week release schedule that quickly rolls out new features like automated software updates, VPP app support, and [policy-based scripts](https://fleetdm.com/guides/policy-automation-run-script). Faster rollouts and best-in-class support from Fleet enable them to stay ahead of their device management needs.
+
+
+## Conclusion
+
+The migration to [Fleet Device Management](https://fleetdm.com/device-management) exemplifies the fintech company’s dedication to leveraging advanced, flexible, and secure tools to support its expansive infrastructure. Fleet’s comprehensive feature set, combined with its commitment to security and scalability, not only addressed the limitations of legacy tools but also empowered them to increase efficiency and capabilities. This strategic move positions them to continue delivering exceptional financial services while maintaining forward-thinking device management practices.
+
+
+
+
+
+
+
+
+
+
diff --git a/articles/fleet-4.61.0.md b/articles/fleet-4.61.0.md
index 65505d82fd..fd13fd1385 100644
--- a/articles/fleet-4.61.0.md
+++ b/articles/fleet-4.61.0.md
@@ -22,7 +22,7 @@ You can now enable email 2FA for Fleet user accounts. This adds an extra layer o
### Automatic Windows migration
-Fleet now supports migrating Windows workstations from your old MDM solution without end user interaction. Once migrated, you can enforce [disk encryption](https://fleetdm.com/guides/enforce-disk-encryption), [OS updates](https://fleetdm.com/guides/enforce-os-updates), and other [custom OS settings](https://fleetdm.com/guides/custom-os-settings) to consolidating device management into a single, cross-platform MDM.
+Fleet now supports migrating Windows workstations from your old MDM solution without end user interaction. Once migrated, you can enforce [disk encryption](https://fleetdm.com/guides/enforce-disk-encryption), [OS updates](https://fleetdm.com/guides/enforce-os-updates), and other [custom OS settings](https://fleetdm.com/guides/custom-os-settings) to consolidate device management into a single, cross-platform MDM.
## Changes
diff --git a/changes/19930-alert-on-cron-errors b/changes/19930-alert-on-cron-errors
new file mode 100644
index 0000000000..0374bf8b05
--- /dev/null
+++ b/changes/19930-alert-on-cron-errors
@@ -0,0 +1,2 @@
+- Send alert via SNS when a scheduled "cron" job returns errors
+- SNS topic for job error alerts can be configured separately from the existing monitor alert by adding "cron_job_failure_monitoring" to sns_topic_arns_map, otherwise defaults to the using the same topic
diff --git a/changes/23557-minio b/changes/23557-minio
new file mode 100644
index 0000000000..2fa00913f4
--- /dev/null
+++ b/changes/23557-minio
@@ -0,0 +1 @@
+* fixed issue where minio software was not scanned for vulnerabilities correctly because of unexpected trailing characters in the version string
\ No newline at end of file
diff --git a/changes/23579-cve-2024-10004-false-positive b/changes/23579-cve-2024-10004-false-positive
new file mode 100644
index 0000000000..de3ed40f7b
--- /dev/null
+++ b/changes/23579-cve-2024-10004-false-positive
@@ -0,0 +1 @@
+* Fixed CVE-2024-10004 false positive on Fleet-supported platforms (vuln is iOS-only and iOS vuln checking is not supported)
diff --git a/changes/23783-controls-subnav-pagination b/changes/23783-controls-subnav-pagination
new file mode 100644
index 0000000000..46094c2e5f
--- /dev/null
+++ b/changes/23783-controls-subnav-pagination
@@ -0,0 +1 @@
+- Fixed UI bug involving pagination of subsections within the "Controls" page.
diff --git a/changes/24315-update-windows-policy-constants b/changes/24315-update-windows-policy-constants
new file mode 100644
index 0000000000..3603279008
--- /dev/null
+++ b/changes/24315-update-windows-policy-constants
@@ -0,0 +1 @@
+- Update example windows policies
diff --git a/changes/24899-software-installer-scripts-secrets b/changes/24899-software-installer-scripts-secrets
new file mode 100644
index 0000000000..f5f11a77c7
--- /dev/null
+++ b/changes/24899-software-installer-scripts-secrets
@@ -0,0 +1 @@
+- Add support for fleet secret validation in software installer scripts
diff --git a/changes/24917-installer-url-length b/changes/24917-installer-url-length
new file mode 100644
index 0000000000..af3287c807
--- /dev/null
+++ b/changes/24917-installer-url-length
@@ -0,0 +1 @@
+* Increased maximum length for installer URLs specified in GitOps to 4000 characters
diff --git a/cmd/fleet/serve.go b/cmd/fleet/serve.go
index 0b770ca43a..2fdab8223f 100644
--- a/cmd/fleet/serve.go
+++ b/cmd/fleet/serve.go
@@ -1132,6 +1132,7 @@ the way that the Fleet server works.
logger,
mdmCheckinAndCommandService,
ddmService,
+ commander,
); err != nil {
initFatal(err, "setup mdm apple services")
}
diff --git a/cmd/fleetctl/apply_test.go b/cmd/fleetctl/apply_test.go
index 3c2bbee356..8369058eb1 100644
--- a/cmd/fleetctl/apply_test.go
+++ b/cmd/fleetctl/apply_test.go
@@ -208,6 +208,9 @@ func TestApplyTeamSpecs(t *testing.T) {
ds.DeleteMDMAppleDeclarationByNameFunc = func(ctx context.Context, teamID *uint, name string) error {
return nil
}
+ ds.ExpandEmbeddedSecretsFunc = func(ctx context.Context, document string) (string, error) {
+ return document, nil
+ }
filename := writeTmpYml(t, `
---
@@ -1359,6 +1362,9 @@ func TestApplyAsGitOps(t *testing.T) {
ds.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) {
return []*fleet.VPPTokenDB{}, nil
}
+ ds.ExpandEmbeddedSecretsFunc = func(ctx context.Context, document string) (string, error) {
+ return document, nil
+ }
// Apply global config.
name := writeTmpYml(t, `---
diff --git a/cmd/fleetctl/gitops_test.go b/cmd/fleetctl/gitops_test.go
index 207dbe480a..93d85eff04 100644
--- a/cmd/fleetctl/gitops_test.go
+++ b/cmd/fleetctl/gitops_test.go
@@ -660,6 +660,10 @@ func TestGitOpsFullGlobal(t *testing.T) {
return []*fleet.ABMToken{}, nil
}
+ ds.ExpandEmbeddedSecretsFunc = func(ctx context.Context, document string) (string, error) {
+ return document, nil
+ }
+
const (
fleetServerURL = "https://fleet.example.com"
orgName = "GitOps Test"
@@ -861,6 +865,10 @@ func TestGitOpsFullTeam(t *testing.T) {
return nil
}
+ ds.ExpandEmbeddedSecretsFunc = func(ctx context.Context, document string) (string, error) {
+ return document, nil
+ }
+
// Queries
query := fleet.Query{}
query.ID = 1
@@ -2591,6 +2599,9 @@ func setupFullGitOpsPremiumServer(t *testing.T) (*mock.Store, **fleet.AppConfig,
ds.SetSetupExperienceScriptFunc = func(ctx context.Context, script *fleet.Script) error {
return nil
}
+ ds.ExpandEmbeddedSecretsFunc = func(ctx context.Context, document string) (string, error) {
+ return document, nil
+ }
t.Setenv("FLEET_SERVER_URL", fleetServerURL)
t.Setenv("ORG_NAME", orgName)
diff --git a/cmd/fleetctl/mdm_test.go b/cmd/fleetctl/mdm_test.go
index 199c59e22c..dee29ff0ec 100644
--- a/cmd/fleetctl/mdm_test.go
+++ b/cmd/fleetctl/mdm_test.go
@@ -274,7 +274,7 @@ func TestMDMRunCommand(t *testing.T) {
return res, nil
}
- enqueuer.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.Command) (map[string]error, error) {
+ enqueuer.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.CommandWithSubtype) (map[string]error, error) {
return map[string]error{}, nil
}
diff --git a/cmd/osquery-perf/agent.go b/cmd/osquery-perf/agent.go
index 6b306da012..0671766fc2 100644
--- a/cmd/osquery-perf/agent.go
+++ b/cmd/osquery-perf/agent.go
@@ -514,8 +514,7 @@ type agent struct {
MDMCheckInInterval time.Duration
DiskEncryptionEnabled bool
- scheduledQueriesMu sync.Mutex // protects the below members
- scheduledQueryData map[string]scheduledQuery
+ scheduledQueryData *sync.Map
// bufferedResults contains result logs that are buffered when
// /api/v1/osquery/log requests to the Fleet server fail.
//
@@ -668,6 +667,7 @@ func newAgent(
disableFleetDesktop: disableFleetDesktop,
loggerTLSMaxLines: loggerTLSMaxLines,
bufferedResults: make(map[resultLog]int),
+ scheduledQueryData: new(sync.Map),
}
}
@@ -777,9 +777,19 @@ func (a *agent) runLoop(i int, onlyAlreadyEnrolled bool) {
// check if we have any scheduled queries that should be returning results
var results []resultLog
now := time.Now().Unix()
- a.scheduledQueriesMu.Lock()
prevCount := a.countBuffered()
- for queryName, query := range a.scheduledQueryData {
+
+ // NOTE The goroutine that pulls in new configurations
+ // MAY replace this map if it happens to run at the
+ // exact same time. The result would be. The result
+ // would be that the query lastRun does not get
+ // updated and cause the query to run more times than
+ // expected.
+ queryData := a.scheduledQueryData
+ queryData.Range(func(key, value any) bool {
+ queryName := key.(string)
+ query := value.(scheduledQuery)
+
if query.lastRun == 0 || now >= (query.lastRun+int64(query.ScheduleInterval)) {
results = append(results, resultLog{
packName: query.packName,
@@ -787,18 +797,18 @@ func (a *agent) runLoop(i int, onlyAlreadyEnrolled bool) {
numRows: int(query.numRows),
})
// Update lastRun
- v := a.scheduledQueryData[queryName]
- v.lastRun = now
- a.scheduledQueryData[queryName] = v
+ query.lastRun = now
+ queryData.Store(queryName, query)
}
- }
+
+ return true
+ })
if prevCount+len(results) < 1_000_000 { // osquery buffered_log_max is 1M
a.addToBuffer(results)
}
a.sendLogsBatch()
newBufferedCount := a.countBuffered() - prevCount
a.stats.UpdateBufferedLogs(newBufferedCount)
- a.scheduledQueriesMu.Unlock()
}
}
@@ -1518,7 +1528,16 @@ func (a *agent) config() error {
return fmt.Errorf("json parse at config: %w", err)
}
- scheduledQueryData := make(map[string]scheduledQuery)
+ existingLastRunData := make(map[string]int64)
+
+ a.scheduledQueryData.Range(func(key, value any) bool {
+ existingLastRunData[key.(string)] = value.(scheduledQuery).lastRun
+
+ return true
+ })
+
+ newScheduledQueryData := new(sync.Map)
+
for packName, pack := range parsedResp.Packs {
for queryName, query := range pack.Queries {
m, ok := query.(map[string]interface{})
@@ -1546,17 +1565,14 @@ func (a *agent) config() error {
q.Query = m["query"].(string)
scheduledQueryName := packName + "_" + queryName
- if existingEntry, ok := a.scheduledQueryData[scheduledQueryName]; ok {
- // Keep lastRun if the query is already scheduled.
- q.lastRun = existingEntry.lastRun
+ if lastRun, ok := existingLastRunData[scheduledQueryName]; ok {
+ q.lastRun = lastRun
}
- scheduledQueryData[scheduledQueryName] = q
+ newScheduledQueryData.Store(scheduledQueryName, q)
}
}
- a.scheduledQueriesMu.Lock()
- a.scheduledQueryData = scheduledQueryData
- a.scheduledQueriesMu.Unlock()
+ a.scheduledQueryData = newScheduledQueryData
return nil
}
@@ -1852,13 +1868,12 @@ func (a *agent) runPolicy(query string) []map[string]string {
}
func (a *agent) randomQueryStats() []map[string]string {
- a.scheduledQueriesMu.Lock()
- defer a.scheduledQueriesMu.Unlock()
-
var stats []map[string]string
- for scheduledQuery := range a.scheduledQueryData {
+ a.scheduledQueryData.Range(func(key, value any) bool {
+ queryName := key.(string)
+
stats = append(stats, map[string]string{
- "name": scheduledQuery,
+ "name": queryName,
"delimiter": "_",
"average_memory": fmt.Sprint(rand.Intn(200) + 10),
"denylisted": "false",
@@ -1871,7 +1886,9 @@ func (a *agent) randomQueryStats() []map[string]string {
"wall_time": fmt.Sprint(rand.Intn(4) + 1),
"wall_time_ms": fmt.Sprint(rand.Intn(4000) + 10),
})
- }
+
+ return true
+ })
return stats
}
diff --git a/docs/Configuration/yaml-files.md b/docs/Configuration/yaml-files.md
index 2a4f3e18bb..f232bd675c 100644
--- a/docs/Configuration/yaml-files.md
+++ b/docs/Configuration/yaml-files.md
@@ -203,6 +203,7 @@ The `controls` section allows you to configure scripts and device management (MD
- `scripts` is a list of paths to macOS, Windows, or Linux scripts.
- `windows_enabled_and_configured` specifies whether or not to turn on Windows MDM features (default: `false`). Can only be configured for all teams (`default.yml`).
+- `windows_migration_enabled` specifies whether or not to automatically migrate Windows hosts connected to another MDM solution. If `false`, MDM is only turned on after hosts are unenrolled from your old MDM solution (default: `false`). Can only be configured for all teams (`default.yml`).
- `enable_disk_encryption` specifies whether or not to enforce disk encryption on macOS, Windows, and Linux hosts (default: `false`).
#### Example
@@ -214,6 +215,7 @@ controls:
- path: ../lib/windows-script.ps1
- path: ../lib/linux-script.sh
windows_enabled_and_configured: true
+ windows_migration_enabled: true # Available in Fleet Premium
enable_disk_encryption: true # Available in Fleet Premium
macos_updates: # Available in Fleet Premium
deadline: "2024-12-31"
diff --git a/docs/Contributing/Testing-and-local-development.md b/docs/Contributing/Testing-and-local-development.md
index 5592d460cd..24fdea7290 100644
--- a/docs/Contributing/Testing-and-local-development.md
+++ b/docs/Contributing/Testing-and-local-development.md
@@ -267,7 +267,11 @@ The Fleet repo includes tools to start testing osquery hosts. Please see the doc
To intercept sent emails while running a Fleet development environment, first, as an Admin in the Fleet UI, navigate to the Organization settings.
-Then, in the "SMTP options" section, enter any email address in the "Sender address" field, set the "SMTP server" to `localhost` on port `1025`, and set "Authentication type" to `None`. Note that you may use any active or inactive sender address.
+Then, in the "SMTP options" section, set:
+- "Sender address" to any email address. Note that you may use any active or inactive sender address.
+- "SMTP server" to `localhost` on port `1025`.
+- "Use SSL/TLS to connect (recommended)" to unchecked.
+- "Authentication type" to `None`.
Visit [localhost:8025](http://localhost:8025) to view MailHog's admin interface displaying all emails sent using the simulated mail server.
@@ -275,9 +279,11 @@ Visit [localhost:8025](http://localhost:8025) to view MailHog's admin interface
Alternatively, if you need to test a SMTP server with plain basic authentication enabled, set:
- "SMTP server" to `localhost` on port `1026`
-- "Authentication type" to `Plain`.
-- "SMTP username" to `mailpit-username`.
-- "SMTP password" to `mailpit-password`.
+- "Use SSL/TLS to connect (recommended)" to unchecked.
+- "Authentication type" to `Username and Password`
+- "SMTP username" to `mailpit-username`
+- "SMTP password" to `mailpit-password`
+- "Auth method" to `Plain`
- Note that you may use any active or inactive sender address.
Visit [localhost:8026](http://localhost:8026) to view Mailpit's admin interface displaying all emails sent using the simulated mail server.
diff --git a/docs/REST API/rest-api.md b/docs/REST API/rest-api.md
index 4ad8cce17f..5843536ef5 100644
--- a/docs/REST API/rest-api.md
+++ b/docs/REST API/rest-api.md
@@ -6790,7 +6790,7 @@ _Available in Fleet Premium_
- [Add policy](#add-policy)
- [Remove policies](#remove-policies)
- [Edit policy](#edit-policy)
-- [Run automation for all failing hosts of a policy](#run-automation-for-all-failing-hosts-of-a-policy)
+- [Reset automations for all hosts failing policies](#reset-automations-for-all-hosts-failing-policies)
Policies are yes or no questions you can ask about your hosts.
@@ -7084,9 +7084,9 @@ For example, a policy might ask “Is Gatekeeper enabled on macOS devices?“ Th
}
```
-### Run automation for all failing hosts of a policy
+### Reset automations for all hosts failing policies
-Triggers [automations](https://fleetdm.com/docs/using-fleet/automations#policy-automations) for *all* hosts failing the specified policies, regardless of whether the policies were previously failing on those hosts.
+Resets [automation](https://fleetdm.com/docs/using-fleet/automations#policy-automations) status for *all* hosts failing the specified policies. On the next automation run, any failing host will be considered newly failing.
`POST /api/v1/fleet/automations/reset`
diff --git a/ee/bulk-operations-dashboard/api/controllers/software/edit-software.js b/ee/bulk-operations-dashboard/api/controllers/software/edit-software.js
index 1db0bd1c43..0e69202ee5 100644
--- a/ee/bulk-operations-dashboard/api/controllers/software/edit-software.js
+++ b/ee/bulk-operations-dashboard/api/controllers/software/edit-software.js
@@ -167,6 +167,7 @@ module.exports = {
Authorization: `Bearer ${sails.config.custom.fleetApiToken}`,
...form.getHeaders()
},
+ maxRedirects: 0
});
})()
.then(()=>{
@@ -261,6 +262,7 @@ module.exports = {
Authorization: `Bearer ${sails.config.custom.fleetApiToken}`,
...form.getHeaders()
},
+ maxRedirects: 0,
});
})()
.then(()=>{
diff --git a/ee/bulk-operations-dashboard/api/controllers/software/upload-software.js b/ee/bulk-operations-dashboard/api/controllers/software/upload-software.js
index 86022b50e6..95248be8c9 100644
--- a/ee/bulk-operations-dashboard/api/controllers/software/upload-software.js
+++ b/ee/bulk-operations-dashboard/api/controllers/software/upload-software.js
@@ -89,6 +89,7 @@ module.exports = {
Authorization: `Bearer ${sails.config.custom.fleetApiToken}`,
...form.getHeaders()
},
+ maxRedirects: 0
});
})()
.then(()=>{
diff --git a/ee/bulk-operations-dashboard/entrypoint.sh b/ee/bulk-operations-dashboard/entrypoint.sh
index bbec7ef5da..62c3385610 100644
--- a/ee/bulk-operations-dashboard/entrypoint.sh
+++ b/ee/bulk-operations-dashboard/entrypoint.sh
@@ -12,10 +12,12 @@ elif [ -z "$sails_custom__fleetApiToken" ]; then
fi
# Check if the vulnerability dashboard has been initialized before
-if [ ! -f "/usr/src/app/.initialized" ]; then
+if [ ! -f "/usr/src/app/initialized" ]; then
# if it hasn't, lift the app with in console mode with the --drop flag to create our databsae tables.
echo '.exit' | node ./node_modules/sails/bin/sails console --drop
+ touch /usr/src/app/initialized
+
fi
# Start the dashboard
diff --git a/ee/server/service/maintained_apps.go b/ee/server/service/maintained_apps.go
index 4f4269944e..f459d098cb 100644
--- a/ee/server/service/maintained_apps.go
+++ b/ee/server/service/maintained_apps.go
@@ -43,6 +43,10 @@ func (svc *Service) AddFleetMaintainedApp(
return 0, ctxerr.Wrap(ctx, err, "validating software labels")
}
+ if err := svc.ds.ValidateEmbeddedSecrets(ctx, []string{installScript, postInstallScript, uninstallScript}); err != nil {
+ return 0, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("script", err.Error()))
+ }
+
app, err := svc.ds.GetMaintainedAppByID(ctx, appID)
if err != nil {
return 0, ctxerr.Wrap(ctx, err, "getting maintained app by id")
diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go
index ca9bf5b12f..1773770fad 100644
--- a/ee/server/service/software_installers.go
+++ b/ee/server/service/software_installers.go
@@ -71,6 +71,10 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet.
// Update $PACKAGE_ID in uninstall script
preProcessUninstallScript(payload)
+ if err := svc.ds.ValidateEmbeddedSecrets(ctx, []string{payload.InstallScript, payload.PostInstallScript, payload.UninstallScript}); err != nil {
+ return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("script", err.Error()))
+ }
+
installerID, titleID, err := svc.ds.MatchOrCreateSoftwareInstaller(ctx, payload)
if err != nil {
return ctxerr.Wrap(ctx, err, "matching or creating software installer")
@@ -190,6 +194,22 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet.
teamName = &t.Name
}
+ var scripts []string
+
+ if payload.InstallScript != nil {
+ scripts = append(scripts, *payload.InstallScript)
+ }
+ if payload.PostInstallScript != nil {
+ scripts = append(scripts, *payload.PostInstallScript)
+ }
+ if payload.UninstallScript != nil {
+ scripts = append(scripts, *payload.UninstallScript)
+ }
+
+ if err := svc.ds.ValidateEmbeddedSecrets(ctx, scripts); err != nil {
+ return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("script", err.Error()))
+ }
+
// get software by ID, fail if it does not exist or does not have an existing installer
software, err := svc.ds.SoftwareTitleByID(ctx, payload.TitleID, payload.TeamID, fleet.TeamFilter{
User: vc.User,
@@ -1292,12 +1312,14 @@ func (svc *Service) BatchSetSoftwareInstallers(
return "", ctxerr.Wrap(ctx, err, "validating authorization")
}
+ var allScripts []string
+
// Verify payloads first, to prevent starting the download+upload process if the data is invalid.
for _, payload := range payloads {
if len(payload.URL) > fleet.SoftwareInstallerURLMaxLength {
return "", fleet.NewInvalidArgumentError(
"software.url",
- "software URL is too long, must be less than 256 characters",
+ fmt.Sprintf("software URL is too long, must be %d characters or less", fleet.SoftwareInstallerURLMaxLength),
)
}
if _, err := url.ParseRequestURI(payload.URL); err != nil {
@@ -1311,6 +1333,12 @@ func (svc *Service) BatchSetSoftwareInstallers(
return "", err
}
payload.ValidatedLabels = validatedLabels
+
+ allScripts = append(allScripts, payload.InstallScript, payload.PostInstallScript, payload.UninstallScript)
+ }
+
+ if err := svc.ds.ValidateEmbeddedSecrets(ctx, allScripts); err != nil {
+ return "", ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("script", err.Error()))
}
// keyExpireTime is the current maximum time supported for retrieving
diff --git a/frontend/components/AvatarTopNav/AvatarTopNav.tsx b/frontend/components/AvatarTopNav/AvatarTopNav.tsx
index bcbfa3ff3a..47b3a2a10f 100644
--- a/frontend/components/AvatarTopNav/AvatarTopNav.tsx
+++ b/frontend/components/AvatarTopNav/AvatarTopNav.tsx
@@ -39,6 +39,7 @@ const Avatar = ({ className, size, user }: IAvatarInterface): JSX.Element => {
src={gravatar_url_dark || DEFAULT_GRAVATAR_LINK_DARK}
onError={onError}
onLoad={onLoad}
+ data-testid="user-avatar"
/>
);
diff --git a/frontend/components/AvatarTopNav/_styles.scss b/frontend/components/AvatarTopNav/_styles.scss
index b4cbb6e6bb..04d71e5cc2 100644
--- a/frontend/components/AvatarTopNav/_styles.scss
+++ b/frontend/components/AvatarTopNav/_styles.scss
@@ -1,5 +1,4 @@
.avatar-wrapper-top-nav {
- margin-right: 8px;
svg {
display: block;
}
diff --git a/frontend/components/top_nav/SiteTopNav/SiteTopNav.tests.tsx b/frontend/components/top_nav/SiteTopNav/SiteTopNav.tests.tsx
index 5ef627eea7..d1308b0aa8 100644
--- a/frontend/components/top_nav/SiteTopNav/SiteTopNav.tests.tsx
+++ b/frontend/components/top_nav/SiteTopNav/SiteTopNav.tests.tsx
@@ -37,18 +37,29 @@ describe("SiteTopNav - component", () => {
/>
);
- await user.click(screen.getByTestId("user-menu"));
+ await user.click(screen.getByTestId("user-avatar"));
expect(screen.getByText(/hosts/i)).toBeInTheDocument();
expect(screen.getByText(/controls/i)).toBeInTheDocument();
expect(screen.getByText(/software/i)).toBeInTheDocument();
expect(screen.getByText(/queries/i)).toBeInTheDocument();
expect(screen.getByText(/policies/i)).toBeInTheDocument();
- expect(screen.getByText(/settings/i)).toBeInTheDocument();
- expect(screen.getByText(/manage users/i)).toBeInTheDocument();
- expect(screen.getByText(/my account/i)).toBeInTheDocument();
- expect(screen.getByText(/documentation/i)).toBeInTheDocument();
- expect(screen.getByText(/sign out/i)).toBeInTheDocument();
+ expect(
+ screen.getByRole("menuitem", { name: /settings/i })
+ ).toBeInTheDocument();
+
+ expect(
+ screen.getByRole("menuitem", { name: /manage users/i })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("menuitem", { name: /my account/i })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("menuitem", { name: /documentation/i })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("menuitem", { name: /sign out/i })
+ ).toBeInTheDocument();
});
it("renders correct navigation for free global maintainer", async () => {
const render = createCustomRenderer({
@@ -73,16 +84,22 @@ describe("SiteTopNav - component", () => {
/>
);
- await user.click(screen.getByTestId("user-menu"));
+ await user.click(screen.getByTestId("user-avatar"));
expect(screen.getByText(/hosts/i)).toBeInTheDocument();
expect(screen.getByText(/controls/i)).toBeInTheDocument();
expect(screen.getByText(/software/i)).toBeInTheDocument();
expect(screen.getByText(/queries/i)).toBeInTheDocument();
expect(screen.getByText(/policies/i)).toBeInTheDocument();
- expect(screen.getByText(/my account/i)).toBeInTheDocument();
- expect(screen.getByText(/documentation/i)).toBeInTheDocument();
- expect(screen.getByText(/sign out/i)).toBeInTheDocument();
+ expect(
+ screen.getByRole("menuitem", { name: /my account/i })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("menuitem", { name: /documentation/i })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("menuitem", { name: /sign out/i })
+ ).toBeInTheDocument();
expect(screen.queryByText(/settings/i)).not.toBeInTheDocument();
expect(screen.queryByText(/manage users/i)).not.toBeInTheDocument();
@@ -109,15 +126,21 @@ describe("SiteTopNav - component", () => {
/>
);
- await user.click(screen.getByTestId("user-menu"));
+ await user.click(screen.getByTestId("user-avatar"));
expect(screen.getByText(/hosts/i)).toBeInTheDocument();
expect(screen.getByText(/software/i)).toBeInTheDocument();
expect(screen.getByText(/queries/i)).toBeInTheDocument();
expect(screen.getByText(/policies/i)).toBeInTheDocument();
- expect(screen.getByText(/my account/i)).toBeInTheDocument();
- expect(screen.getByText(/documentation/i)).toBeInTheDocument();
- expect(screen.getByText(/sign out/i)).toBeInTheDocument();
+ expect(
+ screen.getByRole("menuitem", { name: /my account/i })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("menuitem", { name: /documentation/i })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("menuitem", { name: /sign out/i })
+ ).toBeInTheDocument();
expect(screen.queryByText(/controls/i)).not.toBeInTheDocument();
expect(screen.queryByText(/settings/i)).not.toBeInTheDocument();
@@ -143,18 +166,28 @@ describe("SiteTopNav - component", () => {
/>
);
- await user.click(screen.getByTestId("user-menu"));
+ await user.click(screen.getByTestId("user-avatar"));
expect(screen.getByText(/hosts/i)).toBeInTheDocument();
expect(screen.getByText(/controls/i)).toBeInTheDocument();
expect(screen.getByText(/software/i)).toBeInTheDocument();
expect(screen.getByText(/queries/i)).toBeInTheDocument();
expect(screen.getByText(/policies/i)).toBeInTheDocument();
- expect(screen.getByText(/settings/i)).toBeInTheDocument();
- expect(screen.getByText(/manage users/i)).toBeInTheDocument();
- expect(screen.getByText(/my account/i)).toBeInTheDocument();
- expect(screen.getByText(/documentation/i)).toBeInTheDocument();
- expect(screen.getByText(/sign out/i)).toBeInTheDocument();
+ expect(
+ screen.getByRole("menuitem", { name: /settings/i })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("menuitem", { name: /manage users/i })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("menuitem", { name: /my account/i })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("menuitem", { name: /documentation/i })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("menuitem", { name: /sign out/i })
+ ).toBeInTheDocument();
});
it("renders correct navigation for premium global maintainer", async () => {
const render = createCustomRenderer({
@@ -179,16 +212,22 @@ describe("SiteTopNav - component", () => {
/>
);
- await user.click(screen.getByTestId("user-menu"));
+ await user.click(screen.getByTestId("user-avatar"));
expect(screen.getByText(/hosts/i)).toBeInTheDocument();
expect(screen.getByText(/controls/i)).toBeInTheDocument();
expect(screen.getByText(/software/i)).toBeInTheDocument();
expect(screen.getByText(/queries/i)).toBeInTheDocument();
expect(screen.getByText(/policies/i)).toBeInTheDocument();
- expect(screen.getByText(/my account/i)).toBeInTheDocument();
- expect(screen.getByText(/documentation/i)).toBeInTheDocument();
- expect(screen.getByText(/sign out/i)).toBeInTheDocument();
+ expect(
+ screen.getByRole("menuitem", { name: /my account/i })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("menuitem", { name: /documentation/i })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("menuitem", { name: /sign out/i })
+ ).toBeInTheDocument();
expect(screen.queryByText(/settings/i)).not.toBeInTheDocument();
expect(screen.queryByText(/manage users/i)).not.toBeInTheDocument();
@@ -215,15 +254,21 @@ describe("SiteTopNav - component", () => {
/>
);
- await user.click(screen.getByTestId("user-menu"));
+ await user.click(screen.getByTestId("user-avatar"));
expect(screen.getByText(/hosts/i)).toBeInTheDocument();
expect(screen.getByText(/software/i)).toBeInTheDocument();
expect(screen.getByText(/queries/i)).toBeInTheDocument();
expect(screen.getByText(/policies/i)).toBeInTheDocument();
- expect(screen.getByText(/my account/i)).toBeInTheDocument();
- expect(screen.getByText(/documentation/i)).toBeInTheDocument();
- expect(screen.getByText(/sign out/i)).toBeInTheDocument();
+ expect(
+ screen.getByRole("menuitem", { name: /my account/i })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("menuitem", { name: /documentation/i })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("menuitem", { name: /sign out/i })
+ ).toBeInTheDocument();
expect(screen.queryByText(/controls/i)).not.toBeInTheDocument();
expect(screen.queryByText(/settings/i)).not.toBeInTheDocument();
@@ -252,17 +297,25 @@ describe("SiteTopNav - component", () => {
/>
);
- await user.click(screen.getByTestId("user-menu"));
+ await user.click(screen.getByTestId("user-avatar"));
expect(screen.getByText(/hosts/i)).toBeInTheDocument();
expect(screen.getByText(/controls/i)).toBeInTheDocument();
expect(screen.getByText(/software/i)).toBeInTheDocument();
expect(screen.getByText(/queries/i)).toBeInTheDocument();
expect(screen.getByText(/policies/i)).toBeInTheDocument();
- expect(screen.getByText(/settings/i)).toBeInTheDocument();
- expect(screen.getByText(/my account/i)).toBeInTheDocument();
- expect(screen.getByText(/documentation/i)).toBeInTheDocument();
- expect(screen.getByText(/sign out/i)).toBeInTheDocument();
+ expect(
+ screen.getByRole("menuitem", { name: /settings/i })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("menuitem", { name: /my account/i })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("menuitem", { name: /documentation/i })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("menuitem", { name: /sign out/i })
+ ).toBeInTheDocument();
expect(screen.queryByText(/manage users/i)).not.toBeInTheDocument();
});
@@ -289,16 +342,22 @@ describe("SiteTopNav - component", () => {
/>
);
- await user.click(screen.getByTestId("user-menu"));
+ await user.click(screen.getByTestId("user-avatar"));
expect(screen.getByText(/hosts/i)).toBeInTheDocument();
expect(screen.getByText(/controls/i)).toBeInTheDocument();
expect(screen.getByText(/software/i)).toBeInTheDocument();
expect(screen.getByText(/queries/i)).toBeInTheDocument();
expect(screen.getByText(/policies/i)).toBeInTheDocument();
- expect(screen.getByText(/my account/i)).toBeInTheDocument();
- expect(screen.getByText(/documentation/i)).toBeInTheDocument();
- expect(screen.getByText(/sign out/i)).toBeInTheDocument();
+ expect(
+ screen.getByRole("menuitem", { name: /my account/i })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("menuitem", { name: /documentation/i })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("menuitem", { name: /sign out/i })
+ ).toBeInTheDocument();
expect(screen.queryByText(/settings/i)).not.toBeInTheDocument();
expect(screen.queryByText(/manage users/i)).not.toBeInTheDocument();
@@ -325,15 +384,22 @@ describe("SiteTopNav - component", () => {
/>
);
- await user.click(screen.getByTestId("user-menu"));
+ await user.click(screen.getByTestId("user-avatar"));
expect(screen.getByText(/hosts/i)).toBeInTheDocument();
expect(screen.getByText(/software/i)).toBeInTheDocument();
expect(screen.getByText(/queries/i)).toBeInTheDocument();
expect(screen.getByText(/policies/i)).toBeInTheDocument();
- expect(screen.getByText(/my account/i)).toBeInTheDocument();
- expect(screen.getByText(/documentation/i)).toBeInTheDocument();
- expect(screen.getByText(/sign out/i)).toBeInTheDocument();
+
+ expect(
+ screen.getByRole("menuitem", { name: /my account/i })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("menuitem", { name: /documentation/i })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("menuitem", { name: /sign out/i })
+ ).toBeInTheDocument();
expect(screen.queryByText(/controls/i)).not.toBeInTheDocument();
expect(screen.queryByText(/settings/i)).not.toBeInTheDocument();
diff --git a/frontend/components/top_nav/SiteTopNav/SiteTopNav.tsx b/frontend/components/top_nav/SiteTopNav/SiteTopNav.tsx
index 2f0f0d3b1f..c1fc399914 100644
--- a/frontend/components/top_nav/SiteTopNav/SiteTopNav.tsx
+++ b/frontend/components/top_nav/SiteTopNav/SiteTopNav.tsx
@@ -9,10 +9,10 @@ import { IUser } from "interfaces/user";
import { QueryParams } from "utilities/url";
import LinkWithContext from "components/LinkWithContext";
-import UserMenu from "components/top_nav/UserMenu";
// @ts-ignore
import OrgLogoIcon from "components/icons/OrgLogoIcon";
+import UserMenu from "../UserMenu";
import getNavItems, { INavItem } from "./navItems";
interface ISiteTopNavProps {
diff --git a/frontend/components/top_nav/UserMenu/UserMenu.tsx b/frontend/components/top_nav/UserMenu/UserMenu.tsx
index b1228abc25..4a07f1b482 100644
--- a/frontend/components/top_nav/UserMenu/UserMenu.tsx
+++ b/frontend/components/top_nav/UserMenu/UserMenu.tsx
@@ -1,12 +1,22 @@
-import React from "react";
+import React, { useEffect, useState } from "react";
+import { keyframes } from "@emotion/react";
+import Select, {
+ StylesConfig,
+ DropdownIndicatorProps,
+ OptionProps,
+ components,
+ GroupBase,
+} from "react-select-5";
import { IUser } from "interfaces/user";
import { ITeam } from "interfaces/team";
+import { IDropdownOption } from "interfaces/dropdownOption";
+import PATHS from "router/paths";
import { getSortedTeamOptions } from "utilities/helpers";
-import PATHS from "router/paths";
+import { PADDING } from "styles/var/padding";
+import { COLORS } from "styles/var/colors";
-// @ts-ignore
-import DropdownButton from "components/buttons/DropdownButton";
+import Icon from "components/Icon";
import AvatarTopNav from "../../AvatarTopNav";
const baseClass = "user-menu";
@@ -19,6 +29,61 @@ interface IUserMenuProps {
currentUser: IUser;
}
+const bounceDownAnimation = keyframes`
+ 0%, 100% {
+ transform: translateY(0);
+ }
+ 50% {
+ transform: translateY(3px);
+ }
+`;
+
+const getOptionBackgroundColor = (state: any) => {
+ return state.isFocused ? COLORS["ui-vibrant-blue-10"] : "transparent";
+};
+
+const CustomDropdownIndicator = (
+ props: DropdownIndicatorProps<
+ IDropdownOption,
+ false,
+ GroupBase
+ >
+) => {
+ return (
+
+
+
+ );
+};
+
+const CustomOption: React.FC<
+ OptionProps & { isKeyboardFocus: boolean }
+> = (props) => {
+ const { innerRef, data, isFocused, isKeyboardFocus } = props;
+
+ return (
+
+
+ {data.label}
+
+
+ );
+};
+
const UserMenu = ({
onLogout,
onUserMenuItemClick,
@@ -26,28 +91,55 @@ const UserMenu = ({
isGlobalAdmin,
currentUser,
}: IUserMenuProps): JSX.Element => {
- const accountNavigate = onUserMenuItemClick(PATHS.ACCOUNT);
+ // Work around for react-select-5 not having :focus-visible pseudo class that can style dropdown on keyboard tab only
+ // Work around preventing react-select-5 from auto focusing first option unless using keyboard
+ const [isKeyboardFocus, setIsKeyboardFocus] = useState(false);
+
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Tab") {
+ setIsKeyboardFocus(true);
+ }
+ };
+
+ const handleMouseDown = () => {
+ setIsKeyboardFocus(false);
+ };
+
+ document.addEventListener("keydown", handleKeyDown);
+ document.addEventListener("mousedown", handleMouseDown);
+
+ return () => {
+ document.removeEventListener("keydown", handleKeyDown);
+ document.removeEventListener("mousedown", handleMouseDown);
+ };
+ }, []);
+
const dropdownItems = [
{
label: "My account",
- onClick: accountNavigate,
+ value: "my-account",
+ onClick: () => onUserMenuItemClick(PATHS.ACCOUNT),
},
{
label: "Documentation",
- onClick: () => window.open("https://fleetdm.com/docs", "_blank"),
+ value: "documentation",
+ onClick: () => {
+ window.open("https://fleetdm.com/docs", "_blank");
+ },
},
{
label: "Sign out",
+ value: "sign-out",
onClick: onLogout,
},
];
if (isGlobalAdmin) {
- const manageUsersNavigate = onUserMenuItemClick(PATHS.ADMIN_USERS);
-
const manageUserNavItem = {
label: "Manage users",
- onClick: manageUsersNavigate,
+ value: "manage-users",
+ onClick: () => onUserMenuItemClick(PATHS.ADMIN_USERS),
};
dropdownItems.unshift(manageUserNavItem);
}
@@ -61,24 +153,129 @@ const UserMenu = ({
currentUser.global_role === "admin"
? PATHS.ADMIN_ORGANIZATION
: `${PATHS.TEAM_DETAILS_USERS(sortedTeams[0].value)}`;
- const settingsNavigate = onUserMenuItemClick(settingsPath);
const adminNavItem = {
label: "Settings",
- onClick: settingsNavigate,
+ value: "settings",
+ onClick: () => onUserMenuItemClick(settingsPath),
};
dropdownItems.unshift(adminNavItem);
}
+ const customStyles: StylesConfig = {
+ control: (provided, state) => ({
+ ...provided,
+ display: "flex",
+ flexDirection: "row",
+ width: "max-content",
+ padding: "8px",
+ marginRight: "8px",
+ backgroundColor: "initial",
+ border: "2px solid transparent", // So tabbing doesn't shift dropdown
+ borderRadius: "6px",
+ boxShadow: "none",
+ cursor: "pointer",
+ "&:hover": {
+ boxShadow: "none",
+ ".user-menu-select__indicator svg": {
+ animation: `${bounceDownAnimation} 0.3s ease-in-out`,
+ },
+ },
+ ...(state.isFocused &&
+ isKeyboardFocus && {
+ border: `2px solid ${COLORS["ui-blue-25"]}`,
+ }),
+ ...(state.menuIsOpen && {
+ ".user-menu-select__indicator svg": {
+ transform: "rotate(180deg)",
+ },
+ }),
+ }),
+ dropdownIndicator: (provided) => ({
+ ...provided,
+ display: "flex",
+ padding: "6px",
+ svg: {
+ transition: "transform 0.25s ease",
+ },
+ }),
+ menu: (provided) => ({
+ ...provided,
+ boxShadow: "0 2px 6px rgba(0, 0, 0, 0.1)",
+ borderRadius: "4px",
+ zIndex: 6,
+ marginTop: "7px",
+ marginRight: "8px",
+ width: "auto",
+ minWidth: "100%",
+ position: "absolute",
+ left: "auto",
+ right: "0",
+ animation: "fade-in 150ms ease-out",
+ }),
+ menuList: (provided) => ({
+ ...provided,
+ padding: PADDING["pad-small"],
+ maxHeight: "initial", // Override react-select default height of 300px
+ }),
+ valueContainer: (provided) => ({
+ ...provided,
+ padding: 0,
+ }),
+ option: (provided, state) => ({
+ ...provided,
+ padding: "10px 8px",
+ fontSize: "15px",
+ backgroundColor: getOptionBackgroundColor(state),
+ color: COLORS["tooltip-bg"], // TODO: Why the mismatch in names in colors.scss and colors.ts
+ whiteSpace: "nowrap",
+ "&:hover": {
+ backgroundColor: COLORS["ui-vibrant-blue-10"],
+ },
+ "&:active": {
+ backgroundColor: COLORS["ui-vibrant-blue-10"],
+ },
+ "&:last-child, &:nth-last-of-type(2)": {
+ borderTop: `1px solid ${COLORS["ui-fleet-black-10"]}`,
+ },
+ }),
+ };
+
+ const renderPlaceholder = () => {
+ return (
+
+ );
+ };
+
return (
-
-
-
+
+ options={dropdownItems}
+ placeholder={renderPlaceholder()}
+ styles={customStyles}
+ components={{
+ DropdownIndicator: CustomDropdownIndicator,
+ IndicatorSeparator: () => null,
+ Option: (props) => (
+
+ ),
+ SingleValue: () => null,
+ }}
+ controlShouldRenderValue={false}
+ isOptionSelected={() => false}
+ className={baseClass}
+ classNamePrefix={`${baseClass}-select`}
+ menuPlacement="bottom"
+ onChange={(option) => {
+ option?.onClick && option?.onClick();
+ }}
+ isSearchable={false}
+ />
);
};
+
export default UserMenu;
diff --git a/frontend/components/top_nav/UserMenu/_styles.scss b/frontend/components/top_nav/UserMenu/_styles.scss
deleted file mode 100644
index 0ba5416997..0000000000
--- a/frontend/components/top_nav/UserMenu/_styles.scss
+++ /dev/null
@@ -1,60 +0,0 @@
-.user-menu {
- button {
- background-color: transparent;
- margin-right: 24px;
- transition: transform 0.3s ease;
-
- &.focus-visible {
- border: 1px solid $ui-vibrant-blue-10;
- border-right: 0;
- }
- &:focus:has(+ .dropdown-button__options--opened) {
- .icon {
- svg {
- transform: rotate(180deg);
- transition: transform 0.25s ease;
- }
- }
- }
-
- svg {
- transition: transform 0.25s ease;
- }
-
- &:hover svg {
- animation: bounceDown 0.3s ease-in-out;
- }
-
- @keyframes bounceDown {
- 0%,
- 100% {
- transform: translateY(0);
- }
- 50% {
- transform: translateY(3px);
- }
- }
- }
-
- .dropdown-button__options {
- padding: $pad-small;
- right: 10px;
- margin-top: 2px;
-
- button {
- display: inline-block;
- min-width: 125px;
-
- &:hover,
- &:focus {
- background-color: $ui-vibrant-blue-10;
- color: $core-fleet-black;
- }
- }
-
- .dropdown-button__option:last-child,
- .dropdown-button__option:nth-last-child(2) {
- border-top: 1px solid $ui-fleet-black-10;
- }
- }
-}
diff --git a/frontend/interfaces/dropdownOption.ts b/frontend/interfaces/dropdownOption.ts
index c2e7a73e54..455e0ccd6b 100644
--- a/frontend/interfaces/dropdownOption.ts
+++ b/frontend/interfaces/dropdownOption.ts
@@ -16,4 +16,5 @@ export interface IDropdownOption {
helpText?: ReactNode;
premiumOnly?: boolean;
tooltipContent?: TooltipContent;
+ onClick?: (() => void) | void | null;
}
diff --git a/frontend/layouts/CoreLayout/CoreLayout.tsx b/frontend/layouts/CoreLayout/CoreLayout.tsx
index 1074a875de..a6c9d56343 100644
--- a/frontend/layouts/CoreLayout/CoreLayout.tsx
+++ b/frontend/layouts/CoreLayout/CoreLayout.tsx
@@ -50,17 +50,7 @@ const CoreLayout = ({ children, router, location }: ICoreLayoutProps) => {
};
const onUserMenuItemClick = (path: string) => {
- return (evt: React.MouseEvent) => {
- evt.preventDefault();
-
- if (path.indexOf("http") !== -1) {
- global.window.open(path, "_blank");
- return false;
- }
-
- router.push(path);
- return false;
- };
+ router.push(path);
};
const fullWidthFlash = !currentUser;
diff --git a/frontend/pages/ManageControlsPage/ManageControlsPage.tsx b/frontend/pages/ManageControlsPage/ManageControlsPage.tsx
index 5304c1aa4e..c278cd5a3e 100644
--- a/frontend/pages/ManageControlsPage/ManageControlsPage.tsx
+++ b/frontend/pages/ManageControlsPage/ManageControlsPage.tsx
@@ -35,6 +35,8 @@ const controlsSubNav: IControlsSubNavItem[] = [
},
];
+const subNavQueryParams = ["page", "order_key", "order_direction"] as const;
+
interface IManageControlsPageProps {
children: JSX.Element;
location: {
@@ -93,8 +95,15 @@ const ManageControlsPage = ({
const navigateToNav = useCallback(
(i: number): void => {
const navPath = controlsSubNav[i].pathname;
+ // remove query params related to the prior tab
+ const newParams = new URLSearchParams(location?.search);
+ subNavQueryParams.forEach((p) => newParams.delete(p));
+ const newQuery = newParams.toString();
+
router.replace(
- navPath.concat(location?.search || "").concat(location?.hash || "")
+ navPath
+ .concat(newQuery ? `?${newQuery}` : "")
+ .concat(location?.hash || "")
);
},
[location, router]
diff --git a/frontend/pages/policies/constants.ts b/frontend/pages/policies/constants.ts
index 120926c481..99c99fa521 100644
--- a/frontend/pages/policies/constants.ts
+++ b/frontend/pages/policies/constants.ts
@@ -396,7 +396,7 @@ export const DEFAULT_POLICIES: IPolicyNew[] = [
{
key: 31,
query:
- "SELECT 1 FROM registry WHERE path LIKE 'HKEY_LOCAL_MACHINESoftwarePoliciesMicrosoftWindowsFirewallDomainProfileEnableFirewall' AND CAST(data as integer) = 1;",
+ "SELECT 1 FROM registry WHERE path LIKE 'HKEY_LOCAL_MACHINE\\SYSTEM\\CurrentControlSet\\Services\\SharedAccess\\Parameters\\FirewallPolicy\\DomainProfile\\EnableFirewall' AND CAST(data as integer) = 1;",
name: "Windows Firewall, domain profile enabled (Windows)",
description:
"If the Windows Firewall is not enabled for the domain profile, the workstation may be more vulnerable to unauthorized network access and potential security breaches.",
@@ -408,7 +408,7 @@ export const DEFAULT_POLICIES: IPolicyNew[] = [
{
key: 32,
query:
- "SELECT 1 FROM registry WHERE path LIKE 'HKEY_LOCAL_MACHINESoftwarePoliciesMicrosoftWindowsFirewallPrivateProfileEnableFirewall' AND CAST(data as integer) = 1;",
+ "SELECT 1 FROM registry WHERE path LIKE 'HKEY_LOCAL_MACHINE\\SYSTEM\\CurrentControlSet\\Services\\SharedAccess\\Parameters\\FirewallPolicy\\StandardProfile\\EnableFirewall' AND CAST(data as integer) = 1;",
name: "Windows Firewall, private profile enabled (Windows)",
description:
"If the Windows Firewall is not enabled for the private profile, the workstation may be more susceptible to unauthorized access and potential security breaches, particularly when connected to private networks.",
@@ -420,7 +420,7 @@ export const DEFAULT_POLICIES: IPolicyNew[] = [
{
key: 33,
query:
- "SELECT 1 FROM registry WHERE path LIKE 'HKEY_LOCAL_MACHINESoftwarePoliciesMicrosoftWindowsFirewallPublicProfileEnableFirewall' AND CAST(data as integer) = 1;",
+ "SELECT 1 FROM registry WHERE path LIKE 'HKEY_LOCAL_MACHINE\\SYSTEM\\CurrentControlSet\\Services\\SharedAccess\\Parameters\\FirewallPolicy\\PublicProfile\\EnableFirewall' AND CAST(data as integer) = 1;",
name: "Windows Firewall, public profile enabled (Windows)",
description:
"If the Windows Firewall is not enabled for the public profile, the workstation may be more vulnerable to unauthorized access and potential security threats, especially when connected to public networks.",
@@ -454,7 +454,7 @@ export const DEFAULT_POLICIES: IPolicyNew[] = [
{
key: 36,
query:
- "SELECT 1 FROM registry WHERE path LIKE 'HKEY_LOCAL_MACHINESOFTWAREPoliciesMicrosoftWindows NTDNSClientEnableMulticast' AND CAST(data as integer) = 0;",
+ "SELECT 1 FROM registry WHERE path LIKE 'HKEY_LOCAL_MACHINE\\Software\\Policies\\Microsoft\\Windows NT\\DNSClient\\EnableMulticast' AND CAST(data as integer) = 0;",
name: "LLMNR disabled (Windows)",
description:
"If the workstation does not have LLMNR disabled, it could be vulnerable to DNS spoofing attacks, potentially leading to unauthorized access or data interception.",
@@ -466,7 +466,7 @@ export const DEFAULT_POLICIES: IPolicyNew[] = [
{
key: 37,
query:
- "SELECT 1 FROM registry WHERE path LIKE 'HKEY_LOCAL_MACHINESoftwarePoliciesMicrosoftWindowsWindowsUpdateAUNoAutoUpdate' AND CAST(data as integer) = 0;",
+ "SELECT 1 FROM registry WHERE path LIKE 'HKEY_LOCAL_MACHINE\\Software\\Policies\\Microsoft\\Windows\\Windows\\Update\\AU\\NoAutoUpdate' AND CAST(data as integer) = 0;",
name: "Automatic updates enabled (Windows)",
description:
"Enabling automatic updates ensures the computer downloads and installs security and other important updates automatically.",
diff --git a/frontend/styles/var/colors.ts b/frontend/styles/var/colors.ts
index 9b3928c417..3cc7a2aa4e 100644
--- a/frontend/styles/var/colors.ts
+++ b/frontend/styles/var/colors.ts
@@ -3,7 +3,7 @@ export type Colors = keyof typeof COLORS;
export const COLORS = {
// core colors
"core-fleet-black": "#192147",
- "core-fleet-blue": "#6A67FE",
+ "core-fleet-blue": "#6A67FE", // TODO: Why does this match ui-vibrant-blue and not core-fleet-blue
"core-fleet-red": "#FF5C83",
"core-fleet-purple": "#AE6DDF",
"core-fleet-white": "#FFFFFF",
diff --git a/handbook/company/communications.md b/handbook/company/communications.md
index ba5b949816..15447192ae 100644
--- a/handbook/company/communications.md
+++ b/handbook/company/communications.md
@@ -1694,7 +1694,6 @@ This glossary provides definitions to commonly used terms within our space.
| **Internet Engineering Task Force (IETF)** | See: IETF |
| **IR** | (Incident Response) The actions one takes in response to a security breach or cyberattack. |
| **Linux** | An open source operating system. |
-| **Logica** | An IT and management consultancy company based in the United Kingdom. |
| **macOS** | The operating system used in all of Apple's Mac computers. |
| **Munki** | Open-source software deployment tool for macOS. |
| **open core** | Is the business model where a company has a core version of a product with some of the features as (FOSS) Free Open Source Software in addition to a paid commercial version that is proprietary software. |
diff --git a/handbook/digital-experience/README.md b/handbook/digital-experience/README.md
index 2d8cb160dd..49d0347287 100644
--- a/handbook/digital-experience/README.md
+++ b/handbook/digital-experience/README.md
@@ -220,6 +220,11 @@ Use the following steps to cancel a Fleet Premium subscription:
3. Reach out to the community member (using the [correct email template](https://docs.google.com/document/d/1D02k0tc5v-sEJ4uahAouuqnvZ6phxA_gP-IqmkBdMTE/edit#heading=h.vw9mkh5e9msx)) and let them know their subscription was canceled.
+### Register a domain for Fleet
+
+Domain name registrations are handled through Namecheap. Access is managed via 1Password.
+
+
### Secure company-issued equipment for a team member
As soon as an offer is accepted, Fleet provides laptops and YubiKey security keys for core team members to use while working at Fleet. The IT engineer will work with the new team member to get their equipment requested and shipped to them on time.
@@ -266,6 +271,45 @@ Once the Digital Experience department approves inventory to be shipped from Fle
7. Add a comment to the equipment request issue, at-mentioning the requestor with the FedEx tracking info and close the issue.
+### Fix a laptop that's not checking in
+
+It is [possible for end users to remove launch agents](https://github.com/fleetdm/confidential/issues/6088) (this is true not just for osquery, but for anything).
+
+If the host has MDM turned on, use the `fleetctl mdm run-command` CLI command to push the XML file located at https://github.com/fleetdm/fleet/blob/main/it-and-security/lib/macos/commands/macos-send-fleetd.xml to the device, which will reinstall fleetd.
+
+If the host doesn't have MDM turned on or isn't enrolled to dogfood, it is beyond our ability to control remotely.
+
+
+### Enroll a macOS host in dogfood
+
+When a device is purchased using the Apple eCommerce store, the device is automatically enrolled in Apple Business Manager (ABM) and assigned to the correct server to ensure the device is in dogfood.
+You can confirm that the device has been ordered correctly by following these steps:
+- Log into ABM
+- Use the device serial number to find the device.
+ - Note: if the device cannot be found, you will need to manually enroll the device.
+- View device settings and ensure the "MDM Server" selected is "Fleet Dogfood".
+
+On occasion there will be a need to manually enroll a macOS host in dogfood. This could be due to a BYOD arrangement, or because the Fleetie getting the device is in a country when DEP (automatic enrollment) isn't supported. To manually enroll a macOS host in dogfood, follow these steps:
+- If you have physical access to the macOS host, use Apple Configurator (docs are [here](https://support.apple.com/guide/apple-business-manager/add-devices-from-apple-configurator-axm200a54d59/web)).
+- If you do not have physical access to the device, the user will need to undertake the following steps:
+ - Install the fleetd package for your device from shared drive folder [here](https://drive.google.com/drive/folders/1-hMwk4P7NRzCU5kDxkEcOo8Sluuaux1h?usp=drive_link).
+ - Once fleetd is installed, click on Fleet desktop icon in top right menu bar, and select "My device".
+ - In Fleet desktop, follow the instructions to turn on MDM.
+ - Once complete, follow instructions to reset disk encryption key.
+- Disk encryption key will now be stored in Fleet dogfood, which signifies that the device is now enrolled in dogfood.
+
+
+### Enroll a Windows or Ubuntu Linux device in dogfood
+
+To enroll a windows or Ubuntu Linux device in dogfood, instruct the user to install fleetd for their platform from internal shared drive folder [here](https://drive.google.com/drive/folders/1-hMwk4P7NRzCU5kDxkEcOo8Sluuaux1h?usp=drive_link).
+Once the user has installed fleetd, verify the device is correctly enrolled by confirming the device encryption key is in dogfood.
+
+
+### Enroll a ChromeOS device in dogfood
+
+ChromeOS devices are automatically enrolled in dogfood after the IT admin sets up automatic enrollment. This is done in dogfood by following the steps found in the dialog popup when selecting "Add hosts > ChromeOS" from the dogfood Hosts page.
+
+
### Update personnel details
When a Fleetie, consultant or advisor requests an update to their personnel details (name, location, phone, etc), follow these steps to ensure accurate representation across systems.
diff --git a/handbook/engineering/README.md b/handbook/engineering/README.md
index 9bcca30ea7..dbf3bfb1ee 100644
--- a/handbook/engineering/README.md
+++ b/handbook/engineering/README.md
@@ -31,7 +31,7 @@ The 🚀 Engineering department at Fleet is directly responsible for writing and
We write [guides](https://fleetdm.com/guides) for all new features. Feature guides are published before the feature is released so that our users understand how the feature is intended to work. A guide is a type of article, so the process for writing a guide and article is the same.
1. Review and follow the [Fleet writing style guide](https://fleetdm.com/handbook/company/communications#writing).
-2. Make a copy of a guide in the `/articles` directory and replace the content with your article. Make sure to maintain the same heading sizes and update the metadata tags at the bottom.
+2. Make a copy of a guide in the [/articles](https://github.com/fleetdm/fleet/tree/main/articles) directory and replace the content with your article. Make sure to maintain the same heading sizes and update the metadata tags at the bottom.
3. Open a new pull request containing your article into `main` and add the pull request to the milestone this feature will be shipped in. The pull request will automatically be assigned to the appropriate reviewer.
@@ -43,9 +43,9 @@ It is important to frame engineering-initiated user stories the same way we fram
To [create an engineering-initiated user story](https://fleetdm.com/handbook/engineering#creating-an-engineering-initiated-story), follow the [user story drafting process](https://fleetdm.com/handbook/company/development-groups#drafting). Once your user story is created using the [new story template](https://github.com/fleetdm/fleet/issues/new?assignees=lukeheath&labels=story,~engineering-initiated&projects=&template=story.md&title=), make sure the `~engineering-initiated` label is added, the `:product` label is removed, and the engineering output and architecture DRI (@lukeheath) is assigned.
-What happens next? The engineering output and architecture DRI reviews engineering-initiated stories weekly.
+What happens next? The engineering output and architecture DRI reviews and triages engineering-initiated stories weekly on the [engineering board](https://app.zenhub.com/workspaces/engineering-672a4556609a0d000f391584/board).
-If there are product changes (i.e. interface, documentation, or dependency changes), the story is added to the "New requests" column on the drafting board.
+If there are product changes (i.e. interface, documentation, or dependency changes), leave the default labels in place so that it is triaged on the product [drafting board](https://app.zenhub.com/workspaces/drafting-6192dd66ea2562000faea25c/board).
If there are no product changes, and the DRI decides to prioritize the story, the story is added to the "Specified" column on the drafting board so that it can be estimated.
@@ -54,10 +54,7 @@ If there are no product changes, and the DRI decides to prioritize the story, th
### Fix a bug
-All bug fix pull requests should have a mention back to the issue they resolve with `#` in the description or even in a comment. Please do not use any [automated words](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword) since we don't want the tickets auto-closing when PR's are merged.
-If the bug is labeled `~unreleased bug`, branch off and put your PR into `main`. These issues can be closed as soon as they complete QA.
-
-If the bug is labeled `~released bug`, branch off and put your PR into `main`. After merging checkout the latest tag, for example `git checkout fleet-v4.48.2`, then `git fetch; git cherry-pick `. If the cherry-pick fails with a conflict call out in the ticket how to resolve or if it is sufficiently complicated call out this fix is not suited for the patch release process and should only be included in the end of sprint release. This approach makes sure the bug fix is not built on top of unreleased feature code, which can cause merge conflicts during patch releases.
+All bug fix pull requests should reference the issue they resolve with the issue number in the description. Please do not use any [automated words](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword) since we don't want the issues to auto-close when the PR is merged.
### Create a release candidate
@@ -76,14 +73,20 @@ During the release candidate period, the release candidate is deployed to our QA
Open the [confidential repo environment variables](https://github.com/fleetdm/confidential/settings/variables/actions) page and update the `QAWOLF_DEPLOY_TAG` repository variable with the name of the release candidate branch.
-### Merge bug fixes into the release candidate
+### Merge unreleased bug fixes into the release candidate
-Only merge bug fixes during the release candidate period to minimize code churn and help ensure a stable release. To merge a bug fix into the release candidate, it should first be merged into `main`. Then, `git checkout` the release candidate branch and create a new local branch. Next, `git cherry-pick` your commit from `main` into your new local branch, then create a pull request from your new branch to the release candidate. This process ensures your bug fix is included in `main` for future releases, as well as the release candidate branch for the pending release.
+Only merge unreleased bug fixes during the release candidate period to minimize code churn and help ensure a stable release. To merge a bug fix into the release candidate:
-> To allow a stable release test, the final 24 hours before release is a deep freeze when only bugs with the `~release-blocker` or `~unreleased-bug` labels are merged.
+1. Merge the fix into `main`.
+2. `git checkout` the release candidate branch and create a new local branch.
+3. `git cherry-pick` your commit from `main` into your new local branch.
+4. Create a pull request from your new branch to the release candidate.
+
+This process ensures your bug fix is included in `main` for future releases, as well as the release candidate branch for the pending release.
If there is partially merged feature work when the release candidate is created, the previously merged code must be reverted. If there is an exceptional, business-critical need to merge feature work into the release candidate, as determined by the [release ritual DRI](#rituals), the release candidate [feature merge exception process](https://fleetdm.com/handbook/engineering#request-release-candidate-feature-merge-exception) may be followed.
+
### Request release candidate feature merge exception
1. Notify product group EM that feature work will not merge into `main` before the release candidate is cut and requires a feature merge exception.
@@ -105,6 +108,7 @@ Before kicking off release QA, confirm that we are using the latest versions of
> In Go versioning, the number after the first dot is the "major" version, while the number after the second dot is the "minor" version. For example, in Go 1.19.9, "19" is the major version and "9" is the minor version. Major version upgrades are assessed separately by engineering.
+
2. **macadmins-extension**: Latest release
- Check the [latest version of the macadmins-extension](https://github.com/macadmins/osquery-extension/releases).
- Check the [version included in Fleet](https://github.com/fleetdm/fleet/blob/main/go.mod#L60).
@@ -142,7 +146,12 @@ Once a product group completes its QA process during the release candidate perio
### Prepare Fleet release
-Documentation on completing the release process can be found [here](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Releasing-Fleet.md).
+Documentation on completing the Fleet release process can be found [here](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Releasing-Fleet.md).
+
+
+### Prepare fleetd agent release
+
+Documentation on completing the fleetd agent release process can be found [here](https://github.com/fleetdm/fleet/tree/main/tools/tuf/).
### Deploy a new release to dogfood
@@ -196,61 +205,18 @@ The [Fleet releases Google calendar](https://calendar.google.com/calendar/embed?
### Handle process exceptions for non-released code
-Some of our code does not go through a scheduled release process, but is released immediately via GitHub workflows.
-This includes:
-- Our [fleetdm/nvd](https://github.com/fleetdm/nvd) repository
-- Our [fleetdm/vulnerabilities](https://github.com/fleetdm/vulnerabilities) repository
-- Our [website](https://github.com/fleetdm/fleet/tree/main/website) directory
+Some of our code does not go through a scheduled release process, but is released immediately via GitHub workflows. This includes:
+
+- The [fleetdm/nvd](https://github.com/fleetdm/nvd) repository.
+- The [fleetdm/vulnerabilities](https://github.com/fleetdm/vulnerabilities) repository.
+- Our [website](https://github.com/fleetdm/fleet/tree/main/website) directory.
In these cases there are two differences in our process:
+
- QA is done before merging the code change to the main branch.
- Tickets are not moved to "Ready for release". Bug are closed, and user stories are moved to the product drafting board's "Confirm and celebrate" column.
-### Register a domain for Fleet
-
-Domain name registrations are handled through Namecheap. Access is managed via 1Password.
-
-
-### Fix a laptop that's not checking in
-
-It is [possible for end users to remove launch agents](https://github.com/fleetdm/confidential/issues/6088) (this is true not just for osquery, but for anything).
-
-If the host has MDM turned on, use the `fleetctl mdm run-command` CLI command to push the XML file located at https://github.com/fleetdm/fleet/blob/main/it-and-security/lib/macos/commands/macos-send-fleetd.xml to the device, which will reinstall fleetd.
-
-If the host doesn't have MDM turned on or isn't enrolled to dogfood, it is beyond our ability to control remotely.
-
-
-### Enroll a macOS host in dogfood
-
-When a device is purchased using the Apple eCommerce store, the device is automatically enrolled in Apple Business Manager (ABM) and assigned to the correct server to ensure the device is in dogfood.
-You can confirm that the device has been ordered correctly by following these steps:
-- Log into ABM
-- Use the device serial number to find the device.
- - Note: if the device cannot be found, you will need to manually enroll the device.
-- View device settings and ensure the "MDM Server" selected is "Fleet Dogfood".
-
-On occasion there will be a need to manually enroll a macOS host in dogfood. This could be due to a BYOD arrangement, or because the Fleetie getting the device is in a country when DEP (automatic enrollment) isn't supported. To manually enroll a macOS host in dogfood, follow these steps:
-- If you have physical access to the macOS host, use Apple Configurator (docs are [here](https://support.apple.com/guide/apple-business-manager/add-devices-from-apple-configurator-axm200a54d59/web)).
-- If you do not have physical access to the device, the user will need to undertake the following steps:
- - Install the fleetd package for your device from shared drive folder [here](https://drive.google.com/drive/folders/1-hMwk4P7NRzCU5kDxkEcOo8Sluuaux1h?usp=drive_link).
- - Once fleetd is installed, click on Fleet desktop icon in top right menu bar, and select "My device".
- - In Fleet desktop, follow the instructions to turn on MDM.
- - Once complete, follow instructions to reset disk encryption key.
-- Disk encryption key will now be stored in Fleet dogfood, which signifies that the device is now enrolled in dogfood.
-
-
-### Enroll a Windows or Ubuntu Linux device in dogfood
-
-To enroll a windows or Ubuntu Linux device in dogfood, instruct the user to install fleetd for their platform from internal shared drive folder [here](https://drive.google.com/drive/folders/1-hMwk4P7NRzCU5kDxkEcOo8Sluuaux1h?usp=drive_link).
-Once the user has installed fleetd, verify the device is correctly enrolled by confirming the device encryption key is in dogfood.
-
-
-### Enroll a ChromeOS device in dogfood
-
-ChromeOS devices are automatically enrolled in dogfood after the IT admin sets up automatic enrollment. This is done in dogfood by following the steps found in the dialog popup when selecting "Add hosts > ChromeOS" from the dogfood Hosts page.
-
-
### Review a community pull request
If you're assigned a community pull request for review, it is important to keep things moving for the contributor. The goal is to not go more than one business day without following up with the contributor.
@@ -305,20 +271,15 @@ On-call engineers are available during the business hours of 9am - 5pm Pacific.
### Assume developer on-call alias
-The on-call developer is responsible for:
+The on-call developer is responsible for:
+
- Knowing [the on-call rotation](https://fleetdm.com/handbook/company/product-groups#the-developer-on-call-rotation).
- Performing the [on-call responsibilities](https://fleetdm.com/handbook/company/product-groups#developer-on-call-responsibilities).
- [Escalating community questions and issues](https://fleetdm.com/handbook/company/product-groups#escalations).
- Successfully [transferring the on-call persona to the next developer](https://fleetdm.com/handbook/company/product-groups#changing-of-the-guard).
-- Work on an [engineering-initiated story](https://fleetdm.com/handbook/engineering#create-an-engineering-initiated-story).
+- Working on an [engineering-initiated story](https://fleetdm.com/handbook/engineering#create-an-engineering-initiated-story).
-Some additional ideas:
-
-- Do training/learning relevant to your work.
-- Improve the Fleet contributor experience.
-- Hack on a product idea. Note: Experiments are encouraged, but not all experiments will ship! Check in with the product team before shipping user-visible changes.
-- Create a blog post (or other content) for fleetdm.com.
-- Try out an experimental refactor.
+To provide full-time focus to the role, the on-call engineer is not expected to work on sprint issues during their on-call assignment.
### Notify stakeholders when a user story is pushed to the next release
@@ -373,7 +334,7 @@ Conduct a postmortem meetings for every service or feature outage and every crit
### Provide same-day support for major version macOS releases
-Beginning with macOS 16, Fleet will offer same-day support for all major version macOS releases.
+Beginning with macOS 16, Fleet offers same-day support for all major version macOS releases.
1. Install major version macOS beta release on test devices.
2. Create a new [QA release issue](https://github.com/fleetdm/fleet/issues/new?assignees=xpkoala%2Cpezhub&labels=%23g-mdm%2C%23g-endpoint-ops%2C%3Arelease&projects=&template=release-qa.md&title=Release+QA%3A+macOS+16) with the new major version in the issue title.
@@ -383,6 +344,24 @@ Beginning with macOS 16, Fleet will offer same-day support for all major version
6. When all bugs are fixed, follow the [writing a feature guide](https://fleetdm.com/handbook/engineering#write-a-feature-guide) process to publish an article announcing Fleet same-day support for the new major release.
+### Fix flaky Go tests
+
+Sometimes automated tests fail intermittently, causing PRs to become blocked and engineers to become sad and vengeful. Debugging a "flaky" or "rando" test failure typically involves:
+
+- Adding extra logs to the test and/or related code to get more information about the failure.
+- Running the test multiple times to reproduce the failure.
+- Implementing an attempted fix to the test (or the related code, if there's an actual bug).
+- Running the test multiple times to try and verify that the test no longer fails.
+
+To aid in this process, we have the Stress Test Go Test action (aka the RandoKiller™). This is a Github Actions workflow that can be used to run one or more Go tests repeatedly until they fail (or until they pass a certain number of times). To use the RandoKiller:
+
+- Create a branch whose name ends with `-randokiller` (for example `sgress454/enqueue-mdm-command-randokiller`).
+- Modify the [.github/workflows/config/randokiller.json](https://github.com/fleetdm/fleet/blob/main/.github/workflows/config/randokiller.json) file to your specifications (choosing the packages and tests to run, the mysql matrix, and the number of runs to do).
+- Push up the branch with whatever logs/changes you need to help diagnose or fix the flaky test.
+- Monitor the [Stress Test Go Test](https://github.com/fleetdm/fleet/actions/workflows/randokiller-go.yml) workflow for your branch.
+- Repeat until the stress test passes! Every push to your branch will trigger a new run of the workflow.
+
+
### Record engineering KPIs
We track the effectiveness of our processes by observing issue throughput and identifying where buildups (and therefore bottlenecks) are occurring.
@@ -450,23 +429,6 @@ Steps to renew the certificate:
Instructions for creating and maintaining a TUF repo are available on our [TUF handbook page](https://fleetdm.com/handbook/engineering/tuf).
-### Fix flaky Go tests
-
-Sometimes automated tests fail intermittently, causing PRs to become blocked and engineers to become sad and vengeful. Debugging a "flaky" or "rando" test failure typically involves:
-
-* Adding extra logs to the test and/or related code to get more information about the failure.
-* Running the test multiple times to reproduce the failure.
-* Implementing an attempted fix to the test (or the related code, if there's an actual bug).
-* Running the test multiple times to try and verify that the test no longer fails.
-
-To aid in this process, we have the Stress Test Go Test action (aka the RandoKiller™). This is a Github Actions workflow that can be used to run one or more Go tests repeatedly until they fail (or until they pass a certain number of times). To use the RandoKiller:
-
-* Create a branch whose name ends with `-randokiller` (for example `sgress454/enqueue-mdm-command-randokiller`).
-* Modify the [.github/workflows/config/randokiller.json](https://github.com/fleetdm/fleet/blob/main/.github/workflows/config/randokiller.json) file to your specifications (choosing the packages and tests to run, the mysql matrix, and the number of runs to do).
-* Push up the branch with whatever logs/changes you need to help diagnose or fix the flaky test.
-* Monitor the [Stress Test Go Test](https://github.com/fleetdm/fleet/actions/workflows/randokiller-go.yml) workflow for your branch.
-* Repeat until the stress test passes! Every push to your branch will trigger a new run of the workflow.
-
## Rituals
diff --git a/it-and-security/default.yml b/it-and-security/default.yml
index 4b538c2d8d..a492f2926f 100644
--- a/it-and-security/default.yml
+++ b/it-and-security/default.yml
@@ -31,6 +31,7 @@ org_settings:
- "💻🐣 Workstations (canary)"
- "📱🏢 Company-owned iPhones"
- "🔳🏢 Company-owned iPads"
+ - "📱🔐 Personally-owned iPhones"
org_info:
contact_url: https://fleetdm.com/company/contact
org_logo_url: ""
@@ -62,8 +63,8 @@ org_settings:
metadata_url: ""
webhook_settings:
failing_policies_webhook:
- destination_url: $DOGFOOD_FAILING_POLICIES_WEBHOOK_URL
- enable_failing_policies_webhook: true
+ destination_url: ""
+ enable_failing_policies_webhook: false
host_batch_size: 0
policy_ids: []
host_status_webhook:
@@ -73,8 +74,8 @@ org_settings:
host_percentage: 25
interval: 1m0s
vulnerabilities_webhook:
- destination_url: $DOGFOOD_VULNERABILITIES_WEBHOOK_URL
- enable_vulnerabilities_webhook: true
+ destination_url: ""
+ enable_vulnerabilities_webhook: false
host_batch_size: 0
policies:
queries:
@@ -86,5 +87,4 @@ controls:
mode: voluntary
webhook_url: $DOGFOOD_MACOS_MIGRATION_WEBHOOK_URL
windows_enabled_and_configured: true
- windows_migration:
- enable: true
+ windows_migration_enabled: true
diff --git a/it-and-security/lib/ios/configuration-profiles/content-filtering.mobileconfig b/it-and-security/lib/ios/configuration-profiles/content-filtering.mobileconfig
deleted file mode 100644
index 11dfeff2ff..0000000000
--- a/it-and-security/lib/ios/configuration-profiles/content-filtering.mobileconfig
+++ /dev/null
@@ -1,48 +0,0 @@
-
-
-
-
- PayloadContent
-
-
- AutoFilterEnabled
-
- BlacklistedURLs
-
- https://example.com
-
-
- FilterBrowsers
-
- FilterSockets
-
- FilterType
- BuiltIn
- PayloadDescription
- Configures content filtering settings
- PayloadDisplayName
- Web Content Filter
- PayloadIdentifier
- com.apple.webcontent-filter.1B111C68-501E-44C3-A564-296C9D5D01C3
- PayloadType
- com.apple.webcontent-filter
- PayloadUUID
- 1B111C68-501E-44C3-A564-296C9D5D01C3
- PayloadVersion
- 1
-
-
- PayloadDisplayName
- Content filtering
- PayloadIdentifier
- Lucass-MacBook-Pro.72E4CE0F-8246-4B81-BC28-AD16C7CD43E0
- PayloadRemovalDisallowed
-
- PayloadType
- Configuration
- PayloadUUID
- 9555632D-5053-4A89-94D9-EC4510BB8DC6
- PayloadVersion
- 1
-
-
diff --git a/it-and-security/lib/ios/configuration-profiles/lock-screen-message.mobileconfig b/it-and-security/lib/ios/configuration-profiles/lock-screen-message.mobileconfig
index 7190fc86f9..c678760218 100644
--- a/it-and-security/lib/ios/configuration-profiles/lock-screen-message.mobileconfig
+++ b/it-and-security/lib/ios/configuration-profiles/lock-screen-message.mobileconfig
@@ -6,7 +6,7 @@
AssetTagInformation
- This is a Fleet owned device
+ This device is owned by Fleet
IfLostReturnToMessage
Fleet Device Management Inc.
PayloadDescription
@@ -26,7 +26,7 @@
PayloadDisplayName
Lock Screen Message
PayloadIdentifier
- Lucass-MacBook-Pro.D0BED3AA-FC16-4276-A8A3-457AA8558C1E
+ com.fleetdm.lockscreenmessage
PayloadRemovalDisallowed
PayloadType
diff --git a/it-and-security/lib/ios/configuration-profiles/restrictions.mobileconfig b/it-and-security/lib/ios/configuration-profiles/restrictions.mobileconfig
deleted file mode 100644
index d63e70fbf9..0000000000
--- a/it-and-security/lib/ios/configuration-profiles/restrictions.mobileconfig
+++ /dev/null
@@ -1,271 +0,0 @@
-
-
-
-
- HasRemovalPasscode
-
- PayloadContent
-
-
- PayloadDescription
- Configures restrictions
- PayloadDisplayName
- Restrictions
- PayloadIdentifier
- com.apple.applicationaccess.A001D62E-9217-47F0-9ECF-C5E3F548F9EF
- PayloadType
- com.apple.applicationaccess
- PayloadUUID
- A001D62E-9217-47F0-9ECF-C5E3F548F9EF
- PayloadVersion
- 1
- allowActivityContinuation
-
- allowAddingGameCenterFriends
-
- allowAirPlayIncomingRequests
-
- allowAirPrint
-
- allowAirPrintCredentialsStorage
-
- allowAirPrintiBeaconDiscovery
-
- allowAppCellularDataModification
-
- allowAppClips
-
- allowAppInstallation
-
- allowAppRemoval
-
- allowApplePersonalizedAdvertising
-
- allowAssistant
-
- allowAssistantWhileLocked
-
- allowAutoCorrection
-
- allowAutoUnlock
-
- allowAutomaticAppDownloads
-
- allowBluetoothModification
-
- allowBookstore
-
- allowBookstoreErotica
-
- allowCamera
-
- allowCellularPlanModification
-
- allowChat
-
- allowCloudBackup
-
- allowCloudDocumentSync
-
- allowCloudPhotoLibrary
-
- allowContinuousPathKeyboard
-
- allowDefinitionLookup
-
- allowDeviceNameModification
-
- allowDeviceSleep
-
- allowDictation
-
- allowESIMModification
-
- allowEnablingRestrictions
-
- allowEnterpriseAppTrust
-
- allowEnterpriseBookBackup
-
- allowEnterpriseBookMetadataSync
-
- allowEraseContentAndSettings
-
- allowExplicitContent
-
- allowFilesNetworkDriveAccess
-
- allowFilesUSBDriveAccess
-
- allowFindMyDevice
-
- allowFindMyFriends
-
- allowFingerprintForUnlock
-
- allowFingerprintModification
-
- allowGameCenter
-
- allowGlobalBackgroundFetchWhenRoaming
-
- allowInAppPurchases
-
- allowKeyboardShortcuts
-
- allowManagedAppsCloudSync
-
- allowMultiplayerGaming
-
- allowMusicService
-
- allowNews
-
- allowNotificationsModification
-
- allowOpenFromManagedToUnmanaged
-
- allowOpenFromUnmanagedToManaged
-
- allowPairedWatch
-
- allowPassbookWhileLocked
-
- allowPasscodeModification
-
- allowPasswordAutoFill
-
- allowPasswordProximityRequests
-
- allowPasswordSharing
-
- allowPersonalHotspotModification
-
- allowPhotoStream
-
- allowPredictiveKeyboard
-
- allowProximitySetupToNewDevice
-
- allowRadioService
-
- allowRemoteAppPairing
-
- allowRemoteScreenObservation
-
- allowSafari
-
- allowScreenShot
-
- allowSharedStream
-
- allowSpellCheck
-
- allowSpotlightInternetResults
-
- allowSystemAppRemoval
-
- allowUIAppInstallation
-
- allowUIConfigurationProfileInstallation
-
- allowUSBRestrictedMode
-
- allowUnpairedExternalBootToRecovery
-
- allowUntrustedTLSPrompt
-
- allowVPNCreation
-
- allowVideoConferencing
-
- allowVoiceDialing
-
- allowWallpaperModification
-
- allowiTunes
-
- forceAirDropUnmanaged
-
- forceAirPrintTrustedTLSRequirement
-
- forceAssistantProfanityFilter
-
- forceAuthenticationBeforeAutoFill
-
- forceAutomaticDateAndTime
-
- forceClassroomAutomaticallyJoinClasses
-
- forceClassroomRequestPermissionToLeaveClasses
-
- forceClassroomUnpromptedAppAndDeviceLock
-
- forceClassroomUnpromptedScreenObservation
-
- forceDelayedSoftwareUpdates
-
- forceEncryptedBackup
-
- forceITunesStorePasswordEntry
-
- forceLimitAdTracking
-
- forceWatchWristDetection
-
- forceWiFiPowerOn
-
- forceWiFiWhitelisting
-
- ratingApps
- 1000
- ratingMovies
- 1000
- ratingRegion
- us
- ratingTVShows
- 1000
- safariAcceptCookies
- 2
- safariAllowAutoFill
-
- safariAllowJavaScript
-
- safariAllowPopups
-
- safariForceFraudWarning
-
-
-
- AssetTagInformation
- This is a FleetDM owned device
- IfLostReturnToMessage
- Fleet Device Management Inc.
- PayloadDescription
- Configures ownership information for a shared device
- PayloadDisplayName
- Lock Screen Message
- PayloadIdentifier
- com.apple.shareddeviceconfiguration.8A2A7B75-4E65-42EF-AC09-B1F8A7EE94B5
- PayloadType
- com.apple.shareddeviceconfiguration
- PayloadUUID
- 8A2A7B75-4E65-42EF-AC09-B1F8A7EE94B5
- PayloadVersion
- 1
-
-
- PayloadDisplayName
- Restrictions
- PayloadIdentifier
- Lucass-MacBook-Pro.47AF8BD0-DC78-4814-98A1-40B927B3408E
- PayloadRemovalDisallowed
-
- PayloadType
- Configuration
- PayloadUUID
- A5EE2362-BF54-45F4-A00F-55B1E990A4C0
- PayloadVersion
- 1
-
-
diff --git a/it-and-security/lib/ios/declaration-profiles/passcode-settings-ddm.json b/it-and-security/lib/ios/declaration-profiles/passcode-settings.json
similarity index 63%
rename from it-and-security/lib/ios/declaration-profiles/passcode-settings-ddm.json
rename to it-and-security/lib/ios/declaration-profiles/passcode-settings.json
index 9b7d59f166..582063a397 100644
--- a/it-and-security/lib/ios/declaration-profiles/passcode-settings-ddm.json
+++ b/it-and-security/lib/ios/declaration-profiles/passcode-settings.json
@@ -2,9 +2,8 @@
"Type": "com.apple.configuration.passcode.settings",
"Identifier": "956e0d14-6019-479b-a6f9-a69ef77668c5",
"Payload": {
- "MaximumFailedAttempts": 10,
"MaximumInactivityInMinutes": 5,
- "MinimumLength": 12,
- "MinimumComplexCharacters": 1
+ "MinimumLength": 6,
+ "RequireComplexPasscode": true
}
}
diff --git a/it-and-security/lib/ios/declaration-profiles/software-update-settings.json b/it-and-security/lib/ios/declaration-profiles/software-update-settings.json
index e6fcff6729..9077eb39ba 100644
--- a/it-and-security/lib/ios/declaration-profiles/software-update-settings.json
+++ b/it-and-security/lib/ios/declaration-profiles/software-update-settings.json
@@ -12,4 +12,4 @@
"Enabled": true
}
}
-}
\ No newline at end of file
+}
diff --git a/it-and-security/lib/ipados/configuration-profiles/lock-screen-message.mobileconfig b/it-and-security/lib/ipados/configuration-profiles/lock-screen-message.mobileconfig
new file mode 100644
index 0000000000..c678760218
--- /dev/null
+++ b/it-and-security/lib/ipados/configuration-profiles/lock-screen-message.mobileconfig
@@ -0,0 +1,39 @@
+
+
+
+
+ PayloadContent
+
+
+ AssetTagInformation
+ This device is owned by Fleet
+ IfLostReturnToMessage
+ Fleet Device Management Inc.
+ PayloadDescription
+ Configures ownership information for a shared device
+ PayloadDisplayName
+ Lock Screen Message
+ PayloadIdentifier
+ com.apple.shareddeviceconfiguration.E6872230-52C6-4443-AE57-4BB6503C6E01
+ PayloadType
+ com.apple.shareddeviceconfiguration
+ PayloadUUID
+ E6872230-52C6-4443-AE57-4BB6503C6E01
+ PayloadVersion
+ 1
+
+
+ PayloadDisplayName
+ Lock Screen Message
+ PayloadIdentifier
+ com.fleetdm.lockscreenmessage
+ PayloadRemovalDisallowed
+
+ PayloadType
+ Configuration
+ PayloadUUID
+ 24C286C4-D755-473D-8E09-5E5C0F152BD1
+ PayloadVersion
+ 1
+
+
diff --git a/it-and-security/lib/ipados/declaration-profiles/passcode-settings.json b/it-and-security/lib/ipados/declaration-profiles/passcode-settings.json
new file mode 100644
index 0000000000..582063a397
--- /dev/null
+++ b/it-and-security/lib/ipados/declaration-profiles/passcode-settings.json
@@ -0,0 +1,9 @@
+{
+ "Type": "com.apple.configuration.passcode.settings",
+ "Identifier": "956e0d14-6019-479b-a6f9-a69ef77668c5",
+ "Payload": {
+ "MaximumInactivityInMinutes": 5,
+ "MinimumLength": 6,
+ "RequireComplexPasscode": true
+ }
+}
diff --git a/it-and-security/lib/ipados/declaration-profiles/software-update-settings.json b/it-and-security/lib/ipados/declaration-profiles/software-update-settings.json
index e6fcff6729..9077eb39ba 100644
--- a/it-and-security/lib/ipados/declaration-profiles/software-update-settings.json
+++ b/it-and-security/lib/ipados/declaration-profiles/software-update-settings.json
@@ -12,4 +12,4 @@
"Enabled": true
}
}
-}
\ No newline at end of file
+}
diff --git a/it-and-security/lib/macos/configuration-profiles/automatically-update-1Password.mobileconfig b/it-and-security/lib/macos/configuration-profiles/automatically-update-1Password.mobileconfig
new file mode 100644
index 0000000000..ebd5c8ffd3
--- /dev/null
+++ b/it-and-security/lib/macos/configuration-profiles/automatically-update-1Password.mobileconfig
@@ -0,0 +1,33 @@
+
+
+
+
+ PayloadContent
+
+
+ PayloadDisplayName
+ 1Password
+ PayloadIdentifier
+ com.1password.1password.1751576D-172A-408C-88F4-2EB754081022
+ PayloadType
+ com.1password.1password
+ PayloadUUID
+ 1751576D-172A-408C-88F4-2EB754081022
+ PayloadVersion
+ 1
+ updates.autoUpdate
+
+
+
+ PayloadDisplayName
+ Automatically check for 1Password updates
+ PayloadIdentifier
+ com.fleetdm.4AF68654-3F58-4ACC-8D10-131B472BBD40
+ PayloadType
+ Configuration
+ PayloadUUID
+ 4AF68654-3F58-4ACC-8D10-131B472BBD40
+ PayloadVersion
+ 1
+
+
diff --git a/it-and-security/teams/company-owned-ipads.yml b/it-and-security/teams/company-owned-ipads.yml
index 439b2aa13e..a873581e2a 100644
--- a/it-and-security/teams/company-owned-ipads.yml
+++ b/it-and-security/teams/company-owned-ipads.yml
@@ -17,6 +17,8 @@ controls:
macos_settings:
custom_settings:
- path: ../lib/ipados/declaration-profiles/software-update-settings.json
+ - path: ../lib/ipados/declaration-profiles/passcode-settings.json
+ - path: ../lib/ipados/configuration-profiles/lock-screen-message.mobileconfig
scripts:
policies:
queries:
diff --git a/it-and-security/teams/company-owned-iphones.yml b/it-and-security/teams/company-owned-iphones.yml
index 7dc44045eb..2132ad7395 100644
--- a/it-and-security/teams/company-owned-iphones.yml
+++ b/it-and-security/teams/company-owned-iphones.yml
@@ -16,10 +16,8 @@ controls:
minimum_version: "18.2"
macos_settings:
custom_settings:
- - path: ../lib/ios/configuration-profiles/restrictions.mobileconfig
- path: ../lib/ios/configuration-profiles/lock-screen-message.mobileconfig
- - path: ../lib/ios/configuration-profiles/content-filtering.mobileconfig
- - path: ../lib/ios/declaration-profiles/passcode-settings-ddm.json
+ - path: ../lib/ios/declaration-profiles/passcode-settings.json
- path: ../lib/ios/declaration-profiles/software-update-settings.json
scripts:
policies:
diff --git a/it-and-security/teams/personally-owned-iphones.yml b/it-and-security/teams/personally-owned-iphones.yml
new file mode 100644
index 0000000000..8bdaf45e53
--- /dev/null
+++ b/it-and-security/teams/personally-owned-iphones.yml
@@ -0,0 +1,27 @@
+name: 📱🔐 Personally-owned iPhones
+team_settings:
+ features:
+ host_expiry_settings:
+ host_expiry_enabled: false
+ host_expiry_window: 0
+ secrets:
+ - secret: $DOGFOOD_PERSONALLY_OWNED_IPHONES_ENROLL_SECRET
+ integrations:
+ google_calendar:
+ enable_calendar_events: false
+agent_options:
+controls:
+ ios_updates:
+ deadline: "2025-01-06"
+ minimum_version: "18.2"
+ macos_settings:
+ custom_settings:
+ - path: ../lib/ios/declaration-profiles/passcode-settings.json
+ - path: ../lib/ios/declaration-profiles/software-update-settings.json
+ scripts:
+policies:
+queries:
+software:
+ app_store_apps:
+ - app_store_id: '618783545' # Slack
+ - app_store_id: '546505307' # Zoom
diff --git a/it-and-security/teams/workstations-canary.yml b/it-and-security/teams/workstations-canary.yml
index f52965dbf3..58b3c09e84 100644
--- a/it-and-security/teams/workstations-canary.yml
+++ b/it-and-security/teams/workstations-canary.yml
@@ -88,6 +88,9 @@ controls:
- path: ../lib/macos/configuration-profiles/ensure-show-status-bar-is-enabled.mobileconfig
- path: ../lib/macos/declaration-profiles/passcode-settings.json
- path: ../lib/macos/declaration-profiles/software-update-settings.json
+ - path: ../lib/macos/configuration-profiles/automatically-update-1Password.mobileconfig
+ labels_include_any:
+ - "Macs with 1Password8 installed"
macos_setup:
bootstrap_package: ""
enable_end_user_authentication: false
diff --git a/pkg/spec/gitops.go b/pkg/spec/gitops.go
index baa43eb5fa..9c30cbde21 100644
--- a/pkg/spec/gitops.go
+++ b/pkg/spec/gitops.go
@@ -853,12 +853,30 @@ func parseSoftware(top map[string]json.RawMessage, result *GitOps, baseDir strin
} else {
softwarePackageSpec = resolveSoftwarePackagePaths(baseDir, item.SoftwarePackageSpec)
}
+ if softwarePackageSpec.InstallScript.Path != "" {
+ if err := gatherFileSecrets(result, softwarePackageSpec.InstallScript.Path); err != nil {
+ multiError = multierror.Append(multiError, err)
+ continue
+ }
+ }
+ if softwarePackageSpec.PostInstallScript.Path != "" {
+ if err := gatherFileSecrets(result, softwarePackageSpec.PostInstallScript.Path); err != nil {
+ multiError = multierror.Append(multiError, err)
+ continue
+ }
+ }
+ if softwarePackageSpec.UninstallScript.Path != "" {
+ if err := gatherFileSecrets(result, softwarePackageSpec.UninstallScript.Path); err != nil {
+ multiError = multierror.Append(multiError, err)
+ continue
+ }
+ }
if softwarePackageSpec.URL == "" {
multiError = multierror.Append(multiError, errors.New("software URL is required"))
continue
}
if len(softwarePackageSpec.URL) > fleet.SoftwareInstallerURLMaxLength {
- multiError = multierror.Append(multiError, fmt.Errorf("software URL %q is too long, must be less than 256 characters", softwarePackageSpec.URL))
+ multiError = multierror.Append(multiError, fmt.Errorf("software URL %q is too long, must be %d characters or less", softwarePackageSpec.URL, fleet.SoftwareInstallerURLMaxLength))
continue
}
if len(softwarePackageSpec.LabelsExcludeAny) > 0 && len(softwarePackageSpec.LabelsIncludeAny) > 0 {
@@ -871,6 +889,20 @@ func parseSoftware(top map[string]json.RawMessage, result *GitOps, baseDir strin
return multiError
}
+func gatherFileSecrets(result *GitOps, filePath string) error {
+ fileBytes, err := os.ReadFile(filePath)
+ if err != nil {
+ return fmt.Errorf("failed to read file %s: %w", filePath, err)
+ }
+
+ err = LookupEnvSecrets(string(fileBytes), result.FleetSecrets)
+ if err != nil {
+ return fmt.Errorf("failed to lookup environment secrets for %s: %w", filePath, err)
+ }
+
+ return nil
+}
+
func resolveSoftwarePackagePaths(baseDir string, softwareSpec fleet.SoftwarePackageSpec) fleet.SoftwarePackageSpec {
if softwareSpec.PreInstallQuery.Path != "" {
softwareSpec.PreInstallQuery.Path = resolveApplyRelativePath(baseDir, softwareSpec.PreInstallQuery.Path)
diff --git a/pkg/spec/gitops_test.go b/pkg/spec/gitops_test.go
index d32868bbf7..a72501409e 100644
--- a/pkg/spec/gitops_test.go
+++ b/pkg/spec/gitops_test.go
@@ -83,6 +83,7 @@ func TestValidGitOpsYaml(t *testing.T) {
"FLEET_SECRET_FLEET_SECRET_": "fleet_secret",
"FLEET_SECRET_NAME": "secret_name",
"FLEET_SECRET_length": "10",
+ "FLEET_SECRET_BANANA": "bread",
},
filePath: "testdata/global_config_no_paths.yml",
},
@@ -94,6 +95,7 @@ func TestValidGitOpsYaml(t *testing.T) {
"FLEET_SECRET_FLEET_SECRET_": "fleet_secret",
"FLEET_SECRET_NAME": "secret_name",
"FLEET_SECRET_length": "10",
+ "FLEET_SECRET_BANANA": "bread",
},
filePath: "testdata/global_config.yml",
},
@@ -102,6 +104,7 @@ func TestValidGitOpsYaml(t *testing.T) {
"FLEET_SECRET_FLEET_SECRET_": "fleet_secret",
"FLEET_SECRET_NAME": "secret_name",
"FLEET_SECRET_length": "10",
+ "FLEET_SECRET_BANANA": "bread",
},
filePath: "testdata/team_config_no_paths.yml",
isTeam: true,
@@ -115,6 +118,7 @@ func TestValidGitOpsYaml(t *testing.T) {
"FLEET_SECRET_FLEET_SECRET_": "fleet_secret",
"FLEET_SECRET_NAME": "secret_name",
"FLEET_SECRET_length": "10",
+ "FLEET_SECRET_BANANA": "bread",
},
filePath: "testdata/team_config.yml",
isTeam: true,
@@ -172,7 +176,7 @@ func TestValidGitOpsYaml(t *testing.T) {
require.Len(t, gitops.Software.Packages, 2)
for _, pkg := range gitops.Software.Packages {
if strings.Contains(pkg.URL, "MicrosoftTeams") {
- assert.Equal(t, "uninstall.sh", pkg.UninstallScript.Path)
+ assert.Equal(t, "testdata/lib/uninstall.sh", pkg.UninstallScript.Path)
} else {
assert.Empty(t, pkg.UninstallScript.Path)
}
@@ -236,10 +240,11 @@ func TestValidGitOpsYaml(t *testing.T) {
assert.True(t, ok, "windows_migration_enabled not found")
_, ok = gitops.Controls.WindowsUpdates.(map[string]interface{})
assert.True(t, ok, "windows_updates not found")
- require.Len(t, gitops.FleetSecrets, 3)
+ require.Len(t, gitops.FleetSecrets, 4)
assert.Equal(t, "fleet_secret", gitops.FleetSecrets["FLEET_SECRET_FLEET_SECRET_"])
assert.Equal(t, "secret_name", gitops.FleetSecrets["FLEET_SECRET_NAME"])
assert.Equal(t, "10", gitops.FleetSecrets["FLEET_SECRET_length"])
+ assert.Equal(t, "bread", gitops.FleetSecrets["FLEET_SECRET_BANANA"])
// Check agent options
assert.NotNil(t, gitops.AgentOptions)
@@ -909,7 +914,7 @@ policies:
assert.ErrorContains(t, err, "empty package_path")
// Software has a URL that's too big
- tooBigURL := fmt.Sprintf("https://ftp.mozilla.org/%s", strings.Repeat("a", 232))
+ tooBigURL := fmt.Sprintf("https://ftp.mozilla.org/%s", strings.Repeat("a", 4000-23))
config = getTeamConfig([]string{"software"})
config += fmt.Sprintf(`
software:
@@ -922,7 +927,7 @@ software:
}
path, basePath := createTempFile(t, "", config)
_, err = GitOpsFromFile(path, basePath, &appConfig, nopLogf)
- assert.ErrorContains(t, err, fmt.Sprintf("software URL \"%s\" is too long, must be less than 256 characters", tooBigURL))
+ assert.ErrorContains(t, err, fmt.Sprintf("software URL \"%s\" is too long, must be 4000 characters or less", tooBigURL))
// Policy references a software installer not present in the team.
config = getTeamConfig([]string{"policies"})
diff --git a/pkg/spec/testdata/lib/collect-fleetd-logs.sh b/pkg/spec/testdata/lib/collect-fleetd-logs.sh
index f81b4da0dd..cd2b5fc62f 100644
--- a/pkg/spec/testdata/lib/collect-fleetd-logs.sh
+++ b/pkg/spec/testdata/lib/collect-fleetd-logs.sh
@@ -1,3 +1,4 @@
# collect fleetd logs
echo a${FLEET_SECRET_FLEET_SECRET_}a
echo $NOT_FLEET_SECRET_X
+echo $FLEET_SECRET_BANANA
diff --git a/pkg/spec/testdata/lib/uninstall.sh b/pkg/spec/testdata/lib/uninstall.sh
new file mode 100644
index 0000000000..9b43cff106
--- /dev/null
+++ b/pkg/spec/testdata/lib/uninstall.sh
@@ -0,0 +1 @@
+echo $FLEET_SECRET_BANANA
diff --git a/pkg/spec/testdata/microsoft-teams.pkg.software.yml b/pkg/spec/testdata/microsoft-teams.pkg.software.yml
index 9f10d148d6..55807b3a10 100644
--- a/pkg/spec/testdata/microsoft-teams.pkg.software.yml
+++ b/pkg/spec/testdata/microsoft-teams.pkg.software.yml
@@ -1,4 +1,4 @@
url: https://statics.teams.cdn.office.net/production-osx/enterprise/webview2/lkg/MicrosoftTeams.pkg
self_service: false
uninstall_script:
- path: ../uninstall.sh
+ path: ./lib/uninstall.sh
diff --git a/server/datastore/mysql/cron_stats.go b/server/datastore/mysql/cron_stats.go
index 74bc63f224..c4cc49d6f2 100644
--- a/server/datastore/mysql/cron_stats.go
+++ b/server/datastore/mysql/cron_stats.go
@@ -2,6 +2,8 @@ package mysql
import (
"context"
+ "database/sql"
+ "encoding/json"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
@@ -63,10 +65,19 @@ func (ds *Datastore) InsertCronStats(ctx context.Context, statsType fleet.CronSt
return int(id), nil
}
-func (ds *Datastore) UpdateCronStats(ctx context.Context, id int, status fleet.CronStatsStatus) error {
- stmt := `UPDATE cron_stats SET status = ? WHERE id = ?`
+func (ds *Datastore) UpdateCronStats(ctx context.Context, id int, status fleet.CronStatsStatus, cronErrors *fleet.CronScheduleErrors) error {
+ stmt := `UPDATE cron_stats SET status = ?, errors = ? WHERE id = ?`
- if _, err := ds.writer(ctx).ExecContext(ctx, stmt, status, id); err != nil {
+ errorsJSON := sql.NullString{}
+ if len(*cronErrors) > 0 {
+ b, err := json.Marshal(cronErrors)
+ if err == nil {
+ errorsJSON.String = string(b)
+ errorsJSON.Valid = true
+ }
+ }
+
+ if _, err := ds.writer(ctx).ExecContext(ctx, stmt, status, errorsJSON, id); err != nil {
return ctxerr.Wrap(ctx, err, "update cron stats")
}
diff --git a/server/datastore/mysql/cron_stats_test.go b/server/datastore/mysql/cron_stats_test.go
index a1ec9c898f..e945c526bd 100644
--- a/server/datastore/mysql/cron_stats_test.go
+++ b/server/datastore/mysql/cron_stats_test.go
@@ -2,6 +2,9 @@ package mysql
import (
"context"
+ "database/sql"
+ "encoding/json"
+ "errors"
"testing"
"time"
@@ -10,6 +13,12 @@ import (
"github.com/stretchr/testify/require"
)
+type testCronStats struct {
+ fleet.CronStats
+ // Errors is a JSON string containing any errors encountered during the run.
+ Errors sql.NullString `db:"errors"`
+}
+
func TestInsertUpdateCronStats(t *testing.T) {
const (
scheduleName = "test_sched"
@@ -28,7 +37,10 @@ func TestInsertUpdateCronStats(t *testing.T) {
require.Equal(t, fleet.CronStatsTypeScheduled, res[0].StatsType)
require.Equal(t, fleet.CronStatsStatusPending, res[0].Status)
- err = ds.UpdateCronStats(ctx, id, fleet.CronStatsStatusCompleted)
+ err = ds.UpdateCronStats(ctx, id, fleet.CronStatsStatusCompleted, &fleet.CronScheduleErrors{
+ "some_job": errors.New("some error"),
+ "some_other_job": errors.New("some other error"),
+ })
require.NoError(t, err)
res, err = ds.GetLatestCronStats(ctx, scheduleName)
@@ -37,6 +49,21 @@ func TestInsertUpdateCronStats(t *testing.T) {
require.Equal(t, id, res[0].ID)
require.Equal(t, fleet.CronStatsTypeScheduled, res[0].StatsType)
require.Equal(t, fleet.CronStatsStatusCompleted, res[0].Status)
+
+ var stats []testCronStats
+ err = sqlx.SelectContext(ctx, ds.reader(ctx), &stats, `SELECT * FROM cron_stats ORDER BY id`)
+ require.NoError(t, err)
+ // Make sure we got valid JSON back.
+ var actualMap map[string]string
+ err = json.Unmarshal([]byte(stats[0].Errors.String), &actualMap)
+ require.NoError(t, err)
+
+ // Compare the error JSON with the expected object.
+ expectedJSON := `{"some_job": "some error", "some_other_job": "some other error"}`
+ var expectedMap map[string]string
+ err = json.Unmarshal([]byte(expectedJSON), &expectedMap)
+ require.NoError(t, err)
+ require.Equal(t, actualMap, expectedMap)
}
func TestGetLatestCronStats(t *testing.T) {
@@ -171,7 +198,7 @@ func TestCleanupCronStats(t *testing.T) {
require.NoError(t, err)
}
- var stats []fleet.CronStats
+ var stats []testCronStats
err := sqlx.SelectContext(ctx, ds.reader(ctx), &stats, `SELECT * FROM cron_stats ORDER BY id`)
require.NoError(t, err)
require.Len(t, stats, len(cases))
@@ -183,7 +210,7 @@ func TestCleanupCronStats(t *testing.T) {
err = ds.CleanupCronStats(ctx)
require.NoError(t, err)
- stats = []fleet.CronStats{}
+ stats = []testCronStats{}
err = sqlx.SelectContext(ctx, ds.reader(ctx), &stats, `SELECT * FROM cron_stats ORDER BY id`)
require.NoError(t, err)
require.Len(t, stats, len(cases)-1) // case[7] was deleted because it exceeded max age
@@ -254,7 +281,7 @@ func TestUpdateAllCronStatsForInstance(t *testing.T) {
require.NoError(t, err)
}
- var stats []fleet.CronStats
+ var stats []testCronStats
err := sqlx.SelectContext(ctx, ds.reader(ctx), &stats, `SELECT * FROM cron_stats ORDER BY id`)
require.NoError(t, err)
require.Len(t, stats, len(cases))
@@ -267,7 +294,7 @@ func TestUpdateAllCronStatsForInstance(t *testing.T) {
err = ds.UpdateAllCronStatsForInstance(ctx, "inst1", fleet.CronStatsStatusPending, fleet.CronStatsStatusCanceled)
require.NoError(t, err)
- stats = []fleet.CronStats{}
+ stats = []testCronStats{}
err = sqlx.SelectContext(ctx, ds.reader(ctx), &stats, `SELECT * FROM cron_stats ORDER BY id`)
require.NoError(t, err)
require.Len(t, stats, len(cases))
diff --git a/server/datastore/mysql/migrations/tables/20241210140021_AddErrorsToCronStatsTable.go b/server/datastore/mysql/migrations/tables/20241210140021_AddErrorsToCronStatsTable.go
new file mode 100644
index 0000000000..78b227b496
--- /dev/null
+++ b/server/datastore/mysql/migrations/tables/20241210140021_AddErrorsToCronStatsTable.go
@@ -0,0 +1,23 @@
+package tables
+
+import (
+ "database/sql"
+ "fmt"
+)
+
+func init() {
+ MigrationClient.AddMigration(Up_20241126140021, Down_20241126140021)
+}
+
+func Up_20241126140021(tx *sql.Tx) error {
+ // Add columns
+ _, err := tx.Exec(`ALTER TABLE cron_stats ADD COLUMN errors JSON`)
+ if err != nil {
+ return fmt.Errorf("failed to add errors to cron_stats: %w", err)
+ }
+ return nil
+}
+
+func Down_20241126140021(tx *sql.Tx) error {
+ return nil
+}
diff --git a/server/datastore/mysql/migrations/tables/20241219180042_LongInstallerURLs.go b/server/datastore/mysql/migrations/tables/20241219180042_LongInstallerURLs.go
new file mode 100644
index 0000000000..ebecdc190b
--- /dev/null
+++ b/server/datastore/mysql/migrations/tables/20241219180042_LongInstallerURLs.go
@@ -0,0 +1,25 @@
+package tables
+
+import (
+ "database/sql"
+ "fmt"
+)
+
+func init() {
+ MigrationClient.AddMigration(Up_20241219180042, Down_20241219180042)
+}
+
+func Up_20241219180042(tx *sql.Tx) error {
+ // The new 'url' column will only be set for software uploaded in batch via GitOps.
+ if _, err := tx.Exec(`
+ ALTER TABLE software_installers
+ CHANGE COLUMN url url VARCHAR(4095) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '';
+ `); err != nil {
+ return fmt.Errorf("failed to lengthen url in software_installers: %w", err)
+ }
+ return nil
+}
+
+func Down_20241219180042(tx *sql.Tx) error {
+ return nil
+}
diff --git a/server/datastore/mysql/migrations/tables/20241219180042_LongInstallerURLs_test.go b/server/datastore/mysql/migrations/tables/20241219180042_LongInstallerURLs_test.go
new file mode 100644
index 0000000000..87074c6cfa
--- /dev/null
+++ b/server/datastore/mysql/migrations/tables/20241219180042_LongInstallerURLs_test.go
@@ -0,0 +1,51 @@
+package tables
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestUp_20241219180042(t *testing.T) {
+ db := applyUpToPrev(t)
+
+ script1 := execNoErrLastID(t, db, "INSERT INTO script_contents(contents, md5_checksum) VALUES ('echo hi', 'a')")
+ script2 := execNoErrLastID(t, db, "INSERT INTO script_contents(contents, md5_checksum) VALUES ('echo bye', 'b')")
+
+ software := execNoErrLastID(t, db, `
+INSERT INTO software_installers (
+ filename,
+ version,
+ platform,
+ install_script_content_id,
+ post_install_script_content_id,
+ uninstall_script_content_id,
+ storage_id,
+ package_ids,
+ url
+) VALUES (
+ 'fleet',
+ '1.0.0',
+ 'windows',
+ ?,
+ ?,
+ ?,
+ 'a',
+ '',
+ ?
+)`, script1, script2, script2, "https://google.com/")
+
+ applyNext(t, db)
+
+ var url string
+ err := db.Get(&url, "SELECT url FROM software_installers WHERE id = ?", software)
+ require.NoError(t, err)
+ require.Equal(t, "https://google.com/", url)
+
+ longUrl := "https://dl.google.com/tag/s/appguid%3D%7B8A69D345-D564-463C-AFF1-A69D9E530F96%7D%26iid%3D%7B53CCDE8D-FD40-46DE-67E7-61E96CFEFCAA%7D%26lang%3Den%26browser%3D4%26usagestats%3D0%26appname%3DGoogle%2520Chrome%26needsadmin%3Dtrue%26ap%3Dx64-stable-statsdef_0%26brand%3DGCEA/dl/chrome/install/googlechromestandaloneenterprise64.msi"
+ execNoErr(t, db, `UPDATE software_installers SET url = ? WHERE id = ?`, longUrl, software)
+
+ err = db.Get(&url, "SELECT url FROM software_installers WHERE id = ?", software)
+ require.NoError(t, err)
+ require.Equal(t, longUrl, url)
+}
diff --git a/server/datastore/mysql/migrations/tables/20241220100000_AddSubtypeToNanoCommands.go b/server/datastore/mysql/migrations/tables/20241220100000_AddSubtypeToNanoCommands.go
new file mode 100644
index 0000000000..a37ee69a44
--- /dev/null
+++ b/server/datastore/mysql/migrations/tables/20241220100000_AddSubtypeToNanoCommands.go
@@ -0,0 +1,39 @@
+package tables
+
+import (
+ "database/sql"
+ "fmt"
+
+ "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
+)
+
+func init() {
+ MigrationClient.AddMigration(Up_20241220100000, Down_20241220100000)
+}
+
+func Up_20241220100000(tx *sql.Tx) error {
+ if !columnExists(tx, "nano_commands", "subtype") {
+ _, err := tx.Exec(fmt.Sprintf(`
+ALTER TABLE nano_commands
+ADD COLUMN subtype enum('%s','%s') CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '%s'`,
+ mdm.CommandSubtypeNone, mdm.CommandSubtypeProfileWithSecrets, mdm.CommandSubtypeNone))
+ if err != nil {
+ return fmt.Errorf("failed to create nano_commands.subtype column: %w", err)
+ }
+ }
+
+ // With secret variable support, it is possible to have the whole profile as one secret ($FLEET_SECRET_PROFILE),
+ // which will not be XML when stored. It is cleaner to remove the check than to add a special caveat to documentation.
+ if constraintExists(tx, "nano_commands", "nano_commands_chk_3") {
+ _, err := tx.Exec(`ALTER TABLE nano_commands DROP CONSTRAINT nano_commands_chk_3`)
+ if err != nil {
+ return fmt.Errorf("failed to drop nano_commands_chk_3 constraint: %w", err)
+ }
+ }
+
+ return nil
+}
+
+func Down_20241220100000(_ *sql.Tx) error {
+ return nil
+}
diff --git a/server/datastore/mysql/migrations/tables/20241220114903_ChangeDDMJSONColumnToText.go b/server/datastore/mysql/migrations/tables/20241220114903_ChangeDDMJSONColumnToText.go
new file mode 100644
index 0000000000..73d443bb52
--- /dev/null
+++ b/server/datastore/mysql/migrations/tables/20241220114903_ChangeDDMJSONColumnToText.go
@@ -0,0 +1,25 @@
+package tables
+
+import (
+ "database/sql"
+ "fmt"
+)
+
+func init() {
+ MigrationClient.AddMigration(Up_20241220114903, Down_20241220114903)
+}
+
+func Up_20241220114903(tx *sql.Tx) error {
+ _, err := tx.Exec(`
+ALTER TABLE mdm_apple_declarations
+ CHANGE raw_json raw_json MEDIUMTEXT COLLATE utf8mb4_unicode_ci NOT NULL -- 16MB max size`)
+ if err != nil {
+ return fmt.Errorf("failed to change mdm_apple_declarations.raw_json column; is there a very large DDM profile?: %w", err)
+ }
+
+ return nil
+}
+
+func Down_20241220114903(tx *sql.Tx) error {
+ return nil
+}
diff --git a/server/datastore/mysql/migrations/tables/20241220114903_ChangeDDMJSONColumnToText_test.go b/server/datastore/mysql/migrations/tables/20241220114903_ChangeDDMJSONColumnToText_test.go
new file mode 100644
index 0000000000..1e26de533c
--- /dev/null
+++ b/server/datastore/mysql/migrations/tables/20241220114903_ChangeDDMJSONColumnToText_test.go
@@ -0,0 +1,36 @@
+package tables
+
+import (
+ "context"
+ "fmt"
+ "testing"
+
+ "github.com/jmoiron/sqlx"
+ "github.com/stretchr/testify/require"
+)
+
+func TestUp_20241220114903(t *testing.T) {
+ db := applyUpToPrev(t)
+
+ myJSON := `{"foo": "bar"}`
+ execNoErr(t, db,
+ fmt.Sprintf(`INSERT INTO mdm_apple_declarations (declaration_uuid, identifier, name, raw_json, checksum, team_id) VALUES ('A', 'A', 'nameA', '%s', '', 0)`,
+ myJSON))
+
+ // Apply current migration.
+ applyNext(t, db)
+
+ var res []struct {
+ DeclarationUUID string `db:"declaration_uuid"`
+ RawJSON string `db:"raw_json"`
+ }
+ err := sqlx.SelectContext(context.Background(), db, &res, `SELECT declaration_uuid, raw_json FROM mdm_apple_declarations`)
+ require.NoError(t, err)
+ require.Len(t, res, 1)
+ require.Equal(t, myJSON, res[0].RawJSON)
+ require.Equal(t, "A", res[0].DeclarationUUID)
+
+ execNoErr(t, db,
+ `INSERT INTO mdm_apple_declarations (declaration_uuid, identifier, name, raw_json, checksum, team_id) VALUES ('B', 'B', 'nameB', '$FLEET_SECRET_BOZO', '', 0)`)
+
+}
diff --git a/server/datastore/mysql/migrations/tables/20241210094935_AddVppAppTeamAndSoftwareInstallerLabelsTable.go b/server/datastore/mysql/migrations/tables/20241220114904_AddVppAppTeamAndSoftwareInstallerLabelsTable.go
similarity index 94%
rename from server/datastore/mysql/migrations/tables/20241210094935_AddVppAppTeamAndSoftwareInstallerLabelsTable.go
rename to server/datastore/mysql/migrations/tables/20241220114904_AddVppAppTeamAndSoftwareInstallerLabelsTable.go
index 69e901c52f..8c009e42d9 100644
--- a/server/datastore/mysql/migrations/tables/20241210094935_AddVppAppTeamAndSoftwareInstallerLabelsTable.go
+++ b/server/datastore/mysql/migrations/tables/20241220114904_AddVppAppTeamAndSoftwareInstallerLabelsTable.go
@@ -7,10 +7,10 @@ import (
)
func init() {
- MigrationClient.AddMigration(Up_20241210094935, Down_20241210094935)
+ MigrationClient.AddMigration(Up_20241220114904, Down_20241220114904)
}
-func Up_20241210094935(tx *sql.Tx) error {
+func Up_20241220114904(tx *sql.Tx) error {
createVppAppStmt := `
CREATE TABLE IF NOT EXISTS vpp_app_team_labels (
id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
@@ -74,6 +74,6 @@ CREATE TABLE IF NOT EXISTS software_installer_labels (
return nil
}
-func Down_20241210094935(tx *sql.Tx) error {
+func Down_20241220114904(tx *sql.Tx) error {
return nil
}
diff --git a/server/datastore/mysql/migrations/tables/migration.go b/server/datastore/mysql/migrations/tables/migration.go
index 779b8c2a87..8908dd6cf7 100644
--- a/server/datastore/mysql/migrations/tables/migration.go
+++ b/server/datastore/mysql/migrations/tables/migration.go
@@ -30,6 +30,22 @@ AND CONSTRAINT_NAME = ?
return count > 0
}
+func constraintExists(tx *sql.Tx, table, name string) bool {
+ var count int
+ err := tx.QueryRow(`
+SELECT COUNT(1)
+FROM information_schema.TABLE_CONSTRAINTS
+WHERE CONSTRAINT_SCHEMA = DATABASE()
+AND TABLE_NAME = ?
+AND CONSTRAINT_NAME = ?
+ `, table, name).Scan(&count)
+ if err != nil {
+ return false
+ }
+
+ return count > 0
+}
+
func columnExists(tx *sql.Tx, table, column string) bool {
return columnsExists(tx, table, column)
}
diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql
index ca77e9805f..aae0648b6f 100644
--- a/server/datastore/mysql/schema.sql
+++ b/server/datastore/mysql/schema.sql
@@ -126,6 +126,7 @@ CREATE TABLE `cron_stats` (
`status` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ `errors` json DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_cron_stats_name_created_at` (`name`,`created_at`)
) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -875,7 +876,7 @@ CREATE TABLE `mdm_apple_declarations` (
`team_id` int unsigned NOT NULL DEFAULT '0',
`identifier` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
`name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
- `raw_json` json NOT NULL,
+ `raw_json` mediumtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
`checksum` binary(16) NOT NULL,
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`uploaded_at` timestamp NULL DEFAULT NULL,
@@ -1104,9 +1105,9 @@ CREATE TABLE `migration_status_tables` (
`is_applied` tinyint(1) NOT NULL,
`tstamp` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
-) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=338 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=342 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
-INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,1,'2020-01-01 01:01:01'),(307,20240829170033,1,'2020-01-01 01:01:01'),(308,20240829170044,1,'2020-01-01 01:01:01'),(309,20240905105135,1,'2020-01-01 01:01:01'),(310,20240905140514,1,'2020-01-01 01:01:01'),(311,20240905200000,1,'2020-01-01 01:01:01'),(312,20240905200001,1,'2020-01-01 01:01:01'),(313,20241002104104,1,'2020-01-01 01:01:01'),(314,20241002104105,1,'2020-01-01 01:01:01'),(315,20241002104106,1,'2020-01-01 01:01:01'),(316,20241002210000,1,'2020-01-01 01:01:01'),(317,20241003145349,1,'2020-01-01 01:01:01'),(318,20241004005000,1,'2020-01-01 01:01:01'),(319,20241008083925,1,'2020-01-01 01:01:01'),(320,20241009090010,1,'2020-01-01 01:01:01'),(321,20241017163402,1,'2020-01-01 01:01:01'),(322,20241021224359,1,'2020-01-01 01:01:01'),(323,20241022140321,1,'2020-01-01 01:01:01'),(324,20241025111236,1,'2020-01-01 01:01:01'),(325,20241025112748,1,'2020-01-01 01:01:01'),(326,20241025141855,1,'2020-01-01 01:01:01'),(327,20241110152839,1,'2020-01-01 01:01:01'),(328,20241110152840,1,'2020-01-01 01:01:01'),(329,20241110152841,1,'2020-01-01 01:01:01'),(330,20241116233322,1,'2020-01-01 01:01:01'),(331,20241122171434,1,'2020-01-01 01:01:01'),(332,20241125150614,1,'2020-01-01 01:01:01'),(333,20241203125346,1,'2020-01-01 01:01:01'),(334,20241203130032,1,'2020-01-01 01:01:01'),(335,20241205122800,1,'2020-01-01 01:01:01'),(336,20241209164540,1,'2020-01-01 01:01:01'),(337,20241210094935,1,'2020-01-01 01:01:01');
+INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,1,'2020-01-01 01:01:01'),(307,20240829170033,1,'2020-01-01 01:01:01'),(308,20240829170044,1,'2020-01-01 01:01:01'),(309,20240905105135,1,'2020-01-01 01:01:01'),(310,20240905140514,1,'2020-01-01 01:01:01'),(311,20240905200000,1,'2020-01-01 01:01:01'),(312,20240905200001,1,'2020-01-01 01:01:01'),(313,20241002104104,1,'2020-01-01 01:01:01'),(314,20241002104105,1,'2020-01-01 01:01:01'),(315,20241002104106,1,'2020-01-01 01:01:01'),(316,20241002210000,1,'2020-01-01 01:01:01'),(317,20241003145349,1,'2020-01-01 01:01:01'),(318,20241004005000,1,'2020-01-01 01:01:01'),(319,20241008083925,1,'2020-01-01 01:01:01'),(320,20241009090010,1,'2020-01-01 01:01:01'),(321,20241017163402,1,'2020-01-01 01:01:01'),(322,20241021224359,1,'2020-01-01 01:01:01'),(323,20241022140321,1,'2020-01-01 01:01:01'),(324,20241025111236,1,'2020-01-01 01:01:01'),(325,20241025112748,1,'2020-01-01 01:01:01'),(326,20241025141855,1,'2020-01-01 01:01:01'),(327,20241110152839,1,'2020-01-01 01:01:01'),(328,20241110152840,1,'2020-01-01 01:01:01'),(329,20241110152841,1,'2020-01-01 01:01:01'),(330,20241116233322,1,'2020-01-01 01:01:01'),(331,20241122171434,1,'2020-01-01 01:01:01'),(332,20241125150614,1,'2020-01-01 01:01:01'),(333,20241203125346,1,'2020-01-01 01:01:01'),(334,20241203130032,1,'2020-01-01 01:01:01'),(335,20241205122800,1,'2020-01-01 01:01:01'),(336,20241209164540,1,'2020-01-01 01:01:01'),(337,20241210140021,1,'2020-01-01 01:01:01'),(338,20241219180042,1,'2020-01-01 01:01:01'),(339,20241220100000,1,'2020-01-01 01:01:01'),(340,20241220114903,1,'2020-01-01 01:01:01'),(341,20241220114904,1,'2020-01-01 01:01:01');
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `mobile_device_management_solutions` (
@@ -1174,10 +1175,10 @@ CREATE TABLE `nano_commands` (
`command` mediumtext COLLATE utf8mb4_unicode_ci NOT NULL,
`created_at` timestamp(6) NULL DEFAULT CURRENT_TIMESTAMP(6),
`updated_at` timestamp(6) NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
+ `subtype` enum('None','ProfileWithSecrets') CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'None',
PRIMARY KEY (`command_uuid`),
CONSTRAINT `nano_commands_chk_1` CHECK ((`command_uuid` <> _utf8mb4'')),
- CONSTRAINT `nano_commands_chk_2` CHECK ((`request_type` <> _utf8mb4'')),
- CONSTRAINT `nano_commands_chk_3` CHECK ((substr(`command`,1,5) = _utf8mb4' _utf8mb4''))
) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40101 SET @saved_cs_client = @@character_set_client */;
@@ -1823,7 +1824,7 @@ CREATE TABLE `software_installers` (
`user_id` int unsigned DEFAULT NULL,
`user_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '',
`user_email` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '',
- `url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '',
+ `url` varchar(4095) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '',
`package_ids` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
`extension` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '',
`uninstall_script_content_id` int unsigned NOT NULL,
diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go
index 1d4c228dbd..848eea200f 100644
--- a/server/datastore/mysql/software_installers.go
+++ b/server/datastore/mysql/software_installers.go
@@ -71,6 +71,24 @@ func (ds *Datastore) GetSoftwareInstallDetails(ctx context.Context, executionId
}
return nil, ctxerr.Wrap(ctx, err, "get software install details")
}
+
+ expandedInstallScript, err := ds.ExpandEmbeddedSecrets(ctx, result.InstallScript)
+ if err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "expanding secrets in install script")
+ }
+ expandedPostInstallScript, err := ds.ExpandEmbeddedSecrets(ctx, result.PostInstallScript)
+ if err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "expanding secrets in post-install script")
+ }
+ expandedUninstallScript, err := ds.ExpandEmbeddedSecrets(ctx, result.UninstallScript)
+ if err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "expanding secrets in uninstall script")
+ }
+
+ result.InstallScript = expandedInstallScript
+ result.PostInstallScript = expandedPostInstallScript
+ result.UninstallScript = expandedUninstallScript
+
return result, nil
}
diff --git a/server/datastore/mysql/software_installers_test.go b/server/datastore/mysql/software_installers_test.go
index 7a9808a589..72b7142908 100644
--- a/server/datastore/mysql/software_installers_test.go
+++ b/server/datastore/mysql/software_installers_test.go
@@ -56,13 +56,29 @@ func testListPendingSoftwareInstalls(t *testing.T, ds *Datastore) {
host2 := test.NewHost(t, ds, "host2", "2", "host2key", "host2uuid", time.Now())
user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true)
+ err := ds.UpsertSecretVariables(ctx, []fleet.SecretVariable{
+ {
+ Name: "RUBBER",
+ Value: "DUCKY",
+ },
+ {
+ Name: "BIG",
+ Value: "BIRD",
+ },
+ {
+ Name: "COOKIE",
+ Value: "MONSTER",
+ },
+ })
+ require.NoError(t, err)
+
tfr1, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir)
require.NoError(t, err)
installerID1, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
- InstallScript: "hello",
+ InstallScript: "hello $FLEET_SECRET_RUBBER",
PreInstallQuery: "SELECT 1",
- PostInstallScript: "world",
- UninstallScript: "goodbye",
+ PostInstallScript: "world $FLEET_SECRET_BIG",
+ UninstallScript: "goodbye $FLEET_SECRET_COOKIE",
InstallerFile: tfr1,
StorageID: "storage1",
Filename: "file1",
@@ -156,12 +172,12 @@ func testListPendingSoftwareInstalls(t *testing.T, ds *Datastore) {
require.Equal(t, host1.ID, exec1.HostID)
require.Equal(t, hostInstall1, exec1.ExecutionID)
- require.Equal(t, "hello", exec1.InstallScript)
- require.Equal(t, "world", exec1.PostInstallScript)
+ require.Equal(t, "hello DUCKY", exec1.InstallScript)
+ require.Equal(t, "world BIRD", exec1.PostInstallScript)
require.Equal(t, installerID1, exec1.InstallerID)
require.Equal(t, "SELECT 1", exec1.PreInstallCondition)
require.False(t, exec1.SelfService)
- assert.Equal(t, "goodbye", exec1.UninstallScript)
+ assert.Equal(t, "goodbye MONSTER", exec1.UninstallScript)
hostInstall6, err := ds.InsertSoftwareInstallRequest(ctx, host1.ID, installerID3, true, nil)
require.NoError(t, err)
diff --git a/server/fleet/cron_schedules.go b/server/fleet/cron_schedules.go
index 12fa1ef7ad..b4a4f7afc8 100644
--- a/server/fleet/cron_schedules.go
+++ b/server/fleet/cron_schedules.go
@@ -1,6 +1,7 @@
package fleet
import (
+ "encoding/json"
"fmt"
"net/http"
"sort"
@@ -147,6 +148,22 @@ func (e triggerNotFoundError) StatusCode() int {
return http.StatusNotFound
}
+type CronScheduleErrors map[string]error
+
+func (cse CronScheduleErrors) MarshalJSON() ([]byte, error) {
+ // Create a temporary map for JSON serialization
+ stringMap := make(map[string]string)
+ for key, err := range cse {
+ if err != nil {
+ stringMap[key] = err.Error()
+ } else {
+ stringMap[key] = ""
+ }
+ }
+ // Serialize the temporary map to JSON
+ return json.Marshal(stringMap)
+}
+
// CronStats represents statistics recorded in connection with a named set of jobs (sometimes
// referred to as a "cron" or "schedule"). Each record represents a separate "run" of the named job set.
type CronStats struct {
@@ -167,6 +184,8 @@ type CronStats struct {
// Status is the current status of the run. Recognized statuses are "pending", "completed", and
// "expired".
Status CronStatsStatus `db:"status"`
+ // Errors is a JSON string containing any errors encountered during the run.
+ Errors string `db:"errors"`
}
// CronStatsType is one of two recognized types of cron stats (i.e. "scheduled" or "triggered")
diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go
index d4363adcd9..3fa785302e 100644
--- a/server/fleet/datastore.go
+++ b/server/fleet/datastore.go
@@ -790,7 +790,7 @@ type Datastore interface {
// InsertCronStats inserts cron stats for the named cron schedule.
InsertCronStats(ctx context.Context, statsType CronStatsType, name string, instance string, status CronStatsStatus) (int, error)
// UpdateCronStats updates the status of the identified cron stats record.
- UpdateCronStats(ctx context.Context, id int, status CronStatsStatus) error
+ UpdateCronStats(ctx context.Context, id int, status CronStatsStatus, cronErrors *CronScheduleErrors) error
// UpdateAllCronStatsForInstance updates all records for the identified instance with the
// specified statuses
UpdateAllCronStatsForInstance(ctx context.Context, instance string, fromStatus CronStatsStatus, toStatus CronStatsStatus) error
diff --git a/server/fleet/secrets.go b/server/fleet/secrets.go
index 0688a825d0..f8ecaac4ad 100644
--- a/server/fleet/secrets.go
+++ b/server/fleet/secrets.go
@@ -20,5 +20,5 @@ func (e MissingSecretsError) Error() string {
if len(secretVars) > 1 {
plural = "s"
}
- return fmt.Sprintf("Couldn't add. Variable%s %s missing", plural, strings.Join(secretVars, ", "))
+ return fmt.Sprintf("Couldn't add. Secret variable%s %s missing from database", plural, strings.Join(secretVars, ", "))
}
diff --git a/server/fleet/software_installer.go b/server/fleet/software_installer.go
index e033efe28a..ad5c848818 100644
--- a/server/fleet/software_installer.go
+++ b/server/fleet/software_installer.go
@@ -564,7 +564,7 @@ type SoftwareInstallerTokenMetadata struct {
TeamID uint `json:"team_id"`
}
-const SoftwareInstallerURLMaxLength = 255
+const SoftwareInstallerURLMaxLength = 4000
// TempFileReader is an io.Reader with all extra io interfaces supported by a
// file on disk reader (e.g. io.ReaderAt, io.Seeker, etc.). When created with
diff --git a/server/mdm/apple/commander.go b/server/mdm/apple/commander.go
index 6a098a5284..4f1a279d7e 100644
--- a/server/mdm/apple/commander.go
+++ b/server/mdm/apple/commander.go
@@ -46,9 +46,18 @@ func NewMDMAppleCommander(mdmStorage fleet.MDMAppleStore, mdmPushService nanomdm
// InstallProfile sends the homonymous MDM command to the given hosts, it also
// takes care of the base64 encoding of the provided profile bytes.
func (svc *MDMAppleCommander) InstallProfile(ctx context.Context, hostUUIDs []string, profile mobileconfig.Mobileconfig, uuid string) error {
+ raw, err := svc.SignAndEncodeInstallProfile(ctx, profile, uuid)
+ if err != nil {
+ return err
+ }
+ err = svc.EnqueueCommand(ctx, hostUUIDs, raw)
+ return ctxerr.Wrap(ctx, err, "commander install profile")
+}
+
+func (svc *MDMAppleCommander) SignAndEncodeInstallProfile(ctx context.Context, profile []byte, commandUUID string) (string, error) {
signedProfile, err := mdmcrypto.Sign(ctx, profile, svc.storage)
if err != nil {
- return ctxerr.Wrap(ctx, err, "signing profile")
+ return "", ctxerr.Wrap(ctx, err, "signing profile")
}
base64Profile := base64.StdEncoding.EncodeToString(signedProfile)
@@ -66,12 +75,11 @@ func (svc *MDMAppleCommander) InstallProfile(ctx context.Context, hostUUIDs []st
%s
-`, uuid, base64Profile)
- err = svc.EnqueueCommand(ctx, hostUUIDs, raw)
- return ctxerr.Wrap(ctx, err, "commander install profile")
+`, commandUUID, base64Profile)
+ return raw, nil
}
-// InstallProfile sends the homonymous MDM command to the given hosts.
+// RemoveProfile sends the homonymous MDM command to the given hosts.
func (svc *MDMAppleCommander) RemoveProfile(ctx context.Context, hostUUIDs []string, profileIdentifier string, uuid string) error {
raw := fmt.Sprintf(`
@@ -366,17 +374,35 @@ func (svc *MDMAppleCommander) EnqueueCommand(ctx context.Context, hostUUIDs []st
return ctxerr.Wrap(ctx, err, "decoding command")
}
- if _, err := svc.storage.EnqueueCommand(ctx, hostUUIDs, cmd); err != nil {
+ return svc.enqueueAndNotify(ctx, hostUUIDs, cmd, mdm.CommandSubtypeNone)
+}
+
+func (svc *MDMAppleCommander) enqueueAndNotify(ctx context.Context, hostUUIDs []string, cmd *mdm.Command,
+ subtype mdm.CommandSubtype) error {
+ if _, err := svc.storage.EnqueueCommand(ctx, hostUUIDs,
+ &mdm.CommandWithSubtype{Command: *cmd, Subtype: subtype}); err != nil {
return ctxerr.Wrap(ctx, err, "enqueuing command")
}
if err := svc.SendNotifications(ctx, hostUUIDs); err != nil {
return ctxerr.Wrap(ctx, err, "sending notifications")
}
-
return nil
}
+// EnqueueCommandInstallProfileWithSecrets is a special case of EnqueueCommand that does not expand secret variables.
+// Secret variables are expanded when the command is sent to the device, and secrets are never stored in the database unencrypted.
+func (svc *MDMAppleCommander) EnqueueCommandInstallProfileWithSecrets(ctx context.Context, hostUUIDs []string,
+ rawCommand mobileconfig.Mobileconfig, commandUUID string) error {
+ cmd := &mdm.Command{
+ CommandUUID: commandUUID,
+ Raw: []byte(rawCommand),
+ }
+ cmd.Command.RequestType = "InstallProfile"
+
+ return svc.enqueueAndNotify(ctx, hostUUIDs, cmd, mdm.CommandSubtypeProfileWithSecrets)
+}
+
func (svc *MDMAppleCommander) SendNotifications(ctx context.Context, hostUUIDs []string) error {
apnsResponses, err := svc.pusher.Push(ctx, hostUUIDs)
if err != nil {
diff --git a/server/mdm/apple/commander_test.go b/server/mdm/apple/commander_test.go
index 397ac63372..b15ca06e6a 100644
--- a/server/mdm/apple/commander_test.go
+++ b/server/mdm/apple/commander_test.go
@@ -44,9 +44,9 @@ func TestMDMAppleCommander(t *testing.T) {
payloadIdentifier := "com-foo-bar"
mc := mobileconfigForTest(payloadName, payloadIdentifier)
- mdmStorage.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.Command) (map[string]error, error) {
+ mdmStorage.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.CommandWithSubtype) (map[string]error, error) {
require.NotNil(t, cmd)
- require.Equal(t, cmd.Command.RequestType, "InstallProfile")
+ require.Equal(t, cmd.Command.Command.RequestType, "InstallProfile")
var fullCmd micromdm.CommandPayload
require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
p7, err := pkcs7.Parse(fullCmd.Command.InstallProfile.Payload)
@@ -96,9 +96,9 @@ func TestMDMAppleCommander(t *testing.T) {
require.True(t, mdmStorage.RetrievePushInfoFuncInvoked)
mdmStorage.RetrievePushInfoFuncInvoked = false
- mdmStorage.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.Command) (map[string]error, error) {
+ mdmStorage.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.CommandWithSubtype) (map[string]error, error) {
require.NotNil(t, cmd)
- require.Equal(t, "RemoveProfile", cmd.Command.RequestType)
+ require.Equal(t, "RemoveProfile", cmd.Command.Command.RequestType)
require.Contains(t, string(cmd.Raw), payloadIdentifier)
return nil, nil
}
@@ -111,9 +111,9 @@ func TestMDMAppleCommander(t *testing.T) {
require.NoError(t, err)
cmdUUID = uuid.New().String()
- mdmStorage.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.Command) (map[string]error, error) {
+ mdmStorage.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.CommandWithSubtype) (map[string]error, error) {
require.NotNil(t, cmd)
- require.Equal(t, "InstallEnterpriseApplication", cmd.Command.RequestType)
+ require.Equal(t, "InstallEnterpriseApplication", cmd.Command.Command.RequestType)
require.Contains(t, string(cmd.Raw), "http://test.example.com")
require.Contains(t, string(cmd.Raw), cmdUUID)
return nil, nil
diff --git a/server/mdm/nanomdm/http/api/api.go b/server/mdm/nanomdm/http/api/api.go
index 69174440f9..7954331dbb 100644
--- a/server/mdm/nanomdm/http/api/api.go
+++ b/server/mdm/nanomdm/http/api/api.go
@@ -181,7 +181,7 @@ func RawCommandEnqueueHandler(enqueuer storage.CommandEnqueuer, pusher push.Push
logs := []interface{}{
"msg", "enqueue",
}
- idErrs, err := enqueuer.EnqueueCommand(ctx, ids, command)
+ idErrs, err := enqueuer.EnqueueCommand(ctx, ids, &mdm.CommandWithSubtype{Command: *command, Subtype: mdm.CommandSubtypeNone})
ct := len(ids) - len(idErrs)
if err != nil {
logs = append(logs, "err", err)
diff --git a/server/mdm/nanomdm/mdm/command.go b/server/mdm/nanomdm/mdm/command.go
index 4238c0a5ef..455c5dc104 100644
--- a/server/mdm/nanomdm/mdm/command.go
+++ b/server/mdm/nanomdm/mdm/command.go
@@ -43,6 +43,13 @@ func DecodeCommandResults(rawResults []byte) (results *CommandResults, err error
return
}
+type CommandSubtype string
+
+const (
+ CommandSubtypeNone CommandSubtype = "None"
+ CommandSubtypeProfileWithSecrets CommandSubtype = "ProfileWithSecrets"
+)
+
// Command represents a generic MDM command without command-specific fields.
type Command struct {
CommandUUID string
@@ -52,6 +59,11 @@ type Command struct {
Raw []byte `plist:"-"` // Original command XML plist
}
+type CommandWithSubtype struct {
+ Command
+ Subtype CommandSubtype
+}
+
// DecodeCommand unmarshals rawCommand into command
func DecodeCommand(rawCommand []byte) (command *Command, err error) {
command = new(Command)
diff --git a/server/mdm/nanomdm/service/nanomdm/service.go b/server/mdm/nanomdm/service/nanomdm/service.go
index d081ec521d..227df7fa1b 100644
--- a/server/mdm/nanomdm/service/nanomdm/service.go
+++ b/server/mdm/nanomdm/service/nanomdm/service.go
@@ -28,6 +28,9 @@ type Service struct {
// GetToken handler
gt service.GetToken
+
+ // ProfileService
+ ps service.ProfileService
}
// normalize generates enrollment IDs that are used by other
@@ -71,6 +74,12 @@ func WithDeclarativeManagement(dm service.DeclarativeManagement) Option {
}
}
+func WithProfileService(ps service.ProfileService) Option {
+ return func(s *Service) {
+ s.ps = ps
+ }
+}
+
// WithUserAuthenticate configures a UserAuthenticate check-in message handler.
func WithUserAuthenticate(ua service.UserAuthenticate) Option {
return func(s *Service) {
@@ -253,25 +262,40 @@ func (s *Service) CommandAndReportResults(r *mdm.Request, results *mdm.CommandRe
if err != nil {
return nil, fmt.Errorf("retrieving next command: %w", err)
}
- if cmd != nil {
+ if cmd == nil {
logger.Debug(
- "msg", "command retrieved",
- "command_uuid", cmd.CommandUUID,
- "request_type", cmd.Command.RequestType,
+ "msg", "no command retrieved",
)
- // We expand secrets in the command before returning it to the caller so that we never store unencrypted secrets in the database.
- expanded, err := s.store.ExpandEmbeddedSecrets(r.Context, string(cmd.Raw))
- if err != nil {
- // This error is not expected since secrets should have been validated on profile upload.
- logger.Info("level", "error", "msg", "expanding embedded secrets", "err", err)
- // Since this error should not happen, we use the command as is, without expanding secrets.
- } else {
- cmd.Raw = []byte(expanded)
- }
- return cmd, nil
+ return nil, nil
}
logger.Debug(
- "msg", "no command retrieved",
+ "msg", "command retrieved",
+ "command_uuid", cmd.CommandUUID,
+ "request_type", cmd.Command.Command.RequestType,
+ "subtype", cmd.Subtype,
)
- return nil, nil
+ // We expand secrets in the command before returning it to the caller so that we never store unencrypted secrets in the database.
+ // User can issue a one-off MDM command that contains a secret variable, for example.
+ expanded, err := s.store.ExpandEmbeddedSecrets(r.Context, string(cmd.Raw))
+ if err != nil {
+ // This error should never happen since secrets have been validated on profile upload.
+ logger.Info("level", "error", "msg", "expanding embedded secrets", "err", err)
+ // Since this error should not happen, we simply use the command as is, without expanding secrets.
+ } else {
+ cmd.Raw = []byte(expanded)
+ }
+ switch cmd.Subtype {
+ case mdm.CommandSubtypeProfileWithSecrets:
+ // Secrets were expanded above. Now we need to base64 encode and sign the configuration profile before returning it to the caller.
+ processed, err := s.ps.SignAndEncodeInstallProfile(r.Context, cmd.Raw, cmd.CommandUUID)
+ if err != nil {
+ logger.Info("level", "error", "msg", "signing and encoding profile", "err", err)
+ // Since this error should not normally happen, we simply use the command as is. This way the client can fetch the next command and will not be blocked.
+ return &cmd.Command, nil
+ }
+ cmd.Raw = []byte(processed)
+ return &cmd.Command, nil
+ default:
+ return &cmd.Command, nil
+ }
}
diff --git a/server/mdm/nanomdm/service/service.go b/server/mdm/nanomdm/service/service.go
index cb0f16ccc6..d4e93c5cdf 100644
--- a/server/mdm/nanomdm/service/service.go
+++ b/server/mdm/nanomdm/service/service.go
@@ -2,6 +2,8 @@
package service
import (
+ "context"
+
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
)
@@ -46,3 +48,8 @@ type CheckinAndCommandService interface {
Checkin
CommandAndReportResults
}
+
+// ProfileService represents the interface to call specific functions from Fleet's main services.
+type ProfileService interface {
+ SignAndEncodeInstallProfile(ctx context.Context, profile []byte, commandUUID string) (string, error)
+}
diff --git a/server/mdm/nanomdm/storage/allmulti/queue.go b/server/mdm/nanomdm/storage/allmulti/queue.go
index 7cf3988d52..f6129284d4 100644
--- a/server/mdm/nanomdm/storage/allmulti/queue.go
+++ b/server/mdm/nanomdm/storage/allmulti/queue.go
@@ -14,11 +14,11 @@ func (ms *MultiAllStorage) StoreCommandReport(r *mdm.Request, report *mdm.Comman
return err
}
-func (ms *MultiAllStorage) RetrieveNextCommand(r *mdm.Request, skipNotNow bool) (*mdm.Command, error) {
+func (ms *MultiAllStorage) RetrieveNextCommand(r *mdm.Request, skipNotNow bool) (*mdm.CommandWithSubtype, error) {
val, err := ms.execStores(r.Context, func(s storage.AllStorage) (interface{}, error) {
return s.RetrieveNextCommand(r, skipNotNow)
})
- return val.(*mdm.Command), err
+ return val.(*mdm.CommandWithSubtype), err
}
func (ms *MultiAllStorage) ClearQueue(r *mdm.Request) error {
@@ -28,7 +28,8 @@ func (ms *MultiAllStorage) ClearQueue(r *mdm.Request) error {
return err
}
-func (ms *MultiAllStorage) EnqueueCommand(ctx context.Context, id []string, cmd *mdm.Command) (map[string]error, error) {
+func (ms *MultiAllStorage) EnqueueCommand(ctx context.Context, id []string, cmd *mdm.CommandWithSubtype) (map[string]error,
+ error) {
val, err := ms.execStores(ctx, func(s storage.AllStorage) (interface{}, error) {
return s.EnqueueCommand(ctx, id, cmd)
})
diff --git a/server/mdm/nanomdm/storage/file/queue.go b/server/mdm/nanomdm/storage/file/queue.go
index 2744b78679..ea52495d39 100644
--- a/server/mdm/nanomdm/storage/file/queue.go
+++ b/server/mdm/nanomdm/storage/file/queue.go
@@ -101,7 +101,8 @@ func (q *queue) getNext() (*mdm.Command, error) {
}
// EnqueueCommand writes the command to disk in the queue directory
-func (s *FileStorage) EnqueueCommand(_ context.Context, ids []string, command *mdm.Command) (map[string]error, error) {
+func (s *FileStorage) EnqueueCommand(_ context.Context, ids []string, command *mdm.CommandWithSubtype) (map[string]error,
+ error) {
idErrs := make(map[string]error)
for _, id := range ids {
e := s.newEnrollment(id)
@@ -153,21 +154,28 @@ func (s *FileStorage) StoreCommandReport(r *mdm.Request, report *mdm.CommandResu
}
// RetrieveNextCommand gets the next command from the queue while minding NotNow status
-func (s *FileStorage) RetrieveNextCommand(r *mdm.Request, skipNotNow bool) (*mdm.Command, error) {
+func (s *FileStorage) RetrieveNextCommand(r *mdm.Request, skipNotNow bool) (*mdm.CommandWithSubtype, error) {
e := s.newEnrollment(r.ID)
var q *queue
if !skipNotNow {
q = e.newQueue(subNotNow)
raw, err := q.getNext()
if err != nil {
- return raw, err
+ return nil, err
}
if raw != nil {
- return raw, nil
+ return &mdm.CommandWithSubtype{Command: *raw}, nil
}
}
q = e.newQueue(subQueue)
- return q.getNext()
+ raw, err := q.getNext()
+ if err != nil {
+ return nil, err
+ }
+ if raw != nil {
+ return &mdm.CommandWithSubtype{Command: *raw}, nil
+ }
+ return nil, nil
}
func (s *FileStorage) ClearQueue(r *mdm.Request) error {
diff --git a/server/mdm/nanomdm/storage/mysql/queue.go b/server/mdm/nanomdm/storage/mysql/queue.go
index 9654d01f88..c26d787a88 100644
--- a/server/mdm/nanomdm/storage/mysql/queue.go
+++ b/server/mdm/nanomdm/storage/mysql/queue.go
@@ -15,14 +15,14 @@ import (
"github.com/micromdm/nanolib/log"
)
-func enqueue(ctx context.Context, tx sqlx.ExtContext, ids []string, cmd *mdm.Command) error {
+func enqueue(ctx context.Context, tx sqlx.ExtContext, ids []string, cmd *mdm.CommandWithSubtype) error {
if len(ids) < 1 {
return errors.New("no id(s) supplied to queue command to")
}
_, err := tx.ExecContext(
ctx,
- `INSERT INTO nano_commands (command_uuid, request_type, command) VALUES (?, ?, ?);`,
- cmd.CommandUUID, cmd.Command.RequestType, cmd.Raw,
+ `INSERT INTO nano_commands (command_uuid, request_type, command, subtype) VALUES (?, ?, ?, ?)`,
+ cmd.CommandUUID, cmd.Command.Command.RequestType, cmd.Raw, cmd.Subtype,
)
if err != nil {
return err
@@ -62,7 +62,8 @@ func (l loggerWrapper) Log(keyvals ...interface{}) error {
return nil
}
-func (m *MySQLStorage) EnqueueCommand(ctx context.Context, ids []string, cmd *mdm.Command) (map[string]error, error) {
+func (m *MySQLStorage) EnqueueCommand(ctx context.Context, ids []string, cmd *mdm.CommandWithSubtype) (map[string]error,
+ error) {
// We need to retry because this transaction may deadlock with updates to nano_enrollment.last_seen_at
// Deadlock seen in 2024/12/12 loadtest: https://docs.google.com/document/d/1-Q6qFTd7CDm-lh7MVRgpNlNNJijk6JZ4KO49R1fp80U
err := common_mysql.WithRetryTxx(ctx, sqlx.NewDb(m.db, ""), func(tx sqlx.ExtContext) error {
@@ -190,8 +191,8 @@ UPDATE
return err
}
-func (m *MySQLStorage) RetrieveNextCommand(r *mdm.Request, skipNotNow bool) (*mdm.Command, error) {
- command := new(mdm.Command)
+func (m *MySQLStorage) RetrieveNextCommand(r *mdm.Request, skipNotNow bool) (*mdm.CommandWithSubtype, error) {
+ command := new(mdm.CommandWithSubtype)
id := "?"
var args []interface{}
// Validate the ID to avoid SQL injection.
@@ -211,7 +212,7 @@ func (m *MySQLStorage) RetrieveNextCommand(r *mdm.Request, skipNotNow bool) (*md
r.Context, fmt.Sprintf(
// The query should use the ANTIJOIN (NOT EXISTS) optimization on the nano_command_results table.
`
-SELECT c.command_uuid, c.request_type, c.command
+SELECT c.command_uuid, c.request_type, c.command, c.subtype
FROM nano_enrollment_queue AS q
INNER JOIN nano_commands AS c
ON q.command_uuid = c.command_uuid
@@ -224,7 +225,7 @@ ORDER BY
q.priority DESC,
q.created_at
LIMIT 1;`, skipNotNow, id), args...,
- ).Scan(&command.CommandUUID, &command.Command.RequestType, &command.Raw)
+ ).Scan(&command.CommandUUID, &command.Command.Command.RequestType, &command.Raw, &command.Subtype)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
diff --git a/server/mdm/nanomdm/storage/mysql/schema.sql b/server/mdm/nanomdm/storage/mysql/schema.sql
index d1f420391d..57e556db0c 100644
--- a/server/mdm/nanomdm/storage/mysql/schema.sql
+++ b/server/mdm/nanomdm/storage/mysql/schema.sql
@@ -138,8 +138,10 @@ CREATE TABLE nano_commands (
-- Raw command Plist
command MEDIUMTEXT NOT NULL,
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
+ updated_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
+
+ subtype ENUM('None','ProfileWithSecrets') CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'None',
PRIMARY KEY (command_uuid),
diff --git a/server/mdm/nanomdm/storage/storage.go b/server/mdm/nanomdm/storage/storage.go
index 366e38d607..89efc1fb4b 100644
--- a/server/mdm/nanomdm/storage/storage.go
+++ b/server/mdm/nanomdm/storage/storage.go
@@ -25,7 +25,7 @@ type CheckinStore interface {
// CommandAndReportResultsStore stores and retrieves MDM command queue data.
type CommandAndReportResultsStore interface {
StoreCommandReport(r *mdm.Request, report *mdm.CommandResults) error
- RetrieveNextCommand(r *mdm.Request, skipNotNow bool) (*mdm.Command, error)
+ RetrieveNextCommand(r *mdm.Request, skipNotNow bool) (*mdm.CommandWithSubtype, error)
ClearQueue(r *mdm.Request) error
}
@@ -73,7 +73,7 @@ type PushCertStore interface {
// CommandEnqueuer is able to enqueue MDM commands.
type CommandEnqueuer interface {
- EnqueueCommand(ctx context.Context, id []string, cmd *mdm.Command) (map[string]error, error)
+ EnqueueCommand(ctx context.Context, id []string, cmd *mdm.CommandWithSubtype) (map[string]error, error)
}
// CertAuthStore stores and retrieves cert-to-enrollment associations.
diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go
index 9d85e81d25..305dd49ee0 100644
--- a/server/mock/datastore_mock.go
+++ b/server/mock/datastore_mock.go
@@ -583,7 +583,7 @@ type GetLatestCronStatsFunc func(ctx context.Context, name string) ([]fleet.Cron
type InsertCronStatsFunc func(ctx context.Context, statsType fleet.CronStatsType, name string, instance string, status fleet.CronStatsStatus) (int, error)
-type UpdateCronStatsFunc func(ctx context.Context, id int, status fleet.CronStatsStatus) error
+type UpdateCronStatsFunc func(ctx context.Context, id int, status fleet.CronStatsStatus, cronErrors *fleet.CronScheduleErrors) error
type UpdateAllCronStatsForInstanceFunc func(ctx context.Context, instance string, fromStatus fleet.CronStatsStatus, toStatus fleet.CronStatsStatus) error
@@ -4897,11 +4897,11 @@ func (s *DataStore) InsertCronStats(ctx context.Context, statsType fleet.CronSta
return s.InsertCronStatsFunc(ctx, statsType, name, instance, status)
}
-func (s *DataStore) UpdateCronStats(ctx context.Context, id int, status fleet.CronStatsStatus) error {
+func (s *DataStore) UpdateCronStats(ctx context.Context, id int, status fleet.CronStatsStatus, cronErrors *fleet.CronScheduleErrors) error {
s.mu.Lock()
s.UpdateCronStatsFuncInvoked = true
s.mu.Unlock()
- return s.UpdateCronStatsFunc(ctx, id, status)
+ return s.UpdateCronStatsFunc(ctx, id, status, &fleet.CronScheduleErrors{})
}
func (s *DataStore) UpdateAllCronStatsForInstance(ctx context.Context, instance string, fromStatus fleet.CronStatsStatus, toStatus fleet.CronStatsStatus) error {
diff --git a/server/mock/mdm/datastore_mdm_mock.go b/server/mock/mdm/datastore_mdm_mock.go
index 5637b82f7c..cbed04c825 100644
--- a/server/mock/mdm/datastore_mdm_mock.go
+++ b/server/mock/mdm/datastore_mdm_mock.go
@@ -25,7 +25,7 @@ type StoreUserAuthenticateFunc func(r *mdm.Request, msg *mdm.UserAuthenticate) e
type StoreCommandReportFunc func(r *mdm.Request, report *mdm.CommandResults) error
-type RetrieveNextCommandFunc func(r *mdm.Request, skipNotNow bool) (*mdm.Command, error)
+type RetrieveNextCommandFunc func(r *mdm.Request, skipNotNow bool) (*mdm.CommandWithSubtype, error)
type ClearQueueFunc func(r *mdm.Request) error
@@ -43,7 +43,7 @@ type RetrievePushCertFunc func(ctx context.Context, topic string) (cert *tls.Cer
type StorePushCertFunc func(ctx context.Context, pemCert []byte, pemKey []byte) error
-type EnqueueCommandFunc func(ctx context.Context, id []string, cmd *mdm.Command) (map[string]error, error)
+type EnqueueCommandFunc func(ctx context.Context, id []string, cmd *mdm.CommandWithSubtype) (map[string]error, error)
type HasCertHashFunc func(r *mdm.Request, hash string) (bool, error)
@@ -184,7 +184,7 @@ func (fs *MDMAppleStore) StoreCommandReport(r *mdm.Request, report *mdm.CommandR
return fs.StoreCommandReportFunc(r, report)
}
-func (fs *MDMAppleStore) RetrieveNextCommand(r *mdm.Request, skipNotNow bool) (*mdm.Command, error) {
+func (fs *MDMAppleStore) RetrieveNextCommand(r *mdm.Request, skipNotNow bool) (*mdm.CommandWithSubtype, error) {
fs.mu.Lock()
fs.RetrieveNextCommandFuncInvoked = true
fs.mu.Unlock()
@@ -247,7 +247,7 @@ func (fs *MDMAppleStore) StorePushCert(ctx context.Context, pemCert []byte, pemK
return fs.StorePushCertFunc(ctx, pemCert, pemKey)
}
-func (fs *MDMAppleStore) EnqueueCommand(ctx context.Context, id []string, cmd *mdm.Command) (map[string]error, error) {
+func (fs *MDMAppleStore) EnqueueCommand(ctx context.Context, id []string, cmd *mdm.CommandWithSubtype) (map[string]error, error) {
fs.mu.Lock()
fs.EnqueueCommandFuncInvoked = true
fs.mu.Unlock()
diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go
index 6009558395..e636b7264c 100644
--- a/server/service/apple_mdm.go
+++ b/server/service/apple_mdm.go
@@ -380,14 +380,10 @@ func (svc *Service) NewMDMAppleConfigProfile(ctx context.Context, teamID uint, r
})
}
- if err := svc.ds.ValidateEmbeddedSecrets(ctx, []string{string(b)}); err != nil {
- return nil, fleet.NewInvalidArgumentError("profile", err.Error())
- }
-
- // Expand secrets in profile for validation
+ // Expand and validate secrets in profile
expanded, err := svc.ds.ExpandEmbeddedSecrets(ctx, string(b))
if err != nil {
- return nil, ctxerr.Wrap(ctx, err, "expanding secrets in profile for parsing")
+ return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("profile", err.Error()))
}
cp, err := fleet.NewMDMAppleConfigProfile([]byte(expanded), &teamID)
@@ -1998,14 +1994,15 @@ func (svc *Service) BatchSetMDMAppleProfiles(ctx context.Context, tmID *uint, tm
fleet.NewInvalidArgumentError(fmt.Sprintf("profiles[%d]", i), err.Error()),
"invalid mobileconfig profile")
}
- // Store original unexpanded profile
- mdmProf.Mobileconfig = prof
if err := mdmProf.ValidateUserProvided(); err != nil {
return ctxerr.Wrap(ctx,
fleet.NewInvalidArgumentError(fmt.Sprintf("profiles[%d]", i), err.Error()))
}
+ // Store original unexpanded profile
+ mdmProf.Mobileconfig = prof
+
if byName[mdmProf.Name] {
return ctxerr.Wrap(ctx,
fleet.NewInvalidArgumentError(fmt.Sprintf("profiles[%d]", i), fmt.Sprintf("Couldn’t edit custom_settings. More than one configuration profile have the same name (PayloadDisplayName): %q", mdmProf.Name)),
@@ -2023,15 +2020,6 @@ func (svc *Service) BatchSetMDMAppleProfiles(ctx context.Context, tmID *uint, tm
profs = append(profs, mdmProf)
}
- profStrings := make([]string, 0, len(profs))
- for _, prof := range profs {
- profStrings = append(profStrings, string(prof.Mobileconfig))
- }
-
- if err := svc.ds.ValidateEmbeddedSecrets(ctx, profStrings); err != nil {
- return fleet.NewInvalidArgumentError("profiles", err.Error())
- }
-
if !skipBulkPending {
// check for duplicates with existing profiles, skipBulkPending signals that the caller
// is responsible for ensuring that the profiles names are unique (e.g., MDMAppleMatchPreassignment)
@@ -3527,12 +3515,18 @@ func ReconcileAppleProfiles(
return ctxerr.Wrap(ctx, err, "get profile contents")
}
- // Insert variables into profile contents
+ // Insert variables into profile contents of install targets. Variables may be host-specific.
err = preprocessProfileContents(ctx, appConfig, ds, installTargets, profileContents, hostProfilesToInstallMap)
if err != nil {
return err
}
+ // Find the profiles containing secret variables.
+ profilesWithSecrets, err := findProfilesWithSecrets(logger, installTargets, profileContents)
+ if err != nil {
+ return err
+ }
+
type remoteResult struct {
Err error
CmdUUID string
@@ -3548,7 +3542,11 @@ func ReconcileAppleProfiles(
var err error
switch op {
case fleet.MDMOperationTypeInstall:
- err = commander.InstallProfile(ctx, target.hostUUIDs, profileContents[profUUID], target.cmdUUID)
+ if _, ok := profilesWithSecrets[profUUID]; ok {
+ err = commander.EnqueueCommandInstallProfileWithSecrets(ctx, target.hostUUIDs, profileContents[profUUID], target.cmdUUID)
+ } else {
+ err = commander.InstallProfile(ctx, target.hostUUIDs, profileContents[profUUID], target.cmdUUID)
+ }
case fleet.MDMOperationTypeRemove:
err = commander.RemoveProfile(ctx, target.hostUUIDs, target.profIdent, target.cmdUUID)
}
@@ -3612,6 +3610,27 @@ func ReconcileAppleProfiles(
return nil
}
+func findProfilesWithSecrets(
+ logger kitlog.Logger,
+ installTargets map[string]*cmdTarget,
+ profileContents map[string]mobileconfig.Mobileconfig,
+) (map[string]struct{}, error) {
+ profilesWithSecrets := make(map[string]struct{})
+ for profUUID := range installTargets {
+ p, ok := profileContents[profUUID]
+ if !ok { // Should never happen
+ level.Error(logger).Log("msg", "profile content not found in ReconcileAppleProfiles", "profile_uuid", profUUID)
+ continue
+ }
+ profileStr := string(p)
+ vars := fleet.ContainsPrefixVars(profileStr, fleet.ServerSecretPrefix)
+ if len(vars) > 0 {
+ profilesWithSecrets[profUUID] = struct{}{}
+ }
+ }
+ return profilesWithSecrets, nil
+}
+
func preprocessProfileContents(
ctx context.Context,
appConfig *fleet.AppConfig,
@@ -4250,8 +4269,13 @@ func (svc *MDMAppleDDMService) handleConfigurationDeclaration(ctx context.Contex
return nil, ctxerr.Wrap(ctx, err, "getting declaration response")
}
+ expanded, err := svc.ds.ExpandEmbeddedSecrets(ctx, string(d.RawJSON))
+ if err != nil {
+ return nil, ctxerr.Wrap(ctx, err, fmt.Sprintf("expanding embedded secrets for identifier:%s hostUUID:%s", parts[2], hostUUID))
+ }
+
var tempd map[string]any
- if err := json.Unmarshal(d.RawJSON, &tempd); err != nil {
+ if err := json.Unmarshal([]byte(expanded), &tempd); err != nil {
return nil, ctxerr.Wrap(ctx, err, "unmarshaling stored declaration")
}
tempd["ServerToken"] = d.Checksum
diff --git a/server/service/apple_mdm_test.go b/server/service/apple_mdm_test.go
index b60c72d851..4722fe3bd4 100644
--- a/server/service/apple_mdm_test.go
+++ b/server/service/apple_mdm_test.go
@@ -109,7 +109,7 @@ func setupAppleMDMService(t *testing.T, license *fleet.LicenseInfo) (fleet.Servi
}
svc, ctx := newTestServiceWithConfig(t, ds, cfg, nil, nil, opts)
- mdmStorage.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.Command) (map[string]error, error) {
+ mdmStorage.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.CommandWithSubtype) (map[string]error, error) {
return nil, nil
}
mdmStorage.RetrievePushInfoFunc = func(ctx context.Context, tokens []string) (map[string]*mdm.Push, error) {
@@ -2293,11 +2293,11 @@ func TestMDMAppleReconcileAppleProfiles(t *testing.T) {
var enqueueFailForOp fleet.MDMOperationType
var mu sync.Mutex
- mdmStorage.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.Command) (map[string]error, error) {
+ mdmStorage.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.CommandWithSubtype) (map[string]error, error) {
require.NotNil(t, cmd)
require.NotEmpty(t, cmd.CommandUUID)
- switch cmd.Command.RequestType {
+ switch cmd.Command.Command.RequestType {
case "InstallProfile":
// may be called for a single host or both
if len(id) == 2 {
@@ -2324,9 +2324,9 @@ func TestMDMAppleReconcileAppleProfiles(t *testing.T) {
require.Contains(t, string(cmd.Raw), "com.remove.profile")
}
switch {
- case enqueueFailForOp == fleet.MDMOperationTypeInstall && cmd.Command.RequestType == "InstallProfile":
+ case enqueueFailForOp == fleet.MDMOperationTypeInstall && cmd.Command.Command.RequestType == "InstallProfile":
return nil, errors.New("enqueue error")
- case enqueueFailForOp == fleet.MDMOperationTypeRemove && cmd.Command.RequestType == "RemoveProfile":
+ case enqueueFailForOp == fleet.MDMOperationTypeRemove && cmd.Command.Command.RequestType == "RemoveProfile":
return nil, errors.New("enqueue error")
}
return nil, nil
@@ -3705,8 +3705,9 @@ func TestRenewSCEPCertificatesBranches(t *testing.T) {
return []fleet.SCEPIdentityAssociation{{HostUUID: "hostUUID1", EnrollReference: ""}}, nil
}
- appleStore.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.Command) (map[string]error, error) {
- require.Equal(t, "InstallProfile", cmd.Command.RequestType)
+ appleStore.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.CommandWithSubtype) (map[string]error,
+ error) {
+ require.Equal(t, "InstallProfile", cmd.Command.Command.RequestType)
wantCommandUUID = cmd.CommandUUID
return map[string]error{}, nil
}
@@ -3731,7 +3732,8 @@ func TestRenewSCEPCertificatesBranches(t *testing.T) {
return []fleet.SCEPIdentityAssociation{{HostUUID: "hostUUID1", EnrollReference: ""}}, nil
}
- appleStore.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.Command) (map[string]error, error) {
+ appleStore.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.CommandWithSubtype) (map[string]error,
+ error) {
return map[string]error{}, errors.New("foo")
}
},
@@ -3744,8 +3746,9 @@ func TestRenewSCEPCertificatesBranches(t *testing.T) {
ds.GetHostCertAssociationsToExpireFunc = func(ctx context.Context, expiryDays int, limit int) ([]fleet.SCEPIdentityAssociation, error) {
return []fleet.SCEPIdentityAssociation{{HostUUID: "hostUUID2", EnrollReference: "ref1"}}, nil
}
- appleStore.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.Command) (map[string]error, error) {
- require.Equal(t, "InstallProfile", cmd.Command.RequestType)
+ appleStore.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.CommandWithSubtype) (map[string]error,
+ error) {
+ require.Equal(t, "InstallProfile", cmd.Command.Command.RequestType)
wantCommandUUID = cmd.CommandUUID
return map[string]error{}, nil
}
@@ -3769,7 +3772,8 @@ func TestRenewSCEPCertificatesBranches(t *testing.T) {
return []fleet.SCEPIdentityAssociation{{HostUUID: "hostUUID1", EnrollReference: "ref1"}}, nil
}
- appleStore.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.Command) (map[string]error, error) {
+ appleStore.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.CommandWithSubtype) (map[string]error,
+ error) {
return map[string]error{}, errors.New("foo")
}
},
diff --git a/server/service/handler.go b/server/service/handler.go
index 1c89abd466..997db84c2c 100644
--- a/server/service/handler.go
+++ b/server/service/handler.go
@@ -1110,11 +1110,12 @@ func RegisterAppleMDMProtocolServices(
logger kitlog.Logger,
checkinAndCommandService nanomdm_service.CheckinAndCommandService,
ddmService nanomdm_service.DeclarativeManagement,
+ profileService nanomdm_service.ProfileService,
) error {
if err := registerSCEP(mux, scepConfig, scepStorage, mdmStorage, logger); err != nil {
return fmt.Errorf("scep: %w", err)
}
- if err := registerMDM(mux, mdmStorage, checkinAndCommandService, ddmService, logger); err != nil {
+ if err := registerMDM(mux, mdmStorage, checkinAndCommandService, ddmService, profileService, logger); err != nil {
return fmt.Errorf("mdm: %w", err)
}
return nil
@@ -1206,6 +1207,7 @@ func registerMDM(
mdmStorage fleet.MDMAppleStore,
checkinAndCommandService nanomdm_service.CheckinAndCommandService,
ddmService nanomdm_service.DeclarativeManagement,
+ profileService nanomdm_service.ProfileService,
logger kitlog.Logger,
) error {
certVerifier := mdmcrypto.NewSCEPVerifier(mdmStorage)
@@ -1219,7 +1221,8 @@ func registerMDM(
// enrollments and updates the Fleet hosts table accordingly with the UDID and serial number of
// the device.
// 5. Run actual MDM service operation (checkin handler or command and results handler).
- coreMDMService := nanomdm.New(mdmStorage, nanomdm.WithLogger(mdmLogger), nanomdm.WithDeclarativeManagement(ddmService))
+ coreMDMService := nanomdm.New(mdmStorage, nanomdm.WithLogger(mdmLogger), nanomdm.WithDeclarativeManagement(ddmService),
+ nanomdm.WithProfileService(profileService))
// NOTE: it is critical that the coreMDMService runs first, as the first
// service in the multi-service feature is run to completion _before_ running
// the other ones in parallel. This way, subsequent services have access to
diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go
index 23db4cea59..9059b18181 100644
--- a/server/service/integration_enterprise_test.go
+++ b/server/service/integration_enterprise_test.go
@@ -8924,6 +8924,31 @@ func (s *integrationEnterpriseTestSuite) TestAllSoftwareTitles() {
}
s.uploadSoftwareInstaller(t, payloadRubyTm1, http.StatusOK, "")
+ payloadEmacsMissingSecret := &fleet.UploadSoftwareInstallerPayload{
+ InstallScript: "install $FLEET_SECRET_INVALID",
+ Filename: "emacs.deb",
+ PostInstallScript: "d",
+ SelfService: true,
+ }
+ s.uploadSoftwareInstaller(t, payloadEmacsMissingSecret, http.StatusUnprocessableEntity, "$FLEET_SECRET_INVALID")
+
+ payloadEmacsMissingPostSecret := &fleet.UploadSoftwareInstallerPayload{
+ InstallScript: "install",
+ Filename: "emacs.deb",
+ PostInstallScript: "d $FLEET_SECRET_INVALID",
+ SelfService: true,
+ }
+ s.uploadSoftwareInstaller(t, payloadEmacsMissingPostSecret, http.StatusUnprocessableEntity, "$FLEET_SECRET_INVALID")
+
+ payloadEmacsMissingUnSecret := &fleet.UploadSoftwareInstallerPayload{
+ InstallScript: "install",
+ Filename: "emacs.deb",
+ PostInstallScript: "d",
+ UninstallScript: "delet $FLEET_SECRET_INVALID",
+ SelfService: true,
+ }
+ s.uploadSoftwareInstaller(t, payloadEmacsMissingUnSecret, http.StatusUnprocessableEntity, "$FLEET_SECRET_INVALID")
+
payloadEmacs := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "install",
Filename: "emacs.deb",
@@ -11411,7 +11436,7 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() {
// software with a too big URL
softwareToInstall = []*fleet.SoftwareInstallerPayload{
- {URL: "https://ftp.mozilla.org/" + strings.Repeat("a", 233)},
+ {URL: "https://ftp.mozilla.org/" + strings.Repeat("a", 4000-23)},
}
s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusUnprocessableEntity, "team_name", tm.Name)
@@ -11455,6 +11480,28 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() {
require.NotNil(t, packages[0].TeamID)
require.Equal(t, tm.ID, *packages[0].TeamID)
+ softwareToInstallBadSecret := []*fleet.SoftwareInstallerPayload{
+ {
+ URL: rubyURL,
+ InstallScript: "echo $FLEET_SECRET_INVALID",
+ },
+ }
+ resp := s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstallBadSecret}, http.StatusUnprocessableEntity, "team_name", tm.Name)
+ errMsg := extractServerErrorText(resp.Body)
+ require.Contains(t, errMsg, "$FLEET_SECRET_INVALID")
+
+ softwareToInstallBadSecret[0].InstallScript = ""
+ softwareToInstallBadSecret[0].PostInstallScript = "echo $FLEET_SECRET_ALSO_INVALID"
+ resp = s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstallBadSecret}, http.StatusUnprocessableEntity, "team_name", tm.Name)
+ errMsg = extractServerErrorText(resp.Body)
+ require.Contains(t, errMsg, "$FLEET_SECRET_ALSO_INVALID")
+
+ softwareToInstallBadSecret[0].PostInstallScript = ""
+ softwareToInstallBadSecret[0].UninstallScript = "echo $FLEET_SECRET_THIRD_INVALID"
+ resp = s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstallBadSecret}, http.StatusUnprocessableEntity, "team_name", tm.Name)
+ errMsg = extractServerErrorText(resp.Body)
+ require.Contains(t, errMsg, "$FLEET_SECRET_THIRD_INVALID")
+
// TODO(roberto): test with a variety of response codes
// check the application status
@@ -15828,6 +15875,22 @@ func (s *integrationEnterpriseTestSuite) TestMaintainedApps() {
getMAResp.FleetMaintainedApp.UninstallScript = ""
require.Equal(t, actualApp, *getMAResp.FleetMaintainedApp)
+ // Try adding ingested app with invalid secret
+ reqInvalidSecret := &addFleetMaintainedAppRequest{
+ AppID: 1,
+ TeamID: &team.ID,
+ SelfService: true,
+ PreInstallQuery: "SELECT 1",
+ InstallScript: "echo foo $FLEET_SECRET_INVALID1",
+ PostInstallScript: "echo done $FLEET_SECRET_INVALID2",
+ UninstallScript: "echo $FLEET_SECRET_INVALID3",
+ }
+ respBadSecret := s.Do("POST", "/api/latest/fleet/software/fleet_maintained_apps", reqInvalidSecret, http.StatusUnprocessableEntity)
+ errMsg := extractServerErrorText(respBadSecret.Body)
+ require.Contains(t, errMsg, "$FLEET_SECRET_INVALID1")
+ require.Contains(t, errMsg, "$FLEET_SECRET_INVALID2")
+ require.Contains(t, errMsg, "$FLEET_SECRET_INVALID3")
+
// Add an ingested app to the team
var addMAResp addFleetMaintainedAppResponse
req := &addFleetMaintainedAppRequest{
diff --git a/server/service/integration_mdm_ddm_test.go b/server/service/integration_mdm_ddm_test.go
index 25035917b5..220b77cb6e 100644
--- a/server/service/integration_mdm_ddm_test.go
+++ b/server/service/integration_mdm_ddm_test.go
@@ -19,6 +19,7 @@ import (
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
+ "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -278,40 +279,6 @@ INSERT INTO host_mdm_apple_declarations (
return byChecksum
}
- parseTokensResp := func(r *http.Response) fleet.MDMAppleDDMTokensResponse {
- require.NotNil(t, r)
- b, err := io.ReadAll(r.Body)
- require.NoError(t, err)
- defer r.Body.Close()
- r.Body = io.NopCloser(bytes.NewBuffer(b))
- // t.Log("body", string(b))
-
- // unmarsal the response to make sure it's valid
- var tok fleet.MDMAppleDDMTokensResponse
- err = json.NewDecoder(r.Body).Decode(&tok)
- require.NoError(t, err)
- // t.Log("decoded", tok)
-
- return tok
- }
-
- parseDeclarationItemsResp := func(r *http.Response) fleet.MDMAppleDDMDeclarationItemsResponse {
- require.NotNil(t, r)
- b, err := io.ReadAll(r.Body)
- require.NoError(t, err)
- defer r.Body.Close()
- r.Body = io.NopCloser(bytes.NewBuffer(b))
- // t.Log("body", string(b))
-
- // unmarsal the response to make sure it's valid
- var di fleet.MDMAppleDDMDeclarationItemsResponse
- err = json.NewDecoder(r.Body).Decode(&di)
- require.NoError(t, err)
- // t.Log("decoded", di)
-
- return di
- }
-
assertDeclarationResponse := func(r *http.Response, expected fleet.MDMAppleDeclaration) {
require.NotNil(t, r)
@@ -352,7 +319,7 @@ INSERT INTO host_mdm_apple_declarations (
// get tokens, timestamp should be the same as the declaration and token should be non-empty
r, err := mdmDevice.DeclarativeManagement("tokens")
require.NoError(t, err)
- parsed := parseTokensResp(r)
+ parsed := parseTokensResp(t, r)
checkTokensResp(t, parsed, then, "")
currDeclToken = parsed.SyncTokens.DeclarationsToken
@@ -376,7 +343,7 @@ INSERT INTO host_mdm_apple_declarations (
// get tokens again, timestamp and token should have changed
r, err = mdmDevice.DeclarativeManagement("tokens")
require.NoError(t, err)
- parsed = parseTokensResp(r)
+ parsed = parseTokensResp(t, r)
checkTokensResp(t, parsed, then.Add(1*time.Minute), currDeclToken)
currDeclToken = parsed.SyncTokens.DeclarationsToken
})
@@ -384,7 +351,7 @@ INSERT INTO host_mdm_apple_declarations (
t.Run("DeclarationItems", func(t *testing.T) {
r, err := mdmDevice.DeclarativeManagement("declaration-items")
require.NoError(t, err)
- checkDeclarationItemsResp(t, parseDeclarationItemsResp(r), currDeclToken, mapDeclsByChecksum(noTeamDeclsByUUID))
+ checkDeclarationItemsResp(t, parseDeclarationItemsResp(t, r), currDeclToken, mapDeclsByChecksum(noTeamDeclsByUUID))
// insert a new declaration
noTeamDeclsByUUID["789"] = fleet.MDMAppleDeclaration{
@@ -406,13 +373,13 @@ INSERT INTO host_mdm_apple_declarations (
// get tokens again, timestamp and token should have changed
r, err = mdmDevice.DeclarativeManagement("tokens")
require.NoError(t, err)
- toks := parseTokensResp(r)
+ toks := parseTokensResp(t, r)
checkTokensResp(t, toks, then.Add(2*time.Minute), currDeclToken)
currDeclToken = toks.SyncTokens.DeclarationsToken
r, err = mdmDevice.DeclarativeManagement("declaration-items")
require.NoError(t, err)
- checkDeclarationItemsResp(t, parseDeclarationItemsResp(r), currDeclToken, mapDeclsByChecksum(noTeamDeclsByUUID))
+ checkDeclarationItemsResp(t, parseDeclarationItemsResp(t, r), currDeclToken, mapDeclsByChecksum(noTeamDeclsByUUID))
})
t.Run("Status", func(t *testing.T) {
@@ -470,6 +437,212 @@ INSERT INTO host_mdm_apple_declarations (
})
}
+func parseTokensResp(t *testing.T, r *http.Response) fleet.MDMAppleDDMTokensResponse {
+ require.NotNil(t, r)
+ b, err := io.ReadAll(r.Body)
+ require.NoError(t, err)
+ defer r.Body.Close()
+ r.Body = io.NopCloser(bytes.NewBuffer(b))
+
+ // unmarshal the response to make sure it's valid
+ var tok fleet.MDMAppleDDMTokensResponse
+ err = json.NewDecoder(r.Body).Decode(&tok)
+ require.NoError(t, err)
+
+ return tok
+}
+
+func parseDeclarationItemsResp(t *testing.T, r *http.Response) fleet.MDMAppleDDMDeclarationItemsResponse {
+ require.NotNil(t, r)
+ b, err := io.ReadAll(r.Body)
+ require.NoError(t, err)
+ defer r.Body.Close()
+ r.Body = io.NopCloser(bytes.NewBuffer(b))
+
+ // unmarshal the response to make sure it's valid
+ var di fleet.MDMAppleDDMDeclarationItemsResponse
+ err = json.NewDecoder(r.Body).Decode(&di)
+ require.NoError(t, err)
+
+ return di
+}
+
+func (s *integrationMDMTestSuite) TestAppleDDMSecretVariables() {
+ t := s.T()
+ _, mdmDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t)
+
+ checkDeclarationItemsResp := func(t *testing.T, r fleet.MDMAppleDDMDeclarationItemsResponse, expectedDeclTok string,
+ expectedDeclsByChecksum map[string]fleet.MDMAppleDeclaration) {
+ require.Equal(t, expectedDeclTok, r.DeclarationsToken)
+ require.NotEmpty(t, r.Declarations.Activations)
+ require.Empty(t, r.Declarations.Assets)
+ require.Empty(t, r.Declarations.Management)
+ require.Len(t, r.Declarations.Configurations, len(expectedDeclsByChecksum))
+ for _, m := range r.Declarations.Configurations {
+ d, ok := expectedDeclsByChecksum[m.ServerToken]
+ require.True(t, ok)
+ require.Equal(t, d.Identifier, m.Identifier)
+ }
+ }
+ calcChecksum := func(source []byte) string {
+ csum := fmt.Sprintf("%x", md5.Sum(source)) //nolint:gosec
+ return strings.ToUpper(csum)
+ }
+
+ tmpl := `
+{
+ "Type": "com.apple.configuration.decl%d",
+ "Identifier": "com.fleet.config%d",
+ "Payload": {
+ "ServiceType": "com.apple.bash%d",
+ "DataAssetReference": "com.fleet.asset.bash" %s
+ }
+}`
+
+ newDeclBytes := func(i int, payload ...string) []byte {
+ var p string
+ if len(payload) > 0 {
+ p = "," + strings.Join(payload, ",")
+ }
+ return []byte(fmt.Sprintf(tmpl, i, i, i, p))
+ }
+
+ var decls [][]byte
+ for i := 0; i < 3; i++ {
+ decls = append(decls, newDeclBytes(i))
+ }
+ // Use secrets
+ myBash := "com.apple.bash1"
+ decls[1] = []byte(strings.ReplaceAll(string(decls[1]), myBash, "$"+fleet.ServerSecretPrefix+"BASH"))
+ secretProfile := decls[2]
+ decls[2] = []byte("${" + fleet.ServerSecretPrefix + "PROFILE}")
+ declsByChecksum := map[string]fleet.MDMAppleDeclaration{
+ calcChecksum(decls[0]): {
+ Identifier: "com.fleet.config0",
+ },
+ calcChecksum(decls[1]): {
+ Identifier: "com.fleet.config1",
+ },
+ calcChecksum(decls[2]): {
+ Identifier: "com.fleet.config2",
+ },
+ }
+
+ // Create declarations
+ // First dry run
+ s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
+ {Name: "N0", Contents: decls[0]},
+ {Name: "N1", Contents: decls[1]},
+ {Name: "N2", Contents: decls[2]},
+ }}, http.StatusNoContent, "dry_run", "true")
+
+ var resp listMDMConfigProfilesResponse
+ s.DoJSON("GET", "/api/latest/fleet/mdm/profiles", &listMDMConfigProfilesRequest{}, http.StatusOK, &resp)
+ require.Empty(t, resp.Profiles)
+
+ // Add secrets to server
+ req := secretVariablesRequest{
+ SecretVariables: []fleet.SecretVariable{
+ {
+ Name: "FLEET_SECRET_BASH",
+ Value: myBash,
+ },
+ {
+ Name: "FLEET_SECRET_PROFILE",
+ Value: string(secretProfile),
+ },
+ },
+ }
+ secretResp := secretVariablesResponse{}
+ s.DoJSON("PUT", "/api/latest/fleet/spec/secret_variables", req, http.StatusOK, &secretResp)
+
+ // Now real run
+ s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
+ {Name: "N0", Contents: decls[0]},
+ {Name: "N1", Contents: decls[1]},
+ {Name: "N2", Contents: decls[2]},
+ }}, http.StatusNoContent)
+ s.DoJSON("GET", "/api/latest/fleet/mdm/profiles", &listMDMConfigProfilesRequest{}, http.StatusOK, &resp)
+
+ require.Len(t, resp.Profiles, len(decls))
+ checkedProfiles := 0
+ for _, p := range resp.Profiles {
+ switch p.Name {
+ case "N0", "N1", "N2":
+ require.Equal(t, "darwin", p.Platform)
+ checkedProfiles++
+ default:
+ t.Logf("unexpected profile %s", p.Name)
+ }
+ }
+ assert.Equal(t, len(decls), checkedProfiles)
+
+ getDeclaration := func(t *testing.T, name string) fleet.MDMAppleDeclaration {
+ stmt := `
+SELECT
+ declaration_uuid,
+ team_id,
+ identifier,
+ name,
+ raw_json,
+ checksum,
+ created_at,
+ uploaded_at
+FROM mdm_apple_declarations
+WHERE name = ?`
+
+ var decl fleet.MDMAppleDeclaration
+ mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
+ return sqlx.GetContext(context.Background(), q, &decl, stmt, name)
+ })
+ return decl
+ }
+ nameToIdentifier := make(map[string]string, 3)
+ decl := getDeclaration(t, "N0")
+ nameToIdentifier["N0"] = decl.Identifier
+ decl = getDeclaration(t, "N1")
+ assert.NotContains(t, string(decl.RawJSON), myBash)
+ assert.Contains(t, string(decl.RawJSON), "$"+fleet.ServerSecretPrefix+"BASH")
+ nameToIdentifier["N1"] = decl.Identifier
+ decl = getDeclaration(t, "N2")
+ assert.Equal(t, string(decl.RawJSON), "${"+fleet.ServerSecretPrefix+"PROFILE}")
+ nameToIdentifier["N2"] = decl.Identifier
+
+ // trigger a profile sync
+ s.awaitTriggerProfileSchedule(t)
+
+ // get tokens again, timestamp and token should have changed
+ r, err := mdmDevice.DeclarativeManagement("tokens")
+ require.NoError(t, err)
+ tokens := parseTokensResp(t, r)
+ currDeclToken := tokens.SyncTokens.DeclarationsToken
+
+ r, err = mdmDevice.DeclarativeManagement("declaration-items")
+ require.NoError(t, err)
+ itemsResp := parseDeclarationItemsResp(t, r)
+ checkDeclarationItemsResp(t, itemsResp, currDeclToken, declsByChecksum)
+
+ // Now, retrieve the declaration configuration profiles
+ declarationPath := fmt.Sprintf("declaration/configuration/%s", nameToIdentifier["N0"])
+ r, err = mdmDevice.DeclarativeManagement(declarationPath)
+ require.NoError(t, err)
+ var gotParsed fleet.MDMAppleDDMDeclarationResponse
+ require.NoError(t, json.NewDecoder(r.Body).Decode(&gotParsed))
+ assert.EqualValues(t, `{"DataAssetReference":"com.fleet.asset.bash","ServiceType":"com.apple.bash0"}`, gotParsed.Payload)
+
+ declarationPath = fmt.Sprintf("declaration/configuration/%s", nameToIdentifier["N1"])
+ r, err = mdmDevice.DeclarativeManagement(declarationPath)
+ require.NoError(t, err)
+ require.NoError(t, json.NewDecoder(r.Body).Decode(&gotParsed))
+ assert.EqualValues(t, `{"DataAssetReference":"com.fleet.asset.bash","ServiceType":"com.apple.bash1"}`, gotParsed.Payload)
+
+ declarationPath = fmt.Sprintf("declaration/configuration/%s", nameToIdentifier["N2"])
+ r, err = mdmDevice.DeclarativeManagement(declarationPath)
+ require.NoError(t, err)
+ require.NoError(t, json.NewDecoder(r.Body).Decode(&gotParsed))
+ assert.EqualValues(t, `{"DataAssetReference":"com.fleet.asset.bash","ServiceType":"com.apple.bash2"}`, gotParsed.Payload)
+}
+
func (s *integrationMDMTestSuite) TestAppleDDMReconciliation() {
t := s.T()
ctx := context.Background()
diff --git a/server/service/integration_mdm_profiles_test.go b/server/service/integration_mdm_profiles_test.go
index bdb95c14c0..7dc247133d 100644
--- a/server/service/integration_mdm_profiles_test.go
+++ b/server/service/integration_mdm_profiles_test.go
@@ -197,18 +197,60 @@ func (s *integrationMDMTestSuite) TestAppleProfileManagement() {
s.checkMDMProfilesSummaries(t, nil, expectedNoTeamSummary, &expectedNoTeamSummary) // empty because host was transferred
s.checkMDMProfilesSummaries(t, &tm.ID, expectedTeamSummary, &expectedTeamSummary) // host now verifying team profiles
+ // Use secret variables in a profile
+ secretIdentifier := "secret-identifier-1"
+ secretType := "secret.type.1"
+ secretName := "secretName"
+ secretProfile := string(mobileconfigForTest("NS1", "IS1"))
+ req := secretVariablesRequest{
+ SecretVariables: []fleet.SecretVariable{
+ {
+ Name: "FLEET_SECRET_IDENTIFIER",
+ Value: secretIdentifier,
+ },
+ {
+ Name: "FLEET_SECRET_TYPE",
+ Value: secretType,
+ },
+ {
+ Name: "FLEET_SECRET_NAME",
+ Value: secretName,
+ },
+ {
+ Name: "FLEET_SECRET_PROFILE",
+ Value: secretProfile,
+ },
+ },
+ }
+ secretResp := secretVariablesResponse{}
+ s.DoJSON("PUT", "/api/latest/fleet/spec/secret_variables", req, http.StatusOK, &secretResp)
+
// set new team profiles (delete + addition)
teamProfiles = [][]byte{
mobileconfigForTest("N4", "I4"),
- mobileconfigForTest("N5", "I5"),
+ mobileconfigForTestWithContent("N5", "I5", "$FLEET_SECRET_IDENTIFIER", "${FLEET_SECRET_TYPE}",
+ "$FLEET_SECRET_NAME"),
+ // The whole profile is one big secret.
+ []byte("$FLEET_SECRET_PROFILE"),
}
- wantTeamProfiles = teamProfiles
+ // We deep copy one of the team profiles because we will modify the slice in place, and we want to keep the originals for later.
+ wantTeamProfiles = [][]byte{
+ teamProfiles[0],
+ make([]byte, len(teamProfiles[1])),
+ {},
+ }
+ copy(wantTeamProfiles[1], teamProfiles[1])
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: teamProfiles}, http.StatusNoContent,
"team_id", fmt.Sprint(tm.ID))
// trigger a profile sync
s.awaitTriggerProfileSchedule(t)
installs, removes = checkNextPayloads(t, mdmDevice, false)
+ // Manually replace the expected secret variables in the profile
+ wantTeamProfiles[1] = []byte(strings.ReplaceAll(string(wantTeamProfiles[1]), "$FLEET_SECRET_IDENTIFIER", secretIdentifier))
+ wantTeamProfiles[1] = []byte(strings.ReplaceAll(string(wantTeamProfiles[1]), "${FLEET_SECRET_TYPE}", secretType))
+ wantTeamProfiles[1] = []byte(strings.ReplaceAll(string(wantTeamProfiles[1]), "$FLEET_SECRET_NAME", secretName))
+ wantTeamProfiles[2] = []byte(secretProfile)
// verify that we should install the team profiles
s.signedProfilesMatch(wantTeamProfiles, installs)
// verify that we should delete the old team profiles
@@ -223,6 +265,36 @@ func (s *integrationMDMTestSuite) TestAppleProfileManagement() {
require.Empty(t, installs)
require.Empty(t, removes)
+ // Clear the profiles using the new (non-deprecated) endpoint.
+ s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: nil}, http.StatusNoContent, "team_id",
+ fmt.Sprint(tm.ID), "dry_run", "true")
+ s.assertConfigProfilesByIdentifier(&tm.ID, "IS1", true)
+ s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: nil}, http.StatusNoContent, "team_id",
+ fmt.Sprint(tm.ID), "dry_run", "false")
+ s.assertConfigProfilesByIdentifier(&tm.ID, "IS1", false)
+ s.awaitTriggerProfileSchedule(t)
+ installs, removes = checkNextPayloads(t, mdmDevice, false)
+ require.Empty(t, installs)
+ assert.Len(t, removes, 3)
+
+ // And reapply the same profiles using the new (non-deprecated) endpoint.
+ batchRequest := batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
+ {Name: "N4", Contents: teamProfiles[0]},
+ {Name: "N5", Contents: teamProfiles[1]},
+ {Name: "NS1", Contents: teamProfiles[2]},
+ }}
+ s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchRequest, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID), "dry_run", "true")
+ s.assertConfigProfilesByIdentifier(&tm.ID, "I4", false)
+ s.assertConfigProfilesByIdentifier(&tm.ID, "I5", false)
+ s.assertConfigProfilesByIdentifier(&tm.ID, "IS1", false)
+ s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchRequest, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID))
+ s.assertConfigProfilesByIdentifier(&tm.ID, "IS1", true)
+ s.awaitTriggerProfileSchedule(t)
+ installs, removes = checkNextPayloads(t, mdmDevice, false)
+ assert.Empty(t, removes)
+ // verify that we should install the team profiles
+ s.signedProfilesMatch(wantTeamProfiles, installs)
+
var hostResp getHostResponse
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/%d", host.ID), getHostRequest{}, http.StatusOK, &hostResp)
require.NotEmpty(t, hostResp.Host.MDM.Profiles)
diff --git a/server/service/mdm.go b/server/service/mdm.go
index ffc9c3d7e6..640f1dd6d7 100644
--- a/server/service/mdm.go
+++ b/server/service/mdm.go
@@ -1599,10 +1599,7 @@ func (svc *Service) BatchSetMDMProfiles(
appCfg.MDM.WindowsEnabledAndConfigured = *assumeEnabled
}
- if err := validateProfiles(profiles); err != nil {
- return ctxerr.Wrap(ctx, err, "validating profiles")
- }
-
+ // Process labels first, since we do not need to expand secrets in the profiles for this validation.
labels := []string{}
for i := range profiles {
// from this point on (after this condition), only LabelsIncludeAll, LabelsIncludeAny or
@@ -1622,12 +1619,43 @@ func (svc *Service) BatchSetMDMProfiles(
return ctxerr.Wrap(ctx, err, "validating labels")
}
- appleProfiles, appleDecls, err := getAppleProfiles(ctx, tmID, appCfg, profiles, labelMap)
+ // We will not validate the profiles containing secret variables during dry run.
+ // This is because the secret variables may not be available (or correct) in the gitops dry run.
+ if dryRun {
+ var profilesWithoutSecrets []fleet.MDMProfileBatchPayload
+ for _, p := range profiles {
+ if len(fleet.ContainsPrefixVars(string(p.Contents), fleet.ServerSecretPrefix)) == 0 {
+ profilesWithoutSecrets = append(profilesWithoutSecrets, p)
+ }
+ }
+ profiles = profilesWithoutSecrets
+ }
+
+ // Expand secret variables so that profiles can be properly validated.
+ // Important: secret variables should never be exposed or saved in the database unencrypted
+ // In order to map the expanded profiles back to the original profiles, we will use the index.
+ profilesWithSecrets := make(map[int]fleet.MDMProfileBatchPayload, len(profiles))
+ for i, p := range profiles {
+ expanded, err := svc.ds.ExpandEmbeddedSecrets(ctx, string(p.Contents))
+ if err != nil {
+ return err
+ }
+ pCopy := p
+ // If the profile does not contain secrets, then expanded and original content point to the same slice/memory location.
+ pCopy.Contents = []byte(expanded)
+ profilesWithSecrets[i] = pCopy
+ }
+
+ if err := validateProfiles(profilesWithSecrets); err != nil {
+ return ctxerr.Wrap(ctx, err, "validating profiles")
+ }
+
+ appleProfiles, appleDecls, err := getAppleProfiles(ctx, tmID, appCfg, profilesWithSecrets, labelMap)
if err != nil {
return ctxerr.Wrap(ctx, err, "validating macOS profiles")
}
- windowsProfiles, err := getWindowsProfiles(ctx, tmID, appCfg, profiles, labelMap)
+ windowsProfiles, err := getWindowsProfiles(ctx, tmID, appCfg, profilesWithSecrets, labelMap)
if err != nil {
return ctxerr.Wrap(ctx, err, "validating Windows profiles")
}
@@ -1640,18 +1668,30 @@ func (svc *Service) BatchSetMDMProfiles(
return nil
}
- err = svc.validateFleetSecrets(ctx, appleProfiles, windowsProfiles, appleDecls)
- if err != nil {
- return err
- }
-
err = validateFleetVariables(ctx, appleProfiles, windowsProfiles, appleDecls)
if err != nil {
return err
}
+ // Now that validation is done, we remove the exposed secret variables from the profiles
+ appleProfilesSlice := make([]*fleet.MDMAppleConfigProfile, 0, len(appleProfiles))
+ for i, p := range appleProfiles {
+ p.Mobileconfig = profiles[i].Contents
+ appleProfilesSlice = append(appleProfilesSlice, p)
+ }
+ appleDeclsSlice := make([]*fleet.MDMAppleDeclaration, 0, len(appleDecls))
+ for i, p := range appleDecls {
+ p.RawJSON = profiles[i].Contents
+ appleDeclsSlice = append(appleDeclsSlice, p)
+ }
+ windowsProfilesSlice := make([]*fleet.MDMWindowsConfigProfile, 0, len(windowsProfiles))
+ for i, p := range windowsProfiles {
+ p.SyncML = profiles[i].Contents
+ windowsProfilesSlice = append(windowsProfilesSlice, p)
+ }
+
var profUpdates fleet.MDMProfilesUpdates
- if profUpdates, err = svc.ds.BatchSetMDMProfiles(ctx, tmID, appleProfiles, windowsProfiles, appleDecls); err != nil {
+ if profUpdates, err = svc.ds.BatchSetMDMProfiles(ctx, tmID, appleProfilesSlice, windowsProfilesSlice, appleDeclsSlice); err != nil {
return ctxerr.Wrap(ctx, err, "setting config profiles")
}
@@ -1711,8 +1751,8 @@ func (svc *Service) BatchSetMDMProfiles(
return nil
}
-func validateFleetVariables(ctx context.Context, appleProfiles []*fleet.MDMAppleConfigProfile,
- windowsProfiles []*fleet.MDMWindowsConfigProfile, appleDecls []*fleet.MDMAppleDeclaration,
+func validateFleetVariables(ctx context.Context, appleProfiles map[int]*fleet.MDMAppleConfigProfile,
+ windowsProfiles map[int]*fleet.MDMWindowsConfigProfile, appleDecls map[int]*fleet.MDMAppleDeclaration,
) error {
var err error
@@ -1737,24 +1777,8 @@ func validateFleetVariables(ctx context.Context, appleProfiles []*fleet.MDMApple
return nil
}
-func (svc *Service) validateFleetSecrets(ctx context.Context, appleProfiles []*fleet.MDMAppleConfigProfile, windowsProfiles []*fleet.MDMWindowsConfigProfile, appleDecls []*fleet.MDMAppleDeclaration) error {
- allProfiles := make([]string, 0, len(appleProfiles)+len(appleDecls)+len(windowsProfiles))
- for _, p := range appleProfiles {
- allProfiles = append(allProfiles, string(p.Mobileconfig))
- }
- for _, p := range appleDecls {
- allProfiles = append(allProfiles, string(p.RawJSON))
- }
- for _, p := range windowsProfiles {
- allProfiles = append(allProfiles, string(p.SyncML))
- }
- if err := svc.ds.ValidateEmbeddedSecrets(ctx, allProfiles); err != nil {
- return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("profiles", err.Error()))
- }
- return nil
-}
-
-func (svc *Service) validateCrossPlatformProfileNames(ctx context.Context, appleProfiles []*fleet.MDMAppleConfigProfile, windowsProfiles []*fleet.MDMWindowsConfigProfile, appleDecls []*fleet.MDMAppleDeclaration) error {
+func (svc *Service) validateCrossPlatformProfileNames(ctx context.Context, appleProfiles map[int]*fleet.MDMAppleConfigProfile,
+ windowsProfiles map[int]*fleet.MDMWindowsConfigProfile, appleDecls map[int]*fleet.MDMAppleDeclaration) error {
// map all profile names to check for duplicates, regardless of platform; key is name, value is one of
// ".mobileconfig" or ".json" or ".xml"
extByName := make(map[string]string, len(appleProfiles)+len(windowsProfiles)+len(appleDecls))
@@ -1861,17 +1885,17 @@ func getAppleProfiles(
ctx context.Context,
tmID *uint,
appCfg *fleet.AppConfig,
- profiles []fleet.MDMProfileBatchPayload,
+ profiles map[int]fleet.MDMProfileBatchPayload,
labelMap map[string]fleet.ConfigurationProfileLabel,
-) ([]*fleet.MDMAppleConfigProfile, []*fleet.MDMAppleDeclaration, error) {
+) (map[int]*fleet.MDMAppleConfigProfile, map[int]*fleet.MDMAppleDeclaration, error) {
// any duplicate identifier or name in the provided set results in an error
- profs := make([]*fleet.MDMAppleConfigProfile, 0, len(profiles))
- decls := make([]*fleet.MDMAppleDeclaration, 0, len(profiles))
+ profs := make(map[int]*fleet.MDMAppleConfigProfile, len(profiles))
+ decls := make(map[int]*fleet.MDMAppleDeclaration, len(profiles))
// we need to keep track of the names and identifiers to check for duplicates so we will use
// a map where the key is the name oridentifier and the value is either "mobileconfig" or
// "declaration" to differentiate between the two types of profiles
byName, byIdent := make(map[string]string, len(profiles)), make(map[string]string, len(profiles))
- for _, prof := range profiles {
+ for i, prof := range profiles {
if mdm.GetRawProfilePlatform(prof.Contents) != "darwin" {
continue
}
@@ -1937,7 +1961,7 @@ func getAppleProfiles(
"duplicate identifier by identifier")
}
- decls = append(decls, mdmDecl)
+ decls[i] = mdmDecl
continue
}
@@ -2006,7 +2030,7 @@ func getAppleProfiles(
}
byIdent[mdmProf.Identifier] = "mobileconfig"
- profs = append(profs, mdmProf)
+ profs[i] = mdmProf
}
if !appCfg.MDM.EnabledAndConfigured {
@@ -2016,7 +2040,7 @@ func getAppleProfiles(
// custom_settings key, we just return a success response in this
// situation.
if len(profs) == 0 {
- return []*fleet.MDMAppleConfigProfile{}, []*fleet.MDMAppleDeclaration{}, nil
+ return nil, nil, nil
}
return nil, nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("mdm", "cannot set custom settings: Fleet MDM is not configured"))
@@ -2029,12 +2053,12 @@ func getWindowsProfiles(
ctx context.Context,
tmID *uint,
appCfg *fleet.AppConfig,
- profiles []fleet.MDMProfileBatchPayload,
+ profiles map[int]fleet.MDMProfileBatchPayload,
labelMap map[string]fleet.ConfigurationProfileLabel,
-) ([]*fleet.MDMWindowsConfigProfile, error) {
- profs := make([]*fleet.MDMWindowsConfigProfile, 0, len(profiles))
+) (map[int]*fleet.MDMWindowsConfigProfile, error) {
+ profs := make(map[int]*fleet.MDMWindowsConfigProfile, len(profiles))
- for _, profile := range profiles {
+ for i, profile := range profiles {
if mdm.GetRawProfilePlatform(profile.Contents) != "windows" {
continue
}
@@ -2079,7 +2103,7 @@ func getWindowsProfiles(
fleet.NewInvalidArgumentError(fmt.Sprintf("profiles[%s]", profile.Name), err.Error()))
}
- profs = append(profs, mdmProf)
+ profs[i] = mdmProf
}
if !appCfg.MDM.WindowsEnabledAndConfigured {
@@ -2098,7 +2122,7 @@ func getWindowsProfiles(
return profs, nil
}
-func validateProfiles(profiles []fleet.MDMProfileBatchPayload) error {
+func validateProfiles(profiles map[int]fleet.MDMProfileBatchPayload) error {
for _, profile := range profiles {
// validate that only one of labels, labels_include_all and labels_exclude_any is provided.
var count int
diff --git a/server/service/mdm_test.go b/server/service/mdm_test.go
index f89451e210..66f5e411e1 100644
--- a/server/service/mdm_test.go
+++ b/server/service/mdm_test.go
@@ -1716,7 +1716,12 @@ func TestValidateProfiles(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- err := validateProfiles(tt.profiles)
+ // Convert slice to a map
+ profiles := make(map[int]fleet.MDMProfileBatchPayload, len(tt.profiles))
+ for i, profile := range tt.profiles {
+ profiles[i] = profile
+ }
+ err := validateProfiles(profiles)
if tt.wantErr {
require.Error(t, err)
if tt.errMsg != "" {
diff --git a/server/service/osquery_utils/queries.go b/server/service/osquery_utils/queries.go
index bc3dca255f..67b8047e3a 100644
--- a/server/service/osquery_utils/queries.go
+++ b/server/service/osquery_utils/queries.go
@@ -1689,7 +1689,12 @@ var (
return s.Name == "minio" && strings.Contains(s.Version, "RELEASE.")
},
mutateSoftware: func(s *fleet.Software, logger log.Logger) {
+ // trim the "RELEASE." prefix from the version
s.Version = strings.TrimPrefix(s.Version, "RELEASE.")
+ // trim any unexpected trailing characters
+ if idx := strings.Index(s.Version, "_"); idx != -1 {
+ s.Version = s.Version[:idx]
+ }
},
},
{
diff --git a/server/service/osquery_utils/queries_test.go b/server/service/osquery_utils/queries_test.go
index a1c366dfe0..9487883516 100644
--- a/server/service/osquery_utils/queries_test.go
+++ b/server/service/osquery_utils/queries_test.go
@@ -1920,6 +1920,18 @@ func TestSanitizeSoftware(t *testing.T) {
Version: "2020-03-10T00-00-00Z",
},
},
+ {
+ name: "minio with trailing garbage",
+ h: &fleet.Host{},
+ s: &fleet.Software{
+ Name: "minio",
+ Version: "RELEASE.2022-03-10T00-00-00Z_1",
+ },
+ sanitized: &fleet.Software{
+ Name: "minio",
+ Version: "2022-03-10T00-00-00Z",
+ },
+ },
{
name: "JetBrains non-EAP",
h: &fleet.Host{},
diff --git a/server/service/schedule/schedule.go b/server/service/schedule/schedule.go
index 11e8cbf9d8..7cf3606c88 100644
--- a/server/service/schedule/schedule.go
+++ b/server/service/schedule/schedule.go
@@ -8,6 +8,7 @@ import (
"context"
"fmt"
"os"
+ "runtime/debug"
"sync"
"time"
@@ -47,7 +48,8 @@ type Schedule struct {
altLockName string
- jobs []Job
+ jobs []Job
+ errors fleet.CronScheduleErrors
statsStore CronStatsStore
@@ -80,7 +82,7 @@ type CronStatsStore interface {
// InsertCronStats inserts cron stats for the named cron schedule
InsertCronStats(ctx context.Context, statsType fleet.CronStatsType, name string, instance string, status fleet.CronStatsStatus) (int, error)
// UpdateCronStats updates the status of the identified cron stats record
- UpdateCronStats(ctx context.Context, id int, status fleet.CronStatsStatus) error
+ UpdateCronStats(ctx context.Context, id int, status fleet.CronStatsStatus, cronErrors *fleet.CronScheduleErrors) error
}
// Option allows configuring a Schedule.
@@ -178,6 +180,7 @@ func New(
sch.logger = log.NewNopLogger()
}
sch.logger = log.With(sch.logger, "instanceID", instanceID)
+ sch.errors = make(fleet.CronScheduleErrors)
return sch
}
@@ -461,6 +464,7 @@ func (s *Schedule) runAllJobs() {
for _, job := range s.jobs {
level.Debug(s.logger).Log("msg", "starting", "jobID", job.ID)
if err := runJob(s.ctx, job.Fn); err != nil {
+ s.errors[job.ID] = err
level.Error(s.logger).Log("err", "running job", "details", err, "jobID", job.ID)
ctxerr.Handle(s.ctx, err)
}
@@ -472,7 +476,7 @@ func runJob(ctx context.Context, fn JobFn) (err error) {
defer func() {
if os.Getenv("TEST_CRON_NO_RECOVER") != "1" { // for detecting panics in tests
if r := recover(); r != nil {
- err = fmt.Errorf("%v", r)
+ err = fmt.Errorf("%v\n%s", r, string(debug.Stack()))
}
}
}()
@@ -617,7 +621,7 @@ func (s *Schedule) insertStats(statsType fleet.CronStatsType, status fleet.CronS
}
func (s *Schedule) updateStats(id int, status fleet.CronStatsStatus) error {
- return s.statsStore.UpdateCronStats(s.ctx, id, status)
+ return s.statsStore.UpdateCronStats(s.ctx, id, status, &s.errors)
}
func (s *Schedule) getLockName() string {
diff --git a/server/service/schedule/schedule_test.go b/server/service/schedule/schedule_test.go
index f0cf41ba26..2e74bf0cba 100644
--- a/server/service/schedule/schedule_test.go
+++ b/server/service/schedule/schedule_test.go
@@ -192,6 +192,9 @@ func TestMultipleSchedules(t *testing.T) {
}
func TestMultipleJobsInOrder(t *testing.T) {
+ os.Setenv("TEST_CRON_NO_RECOVER", "0")
+ defer os.Unsetenv("TEST_CRON_NO_RECOVER")
+
ctx, cancel := context.WithCancel(context.Background())
jobs := make(chan int)
@@ -211,12 +214,16 @@ func TestMultipleJobsInOrder(t *testing.T) {
}),
WithJob("test_job_2", func(ctx context.Context) error {
jobs <- 2
- return errors.New("test_job_2")
+ return errors.New("whoops")
}),
WithJob("test_job_3", func(ctx context.Context) error {
jobs <- 3
return nil
}),
+ WithJob("test_job_4", func(ctx context.Context) error {
+ jobs <- 4
+ panic("oh no")
+ }),
)
s.Start()
@@ -233,7 +240,7 @@ func TestMultipleJobsInOrder(t *testing.T) {
return fmt.Errorf("mismatch id: %d vs %d", job, i)
}
i++
- if i == 4 {
+ if i == 5 {
i = 1
}
case <-time.After(5 * time.Second):
@@ -253,6 +260,19 @@ func TestMultipleJobsInOrder(t *testing.T) {
err := g.Wait()
require.NoError(t, err)
+
+ // There should be errors from 2 jobs.
+ require.Equal(t, 2, len(s.errors))
+
+ // Check that the correct 2 jobs have errors.
+ test_job_2_err, ok := s.errors["test_job_2"]
+ require.True(t, ok)
+ test_job_4_err, ok := s.errors["test_job_4"]
+ require.True(t, ok)
+
+ // Check the errors that were returned.
+ require.Equal(t, "whoops", test_job_2_err.Error())
+ require.Contains(t, test_job_4_err.Error(), "oh no\n")
}
func TestConfigReloadCheck(t *testing.T) {
diff --git a/server/service/schedule/testing_utils.go b/server/service/schedule/testing_utils.go
index 4bafa74d18..3f6ae461a5 100644
--- a/server/service/schedule/testing_utils.go
+++ b/server/service/schedule/testing_utils.go
@@ -30,7 +30,7 @@ func (NopStatsStore) InsertCronStats(ctx context.Context, statsType fleet.CronSt
return 0, nil
}
-func (NopStatsStore) UpdateCronStats(ctx context.Context, id int, status fleet.CronStatsStatus) error {
+func (NopStatsStore) UpdateCronStats(ctx context.Context, id int, status fleet.CronStatsStatus, cronErrors *fleet.CronScheduleErrors) error {
return nil
}
@@ -179,7 +179,7 @@ func (m *MockStatsStore) InsertCronStats(ctx context.Context, statsType fleet.Cr
return id, nil
}
-func (m *MockStatsStore) UpdateCronStats(ctx context.Context, id int, status fleet.CronStatsStatus) error {
+func (m *MockStatsStore) UpdateCronStats(ctx context.Context, id int, status fleet.CronStatsStatus, cronErrors *fleet.CronScheduleErrors) error {
m.Lock()
defer m.Unlock()
diff --git a/server/service/testing_client.go b/server/service/testing_client.go
index 735730e3e4..df46710cd1 100644
--- a/server/service/testing_client.go
+++ b/server/service/testing_client.go
@@ -218,6 +218,11 @@ func (ts *withServer) commonTearDownTest(t *testing.T) {
_, err := tx.ExecContext(ctx, "DELETE FROM vpp_tokens;")
return err
})
+
+ mysql.ExecAdhocSQL(t, ts.ds, func(tx sqlx.ExtContext) error {
+ _, err := tx.ExecContext(ctx, "DELETE FROM secret_variables")
+ return err
+ })
}
func (ts *withServer) Do(verb, path string, params interface{}, expectedStatusCode int, queryParams ...string) *http.Response {
diff --git a/server/service/testing_utils.go b/server/service/testing_utils.go
index d255ce189a..28954f2ef0 100644
--- a/server/service/testing_utils.go
+++ b/server/service/testing_utils.go
@@ -393,6 +393,7 @@ func RunServerForTestsWithDS(t *testing.T, ds fleet.Datastore, opts ...*TestServ
ds: ds,
logger: logger,
},
+ commander,
)
require.NoError(t, err)
}
diff --git a/server/vulnerabilities/nvd/cpe_matching_rules.go b/server/vulnerabilities/nvd/cpe_matching_rules.go
index 48821344d0..88aae7f2dc 100644
--- a/server/vulnerabilities/nvd/cpe_matching_rules.go
+++ b/server/vulnerabilities/nvd/cpe_matching_rules.go
@@ -249,6 +249,8 @@ func GetKnownNVDBugRules() (CPEMatchingRules, error) {
return cpeMeta.TargetSW != "windows"
},
},
+ // CVE-2024-10004 only targets iOS, and we don't yet support iOS vuln scanning (and can't tell iOS/Mac CPEs apart yet)
+ CPEMatchingRule{CVEs: map[string]struct{}{"CVE-2024-10004": {}}, IgnoreAll: true},
}
for i, rule := range rules {
diff --git a/server/vulnerabilities/nvd/cve_test.go b/server/vulnerabilities/nvd/cve_test.go
index 1169b93b96..4442f73425 100644
--- a/server/vulnerabilities/nvd/cve_test.go
+++ b/server/vulnerabilities/nvd/cve_test.go
@@ -359,6 +359,10 @@ func TestTranslateCPEToCVE(t *testing.T) {
},
continuesToUpdate: true,
},
+ "cpe:2.3:a:mozilla:firefox:*:*:*:*:*:*:*:*": {
+ excludedCVEs: []string{"CVE-2024-10004"},
+ continuesToUpdate: true,
+ },
}
cveOSTests := []struct {
diff --git a/terraform/addons/monitoring/.header.md b/terraform/addons/monitoring/.header.md
index 09e4ddba44..2ee8d34c17 100644
--- a/terraform/addons/monitoring/.header.md
+++ b/terraform/addons/monitoring/.header.md
@@ -55,6 +55,7 @@ module "monitoring" {
sns_topic_arns_map = {
alb_httpcode_5xx = [var.sns_topic_arn]
cron_monitoring = [var.sns_topic_arn]
+ cron_job_failure_monitoring = [var.sns_another_topic_arn]
}
mysql_cluster_members = module.main.byo-vpc.rds.cluster_members
# The cloudposse module seems to have a nested list here.
@@ -85,7 +86,8 @@ Valid targets for `sns_topic_arns_map`:
- alb_helthyhosts
- alb_httpcode_5xx
- backend_response_time
- - cron_monitoring
+ - cron_monitoring (notifications about failures in the cron scheduler)
+ - cron_job_failure_monitoring (notifications about errors in individual cron jobs - defaults to value of `cron_monitoring`)
- rds_cpu_untilizaton_too_high
- rds_db_event_subscription
- redis_cpu_engine_utilization
diff --git a/terraform/addons/monitoring/README.md b/terraform/addons/monitoring/README.md
index d07243b769..756f6ca267 100644
--- a/terraform/addons/monitoring/README.md
+++ b/terraform/addons/monitoring/README.md
@@ -52,8 +52,9 @@ module "monitoring" {
},
]
sns_topic_arns_map = {
- alb_httpcode_5xx = [var.slack_topic_arn]
- cron_monitoring = [var.slack_topic_arn]
+ alb_httpcode_5xx = [var.sns_topic_arn]
+ cron_monitoring = [var.sns_topic_arn]
+ cron_job_failure_monitoring = [var.sns_another_topic_arn]
}
mysql_cluster_members = module.main.byo-vpc.rds.cluster_members
# The cloudposse module seems to have a nested list here.
@@ -84,7 +85,8 @@ Valid targets for `sns_topic_arns_map`:
- alb\_helthyhosts
- alb\_httpcode\_5xx
- backend\_response\_time
- - cron\_monitoring
+ - cron\_monitoring (notifications about failures in the cron scheduler)
+ - cron\_job\_failure\_monitoring (notifications about errors in individual cron jobs - defaults to value of `cron_monitoring`)
- rds\_cpu\_untilizaton\_too\_high
- rds\_db\_event\_subscription
- redis\_cpu\_engine\_utilization
@@ -103,9 +105,9 @@ No requirements.
| Name | Version |
|------|---------|
-| [archive](#provider\_archive) | 2.4.0 |
-| [aws](#provider\_aws) | 5.22.0 |
-| [null](#provider\_null) | 3.2.1 |
+| [archive](#provider\_archive) | 2.7.0 |
+| [aws](#provider\_aws) | 5.81.0 |
+| [null](#provider\_null) | 3.2.3 |
## Modules
@@ -150,8 +152,8 @@ No modules.
| Name | Description | Type | Default | Required |
|------|-------------|------|---------|:--------:|
| [acm\_certificate\_arn](#input\_acm\_certificate\_arn) | n/a | `string` | `null` | no |
-| [albs](#input\_albs) | n/a | list(object({ name = string arn_suffix = string target_group_name = string target_group_arn_suffix = string min_containers = optional(string, 1) ecs_service_name = string alert_thresholds = optional( object({ HTTPCode_ELB_5XX_Count = object({ period = number threshold = number }) HTTPCode_Target_5XX_Count = object({ period = number threshold = number }) }), { HTTPCode_ELB_5XX_Count = { period = 120 threshold = 0 }, HTTPCode_Target_5XX_Count = { period = 120 threshold = 0 } } ) })) | `[]` | no |
-| [cron\_monitoring](#input\_cron\_monitoring) | n/a | object({ mysql_host = string mysql_database = string mysql_user = string mysql_password_secret_name = string vpc_id = string subnet_ids = list(string) rds_security_group_id = string delay_tolerance = string run_interval = string log_retention_in_days = optional(number, 7) }) | `null` | no |
+| [albs](#input\_albs) | n/a | list(object({ name = string arn_suffix = string target_group_name = string target_group_arn_suffix = string min_containers = optional(string, 1) ecs_service_name = string alert_thresholds = optional( object({ HTTPCode_ELB_5XX_Count = object({ period = number threshold = number }) HTTPCode_Target_5XX_Count = object({ period = number threshold = number }) }), { HTTPCode_ELB_5XX_Count = { period = 120 threshold = 0 }, HTTPCode_Target_5XX_Count = { period = 120 threshold = 0 } } ) })) | `[]` | no |
+| [cron\_monitoring](#input\_cron\_monitoring) | n/a | object({ mysql_host = string mysql_database = string mysql_user = string mysql_password_secret_name = string vpc_id = string subnet_ids = list(string) rds_security_group_id = string delay_tolerance = string run_interval = string log_retention_in_days = optional(number, 7) }) | `null` | no |
| [customer\_prefix](#input\_customer\_prefix) | n/a | `string` | `"fleet"` | no |
| [default\_sns\_topic\_arns](#input\_default\_sns\_topic\_arns) | n/a | `list(string)` | `[]` | no |
| [fleet\_ecs\_service\_name](#input\_fleet\_ecs\_service\_name) | n/a | `string` | `null` | no |
diff --git a/terraform/addons/monitoring/lambda/main.go b/terraform/addons/monitoring/lambda/main.go
index eb56e0123b..2e44c1a198 100644
--- a/terraform/addons/monitoring/lambda/main.go
+++ b/terraform/addons/monitoring/lambda/main.go
@@ -24,6 +24,7 @@ import (
"database/sql"
"fmt"
"log"
+ "strconv"
"strings"
"time"
@@ -37,27 +38,41 @@ import (
flags "github.com/jessevdk/go-flags"
)
-type NullEvent struct{}
+type (
+ NullEvent struct{}
+ SNSTopicArnsMap map[string]string
+)
type OptionsStruct struct {
- LambdaRuntimeAPI string `long:"lambda-runtime-api" env:"AWS_LAMBDA_RUNTIME_API"`
- SNSTopicArns string `long:"sns-topic-arn" env:"SNS_TOPIC_ARNS" required:"true"`
- MySQLHost string `long:"mysql-host" env:"MYSQL_HOST" required:"true"`
- MySQLUser string `long:"mysql-user" env:"MYSQL_USER" required:"true"`
- MySQLSMSecret string `long:"mysql-secretsmanager-secret" env:"MYSQL_SECRETSMANAGER_SECRET" required:"true"`
- MySQLDatabase string `long:"mysql-database" env:"MYSQL_DATABASE" required:"true"`
- FleetEnv string `long:"fleet-environment" env:"FLEET_ENV" required:"true"`
- AWSRegion string `long:"aws-region" env:"AWS_REGION" required:"true"`
- CronDelayTolerance string `long:"cron-delay-tolerance" env:"CRON_DELAY_TOLERANCE" default:"2h"`
+ LambdaRuntimeAPI string `long:"lambda-runtime-api" env:"AWS_LAMBDA_RUNTIME_API"`
+ SNSCronSystemTopicArns string `long:"sns-cron-system-topic-arn" env:"CRON_SYSTEM_MONITOR_SNS_TOPIC_ARNS" required:"true"`
+ SNSCronJobFailureTopicArns string `long:"sns-cron-job-failure-topic-arn" env:"CRON_JOB_FAILURE_MONITOR_SNS_TOPIC_ARNS"`
+ MySQLHost string `long:"mysql-host" env:"MYSQL_HOST" required:"true"`
+ MySQLUser string `long:"mysql-user" env:"MYSQL_USER" required:"true"`
+ MySQLSMSecret string `long:"mysql-secretsmanager-secret" env:"MYSQL_SECRETSMANAGER_SECRET" required:"true"`
+ MySQLDatabase string `long:"mysql-database" env:"MYSQL_DATABASE" required:"true"`
+ FleetEnv string `long:"fleet-environment" env:"FLEET_ENV" required:"true"`
+ AWSRegion string `long:"aws-region" env:"AWS_REGION" required:"true"`
+ CronDelayTolerance string `long:"cron-delay-tolerance" env:"CRON_DELAY_TOLERANCE" default:"2h"`
+ CronMonitorInterval string `long:"monitor-run-interval" env:"CRON_MONITOR_RUN_INTERVAL" default:"1 hour"`
}
-var options = OptionsStruct{}
+var (
+ options = OptionsStruct{}
+ snsTopics = make(SNSTopicArnsMap)
+)
+
+func sendSNSMessage(msg string, topic string, sess *session.Session) {
+ topicArns, ok := snsTopics[topic]
+ if !ok {
+ log.Printf("No SNS topic ARNs available for topic '%s'", topic)
+ return
+ }
-func sendSNSMessage(msg string, sess *session.Session) {
log.Printf("Sending SNS Message")
fullMsg := fmt.Sprintf("Environment: %s\nMessage: %s", options.FleetEnv, msg)
svc := sns.New(sess)
- for _, SNSTopicArn := range strings.Split(options.SNSTopicArns, ",") {
+ for _, SNSTopicArn := range strings.Split(topicArns, ",") {
log.Printf("Sending '%s' to '%s'", fullMsg, SNSTopicArn)
result, err := svc.Publish(&sns.PublishInput{
Message: &fullMsg,
@@ -70,21 +85,50 @@ func sendSNSMessage(msg string, sess *session.Session) {
}
}
-func checkDB(sess *session.Session) (err error) {
+func parseLambdaIntervalToDuration(intervalString string) (duration time.Duration, err error) {
+ var number int
+ var unit string
+
+ _, err = fmt.Sscanf(intervalString, "%d %s", &number, &unit)
+ if err != nil {
+ return 0, err
+ }
+
+ switch unit {
+ case "hour", "hours":
+ unit = "h"
+ case "minute", "minutes":
+ unit = "m"
+ case "day", "days":
+ unit = "h"
+ number *= 24
+ }
+
+ return time.ParseDuration(strconv.Itoa(number) + unit)
+}
+
+type CronStatsRow struct {
+ name string
+ status string
+ errors string
+ created_at time.Time
+ updated_at time.Time
+}
+
+func setupDB(sess *session.Session) (db *sql.DB, err error) {
secretCache, err := secretcache.New()
if err != nil {
log.Printf(err.Error())
- sendSNSMessage("Unable to initialise SecretsManager helper. Cron status is unknown.", sess)
- return err
+ sendSNSMessage("Unable to initialise SecretsManager helper. Cron status is unknown.", "cronSystem", sess)
+ return db, err
}
secretCache.Client = secretsmanager.New(sess)
-
MySQLPassword, err := secretCache.GetSecretString(options.MySQLSMSecret)
if err != nil {
log.Printf(err.Error())
- sendSNSMessage("Unable to retrieve SecretsManager secret. Cron status is unknown.", sess)
- return err
+ sendSNSMessage("Unable to retrieve SecretsManager secret. Cron status is unknown.", "cronSystem", sess)
+ return db, err
}
cfg := mysql.Config{
@@ -97,38 +141,36 @@ func checkDB(sess *session.Session) (err error) {
ParseTime: true,
}
- db, err := sql.Open("mysql", cfg.FormatDSN())
- defer db.Close()
+ db, err = sql.Open("mysql", cfg.FormatDSN())
if err != nil {
log.Printf(err.Error())
- sendSNSMessage("Unable to connect to database. Cron status unknown.", sess)
- return err
+ sendSNSMessage("Unable to connect to database. Cron status unknown.", "cronSystem", sess)
+ return db, err
}
if err = db.Ping(); err != nil {
log.Printf(err.Error())
- sendSNSMessage("Unable to connect to database. Cron status unknown.", sess)
- return err
+ sendSNSMessage("Unable to connect to database. Cron status unknown.", "cronSystem", sess)
+ return db, err
}
log.Printf("Connected to database!")
- type CronStatsRow struct {
- name string
- status string
- updated_at time.Time
- }
+ return db, err
+}
+// Check that the cron stats table is reachable, and that no cron jobs have been stuck for > 1 run time.
+func checkDB(db *sql.DB, sess *session.Session) (err error) {
rows, err := db.Query("SELECT b.name,IFNULL(status, 'missing cron'),IFNULL(updated_at, FROM_UNIXTIME(0)) AS updated_at FROM (SELECT 'vulnerabilities' AS name UNION ALL SELECT 'cleanups_then_aggregation') b LEFT JOIN (SELECT name, status, updated_at FROM cron_stats WHERE id IN (SELECT MAX(id) FROM cron_stats WHERE status = 'completed' GROUP BY name)) a ON a.name = b.name;")
defer rows.Close()
if err != nil {
log.Printf(err.Error())
- sendSNSMessage("Unable to SELECT cron_stats table. Unable to continue.", sess)
+ sendSNSMessage("Unable to SELECT cron_stats table. Unable to continue.", "cronSystem", sess)
return err
}
cronDelayDuration, err := time.ParseDuration(options.CronDelayTolerance)
if err != nil {
log.Printf(err.Error())
- sendSNSMessage("Unable to parse cron-delay-tolerance. Check lambda settings.", sess)
+ sendSNSMessage("Unable to parse cron-delay-tolerance. Check lambda settings.", "cronSystem", sess)
return err
}
cronAlertTimestamp := time.Now().Add(-1 * cronDelayDuration)
@@ -136,14 +178,14 @@ func checkDB(sess *session.Session) (err error) {
var row CronStatsRow
if err := rows.Scan(&row.name, &row.status, &row.updated_at); err != nil {
log.Printf(err.Error())
- sendSNSMessage("Error scanning row in cron_stats table. Unable to continue.", sess)
+ sendSNSMessage("Error scanning row in cron_stats table. Unable to continue.", "cronSystem", sess)
return err
}
log.Printf("Row %s last updated at %s", row.name, row.updated_at.String())
if row.updated_at.Before(cronAlertTimestamp) {
log.Printf("*** %s hasn't updated in more than %s, alerting! (status %s)", options.CronDelayTolerance, row.name, row.status)
// Fire on the first match and return. We only need to alert that the crons need looked at, not each cron.
- sendSNSMessage(fmt.Sprintf("Fleet cron '%s' hasn't updated in more than %s. Last status was '%s' at %s.", row.name, options.CronDelayTolerance, row.status, row.updated_at.String()), sess)
+ sendSNSMessage(fmt.Sprintf("Fleet cron '%s' hasn't updated in more than %s. Last status was '%s' at %s.", row.name, options.CronDelayTolerance, row.status, row.updated_at.String()), "cronSystem", sess)
return nil
}
}
@@ -151,6 +193,39 @@ func checkDB(sess *session.Session) (err error) {
return nil
}
+// Check for errors in cron runs.
+func checkCrons(db *sql.DB, sess *session.Session) (err error) {
+ cronMonitorInterval, err := parseLambdaIntervalToDuration(options.CronMonitorInterval)
+ if err != nil {
+ log.Printf(err.Error())
+ sendSNSMessage("Unable to parse cron-delay-tolerance. Check lambda settings.", "cronSystem", sess)
+ return err
+ }
+ cronAlertTimestamp := time.Now().Add(-1 * cronMonitorInterval)
+
+ // Find all cron entries less than cronMonitorInterval old that have errors.
+ rows, err := db.Query("SELECT name, created_at, IFNULL(updated_at, FROM_UNIXTIME(0)) AS updated_at, errors FROM cron_stats WHERE errors IS NOT NULL AND created_at > \"" + cronAlertTimestamp.Format("20060102150405") + "\"")
+ defer rows.Close()
+ if err != nil {
+ log.Printf(err.Error())
+ sendSNSMessage("Unable to SELECT cron_stats table. Unable to continue.", "cronSystem", sess)
+ return err
+ }
+ for rows.Next() {
+ var row CronStatsRow
+ if err := rows.Scan(&row.name, &row.created_at, &row.updated_at, &row.errors); err != nil {
+ log.Printf(err.Error())
+ sendSNSMessage("Error scanning row in cron_stats table. Unable to continue.", "cronSystem", sess)
+ return err
+ }
+ log.Printf("*** %s job had errors, alerting! (errors %s)", row.name, row.errors)
+ // Fire on the first match and return. We only need to alert that the crons need looked at, not each cron.
+ sendSNSMessage(fmt.Sprintf("Fleet cron '%s' (last updated %s) raised errors during its run:\n%s.", row.name, row.updated_at.String(), row.errors), "cronJobFailure", sess)
+ }
+
+ return nil
+}
+
func handler(ctx context.Context, name NullEvent) error {
sess := session.Must(session.NewSessionWithOptions(
session.Options{
@@ -161,7 +236,15 @@ func handler(ctx context.Context, name NullEvent) error {
},
))
- checkDB(sess)
+ db, err := setupDB(sess)
+ defer db.Close()
+
+ if err != nil {
+ return nil
+ }
+
+ checkDB(db, sess)
+ checkCrons(db, sess)
return nil
}
@@ -178,6 +261,14 @@ func main() {
}
}
+ snsTopics["cronSystem"] = options.SNSCronSystemTopicArns
+ snsTopics["cronJobFailure"] = options.SNSCronJobFailureTopicArns
+ // For backwards compatibility, fall back to sending cron failure alerts
+ // to the same SNS topic as cron system alerts.s
+ if snsTopics["cronJobFailure"] == "" {
+ snsTopics["cronJobFailure"] = options.SNSCronSystemTopicArns
+ }
+
// When running from Lambda, this should be read from the environment.
if options.LambdaRuntimeAPI != "" {
log.Printf("Starting Lambda handler.")
diff --git a/terraform/addons/monitoring/main.tf b/terraform/addons/monitoring/main.tf
index 1bea5dbfcf..57a3cb5ae3 100644
--- a/terraform/addons/monitoring/main.tf
+++ b/terraform/addons/monitoring/main.tf
@@ -376,9 +376,11 @@ resource "aws_lambda_function" "cron_monitoring" {
MYSQL_DATABASE = var.cron_monitoring.mysql_database
MYSQL_USER = var.cron_monitoring.mysql_user
MYSQL_SECRETSMANAGER_SECRET = data.aws_secretsmanager_secret.mysql_database_password[0].name
- SNS_TOPIC_ARNS = join(",", lookup(var.sns_topic_arns_map, "cron_monitoring", var.default_sns_topic_arns))
+ CRON_SYSTEM_MONITOR_SNS_TOPIC_ARNS = join(",", lookup(var.sns_topic_arns_map, "cron_monitoring", var.default_sns_topic_arns))
+ CRON_JOB_FAILURE_MONITOR_SNS_TOPIC_ARNS = join(",", lookup(var.sns_topic_arns_map, "cron_job_failure_monitoring", var.default_sns_topic_arns))
FLEET_ENV = var.customer_prefix
CRON_DELAY_TOLERANCE = var.cron_monitoring.delay_tolerance
+ CRON_MONITOR_RUN_INTERVAL = var.cron_monitoring.run_interval
}
}