Merge branch 'main' into feat-setup-experience

This commit is contained in:
Gabriel Hernandez
2024-10-09 15:36:48 +01:00
63 changed files with 1095 additions and 305 deletions
+8 -1
View File
@@ -19,8 +19,15 @@ It is [planned and ready](https://fleetdm.com/handbook/company/development-group
| I want to _________________________________________
| so that I can _________________________________________.
## Objective
<!-- What quarterly objective does this story contribute to, if any? If it doesn't contribute to an objective, explain why it's being prioritized. -->
## Original requests
<!-- Insert the link to the feature request(s) that this story contributes to. Put "None" if it doesn't contribute to a request. For customer requests, add the `customer-xyz` label(s). -->
## Context
- Requestor(s): _________________________ <!-- Who are the non-customer requestor(s) for this story, if any? Put their GitHub usernames here. They should be notified if the story gets de-prioritized. For customer requestors, use the `customer-xyz` label instead. -->
- Product designer: _________________________ <!-- Who is the product designer to contact if folks have questions about the UI, CLI, or API changes? -->
<!--
@@ -97,6 +97,8 @@ The next step to ensure Okta detects the device as managed is to issue a SCEP ce
```
> Make sure to use `.mobileconfig` as the file extension
* Enforce the configuration profile on your hosts. You can follow [this guide on enforcing custom OS settings in Fleet](https://fleetdm.com/guides/custom-os-settings).
* You can optionally verify the issued certificate by opening Keychain Access on the device or by running a [live query](https://fleetdm.com/guides/get-current-telemetry-from-your-devices-with-live-queries):
+2 -2
View File
@@ -33,11 +33,11 @@ Requests sent by Fleet Desktop and the web page that opens when clicking on the
The server uses this token to authenticate requests that give host information. Fleet uses the following methods to secure access to this information.
**Rate Limiting**
**Rate limiting**
To prevent brute-forcing, Fleet rate-limits the endpoints used by Fleet Desktop on a per-IP basis. If an IP requests more than 720 invalid UUIDs in a one-hour interval, Fleet will return HTTP error code 429.
**Token Rotation**
**Token rotation**
```
️ In Fleet v4.22.0, token rotation for Fleet Desktop was introduced.
+1
View File
@@ -0,0 +1 @@
* Fixed a bug where policy failures of a host were not being cleared in the host details page after configuring the host to not run any policies.
+1
View File
@@ -0,0 +1 @@
* Record which policy automation triggered a script run in the activity feed
+15 -15
View File
@@ -76,8 +76,7 @@ import (
var allowedURLPrefixRegexp = regexp.MustCompile("^(?:/[a-zA-Z0-9_.~-]+)+$")
const (
softwareInstallerUploadTimeout = 4 * time.Minute
liveQueryMemCacheDuration = 1 * time.Second
liveQueryMemCacheDuration = 1 * time.Second
)
type initializer interface {
@@ -1153,22 +1152,23 @@ the way that the Fleet server works.
if (req.Method == http.MethodPost && strings.HasSuffix(req.URL.Path, "/fleet/software/package")) ||
(req.Method == http.MethodPatch && strings.HasSuffix(req.URL.Path, "/package") && strings.Contains(req.URL.Path, "/fleet/software/titles/")) {
// when uploading a software installer, the file might be large so
// the read timeout (to read the full request body) must be extended.
var zeroTime time.Time
rc := http.NewResponseController(rw)
// the frontend times out waiting for the upload after 4 minutes,
// use that same timeout:
// https://www.figma.com/design/oQl2oQUG0iRkUy0YOxc307/%2314921-Deploy-security-agents-to-macOS%2C-Windows%2C-and-Linux-hosts?node-id=773-18032&t=QjEU6tc73tddNSqn-0
if err := rc.SetReadDeadline(time.Now().Add(softwareInstallerUploadTimeout)); err != nil {
// For large software installers, the server time needs time to read the full
// request body so we use the zero value to remove the deadline and override the
// default read timeout.
// TODO: Is this really how we want to handle this? Or would an arbitrarily long
// timeout be better?
if err := rc.SetReadDeadline(zeroTime); err != nil {
level.Error(logger).Log("msg", "http middleware failed to override endpoint read timeout", "err", err)
}
// the write timeout should be extended to give the server time to
// store the installer to S3 (or the configured storage location) and
// write a response body, otherwise the connection is terminated
// abruptly. Give it twice the read timeout, so that if it takes
// 3m59s to upload an installer, we don't fail because of a lack of
// time to store to S3.
if err := rc.SetWriteDeadline(time.Now().Add(2 * softwareInstallerUploadTimeout)); err != nil {
// For large software installers, the server time needs time to store the
// installer to S3 (or the configured storage location) and write the response
// body so we use the zero value to remove the deadline and override the
// default write timeout.
// TODO: Is this really how we want to handle this? Or would an arbitrarily long
// timeout be better?
if err := rc.SetWriteDeadline(zeroTime); err != nil {
level.Error(logger).Log("msg", "http middleware failed to override endpoint write timeout", "err", err)
}
req.Body = http.MaxBytesReader(rw, req.Body, service.MaxSoftwareInstallerSize)
+1 -1
View File
@@ -251,7 +251,7 @@ controls:
labels_include_all:
- Label 2
windows_settings:
custom_settings
custom_settings:
- path: ../lib/windows-profile.xml
macos_setup: # Available in Fleet Premium
bootstrap_package: https://example.org/bootstrap_package.pkg
+5 -1
View File
@@ -887,6 +887,8 @@ This activity contains the following fields:
- "script_execution_id": Execution ID of the script run.
- "script_name": Name of the script (empty if it was an anonymous script).
- "async": Whether the script was executed asynchronously.
- "policy_id": ID of the policy whose failure triggered the script run. Null if no associated policy.
- "policy_name": Name of the policy whose failure triggered the script run. Null if no associated policy.
#### Example
@@ -896,7 +898,9 @@ This activity contains the following fields:
"host_display_name": "Anna's MacBook Pro",
"script_name": "set-timezones.sh",
"script_execution_id": "d6cffa75-b5b5-41ef-9230-15073c8a88cf",
"async": false
"async": false,
"policy_id": 123,
"policy_name": "Ensure photon torpedoes are primed"
}
```
+26 -15
View File
@@ -5,10 +5,8 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/fleetdm/fleet/v4/pkg/file"
@@ -19,6 +17,9 @@ import (
"github.com/fleetdm/fleet/v4/server/mdm/maintainedapps"
)
// noCheckHash is used by homebrew to signal that a hash shouldn't be checked.
const noCheckHash = "no_check"
func (svc *Service) AddFleetMaintainedApp(
ctx context.Context,
teamID *uint,
@@ -52,16 +53,25 @@ func (svc *Service) AddFleetMaintainedApp(
return ctxerr.Wrap(ctx, err, "downloading app installer")
}
// Validate the bytes we got are what we expected
h := sha256.New()
_, err = h.Write(installerBytes)
extension, err := maintainedapps.ExtensionForBundleIdentifier(app.BundleIdentifier)
if err != nil {
return ctxerr.Wrap(ctx, err, "generating SHA256 of maintained app installer")
return ctxerr.Errorf(ctx, "getting extension from bundle identifier %q", app.BundleIdentifier)
}
gotHash := hex.EncodeToString(h.Sum(nil))
if gotHash != app.SHA256 {
return ctxerr.New(ctx, "mismatch in maintained app SHA256 hash")
// Validate the bytes we got are what we expected, if homebrew supports
// it, the string "no_check" is a special token used to signal that the
// hash shouldn't be checked.
if app.SHA256 != noCheckHash {
h := sha256.New()
_, err = h.Write(installerBytes)
if err != nil {
return ctxerr.Wrap(ctx, err, "generating SHA256 of maintained app installer")
}
gotHash := hex.EncodeToString(h.Sum(nil))
if gotHash != app.SHA256 {
return ctxerr.New(ctx, "mismatch in maintained app SHA256 hash")
}
}
// Fall back to the filename if we weren't able to extract a filename from the installer response
@@ -69,6 +79,12 @@ func (svc *Service) AddFleetMaintainedApp(
filename = app.Name
}
// The UI requires all filenames to have extensions. If we couldn't get
// one, use the extension we extracted prior
if filepath.Ext(filename) == "" {
filename = filename + "." + extension
}
installScript = file.Dos2UnixNewlines(installScript)
if installScript == "" {
installScript = app.InstallScript
@@ -79,11 +95,6 @@ func (svc *Service) AddFleetMaintainedApp(
uninstallScript = app.UninstallScript
}
installerURL, err := url.Parse(app.InstallerURL)
if err != nil {
return err
}
installerReader := bytes.NewReader(installerBytes)
payload := &fleet.UploadSoftwareInstallerPayload{
InstallerFile: installerReader,
@@ -94,7 +105,7 @@ func (svc *Service) AddFleetMaintainedApp(
Filename: filename,
Platform: string(app.Platform),
Source: "apps",
Extension: strings.TrimPrefix(filepath.Ext(installerURL.Path), "."),
Extension: extension,
BundleIdentifier: app.BundleIdentifier,
StorageID: app.SHA256,
FleetLibraryAppID: &app.ID,
+1 -1
View File
@@ -1286,7 +1286,7 @@ func (svc *Service) softwareBatchUpload(
}
var g errgroup.Group
g.SetLimit(3)
g.SetLimit(3) // TODO: consider lowering this limit, see https://github.com/fleetdm/fleet/issues/22704#issuecomment-2397407837
// critical to avoid data race, the slice is pre-allocated and each
// goroutine only writes to its index.
installers := make([]*fleet.UploadSoftwareInstallerPayload, len(payloads))
+3 -3
View File
@@ -71,8 +71,8 @@ const DEFAULT_SOFTWARE_VERSION_MOCK: ISoftwareVersion = {
name: "test.app",
version: "1.2.3",
bundle_identifier: "com.test.Desktop",
source: "test_package",
browser: "",
source: "apps",
browser: "chrome",
release: "1",
vendor: "test_vendor",
arch: "x86_64",
@@ -161,7 +161,7 @@ const DEFAULT_SOFTWARE_TITLE_DETAILS_MOCK: ISoftwareTitleDetails = {
name: "test.app",
software_package: null,
app_store_app: null,
source: "test_package",
source: "apps",
hosts_count: 1,
versions: [createMockSoftwareTitleVersion()],
bundle_identifier: "com.test.Desktop",
+16 -12
View File
@@ -95,12 +95,12 @@ export interface ISoftwareTitle {
id: number;
name: string;
versions_count: number;
source: string; // "apps" | "ios_apps" | "ipados_apps" | ?
source: SoftwareSource;
hosts_count: number;
versions: ISoftwareTitleVersion[] | null;
software_package: ISoftwarePackage | null;
app_store_app: IAppStoreApp | null;
browser?: string;
browser?: BrowserType;
}
export interface ISoftwareTitleDetails {
@@ -108,12 +108,12 @@ export interface ISoftwareTitleDetails {
name: string;
software_package: ISoftwarePackage | null;
app_store_app: IAppStoreApp | null;
source: string; // "apps" | "ios_apps" | "ipados_apps" | ?
source: SoftwareSource;
hosts_count: number;
versions: ISoftwareTitleVersion[] | null;
versions_updated_at?: string;
bundle_identifier?: string;
browser?: string;
browser?: BrowserType;
versions_count?: number;
}
@@ -134,8 +134,8 @@ export interface ISoftwareVersion {
name: string; // e.g., "Figma.app"
version: string; // e.g., "2.1.11"
bundle_identifier?: string; // e.g., "com.figma.Desktop"
source: string; // "apps" | "ipados_apps" | "ios_apps" | ?
browser: string; // e.g., "chrome"
source: SoftwareSource;
browser: BrowserType;
release: string; // TODO: on software/verions/:id?
vendor: string;
arch: string; // e.g., "x86_64" // TODO: on software/verions/:id?
@@ -144,7 +144,7 @@ export interface ISoftwareVersion {
hosts_count?: number;
}
export const SOURCE_TYPE_CONVERSION: Record<string, string> = {
export const SOURCE_TYPE_CONVERSION = {
apt_sources: "Package (APT)",
deb_packages: "Package (deb)",
portage_packages: "Package (Portage)",
@@ -167,7 +167,9 @@ export const SOURCE_TYPE_CONVERSION: Record<string, string> = {
vscode_extensions: "IDE extension (VS Code)",
} as const;
const BROWSER_TYPE_CONVERSION: Record<string, string> = {
export type SoftwareSource = keyof typeof SOURCE_TYPE_CONVERSION;
const BROWSER_TYPE_CONVERSION = {
chrome: "Chrome",
chromium: "Chromium",
opera: "Opera",
@@ -177,14 +179,16 @@ const BROWSER_TYPE_CONVERSION: Record<string, string> = {
edge_beta: "Edge Beta",
} as const;
export type BrowserType = keyof typeof BROWSER_TYPE_CONVERSION;
export const formatSoftwareType = ({
source,
browser,
}: {
source: string;
browser?: string;
source: SoftwareSource;
browser?: BrowserType;
}) => {
let type = SOURCE_TYPE_CONVERSION[source] || "Unknown";
let type: string = SOURCE_TYPE_CONVERSION[source] || "Unknown";
if (browser) {
type = `Browser plugin (${
BROWSER_TYPE_CONVERSION[browser] || startCase(browser)
@@ -310,7 +314,7 @@ export interface IHostSoftware {
name: string;
software_package: IHostSoftwarePackage | null;
app_store_app: IHostAppStoreApp | null;
source: string;
source: SoftwareSource;
bundle_identifier?: string;
status: Exclude<SoftwareInstallStatus, "uninstalled"> | null;
installed_versions: ISoftwareInstallVersion[] | null;
@@ -1,5 +1,6 @@
import React, { useContext, useEffect } from "react";
import { InjectedRouter } from "react-router";
import { isAxiosError } from "axios";
import PATHS from "router/paths";
import { LEARN_MORE_ABOUT_BASE_LINK } from "utilities/constants";
@@ -8,7 +9,6 @@ import { buildQueryStringFromParams, QueryParams } from "utilities/url";
import softwareAPI, {
MAX_FILE_SIZE_BYTES,
MAX_FILE_SIZE_MB,
UPLOAD_TIMEOUT,
} from "services/entities/software";
import { NotificationContext } from "context/notification";
@@ -43,8 +43,6 @@ const SoftwareCustomPackage = ({
);
useEffect(() => {
let timeout: NodeJS.Timeout;
const beforeUnloadHandler = (e: BeforeUnloadEvent) => {
e.preventDefault();
// Next line with e.returnValue is included for legacy support
@@ -55,9 +53,6 @@ const SoftwareCustomPackage = ({
// set up event listener to prevent user from leaving page while uploading
if (uploadDetails) {
addEventListener("beforeunload", beforeUnloadHandler);
timeout = setTimeout(() => {
removeEventListener("beforeunload", beforeUnloadHandler);
}, UPLOAD_TIMEOUT);
} else {
removeEventListener("beforeunload", beforeUnloadHandler);
}
@@ -65,7 +60,6 @@ const SoftwareCustomPackage = ({
// clean up event listener and timeout on component unmount
return () => {
removeEventListener("beforeunload", beforeUnloadHandler);
clearTimeout(timeout);
};
}, [uploadDetails]);
@@ -103,9 +97,11 @@ const SoftwareCustomPackage = ({
await softwareAPI.addSoftwarePackage({
data: formData,
teamId: currentTeamId,
timeout: UPLOAD_TIMEOUT,
onUploadProgress: (progressEvent) => {
setUploadProgress(progressEvent.progress || 0);
const progress = progressEvent.progress || 0;
// for large uploads it seems to take a bit for the server to finalize its response so we'll keep the
// progress bar at 97% until the server response is received
setUploadProgress(Math.max(progress - 0.03, 0.01));
},
});
renderFlash(
@@ -128,10 +124,17 @@ const SoftwareCustomPackage = ({
`${PATHS.SOFTWARE_TITLES}?${buildQueryStringFromParams(newQueryParams)}`
);
} catch (e) {
const isTimeout =
isAxiosError(e) &&
(e.response?.status === 504 || e.response?.status === 408);
const reason = getErrorReason(e);
if (
reason.includes("Couldn't add. Fleet couldn't read the version from")
) {
if (isTimeout) {
renderFlash(
"error",
`Couldnt upload. Request timeout. Please make sure your server and load balancer timeout is long enough.`
);
} else if (reason.includes("Fleet couldn't read the version from")) {
renderFlash(
"error",
<>
@@ -25,7 +25,11 @@ const EmptyFleetAppsTable = () => (
info={
<>
Can&apos; find app?{" "}
<CustomLink newTab url="" text="File and issue on GitHub" />
<CustomLink
newTab
url="https://github.com/fleetdm/fleet/issues/new/choose"
text="File an issue on GitHub"
/>
</>
}
/>
@@ -1,6 +1,7 @@
import React, { useContext, useState, useEffect } from "react";
import { InjectedRouter } from "react-router";
import classnames from "classnames";
import { isAxiosError } from "axios";
import { getErrorReason } from "interfaces/errors";
@@ -8,7 +9,6 @@ import { NotificationContext } from "context/notification";
import softwareAPI, {
MAX_FILE_SIZE_BYTES,
MAX_FILE_SIZE_MB,
UPLOAD_TIMEOUT,
} from "services/entities/software";
import { LEARN_MORE_ABOUT_BASE_LINK } from "utilities/constants";
@@ -76,8 +76,6 @@ const EditSoftwareModal = ({
]);
useEffect(() => {
let timeout: NodeJS.Timeout;
const beforeUnloadHandler = (e: BeforeUnloadEvent) => {
e.preventDefault();
// Next line with e.returnValue is included for legacy support
@@ -88,9 +86,6 @@ const EditSoftwareModal = ({
// set up event listener to prevent user from leaving page while uploading
if (isUpdatingSoftware) {
addEventListener("beforeunload", beforeUnloadHandler);
timeout = setTimeout(() => {
removeEventListener("beforeunload", beforeUnloadHandler);
}, UPLOAD_TIMEOUT);
} else {
removeEventListener("beforeunload", beforeUnloadHandler);
}
@@ -98,7 +93,6 @@ const EditSoftwareModal = ({
// clean up event listener and timeout on component unmount
return () => {
removeEventListener("beforeunload", beforeUnloadHandler);
clearTimeout(timeout);
};
}, [isUpdatingSoftware]);
@@ -126,9 +120,11 @@ const EditSoftwareModal = ({
data: formData,
softwareId,
teamId,
timeout: UPLOAD_TIMEOUT,
onUploadProgress: (progressEvent) => {
setUploadProgress(progressEvent.progress || 0);
const progress = progressEvent.progress || 0;
// for large uploads it seems to take a bit for the server to finalize its response so we'll keep the
// progress bar at 97% until the server response is received
setUploadProgress(Math.max(progress - 0.03, 0.01));
},
});
@@ -144,8 +140,16 @@ const EditSoftwareModal = ({
onExit();
refetchSoftwareTitle();
} catch (e) {
const isTimeout =
isAxiosError(e) &&
(e.response?.status === 504 || e.response?.status === 408);
const reason = getErrorReason(e);
if (reason.includes("Fleet couldn't read the version from")) {
if (isTimeout) {
renderFlash(
"error",
`Couldnt upload. Request timeout. Please make sure your server and load balancer timeout is long enough.`
);
} else if (reason.includes("Fleet couldn't read the version from")) {
renderFlash(
"error",
<>
@@ -98,7 +98,7 @@ const SelectQueryModal = ({
);
};
const renderResults = (): JSX.Element => {
const renderQueries = (): JSX.Element => {
if (queryErrors) {
return <DataError />;
}
@@ -145,7 +145,7 @@ const SelectQueryModal = ({
iconSvg="search"
iconPosition="start"
/>
<div>{queryList}</div>
<div className={`${baseClass}__query-selection`}>{queryList}</div>
</>
);
}
@@ -188,7 +188,7 @@ const SelectQueryModal = ({
>
<>
{renderDescription()}
{renderResults()}
{renderQueries()}
</>
</Modal>
);
@@ -31,4 +31,10 @@
font-weight: $bold;
}
}
&__query-selection {
.children-wrapper {
width: 680px;
}
}
}
@@ -1,7 +1,11 @@
import React from "react";
import { CellProps, Column } from "react-table";
import { IHostSoftware, SOURCE_TYPE_CONVERSION } from "interfaces/software";
import {
IHostSoftware,
SoftwareSource,
SOURCE_TYPE_CONVERSION,
} from "interfaces/software";
import { IHeaderProps, IStringCellProps } from "interfaces/datatable_config";
import HeaderCell from "components/TableContainer/DataTable/HeaderCell/HeaderCell";
@@ -21,7 +25,7 @@ type IInstalledVersionsCellProps = CellProps<
>;
type IVulnerabilitiesCellProps = IInstalledVersionsCellProps;
const formatSoftwareType = (source: string) => {
const formatSoftwareType = (source: SoftwareSource) => {
const DICT = SOURCE_TYPE_CONVERSION;
return DICT[source] || "Unknown";
};
@@ -8,6 +8,7 @@ import {
IHostSoftware,
IHostSoftwarePackage,
SoftwareInstallStatus,
SoftwareSource,
formatSoftwareType,
isIpadOrIphoneSoftwareSource,
} from "interfaces/software";
@@ -201,7 +202,11 @@ export const generateSoftwareTableHeaders = ({
Cell: (cellProps: ITableStringCellProps) => (
<TextCell
value={cellProps.cell.value}
formatter={() => formatSoftwareType({ source: cellProps.cell.value })}
formatter={() =>
formatSoftwareType({
source: cellProps.cell.value as SoftwareSource,
})
}
/>
),
},
@@ -4,6 +4,7 @@ import { Tab, TabList, TabPanel, Tabs } from "react-tabs";
import {
IHostSoftware,
ISoftwareInstallVersion,
SoftwareSource,
formatSoftwareType,
hasHostSoftwareAppLastInstall,
hasHostSoftwarePackageLastInstall,
@@ -37,7 +38,7 @@ const generateVulnerabilitiesValue = (vulnerabilities: string[]) => {
interface ISoftwareDetailsInfoProps {
installedVersion: ISoftwareInstallVersion;
source: string;
source: SoftwareSource;
bundleIdentifier?: string;
}
@@ -21,17 +21,14 @@ import CustomLink from "components/CustomLink";
import Button from "components/buttons/Button";
import { ISoftwareTitle } from "interfaces/software";
const getPlatformDisplayFromPackageSuffix = (packageName: string) => {
const split = packageName.split(".");
const suff = split[split.length - 1];
switch (suff) {
const getPlatformDisplayFromPackageExtension = (ext: string | undefined) => {
switch (ext) {
case "pkg":
return "macOS";
case "deb":
case "rpm":
return "Linux";
case "exe":
return "Windows";
case "msi":
return "Windows";
default:
@@ -153,10 +150,12 @@ const InstallSoftwareModal = ({
);
const availableSoftwareOptions = titlesAFI?.map((title) => {
const platformDisplay = getPlatformDisplayFromPackageSuffix(
title.software_package?.name ?? ""
);
const platformString = platformDisplay ? `${platformDisplay}` : "";
const splitName = title.software_package?.name.split(".") ?? "";
const ext =
splitName.length > 1 ? splitName[splitName.length - 1] : undefined;
const platformString = ext
? `${getPlatformDisplayFromPackageExtension(ext)} (.${ext}) • `
: "";
return {
label: title.name,
value: title.id,
+1 -2
View File
@@ -143,8 +143,7 @@ interface IAddFleetMaintainedAppPostBody {
const ORDER_KEY = "name";
const ORDER_DIRECTION = "asc";
export const UPLOAD_TIMEOUT = (8 * 60 + 15) * 1000;
export const MAX_FILE_SIZE_MB = 500;
export const MAX_FILE_SIZE_MB = 3000;
export const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
export default {
+2
View File
@@ -19,6 +19,7 @@ require (
github.com/beevik/etree v1.3.0
github.com/beevik/ntp v0.3.0
github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb
github.com/boltdb/bolt v1.3.1
github.com/briandowns/spinner v1.23.1
github.com/cenkalti/backoff v2.2.1+incompatible
github.com/cenkalti/backoff/v4 v4.3.0
@@ -223,6 +224,7 @@ require (
github.com/fatih/structs v1.1.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/garyburd/go-oauth v0.0.0-20180319155456-bca2e7f09a17 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.5.0 // indirect
github.com/go-logfmt/logfmt v0.5.1 // indirect
+4
View File
@@ -301,6 +301,8 @@ github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kB
github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84=
github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb h1:m935MPodAbYS46DG4pJSv7WO+VECIWUQ7OJYSoTrMh4=
github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb/go.mod h1:PkYb9DJNAwrSvRx5DYA+gUcOIgTGVMNkfSCbZM8cWpI=
github.com/boltdb/bolt v1.3.1 h1:JQmyP4ZBrce+ZQu0dY660FMfatumYDLun9hBCUVIkF4=
github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps=
github.com/briandowns/spinner v1.23.1 h1:t5fDPmScwUjozhDj4FA46p5acZWIPXYE30qW2Ptu650=
github.com/briandowns/spinner v1.23.1/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM=
github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
@@ -463,6 +465,8 @@ github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4
github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/garyburd/go-oauth v0.0.0-20180319155456-bca2e7f09a17 h1:GOfMz6cRgTJ9jWV0qAezv642OhPnKEG7gtUjJSdStHE=
github.com/garyburd/go-oauth v0.0.0-20180319155456-bca2e7f09a17/go.mod h1:HfkOCN6fkKKaPSAeNq/er3xObxTW4VLeY6UUK895gLQ=
github.com/getsentry/sentry-go v0.18.0 h1:MtBW5H9QgdcJabtZcuJG80BMOwaBpkRDZkxRkNC1sN0=
github.com/getsentry/sentry-go v0.18.0/go.mod h1:Kgon4Mby+FJ7ZWHFUAZgVaIa8sxHtnRJRLTXZr51aKQ=
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
+1 -1
View File
@@ -320,7 +320,7 @@ Here are the steps hiring managers follow to get an offer out to a candidate:
3. **Compile feedback into a single doc:** In the "interview packet", include feedback from interviews, reference checks, and challenge submissions. Include any other notes you can think of offhand, and embed links to any supporting documents that were impactful in your final decision-making, such as portfolios or challenge submissions.
- Name the doc with a short, formulaic name that's easy to understand in an instant from just an email subject line (e.g. "_Why hire Jane Doe ("Train Conductor") - 2023-03-21_").
- _Share_ this single document with the CEO.
- _Share_ this single document with the [CEO and Head of Digital Experience](https://fleetdm.com/handbook/digital-experience#team).
4. **Request a CEO interview:** Copy the template below, paste it in the hiring Slack channel for the position, and complete all "TODOs" before sending.
```
+1 -1
View File
@@ -16,7 +16,7 @@ This handbook page details processes specific to working [with](#contact-us) and
- Please use **issue comments and GitHub mentions** to communicate follow-ups or answer questions related to your request.
- Any Fleet team member can [view the kanban board](https://app.zenhub.com/workspaces/g-demand-64e6c8e2d35c7f001a457b7f/board?sprints=none) for this department, including pending tasks and the status of new requests.
> To **make a request** related to **product marketing**, **press**, **brandfronts**, **pitchfronts**, **featurefronts**, **ideal customer profiles (ICPs)**, **personas**, or **targeting** [create an issue](https://github.com/fleetdm/confidential/issues/new?assignees=&labels=%23g-digital-experience&projects=&template=custom-request.md&title=Product%20marketing%20request%3A+_______________________) (If urgent, at-mention the [Head of Product Marketing](#team) in the [#help-leadership](https://fleetdm.slack.com/archives/C0600L1TTPY) Slack channel).
> To **make a request** related to **product marketing**, **brand**, **press**, **brandfronts**, **pitchfronts**, **featurefronts**, **ideal customer profiles (ICPs)**, **personas**, or **targeting** [create an issue](https://github.com/fleetdm/confidential/issues/new?assignees=&labels=%23g-digital-experience&projects=&template=custom-request.md&title=Product%20marketing%20request%3A+_______________________) (If urgent, at-mention the [CEO](https://fleetdm.com/handbook/digital-experience#team) in the [#help-leadership](https://fleetdm.slack.com/archives/C0600L1TTPY) Slack channel).
## Responsibilities
+6 -5
View File
@@ -57,7 +57,7 @@ Once notified, Digital Experience takes the following steps:
### Inform managers about hours worked
Every Friday at 2:00 PM CT, we collect hours worked for all hourly employees at Fleet, including core team members and consultants, regardless of their location.
Every Friday, we collect hours worked for all hourly employees at Fleet, including core team members and consultants, regardless of their location.
Here's how:
@@ -130,12 +130,13 @@ When Digital Experience receives notification of a Fleetie's manager changing, f
> **Note:** The Fleeties spreadsheet is the source of truth for who everyone's manager is and their job titles.
### Recognize employee workiversaries
At Fleet, everyone is recognized on their [workiversary](https://fleetdm.com/handbook/company/communications#workiversaries). To ensure this happens, take the following steps:
1. Bimonthly, use [Fleeties (private google doc)](https://docs.google.com/spreadsheets/d/1OSLn-ZCbGSjPusHPiR5dwQhheH1K8-xqyZdsOe9y7qc/edit#gid=0) to determine who is celebrating their workiversary in the following two months.
2. Post in the #help-classifed Slack channel and cc the Head of Digital Experience. Use the following template:
1. On the 15th of every month, use [Fleeties (private google doc)](https://docs.google.com/spreadsheets/d/1OSLn-ZCbGSjPusHPiR5dwQhheH1K8-xqyZdsOe9y7qc/edit#gid=0) to determine who is celebrating their workiversary in the next month.
2. At-mention the Head of Digital Experience. Use the following template:
```
@@ -143,7 +144,6 @@ At Fleet, everyone is recognized on their [workiversary](https://fleetdm.com/han
[workiversary date (DD-MMM)] - [teammate name] - [number of years at Fleet]
```
The Head of Digital Experience will also use this post to update the [All hands](https://fleetdm.com/handbook/company/communications#all-hands) deck.
3. On the day prior to a workiversary, send the teammates manager a DM on Slack:
@@ -191,6 +191,7 @@ Annually, around mid-year, Fleet will be prompted by Gusto to review company ben
4. Approximately 2-3 months after survery completion, Gusto will suggest plans based on Fleet's responses. Choose plans with minimal changes.
5. Gusto will offer these plans to employees during open enrollment, with new coverage starting 3-4 weeks afterward.
### Grant equity
Equity grants for new hires are queued up as part of the [hiring process](https://fleetdm.com/handbook/digital-experience#hiring), then grants and consents are [batched and processed quarterly](https://github.com/fleetdm/confidential/issues/new/choose).
@@ -520,7 +521,7 @@ Use the following steps to schedule an interview between a candidate and the CEO
1. Once you receive a [CEO interview request](https://fleetdm.com/handbook/company/leadership#hiring-a-new-team-member), apply the "eyes" (👀) emoji to the Slack post to acknowledge you've seen the request.
2. Reach out to the candidate via email to find a time when the CEO and candidate are both available.
> This entire process takes an hour for the CEO: a 30-minute interview followed by a 30-minute "¶¶ Postgame" Be sure to offer times that accommodate this.
3. [Make a copy of the "¶¶ CEO interview template"](https://docs.google.com/document/d/1yARlH6iZY-cP9cQbmL3z6TbMy-Ii7lO64RbuolpWQzI/copy) (private Google doc) and move it to the "[🕵️ ¶±¶ Reference checks & hiring data](https://drive.google.com/drive/folders/1VgKT6_VrQ9zYMnDOwJGE1mT1WrrMFqJw?usp=drive_link)" folder in Google Drive.
3. [Make a copy of the "¶¶ CEO interview template"](https://docs.google.com/document/d/1yARlH6iZY-cP9cQbmL3z6TbMy-Ii7lO64RbuolpWQzI/copy) (private Google doc) and move it to the "[¶¶ Interview feedback](https://drive.google.com/drive/folders/1v5Z1WB9S855hLZMUWgOiXA_ei2EpEGlA?usp=drive_link)" folder in Google Drive.
4. Prep the CEO interview doc:
- Change file name and heading of doc to `¶¶ CANDIDATE_NAME (CANDIDATE_TITLE) <> Mike McNeil, CEO final interview (YYYY-MM-DD)`.
- Add candidate's personal email in the "👥" (attendees) section at the top of the doc.
@@ -210,10 +210,13 @@
-
task: "Recognize and benchmark workiversaries"
startedOn: "2024-07-15"
frequency: "Bimonthly"
frequency: "Monthly"
description: "Identify workiversaries coming up in the next two months and follow the steps to ensure they're recognized and benchmarked"
moreInfoUrl: "https://fleetdm.com/handbook/digital-experience#recognize-employee-workiversaries"
dri: "sampfluger88"
dri: "SFriendLee"
autoIssue:
labels: [ "#g-digital-experience" ]
repo: "confidential"
-
task: "Quarterly grants"
startedOn: "2024-02-01"
@@ -23,7 +23,7 @@
task: "Sprint kickoff review" # 2024-03-06 TODO: Link to responsibility or corresponding "how to" info e.g. https://fleetdm.com/handbook/company/product-groups#making-changes
startedOn: "2024-03-07"
frequency: "Triweekly"
description: "Identify stories that did not make it into this sprint and remove them from the board. Notify relevant requesters/stakeholders. Ensure bugs have been effectively prioritized across teams. Recommend highlights for next release notes. Record the number of drops for KPI reporting. Consider product group staffing. Are we scheduling what we prioritized? Did we finish what we scheduled in the sprint? (Look at org chart.)"
description: "Review stories that made it into this sprint and recommend highlights for next release notes. Then, review stories that did not make it into this sprint. Ensure stories/bugs have been effectively prioritized across teams."
moreInfoUrl:
dri: "noahtalerman"
-
+2 -2
View File
@@ -7,8 +7,8 @@ Following are the currently deployed versions of fleetd components on the `stabl
| Component\OS | macOS | Linux | Windows | Linux (arm64) |
|--------------|--------------|--------|---------|---------------|
| orbit | 1.33.0 | 1.33.0 | 1.33.0 | 1.33.0 |
| desktop | 1.33.0 | 1.33.0 | 1.33.0 | 1.33.0 |
| orbit | 1.34.0 | 1.34.0 | 1.34.0 | 1.34.0 |
| desktop | 1.34.0 | 1.34.0 | 1.34.0 | 1.34.0 |
| osqueryd | 5.13.1 | 5.13.1 | 5.13.1 | 5.13.1 |
| nudge | 1.1.10.81462 | - | - | - |
| swiftDialog | 2.1.0 | - | - | - |
+1 -1
View File
@@ -9,7 +9,7 @@
"evented": false,
"cacheable": false,
"notes": "- The values in this OpenDirectory table are related to account creation. In the past, it was fairly common to use OpenDirectory to have a home folder (`~`) on a server, and then log in and get that folder wherever they are. (These days, this use case is more uncommon.)\n- To determine who is logged in to the Mac, or for example, to check the record name versus the computer's \"short name\", consider using the data in [the DSCL table](https://fleetdm.com/tables/dscl).\n- Many installers incorporate scripts due to actions that are handled by pre or post-scripts vs installer package payloads. These script actions aren't tracked in the \"bill of materials\" (.bom) file. So, don't blindly trust the \"bill of materials\" (.bom) file as the source of truth on what has or hasn't been installed.",
"examples": "Query the creation date of user accounts. You could also query the date of the last failed login attempt or password change.\n\n```\nSELECT strftime('%Y-%m-%d %H:%M:%S',creation_time,'unixepoch') AS creationdate FROM account_policy_data;\n```\n\nSee each user's last password set date and number of failed logins since last successful login to detect any intrusion attempts.\n\n```\nSELECT u.username u.uid, strftime('%Y-%m-%dT%H:%M:%S', a.password_last_set_time, 'unixepoch') AS password_last_set_time, a.failed_login_count, strftime('%Y-%m-%dT%H:%M:%S', a.failed_login_timestamp, 'unixepoch') AS failed_login_timestamp FROM account_policy_data AS a CROSS JOIN users AS u USING (uid) ORDER BY password_last_set_time ASC;\n```",
"examples": "Query the creation date of user accounts. You could also query the date of the last failed login attempt or password change.\n\n```\nSELECT strftime('%Y-%m-%d %H:%M:%S',creation_time,'unixepoch') AS creationdate FROM account_policy_data;\n```\n\nSee each user's last password set date and number of failed logins since last successful login to detect any intrusion attempts.\n\n```\nSELECT u.username, u.uid, strftime('%Y-%m-%dT%H:%M:%S', a.password_last_set_time, 'unixepoch') AS password_last_set_time, a.failed_login_count, strftime('%Y-%m-%dT%H:%M:%S', a.failed_login_timestamp, 'unixepoch') AS failed_login_timestamp FROM account_policy_data AS a CROSS JOIN users AS u USING (uid) ORDER BY password_last_set_time ASC;\n```",
"columns": [
{
"name": "uid",
+1 -1
View File
@@ -19,5 +19,5 @@ examples: |-
See each user's last password set date and number of failed logins since last successful login to detect any intrusion attempts.
```
SELECT u.username u.uid, strftime('%Y-%m-%dT%H:%M:%S', a.password_last_set_time, 'unixepoch') AS password_last_set_time, a.failed_login_count, strftime('%Y-%m-%dT%H:%M:%S', a.failed_login_timestamp, 'unixepoch') AS failed_login_timestamp FROM account_policy_data AS a CROSS JOIN users AS u USING (uid) ORDER BY password_last_set_time ASC;
SELECT u.username, u.uid, strftime('%Y-%m-%dT%H:%M:%S', a.password_last_set_time, 'unixepoch') AS password_last_set_time, a.failed_login_count, strftime('%Y-%m-%dT%H:%M:%S', a.failed_login_timestamp, 'unixepoch') AS failed_login_timestamp FROM account_policy_data AS a CROSS JOIN users AS u USING (uid) ORDER BY password_last_set_time ASC;
```
+12 -2
View File
@@ -15,6 +15,8 @@ import (
"github.com/jmoiron/sqlx"
)
var automationActivityAuthor = "Fleet"
// NewActivity stores an activity item that the user performed
func (ds *Datastore) NewActivity(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
@@ -39,6 +41,10 @@ func (ds *Datastore) NewActivity(
}
userName = &user.Name
userEmail = &user.Email
} else if ranScriptActivity, ok := activity.(fleet.ActivityTypeRanScript); ok {
if ranScriptActivity.PolicyID != nil {
userName = &automationActivityAuthor
}
}
cols := []string{"user_id", "user_name", "activity_type", "details", "created_at"}
@@ -293,7 +299,7 @@ func (ds *Datastore) ListHostUpcomingActivities(ctx context.Context, hostID uint
// list pending scripts
`SELECT
hsr.execution_id as uuid,
u.name as name,
IF(hsr.policy_id IS NOT NULL, 'Fleet', u.name) as name,
u.id as user_id,
u.gravatar_url as gravatar_url,
u.email as user_email,
@@ -304,12 +310,16 @@ func (ds *Datastore) ListHostUpcomingActivities(ctx context.Context, hostID uint
'host_display_name', COALESCE(hdn.display_name, ''),
'script_name', COALESCE(scr.name, ''),
'script_execution_id', hsr.execution_id,
'async', NOT hsr.sync_request
'async', NOT hsr.sync_request,
'policy_id', hsr.policy_id,
'policy_name', p.name
) as details
FROM
host_script_results hsr
LEFT OUTER JOIN
users u ON u.id = hsr.user_id
LEFT OUTER JOIN
policies p ON p.id = hsr.policy_id
LEFT OUTER JOIN
host_display_names hdn ON hdn.host_id = hsr.host_id
LEFT OUTER JOIN
@@ -0,0 +1,26 @@
package tables
import (
"database/sql"
"fmt"
)
func init() {
MigrationClient.AddMigration(Up_20241004005000, Down_20241004005000)
}
func Up_20241004005000(tx *sql.Tx) error {
if _, err := tx.Exec(`
ALTER TABLE host_script_results
ADD COLUMN policy_id INT UNSIGNED DEFAULT NULL,
ADD FOREIGN KEY fk_script_result_policy_id (policy_id) REFERENCES policies (id) ON DELETE SET NULL
`); err != nil {
return fmt.Errorf("failed to add policy_id to host script results: %w", err)
}
return nil
}
func Down_20241004005000(tx *sql.Tx) error {
return nil
}
@@ -0,0 +1,41 @@
package tables
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestUp_20241004005000(t *testing.T) {
db := applyUpToPrev(t)
// insert a team
teamID := execNoErrLastID(t, db, `INSERT INTO teams (name) VALUES ("Foo")`)
// insert a policy
policyID := execNoErrLastID(t, db, `INSERT INTO policies (name, query, description, team_id, checksum)
VALUES ('test_policy', "SELECT 1", "", ?, "a123b123")`, teamID)
// insert a script
scriptContentID := execNoErrLastID(t, db, `INSERT INTO script_contents (md5_checksum, contents) VALUES ("md5", "echo 'Hello World'")`)
scriptID := execNoErrLastID(t, db, `INSERT INTO scripts (
team_id, global_or_team_id, name, script_content_id
) VALUES (?, ?, "hello-world.sh", ?)`, teamID, teamID, scriptContentID)
// Apply current migration.
applyNext(t, db)
// insert a script result
hostScriptResultID := execNoErrLastID(t, db, `INSERT INTO host_script_results
(host_id, execution_id, script_content_id, output, script_id, policy_id, user_id, sync_request)
VALUES (1, 'a123b123', ?, '', ?, ?, NULL, FALSE)`, scriptContentID, scriptID, policyID)
// delete the associated policy
execNoErr(t, db, `DELETE FROM policies WHERE id = ?`, policyID)
// policy ID should be null but script result should still exist
var count int
err := db.Get(&count, "SELECT COUNT(*) FROM host_script_results WHERE policy_id IS NULL AND id = ?", hostScriptResultID)
require.NoError(t, err)
require.Equal(t, 1, count)
}
+15 -9
View File
@@ -26,8 +26,10 @@ const policyCols = `
p.calendar_events_enabled, p.software_installer_id, p.script_id
`
var errSoftwareTitleIDOnGlobalPolicy = errors.New("install software title id can be only be set on team policies")
var errScriptIDOnGlobalPolicy = errors.New("run script id can only be set on team or \"no team\" policies")
var (
errSoftwareTitleIDOnGlobalPolicy = errors.New("install software title id can be only be set on team policies")
errScriptIDOnGlobalPolicy = errors.New("run script id can only be set on team or \"no team\" policies")
)
var policySearchColumns = []string{"p.name"}
@@ -123,7 +125,7 @@ func (ds *Datastore) PolicyLite(ctx context.Context, id uint) (*fleet.PolicyLite
var policy fleet.PolicyLite
err := sqlx.GetContext(
ctx, ds.reader(ctx), &policy,
`SELECT id, description, resolution FROM policies WHERE id=?`, id,
`SELECT id, name, description, resolution FROM policies WHERE id=?`, id,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
@@ -177,8 +179,10 @@ func (ds *Datastore) SavePolicy(ctx context.Context, p *fleet.Policy, shouldRemo
)
}
var errMismatchedInstallerTeam = &fleet.BadRequestError{Message: "software installer is associated with a different team"}
var errMismatchedScriptTeam = &fleet.BadRequestError{Message: "script is associated with a different team"}
var (
errMismatchedInstallerTeam = &fleet.BadRequestError{Message: "software installer is associated with a different team"}
errMismatchedScriptTeam = &fleet.BadRequestError{Message: "script is associated with a different team"}
)
func (ds *Datastore) assertTeamMatches(ctx context.Context, teamID uint, softwareInstallerID *uint, scriptID *uint) error {
if softwareInstallerID != nil {
@@ -382,10 +386,12 @@ func (ds *Datastore) RecordPolicyQueryExecutions(ctx context.Context, host *flee
if err != nil {
return err
}
if len(results) > 0 {
if err := ds.UpdateHostIssuesFailingPolicies(ctx, []uint{host.ID}); err != nil {
return err
}
// ds.UpdateHostIssuesFailingPolicies should be executed even if len(results) == 0
// because this means the host is configured to run no policies and we would like
// to cleanup the counts (if any).
if err := ds.UpdateHostIssuesFailingPolicies(ctx, []uint{host.ID}); err != nil {
return err
}
if deferredSaveHost {
File diff suppressed because one or more lines are too long
+4 -2
View File
@@ -39,8 +39,8 @@ func (ds *Datastore) NewHostScriptExecutionRequest(ctx context.Context, request
func newHostScriptExecutionRequest(ctx context.Context, tx sqlx.ExtContext, request *fleet.HostScriptRequestPayload) (*fleet.HostScriptResult, error) {
const (
insStmt = `INSERT INTO host_script_results (host_id, execution_id, script_content_id, output, script_id, user_id, sync_request) VALUES (?, ?, ?, '', ?, ?, ?)`
getStmt = `SELECT hsr.id, hsr.host_id, hsr.execution_id, hsr.created_at, hsr.script_id, hsr.user_id, hsr.sync_request, sc.contents as script_contents FROM host_script_results hsr JOIN script_contents sc WHERE sc.id = hsr.script_content_id AND hsr.id = ?`
insStmt = `INSERT INTO host_script_results (host_id, execution_id, script_content_id, output, script_id, policy_id, user_id, sync_request) VALUES (?, ?, ?, '', ?, ?, ?, ?)`
getStmt = `SELECT hsr.id, hsr.host_id, hsr.execution_id, hsr.created_at, hsr.script_id, hsr.policy_id, hsr.user_id, hsr.sync_request, sc.contents as script_contents FROM host_script_results hsr JOIN script_contents sc WHERE sc.id = hsr.script_content_id AND hsr.id = ?`
)
execID := uuid.New().String()
@@ -49,6 +49,7 @@ func newHostScriptExecutionRequest(ctx context.Context, tx sqlx.ExtContext, requ
execID,
request.ScriptContentID,
request.ScriptID,
request.PolicyID,
request.UserID,
request.SyncRequest,
)
@@ -260,6 +261,7 @@ func (ds *Datastore) getHostScriptExecutionResultDB(ctx context.Context, q sqlx.
hsr.execution_id,
sc.contents as script_contents,
hsr.script_id,
hsr.policy_id,
hsr.output,
hsr.runtime,
hsr.exit_code,
+13 -7
View File
@@ -1166,11 +1166,13 @@ func (a ActivityTypeDisabledWindowsMDM) Documentation() (activity, details, deta
}
type ActivityTypeRanScript struct {
HostID uint `json:"host_id"`
HostDisplayName string `json:"host_display_name"`
ScriptExecutionID string `json:"script_execution_id"`
ScriptName string `json:"script_name"`
Async bool `json:"async"`
HostID uint `json:"host_id"`
HostDisplayName string `json:"host_display_name"`
ScriptExecutionID string `json:"script_execution_id"`
ScriptName string `json:"script_name"`
Async bool `json:"async"`
PolicyID *uint `json:"policy_id"`
PolicyName *string `json:"policy_name"`
}
func (a ActivityTypeRanScript) ActivityName() string {
@@ -1188,12 +1190,16 @@ func (a ActivityTypeRanScript) Documentation() (activity, details, detailsExampl
- "host_display_name": Display name of the host.
- "script_execution_id": Execution ID of the script run.
- "script_name": Name of the script (empty if it was an anonymous script).
- "async": Whether the script was executed asynchronously.`, `{
- "async": Whether the script was executed asynchronously.
- "policy_id": ID of the policy whose failure triggered the script run. Null if no associated policy.
- "policy_name": Name of the policy whose failure triggered the script run. Null if no associated policy.`, `{
"host_id": 1,
"host_display_name": "Anna's MacBook Pro",
"script_name": "set-timezones.sh",
"script_execution_id": "d6cffa75-b5b5-41ef-9230-15073c8a88cf",
"async": false
"async": false,
"policy_id": 123,
"policy_name": "Ensure photon torpedoes are primed"
}`
}
+2
View File
@@ -271,6 +271,8 @@ type PolicyScriptData struct {
// PolicyLite is a stripped down version of the policy.
type PolicyLite struct {
ID uint `db:"id"`
// Name is the name of the policy.
Name string `db:"name"`
// Description describes the policy.
Description string `db:"description"`
// Resolution describes how to solve a failing policy.
+4
View File
@@ -138,6 +138,7 @@ func (hs *HostScriptDetail) setLastExecution(executionID *string, executedAt *ti
type HostScriptRequestPayload struct {
HostID uint `json:"host_id"`
ScriptID *uint `json:"script_id"`
PolicyID *uint `json:"policy_id"`
ScriptContents string `json:"script_contents"`
ScriptContentID uint `json:"-"`
ScriptName string `json:"script_name"`
@@ -217,6 +218,9 @@ type HostScriptResult struct {
// ScriptID is the id of the saved script to execute, or nil if this was an
// anonymous script execution.
ScriptID *uint `json:"script_id" db:"script_id"`
// PolicyID is the id of the policy that triggered the script execution, or
// nil if the execution was not triggered by a policy failure
PolicyID *uint `json:"policy_id" db:"policy_id"`
// UserID is the id of the user that requested execution. It is not part of
// the rendered JSON as it is only returned by the
// /hosts/:id/activities/upcoming endpoint which doesn't use this struct as
+23
View File
@@ -54,6 +54,29 @@ func Refresh(ctx context.Context, ds fleet.Datastore, logger kitlog.Logger) erro
return i.ingest(ctx, apps)
}
// ExtensionForBundleIdentifier returns an extension for the given FMA
// identifier. If one can't be found it returns an empty string.
//
// This function is used because we can't always extract the extension based on
// the installer URL.
func ExtensionForBundleIdentifier(identifier string) (string, error) {
var apps []maintainedApp
if err := json.Unmarshal(appsJSON, &apps); err != nil {
return "", fmt.Errorf("unmarshal embedded apps.json: %w", err)
}
for _, app := range apps {
if app.BundleIdentifier == identifier {
formats := strings.Split(app.InstallerFormat, ":")
if len(formats) > 0 {
return formats[0], nil
}
}
}
return "", nil
}
type ingester struct {
baseURL string
ds fleet.Datastore
+54
View File
@@ -166,3 +166,57 @@ func TestIngestValidations(t *testing.T) {
})
}
}
func TestExtensionForBundleIdentifier(t *testing.T) {
testCases := []struct {
name string
identifier string
expected string
expectErr bool
}{
{
name: "Valid identifier with zip format",
identifier: "com.1password.1password",
expected: "zip",
expectErr: false,
},
{
name: "Valid identifier with dmg format",
identifier: "com.adobe.Reader",
expected: "dmg",
expectErr: false,
},
{
name: "Valid identifier with pkg format",
identifier: "com.box.desktop",
expected: "pkg",
expectErr: false,
},
{
name: "Non-existent identifier",
identifier: "com.nonexistent.app",
expected: "",
expectErr: false,
},
{
name: "Empty identifier",
identifier: "",
expected: "",
expectErr: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
extension, err := ExtensionForBundleIdentifier(tc.identifier)
if tc.expectErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
require.Equal(t, tc.expected, extension)
})
}
}
+9 -1
View File
@@ -67,6 +67,8 @@ func (svc *Service) NewActivity(ctx context.Context, user *fleet.User, activity
return newActivity(ctx, user, activity, svc.ds, svc.logger)
}
var automationActivityAuthor = "Fleet"
func newActivity(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, ds fleet.Datastore, logger kitlog.Logger) error {
appConfig, err := ds.AppConfig(ctx)
if err != nil {
@@ -84,6 +86,8 @@ func newActivity(ctx context.Context, user *fleet.User, activity fleet.ActivityD
var userID *uint
var userName *string
var userEmail *string
activityType := activity.ActivityName()
if user != nil {
// To support creating activities with users that were deleted. This can happen
// for automatically installed software which uses the author of the upload as the author of
@@ -93,8 +97,12 @@ func newActivity(ctx context.Context, user *fleet.User, activity fleet.ActivityD
}
userName = &user.Name
userEmail = &user.Email
} else if ranScriptActivity, ok := activity.(fleet.ActivityTypeRanScript); ok {
if ranScriptActivity.PolicyID != nil {
userName = &automationActivityAuthor
}
}
activityType := activity.ActivityName()
go func() {
retryStrategy := backoff.NewExponentialBackOff()
retryStrategy.MaxElapsedTime = 30 * time.Minute
+60
View File
@@ -12149,3 +12149,63 @@ func (s *integrationTestSuite) TestAutofillPolicies() {
resp = s.Do("POST", "/api/latest/fleet/autofill/policy", req, http.StatusBadRequest)
assertBodyContains(t, resp, "AI features are disabled")
}
func (s *integrationTestSuite) TestHostWithNoPoliciesClearsPolicyCounts() {
t := s.T()
ctx := context.Background()
team, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "Zoobar"})
require.NoError(t, err)
host, err := s.ds.NewHost(ctx, &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now(),
NodeKey: ptr.String("foobar"),
UUID: "foobar",
Hostname: "com.foobar.local",
Platform: "linux",
TeamID: &team.ID,
})
require.NoError(t, err)
policy, err := s.ds.NewTeamPolicy(ctx, team.ID, nil, fleet.PolicyPayload{
Name: "Barfoo",
Query: "SELECT 1;",
})
require.NoError(t, err)
distributedWriteResp := submitDistributedQueryResultsResponse{}
s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults(
host,
map[uint]*bool{
policy.ID: ptr.Bool(false),
},
), http.StatusOK, &distributedWriteResp)
listHostsResp := listHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsResp)
require.Len(t, listHostsResp.Hosts, 1)
require.Equal(t, uint64(1), listHostsResp.Hosts[0].FailingPoliciesCount)
_, err = s.ds.DeleteTeamPolicies(ctx, team.ID, []uint{policy.ID})
require.NoError(t, err)
distributedWriteResp = submitDistributedQueryResultsResponse{}
results := make(map[string]json.RawMessage)
results[hostNoPoliciesWildcard] = json.RawMessage("{\"1\": \"1\"}")
statuses := make(map[string]interface{})
statuses[hostNoPoliciesWildcard] = 0
s.DoJSON("POST", "/api/osquery/distributed/write", submitDistributedQueryResultsRequestShim{
NodeKey: *host.NodeKey,
Results: results,
Statuses: statuses,
Stats: map[string]*fleet.Stats{},
}, http.StatusOK, &distributedWriteResp)
listHostsResp = listHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsResp)
require.Len(t, listHostsResp.Hosts, 1)
require.Equal(t, uint64(0), listHostsResp.Hosts[0].FailingPoliciesCount)
}
+39 -6
View File
@@ -6284,7 +6284,7 @@ func (s *integrationEnterpriseTestSuite) TestRunHostSavedScript() {
s.lastActivityMatches(
fleet.ActivityTypeRanScript{}.ActivityName(),
fmt.Sprintf(
`{"host_id": %d, "host_display_name": %q, "script_name": %q, "script_execution_id": %q, "async": true}`,
`{"host_id": %d, "host_display_name": %q, "script_name": %q, "script_execution_id": %q, "async": true, "policy_id": null, "policy_name": null}`,
host.ID, host.DisplayName(), savedNoTmScript.Name, scriptResultResp.ExecutionID,
),
0,
@@ -12196,7 +12196,7 @@ func (s *integrationEnterpriseTestSuite) TestHostScriptSoftDelete() {
s.lastActivityOfTypeMatches(
fleet.ActivityTypeRanScript{}.ActivityName(),
fmt.Sprintf(
`{"host_id": %d, "host_display_name": %q, "script_name": "", "script_execution_id": %q, "async": true}`,
`{"host_id": %d, "host_display_name": %q, "script_name": "", "script_execution_id": %q, "async": true, "policy_id": null, "policy_name": null}`,
host.ID, host.DisplayName(), scriptExecID), 0)
// create a saved script execution request
@@ -12218,7 +12218,7 @@ func (s *integrationEnterpriseTestSuite) TestHostScriptSoftDelete() {
http.StatusOK)
s.lastActivityOfTypeMatches(
fleet.ActivityTypeRanScript{}.ActivityName(),
fmt.Sprintf(`{"host_id": %d, "host_display_name": %q, "script_name": "script1.sh", "script_execution_id": %q, "async": true}`,
fmt.Sprintf(`{"host_id": %d, "host_display_name": %q, "script_name": "script1.sh", "script_execution_id": %q, "async": true, "policy_id": null, "policy_name": null}`,
host.ID, host.DisplayName(), savedScriptExecID), 0)
// get the anoymous script result details
@@ -14717,11 +14717,12 @@ func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsScripts() {
},
), http.StatusOK, &distributedResp)
hostPendingScript, err = s.ds.IsExecutionPendingForHost(ctx, host3Team2.ID, psScript.ID)
host3PendingScripts, err := s.ds.ListPendingHostScriptExecutions(ctx, host3Team2.ID)
require.NoError(t, err)
require.True(t, hostPendingScript)
require.Len(t, host3PendingScripts, 1)
host3executionID := host3PendingScripts[0].ExecutionID
// Unassociate policy4Team2 from script.
// Dissociate policy4Team2 from script.
mtplr = modifyTeamPolicyResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team2.ID, policy4Team2.ID), modifyTeamPolicyRequest{
ModifyPolicyPayload: fleet.ModifyPolicyPayload{
@@ -14750,6 +14751,38 @@ func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsScripts() {
hostPendingScripts, err := s.ds.ListPendingHostScriptExecutions(ctx, hostVanillaOsquery5Team1.ID)
require.NoError(t, err)
require.Len(t, hostPendingScripts, 0)
// activity feed should show script run as pending, with "Fleet" as author, policy ID and name set in body
var listResp listActivitiesResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", host3Team2.ID), nil, http.StatusOK, &listResp)
require.Len(t, listResp.Activities, 1)
require.Nil(t, listResp.Activities[0].ActorEmail)
require.Equal(t, "Fleet", *listResp.Activities[0].ActorFullName)
require.Nil(t, listResp.Activities[0].ActorGravatar)
require.Equal(t, "ran_script", listResp.Activities[0].Type)
var activityJson map[string]interface{}
err = json.Unmarshal(*listResp.Activities[0].Details, &activityJson)
require.NoError(t, err)
require.Equal(t, float64(policy4Team2.ID), activityJson["policy_id"])
require.Equal(t, "policy4Team2", activityJson["policy_name"])
// post script result response
var orbitPostScriptResp orbitPostScriptResultResponse
s.DoJSON("POST", "/api/fleet/orbit/scripts/result",
json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "execution_id": %q, "exit_code": 0, "output": "ok"}`, *host3Team2.OrbitNodeKey, host3executionID)),
http.StatusOK, &orbitPostScriptResp)
// activity feed should show script run as completed
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities", host3Team2.ID), nil, http.StatusOK, &listResp)
require.Len(t, listResp.Activities, 1)
require.Equal(t, "", *listResp.Activities[0].ActorEmail) // actor email is blank rather than nil here 👀
require.Equal(t, "Fleet", *listResp.Activities[0].ActorFullName)
require.Nil(t, listResp.Activities[0].ActorGravatar)
require.Equal(t, "ran_script", listResp.Activities[0].Type)
err = json.Unmarshal(*listResp.Activities[0].Details, &activityJson)
require.NoError(t, err)
require.Equal(t, float64(policy4Team2.ID), activityJson["policy_id"])
require.Equal(t, "policy4Team2", activityJson["policy_name"])
}
func (s *integrationEnterpriseTestSuite) TestSoftwareInstallersWithoutBundleIdentifier() {
+9
View File
@@ -733,6 +733,13 @@ func (svc *Service) SaveHostScriptResult(ctx context.Context, result *fleet.Host
}
default:
// TODO(sarah): We may need to special case lock/unlock script results here?
var policyName *string
if hsr.PolicyID != nil {
if policy, err := svc.ds.PolicyLite(ctx, *hsr.PolicyID); err == nil {
policyName = &policy.Name // fall back to blank policy name if we can't retrieve the policy
}
}
if err := svc.NewActivity(
ctx,
user,
@@ -742,6 +749,8 @@ func (svc *Service) SaveHostScriptResult(ctx context.Context, result *fleet.Host
ScriptExecutionID: hsr.ExecutionID,
ScriptName: scriptName,
Async: !hsr.SyncRequest,
PolicyID: hsr.PolicyID,
PolicyName: policyName,
},
); err != nil {
return ctxerr.Wrap(ctx, err, "create activity for script execution request")
+4 -1
View File
@@ -1931,6 +1931,8 @@ func (svc *Service) processScriptsForNewlyFailingPolicies(
}
for _, failingPolicyWithScript := range failingPoliciesWithScript {
policyID := failingPolicyWithScript.ID
scriptMetadata, err := svc.ds.Script(ctx, failingPolicyWithScript.ScriptID)
if err != nil {
return ctxerr.Wrap(ctx, err, "get script metadata by id")
@@ -1938,7 +1940,7 @@ func (svc *Service) processScriptsForNewlyFailingPolicies(
logger := log.With(svc.logger,
"host_id", hostID,
"host_platform", hostPlatform,
"policy_id", failingPolicyWithScript.ID,
"policy_id", policyID,
"script_id", failingPolicyWithScript.ScriptID,
"script_name", scriptMetadata.Name,
)
@@ -1989,6 +1991,7 @@ func (svc *Service) processScriptsForNewlyFailingPolicies(
ScriptContentID: scriptMetadata.ScriptContentID,
ScriptID: &scriptMetadata.ID,
TeamID: policyTeamID,
PolicyID: &policyID,
// no user ID as scripts are executed by Fleet
}
+3 -3
View File
@@ -49,7 +49,7 @@ type uploadSoftwareInstallerResponse struct {
// MaxSoftwareInstallerSize is the maximum size allowed for software
// installers. This is enforced by the endpoints that upload installers.
const MaxSoftwareInstallerSize = 500 * units.MiB
const MaxSoftwareInstallerSize = 3000 * units.MiB
// TODO: We parse the whole body before running svc.authz.Authorize.
// An authenticated but unauthorized user could abuse this.
@@ -68,7 +68,7 @@ func (updateSoftwareInstallerRequest) DecodeRequest(ctx context.Context, r *http
var mbe *http.MaxBytesError
if errors.As(err, &mbe) {
return nil, &fleet.BadRequestError{
Message: "The maximum file size is 500 MB.",
Message: "The maximum file size is 3 GB.",
InternalErr: err,
}
}
@@ -91,7 +91,7 @@ func (updateSoftwareInstallerRequest) DecodeRequest(ctx context.Context, r *http
if decoded.File.Size > MaxSoftwareInstallerSize {
// Should never happen here since the request's body is limited to the maximum size.
return nil, &fleet.BadRequestError{
Message: "The maximum file size is 500 MB.",
Message: "The maximum file size is 3 GB.",
}
}
}
@@ -0,0 +1,23 @@
# MicroMDM touchless migration
This Go script reads information from a MicroMDM database and outputs:
- SCEP, APNs and ADE certificates.
- A file with MySQL statements to import the records into a Fleet database.
### Usage
The only requirement is to have a compatible version of Go installed, and a MicroMDM database.
Here's an example of how a successful run looks like:
```sh
$ go run tools/mdm/migration/micromdm/touchless/main.go --db ~/projects/micromdm/micromdm.db
2024/09/11 10:05:53 Open DB for devices
2024/09/11 10:05:53 Found 1 devices
2024/09/11 10:05:53 Wrote device/enrollment records to dump.sql
2024/09/11 10:05:53 Open DB for SCEP cert and key
2024/09/11 10:05:53 Wrote SCEP cert/key to scep.cert/scep.key
```
@@ -0,0 +1,393 @@
package main
import (
"context"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/pem"
"flag"
"fmt"
"log"
"os"
"strings"
"github.com/boltdb/bolt"
scepdepot "github.com/fleetdm/fleet/v4/server/mdm/scep/depot/bolt"
"github.com/groob/plist"
apnsbuiltin "github.com/micromdm/micromdm/platform/apns/builtin"
"github.com/micromdm/micromdm/platform/device"
devicebuiltin "github.com/micromdm/micromdm/platform/device/builtin"
"github.com/micromdm/micromdm/platform/pubsub/inmem"
"go.etcd.io/bbolt"
)
type Authenticate struct {
MessageType string
UDID string
Topic string
BuildVersion string `plist:",omitempty"`
DeviceName string `plist:",omitempty"`
Model string `plist:",omitempty"`
ModelName string `plist:",omitempty"`
OSVersion string `plist:",omitempty"`
ProductName string `plist:",omitempty"`
SerialNumber string `plist:",omitempty"`
IMEI string `plist:",omitempty"`
MEID string `plist:",omitempty"`
}
type TokenUpdate struct {
MessageType string
UDID string
PushMagic string
Topic string
Token []byte
UnlockToken []byte `plist:",omitempty"`
UserID string `plist:",omitempty"`
UserShortName string `plist:",omitempty"`
UserLongName string `plist:",omitempty"`
}
// referenceTime is used as a canary to insert/update records. As long as a
// record has an `updated_at` timestamp, the script will update it, but if the
// timestamp has changed, the record will be completely ignored.
const referenceTime = "2000-01-01 00:00:00"
func main() {
flDB := flag.String("db", "/var/db/micromdm/micromdm.db", "path to micromdm DB")
flag.Parse()
// Device records
func() {
log.Println("Open DB for devices")
boltDB, err := bolt.Open(*flDB, 0o600, nil)
if err != nil {
log.Fatal(err)
}
defer boltDB.Close()
ps := inmem.NewPubSub()
apnsDB, err := apnsbuiltin.NewDB(boltDB, ps)
if err != nil {
log.Fatal(err)
}
deviceDB, err := devicebuiltin.NewDB(boltDB)
if err != nil {
log.Fatal(err)
}
devices, err := deviceDB.List(context.Background(), device.ListDevicesOption{})
if err != nil {
log.Fatal(err)
}
if len(devices) == 0 {
log.Printf("No devices found. Are you sure %s is a MicroMDM DB?", *flDB)
} else {
log.Printf("Found %d devices", len(devices))
}
// SCEP certificates are stored using the certificate CN as the
// key. I couldn't find any CN <-> device association in the
// db, as it's not needed: micro extracts the certificate CN
// from the request and uses it to authenticate devices.
//
// To avoid loading all certs in memory (rough estimation is
// ~2KB per cert) we store a map of the cert hash (which is
// stored along the device record) to the CN for later
// retrieval.
certHashToCertKey := make(map[string][]byte, len(devices))
// NOTE: the depot doesn't expose methods to list certs so we
// need to use bolt directly.
err = boltDB.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte("scep_certificates"))
if bucket == nil {
log.Fatalf("No identity certificates found. Are you sure %s is a MicroMDM DB?", *flDB)
}
return bucket.ForEach(func(k, v []byte) error {
hash := sha256.Sum256(v)
certHashToCertKey[string(hash[:])] = k
return nil
})
})
if err != nil {
log.Fatal(err)
}
var sb strings.Builder
for _, device := range devices {
if len(device.UDID) == 0 {
log.Println("Skipping device with empty UDID. Serial: ", device.SerialNumber, " UUID: ", device.UUID, " Last seen: ", device.LastSeen)
continue
}
pushInfo, err := apnsDB.PushInfo(context.Background(), device.UDID)
if err != nil {
log.Println(device.UDID, " FAILED: ", err)
continue
}
authenticate := &Authenticate{
MessageType: "Authenticate",
UDID: device.UDID,
Topic: pushInfo.MDMTopic,
BuildVersion: device.BuildVersion,
DeviceName: device.DeviceName,
Model: device.Model,
ModelName: device.ModelName,
OSVersion: device.OSVersion,
ProductName: device.ProductName,
SerialNumber: device.SerialNumber,
IMEI: device.IMEI,
MEID: device.MEID,
}
authenticatePlist, err := plist.Marshal(authenticate)
if err != nil {
log.Println(err)
continue
}
token, err := hex.DecodeString(pushInfo.Token)
if err != nil {
log.Println(device.UDID, " FAILED: ", err)
continue
}
unlockToken, err := hex.DecodeString(device.UnlockToken)
if err != nil {
log.Println(device.UDID, " FAILED: ", err)
continue
}
tokenUpdate := &TokenUpdate{
MessageType: "TokenUpdate",
UDID: device.UDID,
PushMagic: pushInfo.PushMagic,
Token: token,
Topic: pushInfo.MDMTopic,
UnlockToken: unlockToken,
}
tokenPlist, err := plist.Marshal(tokenUpdate)
if err != nil {
log.Println(err)
continue
}
certHash, err := deviceDB.GetUDIDCertHash([]byte(device.UDID))
if err != nil {
log.Println(device.UDID, " FAILED: ", err)
continue
}
var certDer []byte
certKey := certHashToCertKey[string(certHash)]
err = boltDB.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte("scep_certificates"))
certDer = bucket.Get(certKey)
return nil
})
if err != nil {
log.Println(device.UDID, " FAILED: ", err)
continue
}
var certExpiration string
var certPEM []byte
if certDer != nil {
// parse the cert to extract the expiration date
cert, err := x509.ParseCertificate(certDer)
if err != nil {
log.Printf("WARN: unable to parse SCEP identity certificate for %s: %s\n", device.UDID, err)
}
certExpiration = cert.NotAfter.Format("2006-01-02 15:04:05")
// encode it to PEM to store it in the DB in
// the format that nano expects. At the moment
// we don't really need this value as we can
// make do with the hash and the expiration,
// but I figured it would be good to have it.
pemBlock := &pem.Block{
Type: "CERTIFICATE",
Bytes: cert.Raw,
}
certPEM = pem.EncodeToMemory(pemBlock)
}
if len(device.BootstrapToken) == 0 {
log.Println("Device with empty bootstrap token: ", device.UDID, " Last seen: ", device.LastSeen.String())
}
base64BootstrapToken := base64.StdEncoding.EncodeToString(device.BootstrapToken)
sb.WriteString(fmt.Sprintf(`
INSERT INTO nano_devices
(
id,
serial_number,
authenticate,
authenticate_at,
token_update,
token_update_at,
bootstrap_token_b64,
bootstrap_token_at,
identity_cert,
updated_at
)
SELECT
'%s',
'%s',
'%s',
CURRENT_TIMESTAMP,
'%s',
CURRENT_TIMESTAMP,
NULLIF('%s', ''),
CURRENT_TIMESTAMP,
NULLIF('%s', ''),
'%s'
WHERE
NOT EXISTS (
SELECT 1 FROM nano_devices
WHERE id = '%s' AND updated_at != '%s'
)
ON DUPLICATE KEY
UPDATE
updated_at = updated_at, -- preserve updated_at
serial_number = VALUES(serial_number),
authenticate = VALUES(authenticate),
authenticate_at = CURRENT_TIMESTAMP,
token_update = VALUES(token_update),
token_update_at = CURRENT_TIMESTAMP,
bootstrap_token_b64 = VALUES(bootstrap_token_b64),
bootstrap_token_at = CURRENT_TIMESTAMP,
identity_cert = VALUES(identity_cert);
`, device.UDID, device.SerialNumber, authenticatePlist, tokenPlist, base64BootstrapToken, certPEM, referenceTime, device.UDID, referenceTime))
sb.WriteString(fmt.Sprintf(`
INSERT INTO nano_enrollments (
id,
device_id,
user_id, type,
topic,
push_magic,
token_hex,
enabled,
last_seen_at,
enrolled_from_migration,
token_update_tally,
updated_at
)
SELECT
'%s',
'%s',
NULL,
'Device',
'%s',
'%s',
'%s',
%t,
CURRENT_TIMESTAMP,
1,
1,
'%s'
WHERE
NOT EXISTS (
SELECT 1 FROM nano_enrollments
WHERE id = '%s' AND updated_at != '%s'
)
ON DUPLICATE KEY
UPDATE
updated_at = updated_at, -- preserve updated_at
device_id = VALUES(device_id),
user_id = VALUES(user_id),
type = VALUES(type),
topic = VALUES(topic),
push_magic = VALUES(push_magic),
token_hex = VALUES(token_hex),
enabled = VALUES(enabled),
last_seen_at = CURRENT_TIMESTAMP,
token_update_tally = nano_enrollments.token_update_tally + 1;`,
device.UDID,
device.UDID,
tokenUpdate.Topic,
tokenUpdate.PushMagic,
hex.EncodeToString(tokenUpdate.Token),
device.Enrolled,
referenceTime,
device.UDID,
referenceTime,
))
sb.WriteString(fmt.Sprintf(`
INSERT INTO nano_cert_auth_associations
(id, sha256, cert_not_valid_after, updated_at)
SELECT
'%s', '%s', NULLIF('%s', ''), '%s'
WHERE
NOT EXISTS (
SELECT 1 FROM nano_cert_auth_associations
WHERE id = '%s' AND updated_at != '%s'
)
ON DUPLICATE KEY UPDATE
id = VALUES(id),
updated_at = updated_at, -- preserve updated_at
sha256 = VALUES(sha256),
cert_not_valid_after = VALUES(cert_not_valid_after);
`, device.UDID, hex.EncodeToString(certHash[:]), certExpiration, referenceTime, device.UDID, referenceTime))
}
sb.WriteString("\n")
if err := os.WriteFile("dump.sql", []byte(sb.String()), 0o600); err != nil {
log.Fatal(err)
}
log.Println("Wrote device/enrollment records to dump.sql")
}()
// SCEP cert/key
func() {
log.Println("Open DB for SCEP cert and key")
bboltDB, err := bbolt.Open(*flDB, 0o600, nil)
if err != nil {
log.Fatal(err)
}
defer bboltDB.Close()
scepBoltDepot, err := scepdepot.NewBoltDepot(bboltDB)
if err != nil {
log.Fatal(err)
}
key, err := scepBoltDepot.CreateOrLoadKey(2048)
if err != nil {
log.Fatal(err)
}
crt, err := scepBoltDepot.CreateOrLoadCA(key, 5, "MicroMDM", "US")
if err != nil {
log.Fatal(err)
}
privateKeyBytes := x509.MarshalPKCS1PrivateKey(key)
privateKeyPEM := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: privateKeyBytes,
})
if err := os.WriteFile("scep.key", privateKeyPEM, 0o600); err != nil {
log.Fatal(err)
}
certPEM := pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: crt.Raw,
})
if err := os.WriteFile("scep.cert", certPEM, 0o600); err != nil {
log.Fatal(err)
}
log.Println("Wrote SCEP cert/key to scep.cert/scep.key")
}()
}
+10 -3
View File
@@ -55,6 +55,7 @@ setup () {
mkdir -p "$REPOSITORY_DIRECTORY"
mkdir -p "$STAGED_DIRECTORY"
cp -r "$KEYS_SOURCE_DIRECTORY" "$KEYS_DIRECTORY"
if ! aws sts get-caller-identity &> /dev/null; then
@@ -86,9 +87,12 @@ setup () {
export FLEET_TARGETS_PASSPHRASE
FLEET_SNAPSHOT_PASSPHRASE=$(op read "op://$SNAPSHOT_PASSPHRASE_1PASSWORD_PATH")
export FLEET_SNAPSHOT_PASSPHRASE
FLEET_TIMESTAMP_PASSPHRASE=$(op read "op://$TIMESTAMP_PASSPHRASE_1PASSWORD_PATH")
export FLEET_TIMESTAMP_PASSPHRASE
elif [[ $ACTION == "update-timestamp" ]]; then
FLEET_TIMESTAMP_PASSPHRASE=$(op read "op://$TIMESTAMP_PASSPHRASE_1PASSWORD_PATH")
export FLEET_TIMESTAMP_PASSPHRASE
fi
FLEET_TIMESTAMP_PASSPHRASE=$(op read "op://$TIMESTAMP_PASSPHRASE_1PASSWORD_PATH")
export FLEET_TIMESTAMP_PASSPHRASE
go build -o "$GO_TOOLS_DIRECTORY/replace" "$SCRIPT_DIR/../../tools/tuf/replace"
go build -o "$GO_TOOLS_DIRECTORY/download-artifacts" "$SCRIPT_DIR/../../tools/tuf/download-artifacts"
@@ -321,6 +325,8 @@ print_reminder () {
elif [[ $COMPONENT == "osqueryd" ]]; then
prompt "Make sure to install fleetd with '--osqueryd-channel=stable' on a Linux, Windows and macOS VM. (To smoke test the release.)"
fi
elif [[ $ACTION == "update-timestamp" ]]; then
:
elif [[ $ACTION != "update-timestamp" ]]; then
echo "Unsupported action: $ACTION"
exit 1
@@ -330,6 +336,7 @@ print_reminder () {
trap clean_up EXIT
print_reminder
setup
pull_from_remote
if [[ $ACTION == "release-to-edge" ]]; then
@@ -343,4 +350,4 @@ else
exit 1
fi
push_to_remote
push_to_remote
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

After

Width:  |  Height:  |  Size: 272 KiB

+1 -1
View File
@@ -28,7 +28,7 @@ img {
h1, h2, h3, h4, h5, h6 {
font-family: @header-font;
}
h1, h2, h3, h4, h5 {
h1, h2, h3, h4, h5, h6 {
font-weight: 800;
color: @core-fleet-black;
}
+1 -1
View File
@@ -160,7 +160,7 @@ html, body {
background-image: url('/images/cta-thumbnail-psystage-4-has-use-case-128x128@2x.png');
}
&.stage-five {
background-image: url('/images/cta-thumbnail-psystage-5-100x100@2x.png');
background-image: url('/images/cta-thumbnail-psystage-5-feeling-confident-128x128@2x.png');
}
&.stage-six {
background-image: url('/images/psystage-6-has-team-buy-in-504x784@2x.png');
+36 -15
View File
@@ -1,26 +1,34 @@
#basic-documentation {
h1 {
font-size: 28px;
line-height: 34px;
font-size: 30px;
}
h2 {
font-size: 24px;
line-height: 28px;
font-size: 28px;
}
h3 {
font-size: 20px;
line-height: 24px;
font-size: 24px;
}
h4 {
margin-top: 24px;
font-size: 18px;
line-height: 24px;
font-size: 20px;
}
h5 {
font-size: 18px;
}
h6 {
font-size: 16px;
}
h1, h2, h3, h4, h5, h6 {
line-height: 1.2;
}
a:not(.btn) {
color: @core-vibrant-blue;
@@ -532,25 +540,33 @@
h3 {
padding-bottom: 16px;
margin-top: 24px;
margin-bottom: 40px;
border-bottom: 1px dashed @core-fleet-black-25;
margin-top: 40px;
scroll-margin-top: 104px;
}
h4 {
padding-bottom: 24px;
margin-top: 24px;
margin-bottom: 0px;
scroll-margin-top: 104px;
}
h5 {
font-size: 16px;
font-weight: 700;
font-family: @main-font;
margin-bottom: 16px;
margin-top: 16px;
margin-bottom: 24px;
scroll-margin-top: 104px;
}
h6 {
font-weight: 700;
font-family: @main-font;
margin-top: 16px;
margin-bottom: 24px;
scroll-margin-top: 104px;
}
pre + hr + h3 {
padding-top: 0px;
}
@@ -750,7 +766,7 @@
border-collapse: collapse;
font-size: 16px;
line-height: 24px;
margin-bottom: 16px;
margin-bottom: 0;
word-wrap: break-word;
th {
@@ -767,6 +783,11 @@
padding: 8px 7px 7px 8px;
}
}
.table-responsive {
margin-bottom: 32px;
}
.note {
background-color: @core-vibrant-blue-10;
border-radius: 12px;
+5 -12
View File
@@ -85,7 +85,7 @@
padding-right: 0px;
}
[purpose='page-section'] {
margin-bottom: 180px;
margin-bottom: 120px;
max-width: 960px;
margin-left: auto;
margin-right: auto;
@@ -282,8 +282,8 @@
}
[purpose='homepage-text-block'] {
margin-top: 160px;
margin-bottom: 120px;
margin-top: 80px;
margin-bottom: 80px;
max-width: 642px;
h2 {
font-weight: 800;
@@ -296,7 +296,7 @@
}
}
[purpose='platform-block'] {
margin-bottom: 64px;
margin-bottom: 120px;
max-width: 1080px;
}
[purpose='platform-block'].visible {
@@ -514,10 +514,6 @@
}
}
[purpose='integrations-section'] {
margin-top: 180px;
}
[purpose='integration-cards'] {
margin-top: 40px;
margin-bottom: 40px;
@@ -716,10 +712,7 @@
padding-left: 60px;
}
[purpose='page-section'] {
margin-bottom: 160px;
}
[purpose='integrations-section'] {
margin-top: 160px;
margin-bottom: 120px;
}
[purpose='video-modal'] {
[purpose='modal-dialog'] {
+6 -2
View File
@@ -82,7 +82,7 @@
background-image: url('/images/psystage-4-has-use-case-508x784@2x.png');
}
&.stage-five {
background-image: url('/images/cropped-fleet-cloud-city-504x784@2x.png');
background-image: url('/images/psystage-5-feeling-confident-504x784@2x.png');
}
&.stage-six {
background-image: url('/images/psystage-6-has-team-buy-in-504x784@2x.png');
@@ -325,7 +325,7 @@
}
[purpose='form-container'] {
padding: 0;
min-width: unset;
// min-width: unset;
max-width: 504px;
margin-left: auto;
margin-right: auto;
@@ -341,6 +341,10 @@
padding-left: 24px;
padding-right: 24px;
}
[purpose='form-container'] {
min-width: unset;
width: 100%;
}
}
@media (max-width: 575px) {
[purpose='logo-container'] {
+115 -114
View File
@@ -37,12 +37,12 @@
<img alt="Operating systems entering a glass device management dome" src="/images/device-management-hero-380x293@2x.png">
</div>
<div purpose="category-text-block" class="d-flex flex-column">
<strong>Everything in one place</strong>
<p>Manage devices consistently in a single, open platform for Apple, Windows, and Linux. Going only Mac or 50/50 Mac and Windows? What about Linux? Its up to you. </p>
<strong>Everything in one place - even Linux</strong>
<p>Enroll all your computing devices from a single system. Going only Apple or 50/50 Mac and Windows? What about Linux? Its up to you. </p>
<strong>Debunk the cross-platform myth</strong>
<p>Fleet exposes familiar concepts like custom attributes and dynamic grouping, but in a way that lets you work directly with data and events from each native operating system.</p>
<p>Fleet exposes familiar concepts from traditional MDMs like custom attributes and dynamic grouping, but in a way that lets you work directly with data and events from each native operating system.</p>
<strong>Less friction</strong>
<p>Fork the CIS benchmarks or easily build your own compliance framework. 100% source available.</p>
<p>Automatically manage updates and patches for apps on macOS, Windows, and Linux computers.</p>
<div>
<a purpose="category-button" class="text-nowrap btn btn-primary" href="/device-management">Start with device management</a>
</div>
@@ -180,8 +180,113 @@
</div>
</div>
<% } %>
<%/* Integration cards */%>
<div purpose="integrations-section">
<div purpose="page-section">
<div purpose="endpoints-banner">
<div purpose="endpoint-banner-text">
<h2>An open interface for every OS</h2>
<p>Normalize how you manage clouds and computers without losing low-level access to OS-specific features.</p>
<div class="d-flex flex-row align-items-center justify-content-md-start justify-content-center">
<animated-arrow-button href="/docs/get-started/why-fleet">Read the docs</animated-arrow-button>
</div>
</div>
<div purpose="endpoints-images">
<div purpose="endpoint-images-row" class="d-md-flex d-none">
<div purpose="endpoint-empty-image-box"></div>
<div purpose="endpoint-empty-image-box"></div>
<div purpose="endpoint-empty-image-box"></div>
<div purpose="endpoint-empty-image-box"></div>
</div>
<div purpose="endpoint-images-row">
<div purpose="endpoint-empty-image-box" class="d-md-none d-flex"></div>
<div purpose="endpoint-empty-image-box" class="d-md-none d-flex"></div>
<div purpose="endpoint-image-box">
<img alt="AWS" src="/images/logo-aws-36x36@2x.png">
<p>AWS</p>
</div>
<div purpose="endpoint-image-box">
<img alt="Google Cloud" src="/images/icon-gcp-36x36@2x.png">
<p>GCP</p>
</div>
<div purpose="endpoint-image-box">
<img alt="Azure" src="/images/logo-azure-37x36@2x.png">
<p>Azure</p>
</div>
<div purpose="endpoint-image-box" class="d-md-none d-flex">
<img alt="Datacenters" src="/images/homepage-platform-greyscale-datacenters-48x48@2x.png">
<p>Data centers</p>
</div>
<div purpose="endpoint-image-box" class="d-md-none d-flex">
<img alt="iOS" src="/images/icon-ios-36x36@2x.png">
<p>iOS/iPadOS</p>
</div>
<div purpose="endpoint-empty-image-box"></div>
<div purpose="endpoint-empty-image-box"></div>
</div>
<div purpose="endpoint-images-row">
<div purpose="endpoint-empty-image-box" class="d-md-none d-flex"></div>
<div purpose="endpoint-empty-image-box" class="d-md-none d-flex"></div>
<div purpose="endpoint-image-box">
<img alt="macOS" src="/images/logo-apple-36x36@2x.png">
<p>macOS</p>
</div>
<div purpose="endpoint-image-box">
<img alt="Windows" src="/images/logo-windows-36x36@2x.png">
<p>Windows</p>
</div>
<div purpose="endpoint-image-box">
<img alt="Linux" src="/images/logo-linux-36x36@2x.png">
<p>Linux</p>
</div>
<div purpose="endpoint-image-box" class="d-md-flex d-none">
<img alt="Datacenters" src="/images/homepage-platform-greyscale-datacenters-48x48@2x.png">
<p>Data centers</p>
</div>
<div purpose="endpoint-image-box" class="d-md-none d-flex">
<img alt="ChromeOS" src="/images/logo-chrome-36x37@2x.png">
<p>Chromebook</p>
</div>
<div purpose="endpoint-image-box" class="d-md-none d-flex">
<img alt="Containers" src="/images/logo-docker-40x40@2x.png">
<p>Containers</p>
</div>
<div purpose="endpoint-empty-image-box"></div>
<div purpose="endpoint-empty-image-box"></div>
</div>
<div purpose="endpoint-images-row">
<div purpose="endpoint-empty-image-box" class="d-md-flex d-none"></div>
<div purpose="endpoint-image-box" class="d-md-flex d-none">
<img alt="ChromeOS" src="/images/logo-chrome-36x37@2x.png">
<p>Chromebook</p>
</div>
<div purpose="endpoint-image-box" class="d-md-flex d-none">
<img alt="Containers" src="/images/logo-docker-40x40@2x.png">
<p>Containers</p>
</div>
<div purpose="endpoint-image-box" class="d-md-flex d-none">
<img alt="iOS" src="/images/icon-ios-36x36@2x.png">
<p>iOS/iPadOS</p>
</div>
<div purpose="endpoint-empty-image-box"></div>
<div purpose="endpoint-empty-image-box"></div>
<div purpose="endpoint-empty-image-box"></div>
<div purpose="endpoint-empty-image-box"></div>
<div purpose="endpoint-empty-image-box"></div>
<div purpose="endpoint-empty-image-box"></div>
<div purpose="endpoint-empty-image-box"></div>
</div>
<div purpose="endpoint-images-row">
<div purpose="endpoint-empty-image-box"></div>
<div purpose="endpoint-empty-image-box"></div>
<div purpose="endpoint-empty-image-box"></div>
<div purpose="endpoint-empty-image-box"></div>
</div>
</div>
</div>
</div>
<%/* Integration cards */%>
<!-- <div purpose="integrations-section">
<h2 class="text-center">Connect your favorite tools</h2>
<div purpose="integration-cards" class="d-flex flex-sm-row flex-column justify-content-center">
<div purpose="integration-card-column" class="d-flex flex-lg-row flex-column">
@@ -231,116 +336,12 @@
<div class="d-flex flex-column align-items-center">
<animated-arrow-button href="/integrations">View all integrations</animated-arrow-button>
</div>
</div>
</div>
<div purpose="page-section">
<div purpose="endpoints-banner">
<div purpose="endpoint-banner-text">
<h2>An open interface for every OS</h2>
<p>Normalize how you manage clouds and computers without losing low-level access to OS-specific features.</p>
<div class="d-flex flex-row align-items-center justify-content-md-start justify-content-center">
<animated-arrow-button href="/docs/get-started/why-fleet">Read the docs</animated-arrow-button>
</div>
</div>
<div purpose="endpoints-images">
<div purpose="endpoint-images-row" class="d-md-flex d-none">
<div purpose="endpoint-empty-image-box"></div>
<div purpose="endpoint-empty-image-box"></div>
<div purpose="endpoint-empty-image-box"></div>
<div purpose="endpoint-empty-image-box"></div>
</div>
<div purpose="endpoint-images-row">
<div purpose="endpoint-empty-image-box" class="d-md-none d-flex"></div>
<div purpose="endpoint-empty-image-box" class="d-md-none d-flex"></div>
<div purpose="endpoint-image-box">
<img alt="AWS" src="/images/logo-aws-36x36@2x.png">
<p>AWS</p>
</div>
<div purpose="endpoint-image-box">
<img alt="Google Cloud" src="/images/icon-gcp-36x36@2x.png">
<p>GCP</p>
</div>
<div purpose="endpoint-image-box">
<img alt="Azure" src="/images/logo-azure-37x36@2x.png">
<p>Azure</p>
</div>
<div purpose="endpoint-image-box" class="d-md-none d-flex">
<img alt="Datacenters" src="/images/homepage-platform-greyscale-datacenters-48x48@2x.png">
<p>Data centers</p>
</div>
<div purpose="endpoint-image-box" class="d-md-none d-flex">
<img alt="IOT" src="/images/icon-iot-36x36@2x.png">
<p>IoT (Linux)</p>
</div>
<div purpose="endpoint-empty-image-box"></div>
<div purpose="endpoint-empty-image-box"></div>
</div>
<div purpose="endpoint-images-row">
<div purpose="endpoint-empty-image-box" class="d-md-none d-flex"></div>
<div purpose="endpoint-empty-image-box" class="d-md-none d-flex"></div>
<div purpose="endpoint-image-box">
<img alt="macOS" src="/images/logo-apple-36x36@2x.png">
<p>macOS</p>
</div>
<div purpose="endpoint-image-box">
<img alt="Windows" src="/images/logo-windows-36x36@2x.png">
<p>Windows</p>
</div>
<div purpose="endpoint-image-box">
<img alt="Linux" src="/images/logo-linux-36x36@2x.png">
<p>Linux</p>
</div>
<div purpose="endpoint-image-box" class="d-md-flex d-none">
<img alt="Datacenters" src="/images/homepage-platform-greyscale-datacenters-48x48@2x.png">
<p>Data centers</p>
</div>
<div purpose="endpoint-image-box" class="d-md-none d-flex">
<img alt="ChromeOS" src="/images/logo-chrome-36x37@2x.png">
<p>Chromebook</p>
</div>
<div purpose="endpoint-image-box" class="d-md-none d-flex">
<img alt="Containers" src="/images/logo-docker-40x40@2x.png">
<p>Containers</p>
</div>
<div purpose="endpoint-empty-image-box"></div>
<div purpose="endpoint-empty-image-box"></div>
</div>
<div purpose="endpoint-images-row">
<div purpose="endpoint-empty-image-box" class="d-md-flex d-none"></div>
<div purpose="endpoint-image-box" class="d-md-flex d-none">
<img alt="ChromeOS" src="/images/logo-chrome-36x37@2x.png">
<p>Chromebook</p>
</div>
<div purpose="endpoint-image-box" class="d-md-flex d-none">
<img alt="Containers" src="/images/logo-docker-40x40@2x.png">
<p>Containers</p>
</div>
<div purpose="endpoint-image-box" class="d-md-flex d-none">
<img alt="IOT" src="/images/icon-iot-36x36@2x.png">
<p>IoT (Linux)</p>
</div>
<div purpose="endpoint-empty-image-box"></div>
<div purpose="endpoint-empty-image-box"></div>
<div purpose="endpoint-empty-image-box"></div>
<div purpose="endpoint-empty-image-box"></div>
<div purpose="endpoint-empty-image-box"></div>
<div purpose="endpoint-empty-image-box"></div>
<div purpose="endpoint-empty-image-box"></div>
</div>
<div purpose="endpoint-images-row">
<div purpose="endpoint-empty-image-box"></div>
<div purpose="endpoint-empty-image-box"></div>
<div purpose="endpoint-empty-image-box"></div>
<div purpose="endpoint-empty-image-box"></div>
</div>
</div>
</div>
</div> -->
</div>
<div purpose="homepage-text-block" class="text-center mx-auto">
<h2>Open by design</h2>
<p>Fleet is dedicated to flexibility, accessibility, and clarity. We think everyone can contribute and that tools should be as easy as possible for everyone to understand.</p>
<p>At Fleet, everyone can contribute. We are dedicated to making tools that are easy for everyone to understand.</p>
</div>
<div purpose="three-column-features" class="mx-auto">
@@ -349,13 +350,13 @@
<div purpose="feature" class="ml-sm-0">
<img alt="transparency" src="/images/homepage-icon-transparency-54x64@2x.png" class="mx-auto mx-md-0">
<h5>Scope transparency</h5>
<p>Let end users see the source code for exactly <a href="/docs/using-fleet/fleet-desktop">how they are being monitored</a>, and set clear expectations about what is and isnt acceptable use of work computers.</p>
<p>Let end users see the source code for exactly <a href="/better">how they are being monitored</a>, and set clear expectations about what is and isnt acceptable use of work computers.</p>
</div>
<div purpose="feature">
<img alt="Free as in free" src="/images/homepage-icon-free-48x64@2x.png">
<h5>Free as in free</h5>
<p>The <a href="/pricing">free version of Fleet</a> will always be free. Fleet is independently backed and actively maintained with the help of many amazing <a href="https://github.com/fleetdm/fleet/graphs/contributors">contributors</a>.</p>
<p>The <a href="https://fleetdm.com/docs/get-started/faq#what-is-your-commitment-to-open-source-stewardship">free version of Fleet</a> will always be free. Fleet is independently backed and actively maintained with the help of many amazing <a href="https://github.com/fleetdm/fleet/graphs/contributors">contributors</a>.</p>
</div>
<div purpose="feature" class="mr-sm-0 mb-0">
+10 -17
View File
@@ -424,29 +424,16 @@
<div purpose="form-progress-bar"><div purpose="current-progress" style="width: 50%"></div></div>
<img purpose="success-icon" alt="🚀" src="/images/icon-form-success-16x16@2x.png">
</div>
<h2>Want to try it?</h2>
<div purpose="form-header" class="mx-auto">
<p class="">We dont have a public sample environment, but you can try Fleet out locally on your computer.</p>
</div>
<h2>Is it any good?</h2>
<div purpose="start-cards">
<a purpose="card" href="/try-fleet?start">
<img alt="Run a local demo of Fleet" src="/images/start-try-fleet-64x64@2x.png">
<h2>Try Fleet yourself</h2>
<p>Try Fleet locally on your device</p>
</a>
<a purpose="card" href="https://youtu.be/QpdRADHWP_o?feature=shared&t=1333" target="_blank">
<a purpose="card" class="w-100" href="https://youtu.be/QpdRADHWP_o?feature=shared&t=1333" target="_blank">
<img alt="Watch a demo" src="/images/play-button-64x64@2x.png">
<h2>Watch a demo</h2>
<p>See what Fleet can do</p>
</a>
</div>
<p>Got questions? <a purpose="contact-link" href="/contact">Ask us anything</a>.</p>
<div purpose="form-tip" class="d-flex flex-row align-items-center justify-content-between">
<div><img alt="A lightbulb" src="/images/icon-suggestion-64x64@2x.png"></div>
<div>
<p>You can come back here any time to continue with your deployment.</p>
</div>
</div>
<div purpose="form-buttons">
<a purpose="back-button" @click="clickGoToPreviousStep()">Back</a>
<ajax-button type="submit" purpose="submit-button" class="btn btn-primary" :syncing="syncing">Continue</ajax-button>
@@ -483,6 +470,12 @@
<div class="invalid-feedback" v-if="formErrors.whatDidYouThink">Please select an option</div>
</div>
<cloud-error v-if="cloudError"></cloud-error>
<div purpose="form-tip" class="d-flex flex-row align-items-center justify-content-start">
<div><img alt="A lightbulb" src="/images/icon-suggestion-64x64@2x.png"></div>
<div>
<p>Not ready to talk? You can also <a href="/try-fleet">run your own trial</a> with Docker.</p>
</div>
</div>
<div purpose="form-buttons">
<a purpose="back-button" @click="clickGoToPreviousStep()">Back</a>
<ajax-button type="submit" purpose="submit-button" class="btn btn-primary" :syncing="syncing">Continue</ajax-button>
@@ -753,7 +746,7 @@
</ajax-form>
</div>
</div>
<div purpose="form-image" :class="currentStep === 'start' ? 'stage-one' : ['thanks-for-checking-out-fleet', 'what-did-you-think'].includes(currentStep) ? 'cloud-city' : ['2 - Aware'].includes(psychologicalStage) ? 'stage-two' : ['3 - Intrigued'].includes(psychologicalStage) ? 'stage-three' : ['4 - Has use case'].includes(psychologicalStage) ? 'stage-four' : ['5 - Personally confident'].includes(psychologicalStage) ? 'stage-five' : ['6 - Has team buy-in'].includes(psychologicalStage) ? 'stage-six' : 'd-none w-0'">
<div purpose="form-image" :class="currentStep === 'start' ? 'stage-one' : ['thanks-for-checking-out-fleet'].includes(currentStep) ? 'cloud-city' : ['2 - Aware'].includes(psychologicalStage) ? 'stage-two' : ['3 - Intrigued'].includes(psychologicalStage) ? 'stage-three' : ['4 - Has use case'].includes(psychologicalStage) ? 'stage-four' : ['5 - Personally confident'].includes(psychologicalStage) ? 'stage-five' : ['6 - Has team buy-in'].includes(psychologicalStage) ? 'stage-six' : 'd-none w-0'">
</div>
</div>
</div>