diff --git a/Makefile b/Makefile
index cd54e4c20a..839f2e8fd8 100644
--- a/Makefile
+++ b/Makefile
@@ -239,7 +239,7 @@ generate-dev: .prefix
NODE_ENV=development yarn run webpack --progress --watch
generate-mock: .prefix
- go generate github.com/fleetdm/fleet/v4/server/mock github.com/fleetdm/fleet/v4/server/mock/mockresult github.com/fleetdm/fleet/v4/server/service/mock
+ go generate github.com/fleetdm/fleet/v4/server/mock github.com/fleetdm/fleet/v4/server/mock/mockresult github.com/fleetdm/fleet/v4/server/service/mock github.com/fleetdm/fleet/v4/server/mdm/android/mock
generate-doc: .prefix
go generate github.com/fleetdm/fleet/v4/server/fleet
diff --git a/articles/configuring-full-names-in-google-workspace.md b/articles/configuring-full-names-in-google-workspace.md
index 39cdf668ce..bf34b6e567 100644
--- a/articles/configuring-full-names-in-google-workspace.md
+++ b/articles/configuring-full-names-in-google-workspace.md
@@ -1,7 +1,5 @@
# Configuring full names in Google Workspace for Fleet integration
-## Introduction
-
Fleet requires user full names to be configured in your Identity Provider (IdP) using specific attributes. Since Google Workspace doesn't natively provide a full name attribute that matches Fleet's requirements, this guide will walk you through setting up automatic synchronization of full names using Google's custom attributes and Apps Script.
## What we're solving
diff --git a/articles/windows-mdm-setup.md b/articles/windows-mdm-setup.md
index a3b2587229..bd137c30aa 100644
--- a/articles/windows-mdm-setup.md
+++ b/articles/windows-mdm-setup.md
@@ -173,6 +173,13 @@ Once the automatic migration is enabled, Fleet sends a notification to each host
You can track migration progress in Fleet. Learn how [here](https://fleetdm.com/guides/mdm-migration#check-migration-progress).
+## Turn off Windows MDM
+
+1. Turn off MDM for each host, by running [this script](https://github.com/fleetdm/fleet/blob/main/it-and-security/lib/windows/scripts/turn-off-mdm.ps1) on all your Windows hosts.
+2. Head to **Settings > Integrations > MDM**.
+3. In the **Mobile device management (MDM)** section, select **Edit** next to "Windows MDM turned on."
+4. Switch **Windows MDM on** to **Windows MDM off** and select **Save**.
+
diff --git a/changes/25834-always-allow-passwords b/changes/25834-always-allow-passwords
new file mode 100644
index 0000000000..aa11717ed9
--- /dev/null
+++ b/changes/25834-always-allow-passwords
@@ -0,0 +1 @@
+- Fixed password authentication getting disabled when SMTP isn't configured
diff --git a/frontend/components/PlatformCompatibility/PlatformCompatibility.tsx b/frontend/components/PlatformCompatibility/PlatformCompatibility.tsx
index 05ae3619a3..25cbe7f4ea 100644
--- a/frontend/components/PlatformCompatibility/PlatformCompatibility.tsx
+++ b/frontend/components/PlatformCompatibility/PlatformCompatibility.tsx
@@ -48,6 +48,31 @@ const displayIncompatibilityText = (err: Error) => {
}
};
+// const tipContent = (
+// <>
+// Estimated compatibility based on the
+// tables used in the query. Querying
+// iPhones, iPads, and Android hosts is not
+// supported.
+// >
+// );
+
+// TODO(android): replace with the above tipContent when Android feature flag is removed
+const tipContent = (
+ <>
+ Estimated compatibility based on the
+ tables used in the query. Check the
+ table documentation (schema) to verify
+ compatibility of individual columns.
+
+
+ Only live queries are supported on ChromeOS.
+
+
+ Querying iPhones & iPads is not supported.
+ >
+);
+
const PlatformCompatibility = ({
compatiblePlatforms,
error,
@@ -84,22 +109,7 @@ const PlatformCompatibility = ({
return (
-
- Estimated compatibility based on the
- tables used in the query. Check the
- table documentation (schema) to verify
- compatibility of individual columns.
-
-
- Only live queries are supported on ChromeOS.
-
-
- Querying iPhones & iPads is not supported.
- >
- }
- >
+
Compatible with:
diff --git a/frontend/components/icons/Android.tsx b/frontend/components/icons/Android.tsx
new file mode 100644
index 0000000000..6ca60464e4
--- /dev/null
+++ b/frontend/components/icons/Android.tsx
@@ -0,0 +1,30 @@
+import React from "react";
+import { COLORS, Colors } from "styles/var/colors";
+import { ICON_SIZES, IconSizes } from "styles/var/icon_sizes";
+
+const Android = ({
+ size = "medium",
+ color = "ui-fleet-black-75",
+}: {
+ size: IconSizes;
+ color?: Colors;
+}) => {
+ return (
+
+
+
+ );
+};
+
+export default Android;
diff --git a/frontend/components/icons/index.ts b/frontend/components/icons/index.ts
index 810998b63e..754e683c9a 100644
--- a/frontend/components/icons/index.ts
+++ b/frontend/components/icons/index.ts
@@ -69,6 +69,7 @@ import AutomaticSelfService from "./AutomaticSelfService";
import User from "./User";
import InfoOutline from "./InfoOutline";
import GitOpsMode from "./GitOpsMode";
+import Android from "./Android";
// a mapping of the usable names of icons to the icon source.
export const ICON_MAP = {
@@ -133,6 +134,7 @@ export const ICON_MAP = {
iPadOS,
ios: iOS,
iOS,
+ android: Android,
"premium-feature": PremiumFeature,
profile: Profile,
download: Download,
diff --git a/frontend/interfaces/platform.ts b/frontend/interfaces/platform.ts
index 9fd2d4502c..753cfc41b8 100644
--- a/frontend/interfaces/platform.ts
+++ b/frontend/interfaces/platform.ts
@@ -11,24 +11,27 @@ export const PLATFORM_DISPLAY_NAMES = {
windows: "Windows",
linux: "Linux",
chrome: "ChromeOS",
+ android: "Android",
...APPLE_PLATFORM_DISPLAY_NAMES,
} as const;
+export const QUERYABLE_PLATFORMS = [
+ "darwin",
+ "windows",
+ "linux",
+ "chrome",
+] as const;
+
+export const NON_QUERYABLE_PLATFORMS = ["ios", "ipados", "android"] as const;
+
export type Platform = keyof typeof PLATFORM_DISPLAY_NAMES;
export type DisplayPlatform = typeof PLATFORM_DISPLAY_NAMES[keyof typeof PLATFORM_DISPLAY_NAMES];
export type QueryableDisplayPlatform = Exclude<
DisplayPlatform,
- "iOS" | "iPadOS"
+ typeof PLATFORM_DISPLAY_NAMES[typeof NON_QUERYABLE_PLATFORMS[number]]
>;
-export type QueryablePlatform = Exclude
;
-
-export const QUERYABLE_PLATFORMS: QueryablePlatform[] = [
- "darwin",
- "windows",
- "linux",
- "chrome",
-];
+export type QueryablePlatform = typeof QUERYABLE_PLATFORMS[number];
export const isQueryablePlatform = (
platform: string | undefined
@@ -111,7 +114,8 @@ export type HostPlatform =
| typeof HOST_LINUX_PLATFORMS[number]
| typeof HOST_APPLE_PLATFORMS[number]
| "windows"
- | "chrome";
+ | "chrome"
+ | "android";
/**
* Checks if the provided platform is a Linux-like OS. We can recieve many
@@ -133,6 +137,14 @@ export const isAppleDevice = (platform: string) => {
export const isIPadOrIPhone = (platform: string | HostPlatform) =>
["ios", "ipados"].includes(platform);
+export const isAndroid = (
+ platform: string | HostPlatform
+): platform is "android" => platform === "android";
+
+/** isMobilePlatform checks if the platform is an iPad or iPhone or Android. */
+export const isMobilePlatform = (platform: string | HostPlatform) =>
+ isIPadOrIPhone(platform) || isAndroid(platform);
+
export const DISK_ENCRYPTION_SUPPORTED_LINUX_PLATFORMS = [
"ubuntu", // covers Kubuntu
"rhel", // *included here to support Fedora systems. Necessary to cross-check with `os_versions` as well to confrim host is Fedora and not another, non-support rhel-like platform.
@@ -161,6 +173,9 @@ export const platformSupportsDiskEncryption = (
/** os_version necessary to differentiate Fedora from other rhel-like platforms */
os_version?: string
) => {
+ if (isAndroid(platform)) {
+ return false;
+ }
if (platform === "rhel") {
return !!os_version && os_version.toLowerCase().includes("fedora");
}
@@ -179,6 +194,9 @@ export const isOsSettingsDisplayPlatform = (
platform: HostPlatform,
os_version: string
) => {
+ if (isAndroid(platform)) {
+ return false;
+ }
if (platform === "rhel") {
return !!os_version && os_version.toLowerCase().includes("fedora");
}
diff --git a/frontend/interfaces/script.ts b/frontend/interfaces/script.ts
index 15497dcc90..7bbdd4dc21 100644
--- a/frontend/interfaces/script.ts
+++ b/frontend/interfaces/script.ts
@@ -9,7 +9,7 @@ export interface IScript {
}
export const isScriptSupportedPlatform = (hostPlatform: string) =>
- ["darwin", "windows", ...HOST_LINUX_PLATFORMS].includes(hostPlatform); // excludes chrome, ios, ipados see also https://github.com/fleetdm/fleet/blob/5a21e2cfb029053ddad0508869eb9f1f23997bf2/server/fleet/hosts.go#L775
+ ["darwin", "windows", ...HOST_LINUX_PLATFORMS].includes(hostPlatform); // excludes chrome, ios, ipados, android see also https://github.com/fleetdm/fleet/blob/5a21e2cfb029053ddad0508869eb9f1f23997bf2/server/fleet/hosts.go#L775
export type IScriptExecutionStatus = "ran" | "pending" | "error";
diff --git a/frontend/pages/DashboardPage/DashboardPage.tsx b/frontend/pages/DashboardPage/DashboardPage.tsx
index 8a1489250c..505a13c9c5 100644
--- a/frontend/pages/DashboardPage/DashboardPage.tsx
+++ b/frontend/pages/DashboardPage/DashboardPage.tsx
@@ -137,6 +137,7 @@ const DashboardPage = ({ router, location }: IDashboardProps): JSX.Element => {
const [chromeCount, setChromeCount] = useState(0);
const [iosCount, setIosCount] = useState(0);
const [ipadosCount, setIpadosCount] = useState(0);
+ const [androidCount, setAndroidCount] = useState(0);
const [missingCount, setMissingCount] = useState(0);
const [lowDiskSpaceCount, setLowDiskSpaceCount] = useState(0);
const [showActivityFeedTitle, setShowActivityFeedTitle] = useState(false);
@@ -191,6 +192,14 @@ const DashboardPage = ({ router, location }: IDashboardProps): JSX.Element => {
IConfig
>(["config"], () => configAPI.loadAll(), { ...DEFAULT_USE_QUERY_OPTIONS });
+ // TODO(android): remove this when the feature flag is removed
+ const platformOptions = useMemo(() => {
+ if (!config?.android_enabled) {
+ return PLATFORM_DROPDOWN_OPTIONS.filter((o) => o.value !== "android");
+ }
+ return [...PLATFORM_DROPDOWN_OPTIONS];
+ }, [config?.android_enabled]);
+
const { data: teams, isLoading: isLoadingTeams } = useQuery<
ILoadTeamsResponse,
Error,
@@ -242,12 +251,17 @@ const DashboardPage = ({ router, location }: IDashboardProps): JSX.Element => {
(platform: IHostSummaryPlatforms) => platform.platform === "ipados"
) || { platform: "ipados", hosts_count: 0 };
+ const android = data.platforms?.find(
+ (platform: IHostSummaryPlatforms) => platform.platform === "android"
+ ) || { platform: "android", hosts_count: 0 };
+
setMacCount(macHosts.hosts_count);
setWindowsCount(windowsHosts.hosts_count);
setLinuxCount(data.all_linux_count);
setChromeCount(chromebooks.hosts_count);
setIosCount(iphones.hosts_count);
setIpadosCount(ipads.hosts_count);
+ setAndroidCount(android.hosts_count);
setShowHostsUI(true);
},
}
@@ -540,6 +554,7 @@ const DashboardPage = ({ router, location }: IDashboardProps): JSX.Element => {
const HostCountCards = (
<>
{
chromeCount={chromeCount}
iosCount={iosCount}
ipadosCount={ipadosCount}
+ androidCount={androidCount}
builtInLabels={labels}
selectedPlatform={selectedPlatform}
errorHosts={!!errorHosts}
@@ -764,6 +780,12 @@ const DashboardPage = ({ router, location }: IDashboardProps): JSX.Element => {
>
);
+ const androidLayout = () => (
+ <>
+ {showMdmCard && {MDMCard}
}
+ >
+ );
+
const renderCards = () => {
switch (selectedPlatform) {
case "darwin":
@@ -778,6 +800,8 @@ const DashboardPage = ({ router, location }: IDashboardProps): JSX.Element => {
return iosLayout();
case "ipados":
return ipadosLayout();
+ case "android":
+ return androidLayout();
default:
return allLayout();
}
@@ -861,7 +885,7 @@ const DashboardPage = ({ router, location }: IDashboardProps): JSX.Element => {
name="platform-filter"
value={selectedPlatform || ""}
className={`${baseClass}__platform-filter`}
- options={PLATFORM_DROPDOWN_OPTIONS}
+ options={platformOptions}
onChange={(option: SingleValue) => {
const selectedPlatformOption = PLATFORM_DROPDOWN_OPTIONS.find(
(platform) => platform.value === option?.value
diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx
index d7fffd988b..423ea53824 100644
--- a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx
+++ b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx
@@ -1340,7 +1340,7 @@ const GlobalActivityItem = ({
const hasDetails = ACTIVITIES_WITH_DETAILS.has(activity.type);
const renderActivityPrefix = () => {
- const DEFAULT_ACTOR_DISPLAY = {activity.actor_full_name} ;
+ const DEFAULT_ACTOR_DISPLAY = {activity.actor_full_name ?? "Fleet"} ;
switch (activity.type) {
case ActivityType.UserLoggedIn:
diff --git a/frontend/pages/DashboardPage/helpers.ts b/frontend/pages/DashboardPage/helpers.ts
index 34d5f96dda..e01d3336f7 100644
--- a/frontend/pages/DashboardPage/helpers.ts
+++ b/frontend/pages/DashboardPage/helpers.ts
@@ -1,18 +1,7 @@
import paths from "router/paths";
-import {
- PlatformLabelOptions,
- PlatformValueOptions,
-} from "utilities/constants";
-
-interface IPlatformDropdownOptions {
- label: PlatformLabelOptions;
- value: PlatformValueOptions;
- path: string;
-}
-
/** Select platform */
-export const PLATFORM_DROPDOWN_OPTIONS: IPlatformDropdownOptions[] = [
+export const PLATFORM_DROPDOWN_OPTIONS = [
{ label: "All", value: "all", path: paths.DASHBOARD },
{ label: "macOS", value: "darwin", path: paths.DASHBOARD_MAC },
{ label: "Windows", value: "windows", path: paths.DASHBOARD_WINDOWS },
@@ -20,7 +9,8 @@ export const PLATFORM_DROPDOWN_OPTIONS: IPlatformDropdownOptions[] = [
{ label: "ChromeOS", value: "chrome", path: paths.DASHBOARD_CHROME },
{ label: "iOS", value: "ios", path: paths.DASHBOARD_IOS },
{ label: "iPadOS", value: "ipados", path: paths.DASHBOARD_IPADOS },
-];
+ { label: "Android", value: "android", path: paths.DASHBOARD_ANDROID },
+] as const;
/** Selected platform value mapped to built in label name */
export const PLATFORM_NAME_TO_LABEL_NAME = {
@@ -30,7 +20,8 @@ export const PLATFORM_NAME_TO_LABEL_NAME = {
chrome: "chrome",
ios: "iOS",
ipados: "iPadOS",
-};
+ android: "Android",
+} as const;
/** Premium feature, Gb must be set between 1-100 */
export const LOW_DISK_SPACE_GB = 32;
diff --git a/frontend/pages/DashboardPage/sections/MetricsHostCounts/MetricsHostCounts.tsx b/frontend/pages/DashboardPage/sections/MetricsHostCounts/MetricsHostCounts.tsx
index a44b3f9427..90d77e3562 100644
--- a/frontend/pages/DashboardPage/sections/MetricsHostCounts/MetricsHostCounts.tsx
+++ b/frontend/pages/DashboardPage/sections/MetricsHostCounts/MetricsHostCounts.tsx
@@ -66,7 +66,8 @@ const MetricsHostCounts = ({
{selectedPlatform === "all" && TotalHostsCard}
{isPremiumTier &&
selectedPlatform !== "ios" &&
- selectedPlatform !== "ipados" && (
+ selectedPlatform !== "ipados" &&
+ selectedPlatform !== "android" && (
<>
{MissingHostsCard}
{LowDiskSpaceHostsCard}
diff --git a/frontend/pages/DashboardPage/sections/PlatformHostCounts/PlatformHostCounts.tsx b/frontend/pages/DashboardPage/sections/PlatformHostCounts/PlatformHostCounts.tsx
index 4e4133c9c4..a9676b3a1f 100644
--- a/frontend/pages/DashboardPage/sections/PlatformHostCounts/PlatformHostCounts.tsx
+++ b/frontend/pages/DashboardPage/sections/PlatformHostCounts/PlatformHostCounts.tsx
@@ -12,6 +12,7 @@ import HostCountCard from "../../cards/HostCountCard";
const baseClass = "platform-host-counts";
interface IPlatformHostCountsProps {
+ androidDevEnabled: boolean; // TODO(android): remove when feature flag is removed
currentTeamId: number | undefined;
macCount: number;
windowsCount: number;
@@ -19,6 +20,7 @@ interface IPlatformHostCountsProps {
chromeCount: number;
iosCount: number;
ipadosCount: number;
+ androidCount: number;
builtInLabels?: IHostSummary["builtin_labels"];
errorHosts: boolean;
selectedPlatform?: PlatformValueOptions;
@@ -26,6 +28,7 @@ interface IPlatformHostCountsProps {
}
const PlatformHostCounts = ({
+ androidDevEnabled,
currentTeamId,
macCount,
windowsCount,
@@ -33,6 +36,7 @@ const PlatformHostCounts = ({
chromeCount,
iosCount,
ipadosCount,
+ androidCount,
builtInLabels,
errorHosts,
selectedPlatform,
@@ -187,6 +191,34 @@ const PlatformHostCounts = ({
);
};
+ const renderAndroidCount = (teamId?: number) => {
+ if (!androidDevEnabled) {
+ // TODO(android): remove when feature flag is removed
+ return null;
+ }
+
+ const androidLabelId = getBuiltinLabelId("android");
+
+ if (hidePlatformCard(androidCount)) {
+ return null;
+ }
+
+ if (androidLabelId === undefined) {
+ return <>>;
+ }
+
+ return (
+
+ );
+ };
+
const renderCounts = (teamId?: number) => {
switch (selectedPlatform) {
case "darwin":
@@ -201,7 +233,10 @@ const PlatformHostCounts = ({
return renderIosCount(teamId);
case "ipados":
return renderIpadosCount(teamId);
+ case "android":
+ return renderAndroidCount(teamId);
default:
+ // TODO(android): responsive layout with variable column widths (see figma for 2x2x3 grid)
return (
<>
{renderMacCard(teamId)}
@@ -210,6 +245,7 @@ const PlatformHostCounts = ({
{renderChromeCard(teamId)}
{renderIosCount(teamId)}
{renderIpadosCount(teamId)}
+ {renderAndroidCount(teamId)}
>
);
}
diff --git a/frontend/pages/ManageControlsPage/OSUpdates/OSUpdates.tsx b/frontend/pages/ManageControlsPage/OSUpdates/OSUpdates.tsx
index ebbda487a9..2306b79739 100644
--- a/frontend/pages/ManageControlsPage/OSUpdates/OSUpdates.tsx
+++ b/frontend/pages/ManageControlsPage/OSUpdates/OSUpdates.tsx
@@ -6,7 +6,7 @@ import { AppContext } from "context/app";
import { IConfig } from "interfaces/config";
import { ITeamConfig } from "interfaces/team";
-import { ApplePlatform } from "interfaces/platform";
+import { ApplePlatform, isAndroid } from "interfaces/platform";
import configAPI from "services/entities/config";
import teamsAPI, { ILoadTeamResponse } from "services/entities/teams";
@@ -22,11 +22,13 @@ import { parseOSUpdatesCurrentVersionsQueryParams } from "./components/CurrentVe
export type OSUpdatesSupportedPlatform = ApplePlatform | "windows";
+export type OSUpdatesTargetPlatform = OSUpdatesSupportedPlatform | "android"; // used for displaying "coming soon" messaging
+
const baseClass = "os-updates";
-const getSelectedPlatform = (
+const getDefaultSelectedPlatform = (
appConfig: IConfig | null
-): OSUpdatesSupportedPlatform => {
+): OSUpdatesTargetPlatform => {
// We dont have the data ready yet so we default to mac.
// This is usually when the users first comes to this page.
if (appConfig === null) return "darwin";
@@ -34,7 +36,7 @@ const getSelectedPlatform = (
// if the mac mdm is enable and configured we check the app config to see if
// the mdm for mac is enabled. If it is, it does not matter if windows is
// enabled and configured and we will always return "mac".
- return appConfig.mdm.enabled_and_configured ? "darwin" : "windows";
+ return appConfig.mdm.enabled_and_configured ? "darwin" : "windows"; // TODO(android): adjust this when android is supported
};
interface IOSUpdates {
@@ -49,7 +51,7 @@ const OSUpdates = ({ router, teamIdForApi, queryParams }: IOSUpdates) => {
const [
selectedPlatformTab,
setSelectedPlatformTab,
- ] = useState(null);
+ ] = useState(null);
const {
isError: isErrorConfig,
@@ -102,7 +104,8 @@ const OSUpdates = ({ router, teamIdForApi, queryParams }: IOSUpdates) => {
// If the user has not selected a platform yet, we default to the platform that
// is enabled and configured.
- const selectedPlatform = selectedPlatformTab || getSelectedPlatform(config);
+ const selectedPlatform =
+ selectedPlatformTab || getDefaultSelectedPlatform(config);
return (
@@ -131,9 +134,11 @@ const OSUpdates = ({ router, teamIdForApi, queryParams }: IOSUpdates) => {
refetchTeamConfig={refetchTeamConfig}
/>
-
-
-
+ {!isAndroid(selectedPlatform) && (
+
+
+
+ )}
);
diff --git a/frontend/pages/ManageControlsPage/OSUpdates/components/PlatformTabs/PlatformTabs.tsx b/frontend/pages/ManageControlsPage/OSUpdates/components/PlatformTabs/PlatformTabs.tsx
index df5be12011..d69a9ec838 100644
--- a/frontend/pages/ManageControlsPage/OSUpdates/components/PlatformTabs/PlatformTabs.tsx
+++ b/frontend/pages/ManageControlsPage/OSUpdates/components/PlatformTabs/PlatformTabs.tsx
@@ -1,9 +1,11 @@
import React from "react";
import { Tab, TabList, TabPanel, Tabs } from "react-tabs";
import TabsWrapper from "components/TabsWrapper";
+import CustomLink from "components/CustomLink";
+import { SUPPORT_LINK } from "utilities/constants";
import WindowsTargetForm from "../WindowsTargetForm";
-import { OSUpdatesSupportedPlatform } from "../../OSUpdates";
+import { OSUpdatesTargetPlatform } from "../../OSUpdates";
import AppleOSTargetForm from "../AppleOSTargetForm";
const baseClass = "platform-tabs";
@@ -18,11 +20,12 @@ interface IPlatformTabsProps {
defaultIPadOSDeadline: string;
defaultWindowsDeadlineDays: string;
defaultWindowsGracePeriodDays: string;
- selectedPlatform: OSUpdatesSupportedPlatform;
- onSelectPlatform: (platform: OSUpdatesSupportedPlatform) => void;
+ selectedPlatform: OSUpdatesTargetPlatform;
+ onSelectPlatform: (platform: OSUpdatesTargetPlatform) => void;
refetchAppConfig: () => void;
refetchTeamConfig: () => void;
isWindowsMdmEnabled: boolean;
+ isAndroidMdmEnabled: boolean;
}
const PlatformTabs = ({
@@ -40,23 +43,28 @@ const PlatformTabs = ({
refetchAppConfig,
refetchTeamConfig,
isWindowsMdmEnabled,
+ isAndroidMdmEnabled,
}: IPlatformTabsProps) => {
// FIXME: This behaves unexpectedly when a user switches tabs or changes the teams dropdown while a form is
// submitting.
- const PLATFORM_BY_INDEX: OSUpdatesSupportedPlatform[] = isWindowsMdmEnabled
+ const platformByIndex: OSUpdatesTargetPlatform[] = isWindowsMdmEnabled
? ["darwin", "windows", "ios", "ipados"]
: ["darwin", "ios", "ipados"];
+ if (isAndroidMdmEnabled) {
+ platformByIndex.push("android");
+ }
+
const onTabChange = (index: number) => {
- onSelectPlatform(PLATFORM_BY_INDEX[index]);
+ onSelectPlatform(platformByIndex[index]);
};
return (
@@ -76,6 +84,11 @@ const PlatformTabs = ({
iPadOS
+ {isAndroidMdmEnabled && (
+
+ Android
+
+ )}
+ {isAndroidMdmEnabled && (
+
+
+
+ Android updates are coming soon.
+
+
+ Need to encourage installation of Android updates?{" "}
+
+
+
+
+ )}
diff --git a/frontend/pages/ManageControlsPage/OSUpdates/components/PlatformTabs/_styles.scss b/frontend/pages/ManageControlsPage/OSUpdates/components/PlatformTabs/_styles.scss
index 2cf0a6f4ab..d38e98c5f2 100644
--- a/frontend/pages/ManageControlsPage/OSUpdates/components/PlatformTabs/_styles.scss
+++ b/frontend/pages/ManageControlsPage/OSUpdates/components/PlatformTabs/_styles.scss
@@ -2,4 +2,10 @@
.react-tabs__tab-list {
margin-bottom: $pad-large;
}
+ &__coming-soon {
+ p {
+ margin: 0;
+ padding-bottom: $pad-small;
+ }
+ }
}
diff --git a/frontend/pages/ManageControlsPage/OSUpdates/components/TargetSection/TargetSection.tsx b/frontend/pages/ManageControlsPage/OSUpdates/components/TargetSection/TargetSection.tsx
index b0bb07e3c1..f4704e5de0 100644
--- a/frontend/pages/ManageControlsPage/OSUpdates/components/TargetSection/TargetSection.tsx
+++ b/frontend/pages/ManageControlsPage/OSUpdates/components/TargetSection/TargetSection.tsx
@@ -9,7 +9,7 @@ import Spinner from "components/Spinner";
import WindowsTargetForm from "../WindowsTargetForm";
import PlatformTabs from "../PlatformTabs";
-import { OSUpdatesSupportedPlatform } from "../../OSUpdates";
+import { OSUpdatesTargetPlatform } from "../../OSUpdates";
const baseClass = "os-updates-target-section";
@@ -86,9 +86,9 @@ interface ITargetSectionProps {
appConfig: IConfig;
currentTeamId: number;
isFetching: boolean;
- selectedPlatform: OSUpdatesSupportedPlatform;
+ selectedPlatform: OSUpdatesTargetPlatform;
teamConfig?: ITeamConfig;
- onSelectPlatform: (platform: OSUpdatesSupportedPlatform) => void;
+ onSelectPlatform: (platform: OSUpdatesTargetPlatform) => void;
refetchAppConfig: () => void;
refetchTeamConfig: () => void;
}
@@ -107,6 +107,8 @@ const TargetSection = ({
return ;
}
+ const isAndroidMdmEnabled = appConfig.mdm.android_enabled_and_configured;
+
const isAppleMdmEnabled = appConfig.mdm.enabled_and_configured;
const isWindowsMdmEnabled = appConfig.mdm.windows_enabled_and_configured;
@@ -161,33 +163,34 @@ const TargetSection = ({
});
const renderTargetForms = () => {
- if (isAppleMdmEnabled) {
+ if (isWindowsMdmEnabled && !isAppleMdmEnabled && !isAndroidMdmEnabled) {
return (
-
);
}
return (
-
);
};
diff --git a/frontend/pages/admin/UserManagementPage/components/UserForm/UserForm.tsx b/frontend/pages/admin/UserManagementPage/components/UserForm/UserForm.tsx
index cb5dd38f2f..e1148242f4 100644
--- a/frontend/pages/admin/UserManagementPage/components/UserForm/UserForm.tsx
+++ b/frontend/pages/admin/UserManagementPage/components/UserForm/UserForm.tsx
@@ -589,7 +589,6 @@ const UserForm = ({
id="password-authentication"
// allow the user to change auth back to password if they only changed the form to SSO in
// the current session, that is, in the db, the user is still password authenticated
- disabled={!(smtpConfigured || sesConfigured) && !initiallyPasswordAuth}
checked={!formData.sso_enabled}
value="false"
name="authentication-type"
diff --git a/frontend/pages/hosts/ManageHostsPage/HostTableConfig.tsx b/frontend/pages/hosts/ManageHostsPage/HostTableConfig.tsx
index f5837eed38..633901fff4 100644
--- a/frontend/pages/hosts/ManageHostsPage/HostTableConfig.tsx
+++ b/frontend/pages/hosts/ManageHostsPage/HostTableConfig.tsx
@@ -6,6 +6,8 @@ import { CellProps, Column } from "react-table";
import ReactTooltip from "react-tooltip";
import { IDeviceUser, IHost } from "interfaces/host";
+import { isAndroid, isMobilePlatform } from "interfaces/platform";
+
import Checkbox from "components/forms/fields/Checkbox";
import DiskSpaceIndicator from "pages/hosts/components/DiskSpaceIndicator";
import HeaderCell from "components/TableContainer/DataTable/HeaderCell/HeaderCell";
@@ -225,10 +227,7 @@ const allHostTableHeaders: IHostTableColumnConfig[] = [
accessor: "status",
id: "status",
Cell: (cellProps: IHostTableStringCellProps) => {
- if (
- cellProps.row.original.platform === "ios" ||
- cellProps.row.original.platform === "ipados"
- ) {
+ if (isMobilePlatform(cellProps.row.original.platform)) {
return NotSupported;
}
const value = cellProps.cell.value;
@@ -247,10 +246,7 @@ const allHostTableHeaders: IHostTableColumnConfig[] = [
id: "issues",
sortDescFirst: true,
Cell: (cellProps: IIssuesCellProps) => {
- if (
- cellProps.row.original.platform === "ios" ||
- cellProps.row.original.platform === "ipados"
- ) {
+ if (isMobilePlatform(cellProps.row.original.platform)) {
return NotSupported;
}
return (
@@ -302,6 +298,7 @@ const allHostTableHeaders: IHostTableColumnConfig[] = [
accessor: "os_version",
id: "os_version",
Cell: (cellProps: IHostTableStringCellProps) => (
+ // TODO(android): is Android supported? what about the os versions endpoint and dashboard card?
),
},
@@ -316,10 +313,7 @@ const allHostTableHeaders: IHostTableColumnConfig[] = [
accessor: "osquery_version",
id: "osquery_version",
Cell: (cellProps: IHostTableStringCellProps) => {
- if (
- cellProps.row.original.platform === "ios" ||
- cellProps.row.original.platform === "ipados"
- ) {
+ if (isMobilePlatform(cellProps.row.original.platform)) {
return NotSupported;
}
return ;
@@ -332,6 +326,7 @@ const allHostTableHeaders: IHostTableColumnConfig[] = [
accessor: "device_mapping",
id: "device_mapping",
Cell: (cellProps: IDeviceUserCellProps) => {
+ // TODO(android): is android supported?
const numUsers = cellProps.cell.value?.length || 0;
const users = condenseDeviceUsers(cellProps.cell.value || []);
if (users.length > 1) {
@@ -364,10 +359,7 @@ const allHostTableHeaders: IHostTableColumnConfig[] = [
accessor: "primary_ip",
id: "primary_ip",
Cell: (cellProps: IHostTableStringCellProps) => {
- if (
- cellProps.row.original.platform === "ios" ||
- cellProps.row.original.platform === "ipados"
- ) {
+ if (isMobilePlatform(cellProps.row.original.platform)) {
return NotSupported;
}
return ;
@@ -442,10 +434,7 @@ const allHostTableHeaders: IHostTableColumnConfig[] = [
accessor: "public_ip",
id: "public_ip",
Cell: (cellProps: IHostTableStringCellProps) => {
- if (
- cellProps.row.original.platform === "ios" ||
- cellProps.row.original.platform === "ipados"
- ) {
+ if (isMobilePlatform(cellProps.row.original.platform)) {
return NotSupported;
}
return (
@@ -478,6 +467,7 @@ const allHostTableHeaders: IHostTableColumnConfig[] = [
accessor: "detail_updated_at",
id: "detail_updated_at",
Cell: (cellProps: IHostTableStringCellProps) => (
+ // TODO(android): android doesn't support refetch?
{
- if (
- cellProps.row.original.platform === "ios" ||
- cellProps.row.original.platform === "ipados"
- ) {
+ if (isMobilePlatform(cellProps.row.original.platform)) {
return NotSupported;
}
return (
@@ -546,11 +533,7 @@ const allHostTableHeaders: IHostTableColumnConfig[] = [
Cell: (cellProps: IHostTableStringCellProps) => {
const { platform, last_restarted_at } = cellProps.row.original;
- if (
- platform === "ios" ||
- platform === "ipados" ||
- platform === "chrome"
- ) {
+ if (isMobilePlatform(platform) || platform === "chrome") {
return NotSupported;
}
return (
@@ -608,9 +591,13 @@ const allHostTableHeaders: IHostTableColumnConfig[] = [
),
accessor: "primary_mac",
id: "primary_mac",
- Cell: (cellProps: IHostTableStringCellProps) => (
-
- ),
+ Cell: (cellProps: IHostTableStringCellProps) => {
+ // TODO(android): is iOS/iPadOS supported?
+ if (isAndroid(cellProps.row.original.platform)) {
+ return NotSupported;
+ }
+ return ;
+ },
},
{
title: "Serial number",
@@ -622,9 +609,13 @@ const allHostTableHeaders: IHostTableColumnConfig[] = [
),
accessor: "hardware_serial",
id: "hardware_serial",
- Cell: (cellProps: IHostTableStringCellProps) => (
-
- ),
+ Cell: (cellProps: IHostTableStringCellProps) => {
+ // TODO(android): is iOS/iPadOS supported?
+ if (isAndroid(cellProps.row.original.platform)) {
+ return NotSupported;
+ }
+ return ;
+ },
},
{
title: "Hardware model",
diff --git a/frontend/pages/hosts/components/DeleteHostModal/DeleteHostModal.tsx b/frontend/pages/hosts/components/DeleteHostModal/DeleteHostModal.tsx
index 8f11661fa6..45a9356301 100644
--- a/frontend/pages/hosts/components/DeleteHostModal/DeleteHostModal.tsx
+++ b/frontend/pages/hosts/components/DeleteHostModal/DeleteHostModal.tsx
@@ -77,7 +77,11 @@ const DeleteHostModal = ({
newTab
/>
- iOS and iPadOS hosts will re-appear unless MDM is turned off.
+
+ {/* TODO(android): iOS, iPadOS, and Android hosts will re-appear unless MDM is turned
+ off. */}
+ iOS and iPadOS hosts will re-appear unless MDM is turned off.
+
{
isMacMdmEnabledAndConfigured,
} = config;
return (
+ !isAndroid(hostPlatform) && // TODO(android): confirm can't turn off MDM for windows, iOS, iPadOS?
hostPlatform === "darwin" &&
isMacMdmEnabledAndConfigured &&
isEnrolledInMdm &&
@@ -105,10 +111,8 @@ const canEditMdm = (config: IHostActionConfigOptions) => {
};
const canQueryHost = ({ hostPlatform }: IHostActionConfigOptions) => {
- // Currently we cannot query iOS or iPadOS
- const isIosOrIpadosHost = hostPlatform === "ios" || hostPlatform === "ipados";
-
- return !isIosOrIpadosHost;
+ // cannot query iOS, iPadOS, or Android hosts
+ return !isMobilePlatform(hostPlatform);
};
const canLockHost = ({
@@ -132,6 +136,7 @@ const canLockHost = ({
return (
isPremiumTier &&
+ !isAndroid(hostPlatform) &&
hostMdmDeviceStatus === "unlocked" &&
(hostPlatform === "windows" ||
isLinuxLike(hostPlatform) ||
@@ -164,6 +169,7 @@ const canWipeHost = ({
return (
isPremiumTier &&
+ !isAndroid(hostPlatform) &&
hostMdmDeviceStatus === "unlocked" &&
(isLinuxLike(hostPlatform) || canWipeWindowsOrAppleOS) &&
(isGlobalAdmin || isGlobalMaintainer || isTeamAdmin || isTeamMaintainer)
@@ -197,6 +203,7 @@ const canUnlock = ({
return (
isPremiumTier &&
+ !isAndroid(hostPlatform) &&
isValidState &&
(isGlobalAdmin || isGlobalMaintainer || isTeamAdmin || isTeamMaintainer) &&
(canUnlockDarwin || hostPlatform === "windows" || isLinuxLike(hostPlatform))
@@ -215,11 +222,10 @@ const canDeleteHost = (config: IHostActionConfigOptions) => {
const canShowDiskEncryption = (config: IHostActionConfigOptions) => {
const { isPremiumTier, doesStoreEncryptionKey, hostPlatform } = config;
-
- // Currently we cannot show disk encryption key for iOS or iPadOS
- const isIosOrIpadosHost = hostPlatform === "ios" || hostPlatform === "ipados";
-
- return isPremiumTier && doesStoreEncryptionKey && !isIosOrIpadosHost;
+ if (isMobilePlatform(hostPlatform)) {
+ return false;
+ }
+ return isPremiumTier && doesStoreEncryptionKey;
};
const canRunScript = ({
diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx
index 2580cb0cd9..34fb4aaec3 100644
--- a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx
+++ b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx
@@ -44,7 +44,7 @@ import {
DEFAULT_USE_QUERY_OPTIONS,
} from "utilities/constants";
-import { isIPadOrIPhone } from "interfaces/platform";
+import { isAndroid, isIPadOrIPhone } from "interfaces/platform";
import Spinner from "components/Spinner";
import TabsWrapper from "components/TabsWrapper";
@@ -236,7 +236,7 @@ const HostDetailsPage = ({
["deviceMapping", hostIdFromURL],
() => hostAPI.loadHostDetailsExtension(hostIdFromURL, "device_mapping"),
{
- enabled: !!hostIdFromURL,
+ enabled: !!hostIdFromURL, // TODO(android): disable for unsupported platforms?
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
@@ -265,7 +265,7 @@ const HostDetailsPage = ({
["macadmins", hostIdFromURL],
() => hostAPI.loadHostDetailsExtension(hostIdFromURL, "macadmins"),
{
- enabled: !!hostIdFromURL,
+ enabled: !!hostIdFromURL, // TODO(android): disable for unsupported platforms?
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
@@ -302,7 +302,10 @@ const HostDetailsPage = ({
returnedHost.mdm.pending_action
)
);
- if (returnedHost.refetch_requested) {
+ if (
+ returnedHost.refetch_requested &&
+ !isAndroid(returnedHost.platform)
+ ) {
// If the API reports that a Fleet refetch request is pending, we want to check back for fresh
// host details. Here we set a one second timeout and poll the API again using
// fullyReloadHost. We will repeat this process with each onSuccess cycle for a total of
diff --git a/frontend/pages/hosts/details/cards/About/About.tsx b/frontend/pages/hosts/details/cards/About/About.tsx
index 0150c9af76..a980d9b753 100644
--- a/frontend/pages/hosts/details/cards/About/About.tsx
+++ b/frontend/pages/hosts/details/cards/About/About.tsx
@@ -11,13 +11,13 @@ import {
IDeviceUser,
mapDeviceUsersForDisplay,
} from "interfaces/host";
+import { isAndroid, isIPadOrIPhone } from "interfaces/platform";
import {
DEFAULT_EMPTY_CELL_VALUE,
MDM_STATUS_TOOLTIP,
BATTERY_TOOLTIP,
} from "utilities/constants";
import DataSet from "components/DataSet";
-import classnames from "classnames";
const getDeviceUserTipContent = (deviceMapping: IDeviceUser[]) => {
if (deviceMapping.length === 0) {
@@ -49,8 +49,8 @@ const About = ({
munki,
mdm,
}: IAboutProps): JSX.Element => {
- const isIosOrIpadosHost =
- aboutData.platform === "ios" || aboutData.platform === "ipados";
+ const isIosOrIpadosHost = isIPadOrIPhone(aboutData.platform);
+ const isAndroidHost = isAndroid(aboutData.platform);
const renderHardwareSerialAndIPs = () => {
if (isIosOrIpadosHost) {
@@ -62,6 +62,12 @@ const About = ({
);
}
+ if (isAndroidHost) {
+ return (
+
+ );
+ }
+
return (
<>
@@ -205,6 +211,8 @@ const About = ({
);
};
+ // TODO(android): confirm visible fields using actual android device data
+
return (
}
/>
- {!isIosOrIpadosHost && (
+ {!isIosOrIpadosHost && !isAndroidHost && (
End user
) : (
- {actorName}
+ {actorName ?? "Fleet"}
);
return (
diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/RanScriptActivityItem/RanScriptActivityItem.tsx b/frontend/pages/hosts/details/cards/Activity/ActivityItems/RanScriptActivityItem/RanScriptActivityItem.tsx
index 19b8b97bd6..ae4be92455 100644
--- a/frontend/pages/hosts/details/cards/Activity/ActivityItems/RanScriptActivityItem/RanScriptActivityItem.tsx
+++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/RanScriptActivityItem/RanScriptActivityItem.tsx
@@ -26,7 +26,7 @@ const RanScriptActivityItem = ({
isSoloActivity={isSoloActivity}
hideCancel={hideCancel}
>
- {activity.actor_full_name}
+ {activity.actor_full_name ?? "Fleet"}
<>
{" "}
{ranScriptPrefix}{" "}
diff --git a/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx b/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx
index 1c9c8e52ad..3ff3fa7d32 100644
--- a/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx
+++ b/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx
@@ -12,6 +12,7 @@ import { IOSSettings, IHostMaintenanceWindow } from "interfaces/host";
import { IAppleDeviceUpdates } from "interfaces/config";
import {
DiskEncryptionSupportedPlatform,
+ isAndroid,
isDiskEncryptionSupportedLinuxPlatform,
isOsSettingsDisplayPlatform,
platformSupportsDiskEncryption,
@@ -211,10 +212,15 @@ const HostSummary = ({
disk_encryption_enabled: diskEncryptionEnabled,
} = summaryData;
+ const isAndroidHost = isAndroid(platform);
const isChromeHost = platform === "chrome";
const isIosOrIpadosHost = platform === "ios" || platform === "ipados";
const renderRefetch = () => {
+ if (isAndroidHost) {
+ return null;
+ }
+
const isOnline = summaryData.status === "online";
let isDisabled = false;
let tooltip;
@@ -340,7 +346,7 @@ const HostSummary = ({
};
const renderOperatingSystemSummary = () => {
- // No tooltip if minimum version is not set, including all Windows, Linux, ChromeOS operating systems
+ // No tooltip if minimum version is not set, including all Windows, Linux, ChromeOS, Android operating systems
if (!osVersionRequirement?.minimum_version) {
return (
@@ -384,12 +390,12 @@ const HostSummary = ({
};
const renderAgentSummary = () => {
- if (isChromeHost) {
- return ;
+ if (isIosOrIpadosHost || isAndroidHost) {
+ return null;
}
- if (isIosOrIpadosHost) {
- return null;
+ if (isChromeHost) {
+ return ;
}
if (summaryData.orbit_version !== DEFAULT_EMPTY_CELL_VALUE) {
@@ -499,7 +505,7 @@ const HostSummary = ({
largePadding
className={`${baseClass}-card`}
>
- {!isIosOrIpadosHost && (
+ {!isIosOrIpadosHost && !isAndroidHost && (
0 &&
!isIosOrIpadosHost &&
+ !isAndroidHost &&
renderIssues()}
{isPremiumTier && renderHostTeam()}
{/* Rendering of OS Settings data */}
@@ -532,17 +539,19 @@ const HostSummary = ({
}
/>
)}
- {bootstrapPackageData?.status && !isIosOrIpadosHost && (
-
- }
- />
- )}
+ {bootstrapPackageData?.status &&
+ !isIosOrIpadosHost &&
+ !isAndroidHost && (
+
+ }
+ />
+ )}
{!isChromeHost && renderDiskSpaceSummary()}
{renderDiskEncryptionSummary()}
{!isIosOrIpadosHost && (
@@ -555,7 +564,7 @@ const HostSummary = ({
)}
{renderOperatingSystemSummary()}
- {!isIosOrIpadosHost && renderAgentSummary()}
+ {renderAgentSummary()}
{isPremiumTier &&
// TODO - refactor normalizeEmptyValues pattern
!!summaryData.maintenance_window &&
diff --git a/frontend/pages/hosts/details/cards/Policies/HostPolicies.tsx b/frontend/pages/hosts/details/cards/Policies/HostPolicies.tsx
index e3bb3550eb..70e70d515a 100644
--- a/frontend/pages/hosts/details/cards/Policies/HostPolicies.tsx
+++ b/frontend/pages/hosts/details/cards/Policies/HostPolicies.tsx
@@ -3,6 +3,7 @@ import { InjectedRouter } from "react-router";
import { Row } from "react-table";
import { noop } from "lodash";
+import { isAndroid } from "interfaces/platform";
import { IHostPolicy } from "interfaces/policy";
import { PolicyResponse, SUPPORT_LINK } from "utilities/constants";
import { createHostsByPolicyPath } from "utilities/helpers";
@@ -89,6 +90,20 @@ const Policies = ({
);
}
+ if (isAndroid(hostPlatform)) {
+ return (
+ Policies are not supported for this host>}
+ info={
+ <>
+ Interested in detecting device health issues on Android hosts?{" "}
+
+ >
+ }
+ />
+ );
+ }
+
if (policies.length === 0) {
return (
+ Interested in querying Android hosts?{" "}
+
+ >
+ }
+ />
+ );
+ }
+
return (
{
interface IHostSoftwareProps {
/** This is the host id or the device token */
id: number | string;
- platform?: HostPlatform;
+ platform: HostPlatform;
softwareUpdatedAt?: string;
hostCanWriteSoftware: boolean;
router: InjectedRouter;
@@ -100,8 +100,8 @@ const HostSoftware = ({
hostMDMEnrolled,
}: IHostSoftwareProps) => {
const { renderFlash } = useContext(NotificationContext);
- const vulnFilterAndNotSupported =
- isIPadOrIPhone(platform ?? "") && queryParams.vulnerable;
+ const isUnsupported =
+ isAndroid(platform) || (isIPadOrIPhone(platform) && queryParams.vulnerable); // no Android software and no vulnerable software for iOS
const {
isGlobalAdmin,
isGlobalMaintainer,
@@ -139,8 +139,7 @@ const HostSoftware = ({
},
{
...DEFAULT_USE_QUERY_OPTIONS,
- enabled:
- isSoftwareEnabled && !isMyDevicePage && !vulnFilterAndNotSupported,
+ enabled: isSoftwareEnabled && !isMyDevicePage && !isUnsupported,
keepPreviousData: true,
staleTime: 7000,
}
diff --git a/frontend/pages/hosts/details/cards/Software/HostSoftwareTable/HostSoftwareTable.tsx b/frontend/pages/hosts/details/cards/Software/HostSoftwareTable/HostSoftwareTable.tsx
index f9b91667c6..beb090c555 100644
--- a/frontend/pages/hosts/details/cards/Software/HostSoftwareTable/HostSoftwareTable.tsx
+++ b/frontend/pages/hosts/details/cards/Software/HostSoftwareTable/HostSoftwareTable.tsx
@@ -13,6 +13,7 @@ import {
APPLE_PLATFORM_DISPLAY_NAMES,
HostPlatform,
isIPadOrIPhone,
+ isAndroid,
} from "interfaces/platform";
import TableContainer from "components/TableContainer";
@@ -26,6 +27,9 @@ import TableCount from "components/TableContainer/TableCount";
import { VulnsNotSupported } from "pages/SoftwarePage/components/SoftwareVulnerabilitiesTable/SoftwareVulnerabilitiesTable";
import { Row } from "react-table";
import { IHostSoftware } from "interfaces/software";
+import EmptyTable from "components/EmptyTable";
+import CustomLink from "components/CustomLink";
+import { SUPPORT_LINK } from "utilities/constants";
const DEFAULT_PAGE_SIZE = 20;
@@ -239,6 +243,20 @@ const HostSoftwareTable = ({
[onShowSoftwareDetails]
);
+ if (isAndroid(platform)) {
+ return (
+
+ Interested in viewing software for Android hosts?{" "}
+
+ >
+ }
+ />
+ );
+ }
+
return (
+
diff --git a/frontend/router/paths.ts b/frontend/router/paths.ts
index 93c1be95e3..f7093ac7a9 100644
--- a/frontend/router/paths.ts
+++ b/frontend/router/paths.ts
@@ -29,6 +29,7 @@ export default {
DASHBOARD_CHROME: `${URL_PREFIX}/dashboard/chrome`,
DASHBOARD_IOS: `${URL_PREFIX}/dashboard/ios`,
DASHBOARD_IPADOS: `${URL_PREFIX}/dashboard/ipados`,
+ DASHBOARD_ANDROID: `${URL_PREFIX}/dashboard/android`,
/**
* Admin pages
diff --git a/frontend/utilities/constants.tsx b/frontend/utilities/constants.tsx
index bbb0e40f3e..e9e0cad7be 100644
--- a/frontend/utilities/constants.tsx
+++ b/frontend/utilities/constants.tsx
@@ -215,6 +215,7 @@ const PLATFORM_LABEL_NAMES_FROM_API = [
"chrome",
"iOS",
"iPadOS",
+ "Android",
] as const;
export type PlatformLabelNameFromAPI = typeof PLATFORM_LABEL_NAMES_FROM_API[number];
@@ -236,6 +237,8 @@ export const PLATFORM_DISPLAY_NAMES: Record = {
ChromeOS: "ChromeOS",
ios: "iOS",
ipados: "iPadOS",
+ android: "Android",
+ Android: "Android",
} as const;
// as returned by the TARGETS API; based on display_text
@@ -253,6 +256,7 @@ export const PLATFORM_LABEL_DISPLAY_NAMES: Record<
chrome: "ChromeOS",
iOS: "iOS",
iPadOS: "iPadOS",
+ Android: "Android",
} as const;
export const PLATFORM_LABEL_DISPLAY_TYPES: Record<
@@ -269,6 +273,7 @@ export const PLATFORM_LABEL_DISPLAY_TYPES: Record<
chrome: "platform",
iOS: "platform",
iPadOS: "platform",
+ Android: "platform",
} as const;
// For some builtin labels, display different strings than what API returns
@@ -281,27 +286,19 @@ export const LABEL_DISPLAY_MAP: Partial<
"MS Windows": "Windows",
};
-export const PLATFORM_TYPE_ICONS: Record<
- Extract<
- PlatformLabelNameFromAPI,
- "All Linux" | "macOS" | "MS Windows" | "chrome" | "iOS" | "iPadOS"
- >,
- IconNames
-> = {
+export const PLATFORM_TYPE_ICONS = {
"All Linux": "linux",
macOS: "darwin",
"MS Windows": "windows",
chrome: "chrome",
iOS: "iOS",
iPadOS: "iPadOS",
+ Android: "android",
} as const;
export const hasPlatformTypeIcon = (
s: string
-): s is Extract<
- PlatformLabelNameFromAPI,
- "All Linux" | "macOS" | "MS Windows" | "chrome" | "iOS" | "iPadOS"
-> => {
+): s is Extract => {
return !!PLATFORM_TYPE_ICONS[s as keyof typeof PLATFORM_TYPE_ICONS];
};
@@ -309,18 +306,13 @@ export type PlatformLabelOptions = DisplayPlatform | "All";
export type PlatformValueOptions = Platform | "all";
-/** Scheduled queries do not support ChromeOS, iOS, or iPadOS */
-interface ISchedulePlatformDropdownOptions {
- label: Exclude;
- value: Exclude | "";
-}
-
-export const SCHEDULE_PLATFORM_DROPDOWN_OPTIONS: ISchedulePlatformDropdownOptions[] = [
+/** Scheduled queries do not support ChromeOS, iOS, iPadOS, or Android */
+export const SCHEDULE_PLATFORM_DROPDOWN_OPTIONS = [
{ label: "All", value: "" }, // API empty string runs on all platforms
{ label: "macOS", value: "darwin" },
{ label: "Windows", value: "windows" },
{ label: "Linux", value: "linux" },
-];
+] as const;
export const HOSTS_SEARCH_BOX_PLACEHOLDER =
"Search name, hostname, UUID, serial number, or private IP address";
diff --git a/go.mod b/go.mod
index 3f2ed70949..11ffb92b99 100644
--- a/go.mod
+++ b/go.mod
@@ -134,7 +134,7 @@ require (
golang.org/x/net v0.33.0
golang.org/x/oauth2 v0.22.0
golang.org/x/sync v0.10.0
- golang.org/x/sys v0.28.0
+ golang.org/x/sys v0.29.0
golang.org/x/term v0.27.0
golang.org/x/text v0.21.0
golang.org/x/tools v0.23.0
@@ -197,6 +197,7 @@ require (
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/gobwas/glob v0.2.3 // indirect
+ github.com/gofrs/flock v0.12.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/glog v1.2.4 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
diff --git a/go.sum b/go.sum
index bcc3c180ff..fdac1395c8 100644
--- a/go.sum
+++ b/go.sum
@@ -338,6 +338,8 @@ github.com/gocarina/gocsv v0.0.0-20220310154401-d4df709ca055/go.mod h1:5YoVOkjYA
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
+github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
@@ -1097,6 +1099,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
+golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
diff --git a/infrastructure/loadtesting/terraform/docker/loadtest.Dockerfile b/infrastructure/loadtesting/terraform/docker/loadtest.Dockerfile
index e97c4d10be..01a67cf6b0 100644
--- a/infrastructure/loadtesting/terraform/docker/loadtest.Dockerfile
+++ b/infrastructure/loadtesting/terraform/docker/loadtest.Dockerfile
@@ -3,7 +3,7 @@ ARG TAG
RUN apk add git
RUN git clone -b $TAG --depth=1 --no-tags --progress --no-recurse-submodules https://github.com/fleetdm/fleet.git && cd /go/fleet/cmd/osquery-perf/ && go build .
-FROM alpine:3.21@sha256:2c43f33bd1502ec7818bce9eea60e062d04eeadc4aa31cad9dabecb1e48b647b
+FROM alpine:3.21.3@sha256:a8560b36e8b8210634f77d9f7f9efd7ffa463e380b75e2e74aff4511df3ef88c
LABEL maintainer="Fleet Developers"
# Create FleetDM group and user
diff --git a/it-and-security/lib/windows/scripts/turn-off-mdm.ps1 b/it-and-security/lib/windows/scripts/turn-off-mdm.ps1
index 2ffc3be05d..e78e8f0bc3 100644
--- a/it-and-security/lib/windows/scripts/turn-off-mdm.ps1
+++ b/it-and-security/lib/windows/scripts/turn-off-mdm.ps1
@@ -1,3 +1,5 @@
+# Please don't delete. This script is referenced in the guide here: https://fleetdm.com/guides/windows-mdm-setup#turn-off-windows-mdm
+
Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
diff --git a/it-and-security/teams/workstations-canary.yml b/it-and-security/teams/workstations-canary.yml
index a09b4e5a47..9e428bb311 100644
--- a/it-and-security/teams/workstations-canary.yml
+++ b/it-and-security/teams/workstations-canary.yml
@@ -131,6 +131,7 @@ controls:
- path: ../lib/macos/scripts/remove-old-nudge.sh
- path: ../lib/macos/scripts/mdm-migration.sh
- path: ../lib/macos/scripts/system-maintenance.sh
+ - path: ../lib/macos/scripts/santa-block-script.sh
- path: ../lib/windows/scripts/remove-fleetd.ps1
- path: ../lib/windows/scripts/turn-off-mdm.ps1
- path: ../lib/windows/scripts/install-bitdefender.ps1
diff --git a/orbit/changes/25396-fleet-desktop-lockfile b/orbit/changes/25396-fleet-desktop-lockfile
new file mode 100644
index 0000000000..7662dcf6ba
--- /dev/null
+++ b/orbit/changes/25396-fleet-desktop-lockfile
@@ -0,0 +1 @@
+- Ensure only one copy of fleet desktop is running at a time
diff --git a/orbit/cmd/desktop/desktop.go b/orbit/cmd/desktop/desktop.go
index ebb8800ce9..bb76e305fd 100644
--- a/orbit/cmd/desktop/desktop.go
+++ b/orbit/cmd/desktop/desktop.go
@@ -25,6 +25,7 @@ import (
"github.com/fleetdm/fleet/v4/pkg/open"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/service"
+ "github.com/gofrs/flock"
"github.com/oklog/run"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
@@ -110,6 +111,21 @@ func main() {
log.Info().Msgf("got a TUF update root: %s", tufUpdateRoot)
}
+ // We've only seen this bug appear on Linux under certain very
+ // specific conditions
+ if runtime.GOOS == "linux" {
+ // Ensure only one instance of Fleet Desktop is running at a time
+ lockFile, err := getLockfile()
+ if err != nil {
+ log.Fatal().Err(err).Msg("could not secure lock file")
+ }
+ defer func() {
+ if err := lockFile.Unlock(); err != nil {
+ log.Error().Err(err).Msg("unlocking lockfile")
+ }
+ }()
+ }
+
// Setting up working runners such as signalHandler runner
go setupRunners()
@@ -564,6 +580,32 @@ func (m *mdmMigrationHandler) ShowInstructions() error {
return nil
}
+// getLockfile checks for the fleet desktop lock file, and returns an error if it can't secure it.
+func getLockfile() (*flock.Flock, error) {
+ dir, err := logDir()
+ if err != nil {
+ return nil, fmt.Errorf("unable to get logdir for lock: %w", err)
+ }
+ // Same as the log dir in setupLogs()
+ dir = filepath.Join(dir, "Fleet")
+
+ lockFilePath := filepath.Join(dir, "fleet-desktop.lock")
+ log.Debug().Msgf("acquiring fleet desktop lockfile: %s", lockFilePath)
+
+ lock := flock.New(lockFilePath)
+ locked, err := lock.TryLock()
+ if err != nil {
+ return nil, fmt.Errorf("error getting lock on %s: %w", lockFilePath, err)
+ }
+ if !locked {
+ return nil, errors.New("another instance of fleet desktop has the lock")
+ }
+
+ log.Debug().Msgf("lock acquired on %s", lockFilePath)
+
+ return lock, nil
+}
+
// setupLogs configures our logging system to write logs to rolling files, if for some
// reason we can't write a log file the logs are still printed to stderr.
func setupLogs() {
diff --git a/orbit/cmd/orbit/orbit.go b/orbit/cmd/orbit/orbit.go
index 318f105f19..802cf145a7 100644
--- a/orbit/cmd/orbit/orbit.go
+++ b/orbit/cmd/orbit/orbit.go
@@ -1585,14 +1585,6 @@ func newDesktopRunner(
func (d *desktopRunner) Execute() error {
defer close(d.executeDoneCh)
- log.Info().Msg("killing any pre-existing fleet-desktop instances")
-
- if err := platform.SignalProcessBeforeTerminate(constant.DesktopAppExecName); err != nil &&
- !errors.Is(err, platform.ErrProcessNotFound) &&
- !errors.Is(err, platform.ErrComChannelNotFound) {
- log.Error().Err(err).Msg("desktop early terminate")
- }
-
log.Info().Str("path", d.desktopPath).Msg("opening")
url, err := url.Parse(d.fleetURL)
if err != nil {
@@ -1640,6 +1632,13 @@ func (d *desktopRunner) Execute() error {
return true
}
+ log.Info().Msg("killing any pre-existing fleet-desktop instances")
+ if err := platform.SignalProcessBeforeTerminate(constant.DesktopAppExecName); err != nil &&
+ !errors.Is(err, platform.ErrProcessNotFound) &&
+ !errors.Is(err, platform.ErrComChannelNotFound) {
+ log.Error().Err(err).Msg("desktop early terminate")
+ }
+
// Orbit runs as root user on Unix and as SYSTEM (Windows Service) user on Windows.
// To be able to run the desktop application (mostly to register the icon in the system tray)
// we need to run the application as the login user.
@@ -1657,7 +1656,7 @@ func (d *desktopRunner) Execute() error {
// Second retry logic to monitor fleet-desktop.
// Call with waitFirst=true to give some time for the process to start.
if done := retry(15*time.Second, true, d.interruptCh, func() bool {
- switch _, err := platform.GetProcessByName(constant.DesktopAppExecName); {
+ switch _, err := platform.GetProcessesByName(constant.DesktopAppExecName); {
case err == nil:
return true // all good, process is running, retry.
case errors.Is(err, platform.ErrProcessNotFound):
diff --git a/orbit/pkg/platform/platform.go b/orbit/pkg/platform/platform.go
index eb1f894811..af07c59a84 100644
--- a/orbit/pkg/platform/platform.go
+++ b/orbit/pkg/platform/platform.go
@@ -27,13 +27,15 @@ func killProcessByName(name string) error {
return errors.New("process name should not be empty")
}
- foundProcess, err := GetProcessByName(name)
+ foundProcesses, err := GetProcessesByName(name)
if err != nil {
return fmt.Errorf("get process: %w", err)
}
- if err := foundProcess.Kill(); err != nil {
- return fmt.Errorf("kill process %d: %w", foundProcess.Pid, err)
+ for _, foundProcess := range foundProcesses {
+ if err := foundProcess.Kill(); err != nil {
+ return fmt.Errorf("kill process %d: %w", foundProcess.Pid, err)
+ }
}
return nil
diff --git a/orbit/pkg/platform/platform_notwindows.go b/orbit/pkg/platform/platform_notwindows.go
index 930e6dc28a..d3c442c2a4 100644
--- a/orbit/pkg/platform/platform_notwindows.go
+++ b/orbit/pkg/platform/platform_notwindows.go
@@ -54,9 +54,9 @@ func SignalProcessBeforeTerminate(processName string) error {
return nil
}
-// GetProcessByName gets a single running process object by its name.
+// GetProcessesByName gets all running processes by its name.
// Returns ErrProcessNotFound if the process was not found running.
-func GetProcessByName(name string) (*gopsutil_process.Process, error) {
+func GetProcessesByName(name string) ([]*gopsutil_process.Process, error) {
if name == "" {
return nil, errors.New("process name should not be empty")
}
@@ -66,7 +66,7 @@ func GetProcessByName(name string) (*gopsutil_process.Process, error) {
return nil, err
}
- var foundProcess *gopsutil_process.Process
+ var foundProcesses []*gopsutil_process.Process
for _, process := range processes {
processName, err := process.Name()
if err != nil {
@@ -75,16 +75,16 @@ func GetProcessByName(name string) (*gopsutil_process.Process, error) {
}
if strings.HasPrefix(processName, name) {
- foundProcess = process
+ foundProcesses = append(foundProcesses, process)
break
}
}
- if foundProcess == nil {
+ if len(foundProcesses) == 0 {
return nil, ErrProcessNotFound
}
- return foundProcess, nil
+ return foundProcesses, nil
}
func GetSMBiosUUID() (string, UUIDSource, error) {
diff --git a/orbit/pkg/platform/platform_windows.go b/orbit/pkg/platform/platform_windows.go
index a0fc336b07..03a82ed5d6 100644
--- a/orbit/pkg/platform/platform_windows.go
+++ b/orbit/pkg/platform/platform_windows.go
@@ -131,20 +131,23 @@ func SignalProcessBeforeTerminate(processName string) error {
return ErrComChannelNotFound
}
- foundProcess, err := GetProcessByName(processName)
+ foundProcesses, err := GetProcessesByName(processName)
if err != nil {
return fmt.Errorf("get process: %w", err)
}
- if err := foundProcess.Kill(); err != nil {
- return fmt.Errorf("kill process %d: %w", foundProcess.Pid, err)
+ for _, foundProcess := range foundProcesses {
+ if err := foundProcess.Kill(); err != nil {
+ return fmt.Errorf("kill process %d: %w", foundProcess.Pid, err)
+ }
}
+
return nil
}
-// GetProcessByName gets a single running process object by its name.
+// GetProcessesByName returns a list of running process object by name.
// Returns ErrProcessNotFound if the process was not found running.
-func GetProcessByName(name string) (*gopsutil_process.Process, error) {
+func GetProcessesByName(name string) ([]*gopsutil_process.Process, error) {
if name == "" {
return nil, errors.New("process name should not be empty")
}
@@ -164,7 +167,7 @@ func GetProcessByName(name string) (*gopsutil_process.Process, error) {
// Closing the handle to avoid handle leaks.
defer windows.CloseHandle(snapshot) //nolint:errcheck
- var foundProcessID uint32 = 0
+ var foundProcessIDs []uint32
// Initializing work structure PROCESSENTRY32W
// https://learn.microsoft.com/en-us/windows/win32/api/tlhelp32/ns-tlhelp32-processentry32w
@@ -180,10 +183,8 @@ func GetProcessByName(name string) (*gopsutil_process.Process, error) {
// Process32First() is going to return ERROR_NO_MORE_FILES when no more threads present
// it will return FALSE/nil otherwise
for err == nil {
-
if strings.HasPrefix(syscall.UTF16ToString(procEntry.ExeFile[:]), name) {
- foundProcessID = procEntry.ProcessID
- break
+ foundProcessIDs = append(foundProcessIDs, procEntry.ProcessID)
}
// Process32Next() is calling to keep iterating the snapshot
@@ -191,17 +192,27 @@ func GetProcessByName(name string) (*gopsutil_process.Process, error) {
err = windows.Process32Next(snapshot, &procEntry)
}
- process, err := gopsutil_process.NewProcess(int32(foundProcessID))
- if err != nil {
- return nil, fmt.Errorf("NewProcess: %w", err)
+ var processes []*gopsutil_process.Process
+
+ for _, foundProcessID := range foundProcessIDs {
+ process, err := gopsutil_process.NewProcess(int32(foundProcessID))
+ if err != nil {
+ continue
+ }
+
+ isRunning, err := process.IsRunning()
+ if err != nil || !isRunning {
+ continue
+ }
+
+ processes = append(processes, process)
}
- isRunning, err := process.IsRunning()
- if err != nil || !isRunning {
+ if len(processes) == 0 {
return nil, ErrProcessNotFound
}
- return process, nil
+ return processes, nil
}
// It obtains the BIOS UUID by calling "cmd.exe /c wmic csproduct get UUID" and parsing the results
diff --git a/pkg/open/open_linux.go b/pkg/open/open_linux.go
index b68c704d19..a84d6f8bf1 100644
--- a/pkg/open/open_linux.go
+++ b/pkg/open/open_linux.go
@@ -41,18 +41,18 @@ func browser(url string) error {
// getXWaylandAuthority retrieves the X authority file path from
// the running XWayland process environment.
func getXWaylandAuthority() (xAuthorityPath string, err error) {
- xWaylandProcess, err := platform.GetProcessByName("Xwayland")
+ xWaylandProcess, err := platform.GetProcessesByName("Xwayland")
if err != nil {
return "", fmt.Errorf("get process by name: %w", err)
}
- executablePath, err := xWaylandProcess.Exe()
+ executablePath, err := xWaylandProcess[0].Exe()
if err != nil {
return "", fmt.Errorf("get executable path: %w", err)
}
if executablePath != "/usr/bin/Xwayland" {
return "", fmt.Errorf("invalid Xwayland path: %q", executablePath)
}
- envs, err := xWaylandProcess.Environ()
+ envs, err := xWaylandProcess[0].Environ()
if err != nil {
return "", fmt.Errorf("get environment: %w", err)
}
diff --git a/server/authz/policy.rego b/server/authz/policy.rego
index 4982173225..937c73d279 100644
--- a/server/authz/policy.rego
+++ b/server/authz/policy.rego
@@ -1027,5 +1027,5 @@ allow {
allow {
object.type == "android_enterprise"
subject.global_role == admin
- action == write
+ action == [read, write][_]
}
diff --git a/server/datastore/mysql/android.go b/server/datastore/mysql/android.go
index b4bff11baf..e10c3fd60c 100644
--- a/server/datastore/mysql/android.go
+++ b/server/datastore/mysql/android.go
@@ -4,11 +4,13 @@ import (
"context"
"database/sql"
"errors"
+ "fmt"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/datastore/mysql/common_mysql"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/android"
+ "github.com/go-kit/log/level"
"github.com/jmoiron/sqlx"
)
@@ -38,7 +40,7 @@ func (ds *Datastore) NewAndroidHost(ctx context.Context, host *fleet.AndroidHost
detail_updated_at,
label_updated_at
) VALUES (
- :node_key,
+ :node_key,
:hostname,
:computer_name,
:platform,
@@ -77,6 +79,10 @@ func (ds *Datastore) NewAndroidHost(ctx context.Context, host *fleet.AndroidHost
if err != nil {
return ctxerr.Wrap(ctx, err, "new Android host display name")
}
+ err = ds.insertAndroidHostLabelMembershipTx(ctx, tx, host.Host.ID)
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "new Android host label membership")
+ }
host.Device, err = ds.androidDS.CreateDeviceTx(ctx, tx, host.Device)
if err != nil {
@@ -141,7 +147,7 @@ func (ds *Datastore) AndroidHostLite(ctx context.Context, enterpriseSpecificID s
TeamID *uint `db:"team_id"`
*android.Device
}
- stmt := `SELECT
+ stmt := `SELECT
h.team_id,
ad.id,
ad.host_id,
@@ -170,3 +176,44 @@ func (ds *Datastore) AndroidHostLite(ctx context.Context, enterpriseSpecificID s
result.SetNodeKey(enterpriseSpecificID)
return result, nil
}
+
+func (ds *Datastore) insertAndroidHostLabelMembershipTx(ctx context.Context, tx sqlx.ExtContext, hostID uint) error {
+ // Insert the host in the builtin label memberships, adding them to the "All
+ // Hosts" and "Android" labels.
+ var labels []struct {
+ ID uint `db:"id"`
+ Name string `db:"name"`
+ }
+ err := sqlx.SelectContext(ctx, tx, &labels, `SELECT id, name FROM labels WHERE label_type = 1 AND (name = ? OR name = ?)`,
+ fleet.BuiltinLabelNameAllHosts, fleet.BuiltinLabelNameAndroid)
+ switch {
+ case err != nil:
+ return ctxerr.Wrap(ctx, err, "get builtin labels")
+ case len(labels) != 2:
+ // Builtin labels can get deleted so it is important that we check that
+ // they still exist before we continue.
+ // Note that this is the same behavior as for the iOS/iPadOS host labels.
+ level.Error(ds.logger).Log("err", fmt.Sprintf("expected 2 builtin labels but got %d", len(labels)))
+ return nil
+ }
+
+ // We cannot assume IDs on labels, thus we look by name.
+ var allHostsLabelID, androidLabelID uint
+ for _, label := range labels {
+ switch label.Name {
+ case fleet.BuiltinLabelNameAllHosts:
+ allHostsLabelID = label.ID
+ case fleet.BuiltinLabelNameAndroid:
+ androidLabelID = label.ID
+ }
+ }
+
+ _, err = tx.ExecContext(ctx, `
+ INSERT INTO label_membership (host_id, label_id) VALUES (?, ?), (?, ?)
+ ON DUPLICATE KEY UPDATE host_id = host_id`,
+ hostID, allHostsLabelID, hostID, androidLabelID)
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "set label membership")
+ }
+ return nil
+}
diff --git a/server/datastore/mysql/android_test.go b/server/datastore/mysql/android_test.go
index 551f0b2d3b..f9b533fcab 100644
--- a/server/datastore/mysql/android_test.go
+++ b/server/datastore/mysql/android_test.go
@@ -8,12 +8,15 @@ import (
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/android"
"github.com/fleetdm/fleet/v4/server/ptr"
+ "github.com/fleetdm/fleet/v4/server/test"
+ "github.com/jmoiron/sqlx"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAndroid(t *testing.T) {
ds := CreateMySQLDS(t)
+ TruncateTables(t, ds)
cases := []struct {
name string
@@ -31,6 +34,8 @@ func TestAndroid(t *testing.T) {
}
func testNewAndroidHost(t *testing.T, ds *Datastore) {
+ test.AddBuiltinLabels(t, ds)
+
const enterpriseSpecificID = "enterprise_specific_id"
host := createAndroidHost(enterpriseSpecificID)
@@ -39,6 +44,12 @@ func testNewAndroidHost(t *testing.T, ds *Datastore) {
assert.NotZero(t, result.Host.ID)
assert.NotZero(t, result.Device.ID)
+ lbls, err := ds.ListLabelsForHost(testCtx(), result.Host.ID)
+ require.NoError(t, err)
+ require.Len(t, lbls, 2)
+ names := []string{lbls[0].Name, lbls[1].Name}
+ require.ElementsMatch(t, []string{fleet.BuiltinLabelNameAllHosts, fleet.BuiltinLabelNameAndroid}, names)
+
resultLite, err := ds.AndroidHostLite(testCtx(), enterpriseSpecificID)
require.NoError(t, err)
assert.Equal(t, result.Host.ID, resultLite.Host.ID)
@@ -50,6 +61,22 @@ func testNewAndroidHost(t *testing.T, ds *Datastore) {
require.NoError(t, err)
assert.Equal(t, result.Host.ID, resultCopy.Host.ID)
assert.Equal(t, result.Device.ID, resultCopy.Device.ID)
+
+ // create another host, this time delete the Android label
+ ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
+ _, err := q.ExecContext(testCtx(), `DELETE FROM labels WHERE name = ?`, fleet.BuiltinLabelNameAndroid)
+ return err
+ })
+ const enterpriseSpecificID2 = "enterprise_specific_id2"
+ host2 := createAndroidHost(enterpriseSpecificID2)
+
+ // still passes, but no label membership was recorded
+ result, err = ds.NewAndroidHost(testCtx(), host2)
+ require.NoError(t, err)
+
+ lbls, err = ds.ListLabelsForHost(testCtx(), result.Host.ID)
+ require.NoError(t, err)
+ require.Empty(t, lbls)
}
func createAndroidHost(enterpriseSpecificID string) *fleet.AndroidHost {
diff --git a/server/datastore/mysql/apple_mdm.go b/server/datastore/mysql/apple_mdm.go
index f93e2673a4..a78b007f74 100644
--- a/server/datastore/mysql/apple_mdm.go
+++ b/server/datastore/mysql/apple_mdm.go
@@ -1342,7 +1342,7 @@ func upsertMDMAppleHostLabelMembershipDB(ctx context.Context, tx sqlx.ExtContext
// query results are received; however, we want to insert pending MDM hosts
// now because it may still be some time before osquery is running on these
// devices. Because these are Apple devices, we're adding them to the "All
- // Hosts" and "macOS" labels.
+ // Hosts" and one of "macOS", "iOS", "iPadOS" labels.
labels := []struct {
ID uint `db:"id"`
Name string `db:"name"`
diff --git a/server/datastore/mysql/hosts.go b/server/datastore/mysql/hosts.go
index 1055ed9a54..24126149c6 100644
--- a/server/datastore/mysql/hosts.go
+++ b/server/datastore/mysql/hosts.go
@@ -4156,6 +4156,7 @@ func (ds *Datastore) AggregatedMDMSolutions(ctx context.Context, teamID *uint, p
func (ds *Datastore) GenerateAggregatedMunkiAndMDM(ctx context.Context) error {
var (
+ // TODO(android): add android to this list?
platforms = []string{"", "darwin", "windows", "ios", "ipados"}
teamIDs []uint
)
diff --git a/server/datastore/mysql/migrations/tables/20250225085436_AddAndroidBuiltinLabel.go b/server/datastore/mysql/migrations/tables/20250225085436_AddAndroidBuiltinLabel.go
new file mode 100644
index 0000000000..4cdd9c7da0
--- /dev/null
+++ b/server/datastore/mysql/migrations/tables/20250225085436_AddAndroidBuiltinLabel.go
@@ -0,0 +1,56 @@
+package tables
+
+import (
+ "database/sql"
+ "fmt"
+ "time"
+
+ "github.com/VividCortex/mysqlerr"
+ "github.com/fleetdm/fleet/v4/server/fleet"
+ "github.com/go-sql-driver/mysql"
+)
+
+func init() {
+ MigrationClient.AddMigration(Up_20250225085436, Down_20250225085436)
+}
+
+func Up_20250225085436(tx *sql.Tx) error {
+ const stmt = `
+ INSERT INTO labels (
+ name,
+ description,
+ query,
+ platform,
+ label_type,
+ label_membership_type,
+ created_at,
+ updated_at
+ ) VALUES (?, ?, '', ?, ?, ?, ?, ?)
+`
+
+ // hard-coded timestamps are used so that schema.sql is stable
+ ts := time.Date(2025, 2, 25, 0, 0, 0, 0, time.UTC)
+ _, err := tx.Exec(
+ stmt,
+ fleet.BuiltinLabelNameAndroid,
+ "All Android hosts",
+ "android",
+ fleet.LabelTypeBuiltIn,
+ fleet.LabelMembershipTypeManual,
+ ts,
+ ts,
+ )
+ if err != nil {
+ if driverErr, ok := err.(*mysql.MySQLError); ok {
+ if driverErr.Number == mysqlerr.ER_DUP_ENTRY {
+ return fmt.Errorf("a label with the name %q already exists, please rename it before applying this migration: %w", fleet.BuiltinLabelNameAndroid, err)
+ }
+ }
+ return err
+ }
+ return nil
+}
+
+func Down_20250225085436(tx *sql.Tx) error {
+ return nil
+}
diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql
index b139ce35fb..6190e3ac1a 100644
--- a/server/datastore/mysql/schema.sql
+++ b/server/datastore/mysql/schema.sql
@@ -894,9 +894,9 @@ CREATE TABLE `labels` (
PRIMARY KEY (`id`),
UNIQUE KEY `idx_label_unique_name` (`name`),
FULLTEXT KEY `labels_search` (`name`)
-) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
-INSERT INTO `labels` VALUES (1,'2024-04-03 00:00:00','2024-04-03 00:00:00','macOS 14+ (Sonoma+)','macOS hosts with version 14 and above','select 1 from os_version where platform = \'darwin\' and major >= 14;','darwin',1,0),(2,'2024-06-28 00:00:00','2024-06-28 00:00:00','iOS','All iOS hosts','','ios',1,1),(3,'2024-06-28 00:00:00','2024-06-28 00:00:00','iPadOS','All iPadOS hosts','','ipados',1,1),(4,'2024-09-27 00:00:00','2024-09-27 00:00:00','Fedora Linux','All Fedora hosts','select 1 from os_version where name = \'Fedora Linux\';','rhel',1,0);
+INSERT INTO `labels` VALUES (1,'2024-04-03 00:00:00','2024-04-03 00:00:00','macOS 14+ (Sonoma+)','macOS hosts with version 14 and above','select 1 from os_version where platform = \'darwin\' and major >= 14;','darwin',1,0),(2,'2024-06-28 00:00:00','2024-06-28 00:00:00','iOS','All iOS hosts','','ios',1,1),(3,'2024-06-28 00:00:00','2024-06-28 00:00:00','iPadOS','All iPadOS hosts','','ipados',1,1),(4,'2024-09-27 00:00:00','2024-09-27 00:00:00','Fedora Linux','All Fedora hosts','select 1 from os_version where name = \'Fedora Linux\';','rhel',1,0),(5,'2025-02-25 00:00:00','2025-02-25 00:00:00','Android','All Android hosts','','android',1,1);
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `locks` (
@@ -1191,7 +1191,7 @@ CREATE TABLE `migration_status_tables` (
PRIMARY KEY (`id`)
) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=360 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,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'),(342,20241224000000,1,'2020-01-01 01:01:01'),(343,20241230000000,1,'2020-01-01 01:01:01'),(344,20241231112624,1,'2020-01-01 01:01:01'),(345,20250102121439,1,'2020-01-01 01:01:01'),(346,20250121094045,1,'2020-01-01 01:01:01'),(347,20250121094500,1,'2020-01-01 01:01:01'),(348,20250121094600,1,'2020-01-01 01:01:01'),(349,20250121094700,1,'2020-01-01 01:01:01'),(350,20250124194347,1,'2020-01-01 01:01:01'),(351,20250127162751,1,'2020-01-01 01:01:01'),(352,20250211141712,1,'2020-01-01 01:01:01'),(353,20250213104005,1,'2020-01-01 01:01:01'),(354,20250214205657,1,'2020-01-01 01:01:01'),(355,20250217093329,1,'2020-01-01 01:01:01'),(356,20250219090511,1,'2020-01-01 01:01:01'),(357,20250219100000,1,'2020-01-01 01:01:01'),(358,20250219142401,1,'2020-01-01 01:01:01'),(359,20250224184002,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'),(342,20241224000000,1,'2020-01-01 01:01:01'),(343,20241230000000,1,'2020-01-01 01:01:01'),(344,20241231112624,1,'2020-01-01 01:01:01'),(345,20250102121439,1,'2020-01-01 01:01:01'),(346,20250121094045,1,'2020-01-01 01:01:01'),(347,20250121094500,1,'2020-01-01 01:01:01'),(348,20250121094600,1,'2020-01-01 01:01:01'),(349,20250121094700,1,'2020-01-01 01:01:01'),(350,20250124194347,1,'2020-01-01 01:01:01'),(351,20250127162751,1,'2020-01-01 01:01:01'),(352,20250213104005,1,'2020-01-01 01:01:01'),(353,20250214205657,1,'2020-01-01 01:01:01'),(354,20250217093329,1,'2020-01-01 01:01:01'),(355,20250219090511,1,'2020-01-01 01:01:01'),(356,20250219100000,1,'2020-01-01 01:01:01'),(357,20250219142401,1,'2020-01-01 01:01:01'),(358,20250224184002,1,'2020-01-01 01:01:01'),(359,20250225085436,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` (
diff --git a/server/fleet/emails.go b/server/fleet/emails.go
index cd21071c81..941dc5b9ef 100644
--- a/server/fleet/emails.go
+++ b/server/fleet/emails.go
@@ -1,10 +1,13 @@
package fleet
import (
+ "errors"
"regexp"
"time"
)
+var ErrPasswordResetNotConfigured = errors.New("Cannot send password reset. SMTP or SES Is not configured.")
+
// Mailer is an email campaign
// Types which implement the Campaign interface
// can be marshalled into an email body
diff --git a/server/fleet/hosts.go b/server/fleet/hosts.go
index 12ea8971ee..efbd20a83e 100644
--- a/server/fleet/hosts.go
+++ b/server/fleet/hosts.go
@@ -849,7 +849,7 @@ func (h *Host) FleetPlatform() string {
// SupportsOsquery returns whether the device runs osquery.
func (h *Host) SupportsOsquery() bool {
- return h.Platform != "ios" && h.Platform != "ipados"
+ return h.Platform != "ios" && h.Platform != "ipados" && h.Platform != "android"
}
// HostLinuxOSs are the possible linux values for Host.Platform.
@@ -894,7 +894,8 @@ func PlatformFromHost(hostPlatform string) string {
// Fleet now supports Chrome via fleetd
hostPlatform == "chrome",
hostPlatform == "ios",
- hostPlatform == "ipados":
+ hostPlatform == "ipados",
+ hostPlatform == "android":
return hostPlatform
default:
return ""
diff --git a/server/fleet/labels.go b/server/fleet/labels.go
index 79a01af3ae..f14cb78a49 100644
--- a/server/fleet/labels.go
+++ b/server/fleet/labels.go
@@ -160,6 +160,7 @@ const (
BuiltinLabelIOS = "iOS"
BuiltinLabelIPadOS = "iPadOS"
BuiltinLabelFedoraLinux = "Fedora Linux"
+ BuiltinLabelNameAndroid = "Android"
)
// ReservedLabelNames returns a map of label name strings
@@ -178,6 +179,7 @@ func ReservedLabelNames() map[string]struct{} {
BuiltinLabelIOS: {},
BuiltinLabelIPadOS: {},
BuiltinLabelFedoraLinux: {},
+ BuiltinLabelNameAndroid: {},
}
}
diff --git a/server/fleet/mdm.go b/server/fleet/mdm.go
index 54608e5268..5adc4082de 100644
--- a/server/fleet/mdm.go
+++ b/server/fleet/mdm.go
@@ -770,6 +770,7 @@ func MDMPlatform(hostPlatform string) string {
return "darwin"
case "windows":
return "windows"
+ // TODO(android): add android to this list?
}
return ""
}
diff --git a/server/mdm/android/arch_test.go b/server/mdm/android/arch_test.go
index 3f07acf5b4..fd753ad881 100644
--- a/server/mdm/android/arch_test.go
+++ b/server/mdm/android/arch_test.go
@@ -22,6 +22,7 @@ func TestAllAndroidPackageDependencies(t *testing.T) {
"github.com/fleetdm/fleet/v4/server/service/middleware/auth",
"github.com/fleetdm/fleet/v4/server/service/middleware/authzcheck",
"github.com/fleetdm/fleet/v4/server/service/middleware/endpoint_utils",
+ "github.com/fleetdm/fleet/v4/server/service/middleware/log",
"github.com/fleetdm/fleet/v4/server/service/middleware/ratelimit",
).
ShouldNotDependOn(
diff --git a/server/mdm/android/datastore.go b/server/mdm/android/datastore.go
index 8ff7806687..e5495f1916 100644
--- a/server/mdm/android/datastore.go
+++ b/server/mdm/android/datastore.go
@@ -11,7 +11,7 @@ type Datastore interface {
GetEnterpriseByID(ctx context.Context, ID uint) (*EnterpriseDetails, error)
GetEnterprise(ctx context.Context) (*Enterprise, error)
UpdateEnterprise(ctx context.Context, enterprise *EnterpriseDetails) error
- DeleteEnterprises(ctx context.Context) error
+ DeleteAllEnterprises(ctx context.Context) error
DeleteOtherEnterprises(ctx context.Context, ID uint) error
CreateDeviceTx(ctx context.Context, tx sqlx.ExtContext, device *Device) (*Device, error)
diff --git a/server/mdm/android/mock/android.go b/server/mdm/android/mock/android.go
new file mode 100644
index 0000000000..7f7da70861
--- /dev/null
+++ b/server/mdm/android/mock/android.go
@@ -0,0 +1,4 @@
+package mock
+
+//go:generate go run ../../../mock/mockimpl/impl.go -o proxy.go "p *Proxy" "android.Proxy"
+//go:generate go run ../../../mock/mockimpl/impl.go -o datastore.go "ds *Datastore" "android.Datastore"
diff --git a/server/mdm/android/mock/datastore.go b/server/mdm/android/mock/datastore.go
new file mode 100644
index 0000000000..ac0c9f23d4
--- /dev/null
+++ b/server/mdm/android/mock/datastore.go
@@ -0,0 +1,113 @@
+// Automatically generated by mockimpl. DO NOT EDIT!
+
+package mock
+
+import (
+ "context"
+ "sync"
+
+ "github.com/fleetdm/fleet/v4/server/mdm/android"
+ "github.com/jmoiron/sqlx"
+)
+
+var _ android.Datastore = (*Datastore)(nil)
+
+type CreateEnterpriseFunc func(ctx context.Context) (uint, error)
+
+type GetEnterpriseByIDFunc func(ctx context.Context, ID uint) (*android.EnterpriseDetails, error)
+
+type GetEnterpriseFunc func(ctx context.Context) (*android.Enterprise, error)
+
+type UpdateEnterpriseFunc func(ctx context.Context, enterprise *android.EnterpriseDetails) error
+
+type DeleteAllEnterprisesFunc func(ctx context.Context) error
+
+type DeleteOtherEnterprisesFunc func(ctx context.Context, ID uint) error
+
+type CreateDeviceTxFunc func(ctx context.Context, tx sqlx.ExtContext, device *android.Device) (*android.Device, error)
+
+type UpdateDeviceTxFunc func(ctx context.Context, tx sqlx.ExtContext, device *android.Device) error
+
+type Datastore struct {
+ CreateEnterpriseFunc CreateEnterpriseFunc
+ CreateEnterpriseFuncInvoked bool
+
+ GetEnterpriseByIDFunc GetEnterpriseByIDFunc
+ GetEnterpriseByIDFuncInvoked bool
+
+ GetEnterpriseFunc GetEnterpriseFunc
+ GetEnterpriseFuncInvoked bool
+
+ UpdateEnterpriseFunc UpdateEnterpriseFunc
+ UpdateEnterpriseFuncInvoked bool
+
+ DeleteAllEnterprisesFunc DeleteAllEnterprisesFunc
+ DeleteAllEnterprisesFuncInvoked bool
+
+ DeleteOtherEnterprisesFunc DeleteOtherEnterprisesFunc
+ DeleteOtherEnterprisesFuncInvoked bool
+
+ CreateDeviceTxFunc CreateDeviceTxFunc
+ CreateDeviceTxFuncInvoked bool
+
+ UpdateDeviceTxFunc UpdateDeviceTxFunc
+ UpdateDeviceTxFuncInvoked bool
+
+ mu sync.Mutex
+}
+
+func (ds *Datastore) CreateEnterprise(ctx context.Context) (uint, error) {
+ ds.mu.Lock()
+ ds.CreateEnterpriseFuncInvoked = true
+ ds.mu.Unlock()
+ return ds.CreateEnterpriseFunc(ctx)
+}
+
+func (ds *Datastore) GetEnterpriseByID(ctx context.Context, ID uint) (*android.EnterpriseDetails, error) {
+ ds.mu.Lock()
+ ds.GetEnterpriseByIDFuncInvoked = true
+ ds.mu.Unlock()
+ return ds.GetEnterpriseByIDFunc(ctx, ID)
+}
+
+func (ds *Datastore) GetEnterprise(ctx context.Context) (*android.Enterprise, error) {
+ ds.mu.Lock()
+ ds.GetEnterpriseFuncInvoked = true
+ ds.mu.Unlock()
+ return ds.GetEnterpriseFunc(ctx)
+}
+
+func (ds *Datastore) UpdateEnterprise(ctx context.Context, enterprise *android.EnterpriseDetails) error {
+ ds.mu.Lock()
+ ds.UpdateEnterpriseFuncInvoked = true
+ ds.mu.Unlock()
+ return ds.UpdateEnterpriseFunc(ctx, enterprise)
+}
+
+func (ds *Datastore) DeleteAllEnterprises(ctx context.Context) error {
+ ds.mu.Lock()
+ ds.DeleteAllEnterprisesFuncInvoked = true
+ ds.mu.Unlock()
+ return ds.DeleteAllEnterprisesFunc(ctx)
+}
+
+func (ds *Datastore) DeleteOtherEnterprises(ctx context.Context, ID uint) error {
+ ds.mu.Lock()
+ ds.DeleteOtherEnterprisesFuncInvoked = true
+ ds.mu.Unlock()
+ return ds.DeleteOtherEnterprisesFunc(ctx, ID)
+}
+
+func (ds *Datastore) CreateDeviceTx(ctx context.Context, tx sqlx.ExtContext, device *android.Device) (*android.Device, error) {
+ ds.mu.Lock()
+ ds.CreateDeviceTxFuncInvoked = true
+ ds.mu.Unlock()
+ return ds.CreateDeviceTxFunc(ctx, tx, device)
+}
+
+func (ds *Datastore) UpdateDeviceTx(ctx context.Context, tx sqlx.ExtContext, device *android.Device) error {
+ ds.mu.Lock()
+ ds.UpdateDeviceTxFuncInvoked = true
+ ds.mu.Unlock()
+ return ds.UpdateDeviceTxFunc(ctx, tx, device)
+}
diff --git a/server/mdm/android/mock/datastore_setup.go b/server/mdm/android/mock/datastore_setup.go
new file mode 100644
index 0000000000..18d6284597
--- /dev/null
+++ b/server/mdm/android/mock/datastore_setup.go
@@ -0,0 +1,28 @@
+package mock
+
+import (
+ "context"
+
+ "github.com/fleetdm/fleet/v4/server/mdm/android"
+)
+
+func (s *Datastore) InitCommonMocks() {
+ s.CreateEnterpriseFunc = func(ctx context.Context) (uint, error) {
+ return 1, nil
+ }
+ s.UpdateEnterpriseFunc = func(ctx context.Context, enterprise *android.EnterpriseDetails) error {
+ return nil
+ }
+ s.GetEnterpriseFunc = func(ctx context.Context) (*android.Enterprise, error) {
+ return &android.Enterprise{}, nil
+ }
+ s.GetEnterpriseByIDFunc = func(ctx context.Context, ID uint) (*android.EnterpriseDetails, error) {
+ return &android.EnterpriseDetails{}, nil
+ }
+ s.DeleteAllEnterprisesFunc = func(ctx context.Context) error {
+ return nil
+ }
+ s.DeleteOtherEnterprisesFunc = func(ctx context.Context, ID uint) error {
+ return nil
+ }
+}
diff --git a/server/mdm/android/mock/proxy.go b/server/mdm/android/mock/proxy.go
new file mode 100644
index 0000000000..15a90a12c9
--- /dev/null
+++ b/server/mdm/android/mock/proxy.go
@@ -0,0 +1,77 @@
+// Automatically generated by mockimpl. DO NOT EDIT!
+
+package mock
+
+import (
+ "context"
+ "sync"
+
+ "github.com/fleetdm/fleet/v4/server/mdm/android"
+ "google.golang.org/api/androidmanagement/v1"
+)
+
+var _ android.Proxy = (*Proxy)(nil)
+
+type SignupURLsCreateFunc func(callbackURL string) (*android.SignupDetails, error)
+
+type EnterprisesCreateFunc func(ctx context.Context, req android.ProxyEnterprisesCreateRequest) (string, string, error)
+
+type EnterprisesPoliciesPatchFunc func(enterpriseID string, policyName string, policy *androidmanagement.Policy) error
+
+type EnterprisesEnrollmentTokensCreateFunc func(enterpriseName string, token *androidmanagement.EnrollmentToken) (*androidmanagement.EnrollmentToken, error)
+
+type EnterpriseDeleteFunc func(enterpriseID string) error
+
+type Proxy struct {
+ SignupURLsCreateFunc SignupURLsCreateFunc
+ SignupURLsCreateFuncInvoked bool
+
+ EnterprisesCreateFunc EnterprisesCreateFunc
+ EnterprisesCreateFuncInvoked bool
+
+ EnterprisesPoliciesPatchFunc EnterprisesPoliciesPatchFunc
+ EnterprisesPoliciesPatchFuncInvoked bool
+
+ EnterprisesEnrollmentTokensCreateFunc EnterprisesEnrollmentTokensCreateFunc
+ EnterprisesEnrollmentTokensCreateFuncInvoked bool
+
+ EnterpriseDeleteFunc EnterpriseDeleteFunc
+ EnterpriseDeleteFuncInvoked bool
+
+ mu sync.Mutex
+}
+
+func (p *Proxy) SignupURLsCreate(callbackURL string) (*android.SignupDetails, error) {
+ p.mu.Lock()
+ p.SignupURLsCreateFuncInvoked = true
+ p.mu.Unlock()
+ return p.SignupURLsCreateFunc(callbackURL)
+}
+
+func (p *Proxy) EnterprisesCreate(ctx context.Context, req android.ProxyEnterprisesCreateRequest) (string, string, error) {
+ p.mu.Lock()
+ p.EnterprisesCreateFuncInvoked = true
+ p.mu.Unlock()
+ return p.EnterprisesCreateFunc(ctx, req)
+}
+
+func (p *Proxy) EnterprisesPoliciesPatch(enterpriseID string, policyName string, policy *androidmanagement.Policy) error {
+ p.mu.Lock()
+ p.EnterprisesPoliciesPatchFuncInvoked = true
+ p.mu.Unlock()
+ return p.EnterprisesPoliciesPatchFunc(enterpriseID, policyName, policy)
+}
+
+func (p *Proxy) EnterprisesEnrollmentTokensCreate(enterpriseName string, token *androidmanagement.EnrollmentToken) (*androidmanagement.EnrollmentToken, error) {
+ p.mu.Lock()
+ p.EnterprisesEnrollmentTokensCreateFuncInvoked = true
+ p.mu.Unlock()
+ return p.EnterprisesEnrollmentTokensCreateFunc(enterpriseName, token)
+}
+
+func (p *Proxy) EnterpriseDelete(enterpriseID string) error {
+ p.mu.Lock()
+ p.EnterpriseDeleteFuncInvoked = true
+ p.mu.Unlock()
+ return p.EnterpriseDeleteFunc(enterpriseID)
+}
diff --git a/server/mdm/android/mock/proxy_setup.go b/server/mdm/android/mock/proxy_setup.go
new file mode 100644
index 0000000000..99caaef35d
--- /dev/null
+++ b/server/mdm/android/mock/proxy_setup.go
@@ -0,0 +1,23 @@
+package mock
+
+import (
+ "context"
+
+ "github.com/fleetdm/fleet/v4/server/mdm/android"
+ "google.golang.org/api/androidmanagement/v1"
+)
+
+func (p *Proxy) InitCommonMocks() {
+ p.EnterpriseDeleteFunc = func(enterpriseID string) error {
+ return nil
+ }
+ p.SignupURLsCreateFunc = func(callbackURL string) (*android.SignupDetails, error) {
+ return &android.SignupDetails{}, nil
+ }
+ p.EnterprisesCreateFunc = func(ctx context.Context, req android.ProxyEnterprisesCreateRequest) (string, string, error) {
+ return "enterpriseName", "projects/project/topics/topic", nil
+ }
+ p.EnterprisesPoliciesPatchFunc = func(enterpriseID string, policyName string, policy *androidmanagement.Policy) error {
+ return nil
+ }
+}
diff --git a/server/mdm/android/mysql/enterprises.go b/server/mdm/android/mysql/enterprises.go
index 6527352c50..aedcd122cb 100644
--- a/server/mdm/android/mysql/enterprises.go
+++ b/server/mdm/android/mysql/enterprises.go
@@ -77,7 +77,7 @@ func (ds *Datastore) DeleteOtherEnterprises(ctx context.Context, id uint) error
return nil
}
-func (ds *Datastore) DeleteEnterprises(ctx context.Context) error {
+func (ds *Datastore) DeleteAllEnterprises(ctx context.Context) error {
stmt := `DELETE FROM android_enterprises`
_, err := ds.Writer(ctx).ExecContext(ctx, stmt)
if err != nil {
diff --git a/server/mdm/android/mysql/enterprises_test.go b/server/mdm/android/mysql/enterprises_test.go
index 58128fde1a..c645f04f15 100644
--- a/server/mdm/android/mysql/enterprises_test.go
+++ b/server/mdm/android/mysql/enterprises_test.go
@@ -20,7 +20,7 @@ func TestEnterprise(t *testing.T) {
}{
{"CreateGetEnterprise", testCreateGetEnterprise},
{"UpdateEnterprise", testUpdateEnterprise},
- {"DeleteEnterprises", testDeleteEnterprises},
+ {"DeleteAllEnterprises", testDeleteEnterprises},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@@ -75,7 +75,7 @@ func testUpdateEnterprise(t *testing.T, ds *Datastore) {
}
func testDeleteEnterprises(t *testing.T, ds *Datastore) {
- err := ds.DeleteEnterprises(testCtx())
+ err := ds.DeleteAllEnterprises(testCtx())
require.NoError(t, err)
err = ds.DeleteOtherEnterprises(testCtx(), 9999)
require.NoError(t, err)
@@ -108,7 +108,7 @@ func testDeleteEnterprises(t *testing.T, ds *Datastore) {
_, err = ds.GetEnterpriseByID(testCtx(), tempEnterprise.ID)
assert.True(t, fleet.IsNotFound(err))
- err = ds.DeleteEnterprises(testCtx())
+ err = ds.DeleteAllEnterprises(testCtx())
require.NoError(t, err)
_, err = ds.GetEnterpriseByID(testCtx(), enterprise.ID)
assert.True(t, fleet.IsNotFound(err))
diff --git a/server/mdm/android/mysql/mysql_test.go b/server/mdm/android/mysql/testing_utils.go
similarity index 71%
rename from server/mdm/android/mysql/mysql_test.go
rename to server/mdm/android/mysql/testing_utils.go
index ab75c05460..7017ecb8b2 100644
--- a/server/mdm/android/mysql/mysql_test.go
+++ b/server/mdm/android/mysql/testing_utils.go
@@ -12,9 +12,11 @@ import (
"github.com/stretchr/testify/require"
)
-// Android MySQL testing utilities.
+// Android MySQL testing utilities. This file should contain VERY LITTLE code since it is also compiled into the production binary.
+// Whenever possible, new code should go into a dedicated testing package (e.g. mdm/android/mysql/tests/testing_utils.go).
// These utilities are used to create a MySQL Datastore for testing the Android MDM MySQL implementation.
-// They are located in the same package as the implementation to prevent a circular dependency.
+// They are located in the same package as the implementation to prevent a circular dependency. If put it in a different package,
+// the circular dependency would be: mysql -> testing_utils -> mysql
func CreateMySQLDS(t testing.TB) *Datastore {
return createMySQLDSWithOptions(t, nil)
@@ -22,14 +24,14 @@ func CreateMySQLDS(t testing.TB) *Datastore {
func createMySQLDSWithOptions(t testing.TB, opts *testing_utils.DatastoreTestOptions) *Datastore {
cleanTestName, opts := testing_utils.ProcessOptions(t, opts)
- ds := initializeDatabase(t, cleanTestName, opts)
+ ds := InitializeDatabase(t, cleanTestName, opts)
t.Cleanup(func() { Close(ds) })
return ds
}
-// initializeDatabase loads the dumped schema into a newly created database in MySQL.
+// InitializeDatabase loads the dumped schema into a newly created database in MySQL.
// This is much faster than running the full set of migrations on each test.
-func initializeDatabase(t testing.TB, testName string, opts *testing_utils.DatastoreTestOptions) *Datastore {
+func InitializeDatabase(t testing.TB, testName string, opts *testing_utils.DatastoreTestOptions) *Datastore {
_, filename, _, _ := runtime.Caller(0)
schemaPath := path.Join(path.Dir(filename), "schema.sql")
testing_utils.LoadSchema(t, testName, opts, schemaPath)
diff --git a/server/mdm/android/proxy.go b/server/mdm/android/proxy.go
new file mode 100644
index 0000000000..8e9c619522
--- /dev/null
+++ b/server/mdm/android/proxy.go
@@ -0,0 +1,22 @@
+package android
+
+import (
+ "context"
+
+ "google.golang.org/api/androidmanagement/v1"
+)
+
+type Proxy interface {
+ SignupURLsCreate(callbackURL string) (*SignupDetails, error)
+ EnterprisesCreate(ctx context.Context, req ProxyEnterprisesCreateRequest) (string, string, error)
+ EnterprisesPoliciesPatch(enterpriseID string, policyName string, policy *androidmanagement.Policy) error
+ EnterprisesEnrollmentTokensCreate(enterpriseName string, token *androidmanagement.EnrollmentToken) (*androidmanagement.EnrollmentToken, error)
+ EnterpriseDelete(enterpriseID string) error
+}
+
+type ProxyEnterprisesCreateRequest struct {
+ androidmanagement.Enterprise
+ EnterpriseToken string
+ SignupUrlName string
+ PubSubPushURL string
+}
diff --git a/server/mdm/android/service.go b/server/mdm/android/service.go
index 929d926bfd..6583c4762a 100644
--- a/server/mdm/android/service.go
+++ b/server/mdm/android/service.go
@@ -7,9 +7,29 @@ import (
type Service interface {
EnterpriseSignup(ctx context.Context) (*SignupDetails, error)
EnterpriseSignupCallback(ctx context.Context, enterpriseID uint, enterpriseToken string) error
+ GetEnterprise(ctx context.Context) (*Enterprise, error)
DeleteEnterprise(ctx context.Context) error
// CreateEnrollmentToken creates an enrollment token for a new Android device.
CreateEnrollmentToken(ctx context.Context, enrollSecret string) (*EnrollmentToken, error)
ProcessPubSubPush(ctx context.Context, token string, message *PubSubMessage) error
}
+
+// /////////////////////////////////////////////
+// Android API request and response structs
+
+type DefaultResponse struct {
+ Err error `json:"error,omitempty"`
+}
+
+func (r DefaultResponse) Error() error { return r.Err }
+
+type GetEnterpriseResponse struct {
+ EnterpriseID string `json:"android_enterprise_id"`
+ DefaultResponse
+}
+
+type EnterpriseSignupResponse struct {
+ Url string `json:"android_enterprise_signup_url"`
+ DefaultResponse
+}
diff --git a/server/mdm/android/service/enterprises_test.go b/server/mdm/android/service/enterprises_test.go
new file mode 100644
index 0000000000..9a99ed730c
--- /dev/null
+++ b/server/mdm/android/service/enterprises_test.go
@@ -0,0 +1,135 @@
+package service
+
+import (
+ "context"
+ "os"
+ "testing"
+
+ "github.com/fleetdm/fleet/v4/server/authz"
+ "github.com/fleetdm/fleet/v4/server/contexts/viewer"
+ "github.com/fleetdm/fleet/v4/server/fleet"
+ "github.com/fleetdm/fleet/v4/server/mdm/android"
+ android_mock "github.com/fleetdm/fleet/v4/server/mdm/android/mock"
+ "github.com/fleetdm/fleet/v4/server/mock"
+ "github.com/fleetdm/fleet/v4/server/ptr"
+ kitlog "github.com/go-kit/log"
+ "github.com/stretchr/testify/require"
+)
+
+func TestEnterprisesAuth(t *testing.T) {
+ proxy := android_mock.Proxy{}
+ proxy.InitCommonMocks()
+ logger := kitlog.NewLogfmtLogger(os.Stdout)
+ fleetDS := InitCommonDSMocks()
+ svc, err := NewServiceWithProxy(logger, fleetDS, &proxy)
+ require.NoError(t, err)
+
+ testCases := []struct {
+ name string
+ user *fleet.User
+ shouldFailWrite bool
+ shouldFailRead bool
+ }{
+ {
+ "global admin",
+ &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)},
+ false,
+ false,
+ },
+ {
+ "global maintainer",
+ &fleet.User{GlobalRole: ptr.String(fleet.RoleMaintainer)},
+ true,
+ true,
+ },
+ {
+ "global gitops",
+ &fleet.User{GlobalRole: ptr.String(fleet.RoleGitOps)},
+ true,
+ true,
+ },
+ {
+ "global observer",
+ &fleet.User{GlobalRole: ptr.String(fleet.RoleObserver)},
+ true,
+ true,
+ },
+ {
+ "global observer+",
+ &fleet.User{GlobalRole: ptr.String(fleet.RoleObserverPlus)},
+ true,
+ true,
+ },
+ {
+ "team admin",
+ &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}}},
+ true,
+ true,
+ },
+ {
+ "team maintainer",
+ &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleMaintainer}}},
+ true,
+ true,
+ },
+ {
+ "team observer",
+ &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserver}}},
+ true,
+ true,
+ },
+ {
+ "team observer+",
+ &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserverPlus}}},
+ true,
+ true,
+ },
+ }
+ for _, tt := range testCases {
+ t.Run(tt.name, func(t *testing.T) {
+ ctx := viewer.NewContext(context.Background(), viewer.Viewer{User: tt.user})
+
+ _, err := svc.GetEnterprise(ctx)
+ checkAuthErr(t, tt.shouldFailRead, err)
+
+ err = svc.DeleteEnterprise(ctx)
+ checkAuthErr(t, tt.shouldFailWrite, err)
+
+ _, err = svc.EnterpriseSignup(ctx)
+ checkAuthErr(t, tt.shouldFailWrite, err)
+ })
+ }
+
+ t.Run("unauthorized", func(t *testing.T) {
+ err := svc.EnterpriseSignupCallback(context.Background(), 1, "token")
+ checkAuthErr(t, false, err)
+ })
+}
+
+func checkAuthErr(t *testing.T, shouldFail bool, err error) {
+ t.Helper()
+ if shouldFail {
+ require.Error(t, err)
+ var forbiddenError *authz.Forbidden
+ require.ErrorAs(t, err, &forbiddenError)
+ } else {
+ require.NoError(t, err)
+ }
+}
+
+func InitCommonDSMocks() *mock.Store {
+ fleetDS := mock.Store{}
+ ds := android_mock.Datastore{}
+ ds.InitCommonMocks()
+
+ fleetDS.GetAndroidDSFunc = func() android.Datastore {
+ return &ds
+ }
+ fleetDS.AppConfigFunc = func(_ context.Context) (*fleet.AppConfig, error) {
+ return &fleet.AppConfig{}, nil
+ }
+ fleetDS.SetAndroidEnabledAndConfiguredFunc = func(_ context.Context, configured bool) error {
+ return nil
+ }
+ return &fleetDS
+}
diff --git a/server/mdm/android/service/handler.go b/server/mdm/android/service/handler.go
index b38aa553c0..a5accf6340 100644
--- a/server/mdm/android/service/handler.go
+++ b/server/mdm/android/service/handler.go
@@ -18,20 +18,21 @@ const pubSubPushPath = "/api/v1/fleet/android_enterprise/pubsub"
func attachFleetAPIRoutes(r *mux.Router, fleetSvc fleet.Service, svc android.Service, opts []kithttp.ServerOption) {
- // user-authenticated endpoints
+ // //////////////////////////////////////////
+ // User-authenticated endpoints
ue := newUserAuthenticatedEndpointer(fleetSvc, svc, opts, r, apiVersions()...)
ue.GET("/api/_version_/fleet/android_enterprise/signup_url", enterpriseSignupEndpoint, nil)
+ ue.GET("/api/_version_/fleet/android_enterprise", getEnterpriseEndpoint, nil)
ue.DELETE("/api/_version_/fleet/android_enterprise", deleteEnterpriseEndpoint, nil)
- // unauthenticated endpoints
- // They typically do one-time authentication by verifying that a valid secret token is provided with the request.
+ // //////////////////////////////////////////
+ // Unauthenticated endpoints
+ // These endpoints should do custom one-time authentication by verifying that a valid secret token is provided with the request.
ne := newNoAuthEndpointer(fleetSvc, svc, opts, r, apiVersions()...)
- ne.GET("/api/_version_/fleet/android_enterprise/{id:[0-9]+}/connect", enterpriseSignupCallbackEndpoint,
- enterpriseSignupCallbackRequest{})
- ne.GET("/api/_version_/fleet/android_enterprise/enrollment_token", enrollmentTokenEndpoint,
- enrollmentTokenRequest{})
+ ne.GET("/api/_version_/fleet/android_enterprise/{id:[0-9]+}/connect", enterpriseSignupCallbackEndpoint, enterpriseSignupCallbackRequest{})
+ ne.GET("/api/_version_/fleet/android_enterprise/enrollment_token", enrollmentTokenEndpoint, enrollmentTokenRequest{})
ne.POST(pubSubPushPath, pubSubPushEndpoint, pubSubPushRequest{})
}
diff --git a/server/mdm/android/service/proxy/proxy.go b/server/mdm/android/service/proxy/proxy.go
index 18676126b8..72eb0fbb56 100644
--- a/server/mdm/android/service/proxy/proxy.go
+++ b/server/mdm/android/service/proxy/proxy.go
@@ -32,6 +32,9 @@ type Proxy struct {
mgmt *androidmanagement.Service
}
+// Compile-time check to ensure that Proxy implements android.Proxy.
+var _ android.Proxy = &Proxy{}
+
func NewProxy(ctx context.Context, logger kitlog.Logger) *Proxy {
if androidServiceCredentials == "" {
return nil
@@ -74,28 +77,27 @@ func (p *Proxy) SignupURLsCreate(callbackURL string) (*android.SignupDetails, er
}, nil
}
-func (p *Proxy) EnterprisesCreate(ctx context.Context, enabledNotificationTypes []string, enterpriseToken string,
- signupUrlName string, pushURL string) (string, string, error) {
+func (p *Proxy) EnterprisesCreate(ctx context.Context, req android.ProxyEnterprisesCreateRequest) (string, string, error) {
if p == nil || p.mgmt == nil {
return "", "", errors.New("android management service not initialized")
}
- topicName, err := p.createPubSubTopic(ctx, pushURL)
+ topicName, err := p.createPubSubTopic(ctx, req.PubSubPushURL)
if err != nil {
return "", "", fmt.Errorf("creating PubSub topic: %w", err)
}
enterprise, err := p.mgmt.Enterprises.Create(&androidmanagement.Enterprise{
- EnabledNotificationTypes: enabledNotificationTypes,
+ EnabledNotificationTypes: req.EnabledNotificationTypes,
PubsubTopic: topicName,
}).
ProjectId(androidProjectID).
- EnterpriseToken(enterpriseToken).
- SignupUrlName(signupUrlName).
+ EnterpriseToken(req.EnterpriseToken).
+ SignupUrlName(req.SignupUrlName).
Do()
switch {
case googleapi.IsNotModified(err):
- return "", "", fmt.Errorf("android enterprise %s was already created", signupUrlName)
+ return "", "", fmt.Errorf("android enterprise %s was already created", req.SignupUrlName)
case err != nil:
return "", "", fmt.Errorf("creating enterprise: %w", err)
}
diff --git a/server/mdm/android/service/pubsub.go b/server/mdm/android/service/pubsub.go
index 3383e352c9..7e710d2031 100644
--- a/server/mdm/android/service/pubsub.go
+++ b/server/mdm/android/service/pubsub.go
@@ -26,7 +26,7 @@ type pubSubPushRequest struct {
func pubSubPushEndpoint(ctx context.Context, request interface{}, svc android.Service) fleet.Errorer {
req := request.(*pubSubPushRequest)
err := svc.ProcessPubSubPush(ctx, req.Token, &req.PubSubMessage)
- return defaultResponse{Err: err}
+ return android.DefaultResponse{Err: err}
}
func (svc *Service) ProcessPubSubPush(ctx context.Context, token string, message *android.PubSubMessage) error {
diff --git a/server/mdm/android/service/service.go b/server/mdm/android/service/service.go
index f2ab5b4f65..e786f67c83 100644
--- a/server/mdm/android/service/service.go
+++ b/server/mdm/android/service/service.go
@@ -24,43 +24,39 @@ type Service struct {
authz *authz.Authorizer
ds android.Datastore
fleetDS fleet.Datastore
- proxy *proxy.Proxy
+ proxy android.Proxy
}
func NewService(
ctx context.Context,
logger kitlog.Logger,
fleetDS fleet.Datastore,
+) (android.Service, error) {
+ prx := proxy.NewProxy(ctx, logger)
+ return NewServiceWithProxy(logger, fleetDS, prx)
+}
+
+func NewServiceWithProxy(
+ logger kitlog.Logger,
+ fleetDS fleet.Datastore,
+ proxy android.Proxy,
) (android.Service, error) {
authorizer, err := authz.NewAuthorizer()
if err != nil {
return nil, fmt.Errorf("new authorizer: %w", err)
}
- prx := proxy.NewProxy(ctx, logger)
-
return &Service{
logger: logger,
authz: authorizer,
ds: fleetDS.GetAndroidDS(),
fleetDS: fleetDS,
- proxy: prx,
+ proxy: proxy,
}, nil
}
-type defaultResponse struct {
- Err error `json:"error,omitempty"`
-}
-
-func (r defaultResponse) Error() error { return r.Err }
-
-func newErrResponse(err error) defaultResponse {
- return defaultResponse{Err: err}
-}
-
-type androidEnterpriseSignupResponse struct {
- Url string `json:"android_enterprise_signup_url"`
- defaultResponse
+func newErrResponse(err error) android.DefaultResponse {
+ return android.DefaultResponse{Err: err}
}
func enterpriseSignupEndpoint(ctx context.Context, _ interface{}, svc android.Service) fleet.Errorer {
@@ -68,7 +64,7 @@ func enterpriseSignupEndpoint(ctx context.Context, _ interface{}, svc android.Se
if err != nil {
return newErrResponse(err)
}
- return androidEnterpriseSignupResponse{Url: result.Url}
+ return android.EnterpriseSignupResponse{Url: result.Url}
}
func (svc *Service) EnterpriseSignup(ctx context.Context) (*android.SignupDetails, error) {
@@ -125,7 +121,7 @@ type enterpriseSignupCallbackRequest struct {
func enterpriseSignupCallbackEndpoint(ctx context.Context, request interface{}, svc android.Service) fleet.Errorer {
req := request.(*enterpriseSignupCallbackRequest)
err := svc.EnterpriseSignupCallback(ctx, req.ID, req.EnterpriseToken)
- return defaultResponse{Err: err}
+ return android.DefaultResponse{Err: err}
}
func (svc *Service) EnterpriseSignupCallback(ctx context.Context, id uint, enterpriseToken string) error {
@@ -156,10 +152,19 @@ func (svc *Service) EnterpriseSignupCallback(ctx context.Context, id uint, enter
name, topicName, err := svc.proxy.EnterprisesCreate(
ctx,
- []string{android.PubSubEnrollment, android.PubSubStatusReport, android.PubSubCommand, android.PubSubUsageLogs},
- enterpriseToken,
- enterprise.SignupName,
- appConfig.ServerSettings.ServerURL+pubSubPushPath+"?token="+pubSubToken,
+ android.ProxyEnterprisesCreateRequest{
+ Enterprise: androidmanagement.Enterprise{
+ EnabledNotificationTypes: []string{
+ android.PubSubEnrollment,
+ android.PubSubStatusReport,
+ android.PubSubCommand,
+ android.PubSubUsageLogs,
+ },
+ },
+ EnterpriseToken: enterpriseToken,
+ SignupUrlName: enterprise.SignupName,
+ PubSubPushURL: appConfig.ServerSettings.ServerURL + pubSubPushPath + "?token=" + pubSubToken,
+ },
)
if err != nil {
return ctxerr.Wrap(ctx, err, "creating enterprise")
@@ -222,9 +227,31 @@ func topicIDFromName(name string) (string, error) {
return name[lastSlash+1:], nil
}
+func getEnterpriseEndpoint(ctx context.Context, _ interface{}, svc android.Service) fleet.Errorer {
+ enterprise, err := svc.GetEnterprise(ctx)
+ if err != nil {
+ return android.DefaultResponse{Err: err}
+ }
+ return android.GetEnterpriseResponse{EnterpriseID: enterprise.EnterpriseID}
+}
+
+func (svc *Service) GetEnterprise(ctx context.Context) (*android.Enterprise, error) {
+ if err := svc.authz.Authorize(ctx, &android.Enterprise{}, fleet.ActionRead); err != nil {
+ return nil, err
+ }
+ enterprise, err := svc.ds.GetEnterprise(ctx)
+ switch {
+ case fleet.IsNotFound(err):
+ return nil, fleet.NewInvalidArgumentError("enterprise", "No enterprise found").WithStatus(http.StatusNotFound)
+ case err != nil:
+ return nil, ctxerr.Wrap(ctx, err, "getting enterprise")
+ }
+ return enterprise, nil
+}
+
func deleteEnterpriseEndpoint(ctx context.Context, _ interface{}, svc android.Service) fleet.Errorer {
err := svc.DeleteEnterprise(ctx)
- return defaultResponse{Err: err}
+ return android.DefaultResponse{Err: err}
}
func (svc *Service) DeleteEnterprise(ctx context.Context) error {
@@ -246,7 +273,7 @@ func (svc *Service) DeleteEnterprise(ctx context.Context) error {
}
}
- err = svc.ds.DeleteEnterprises(ctx)
+ err = svc.ds.DeleteAllEnterprises(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "deleting enterprises")
}
@@ -263,18 +290,18 @@ type enrollmentTokenRequest struct {
EnrollSecret string `query:"enroll_secret"`
}
-type androidEnrollmentTokenResponse struct {
+type enrollmentTokenResponse struct {
*android.EnrollmentToken
- defaultResponse
+ android.DefaultResponse
}
func enrollmentTokenEndpoint(ctx context.Context, request interface{}, svc android.Service) fleet.Errorer {
req := request.(*enrollmentTokenRequest)
token, err := svc.CreateEnrollmentToken(ctx, req.EnrollSecret)
if err != nil {
- return defaultResponse{Err: err}
+ return android.DefaultResponse{Err: err}
}
- return androidEnrollmentTokenResponse{EnrollmentToken: token}
+ return enrollmentTokenResponse{EnrollmentToken: token}
}
func (svc *Service) CreateEnrollmentToken(ctx context.Context, enrollSecret string) (*android.EnrollmentToken, error) {
diff --git a/server/mdm/android/tests/README.md b/server/mdm/android/tests/README.md
new file mode 100644
index 0000000000..5a0a375e39
--- /dev/null
+++ b/server/mdm/android/tests/README.md
@@ -0,0 +1,5 @@
+This package contains API Android tests with the real Android service and Android MySQL database.
+
+We use testify Suite to run these tests. Since [testify Suite does not support parallel execution](https://github.com/stretchr/testify/issues/187),
+we put each test in their own package/directory. This allows these tests to run in parallel because each package is a separate compile unit. If you
+create a large test, please put it in a separate file within the same Suite/package.
diff --git a/server/mdm/android/tests/enterprise/enterprise_test.go b/server/mdm/android/tests/enterprise/enterprise_test.go
new file mode 100644
index 0000000000..d8793239a6
--- /dev/null
+++ b/server/mdm/android/tests/enterprise/enterprise_test.go
@@ -0,0 +1,52 @@
+package enterprise_test
+
+import (
+ "net/http"
+ "testing"
+
+ "github.com/fleetdm/fleet/v4/server/mdm/android"
+ "github.com/fleetdm/fleet/v4/server/mdm/android/tests"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/suite"
+)
+
+func TestServiceEnterprise(t *testing.T) {
+ testingSuite := new(enterpriseTestSuite)
+ suite.Run(t, testingSuite)
+}
+
+type enterpriseTestSuite struct {
+ tests.WithServer
+}
+
+func (s *enterpriseTestSuite) SetupSuite() {
+ s.WithServer.SetupSuite(s.T(), "androidEnterpriseTestSuite")
+ s.Token = "bozo"
+}
+
+func (s *enterpriseTestSuite) TearDownSuite() {
+ s.WithServer.TearDownSuite()
+}
+
+func (s *enterpriseTestSuite) TestGetEnterprise() {
+ // Enterprise doesn't exist.
+ var resp android.GetEnterpriseResponse
+ s.DoJSON("GET", "/api/v1/fleet/android_enterprise", nil, http.StatusNotFound, &resp)
+
+ // Create enterprise
+ var signupResp android.EnterpriseSignupResponse
+ s.DoJSON("GET", "/api/v1/fleet/android_enterprise/signup_url", nil, http.StatusOK, &signupResp)
+ assert.Equal(s.T(), tests.EnterpriseSignupURL, signupResp.Url)
+ s.T().Logf("callbackURL: %s", s.ProxyCallbackURL)
+ const enterpriseToken = "enterpriseToken"
+ s.DoJSON("GET", s.ProxyCallbackURL, nil, http.StatusOK, &resp, "enterpriseToken", enterpriseToken)
+
+ // Now enterprise exists and we can retrieve it.
+ resp = android.GetEnterpriseResponse{}
+ s.DoJSON("GET", "/api/v1/fleet/android_enterprise", nil, http.StatusOK, &resp)
+ assert.Equal(s.T(), tests.EnterpriseID, resp.EnterpriseID)
+
+ // Delete enterprise and make sure we can't find it.
+ s.Do("DELETE", "/api/v1/fleet/android_enterprise", nil, http.StatusOK)
+ s.DoJSON("GET", "/api/v1/fleet/android_enterprise", nil, http.StatusNotFound, &resp)
+}
diff --git a/server/mdm/android/tests/http.go b/server/mdm/android/tests/http.go
new file mode 100644
index 0000000000..71a17b6009
--- /dev/null
+++ b/server/mdm/android/tests/http.go
@@ -0,0 +1,49 @@
+package tests
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+
+ "github.com/fleetdm/fleet/v4/server/fleet"
+ "github.com/fleetdm/fleet/v4/server/test/httptest"
+ "github.com/go-json-experiment/json"
+ "github.com/stretchr/testify/require"
+)
+
+func (ts *WithServer) DoJSON(verb, path string, params interface{}, expectedStatusCode int, v interface{}, queryParams ...string) {
+ resp := ts.Do(verb, path, params, expectedStatusCode, queryParams...)
+ err := json.UnmarshalRead(resp.Body, v)
+ require.NoError(ts.T(), err)
+ if e, ok := v.(fleet.Errorer); ok {
+ require.NoError(ts.T(), e.Error())
+ }
+}
+
+func (ts *WithServer) Do(verb, path string, params interface{}, expectedStatusCode int, queryParams ...string) *http.Response {
+ j, err := json.Marshal(params)
+ require.NoError(ts.T(), err)
+
+ resp := ts.DoRaw(verb, path, j, expectedStatusCode, queryParams...)
+
+ ts.T().Cleanup(func() {
+ resp.Body.Close()
+ })
+ return resp
+}
+
+func (ts *WithServer) DoRaw(verb string, path string, rawBytes []byte, expectedStatusCode int, queryParams ...string) *http.Response {
+ return ts.DoRawWithHeaders(verb, path, rawBytes, expectedStatusCode, map[string]string{
+ "Authorization": fmt.Sprintf("Bearer %s", ts.Token),
+ }, queryParams...)
+}
+
+func (ts *WithServer) DoRawWithHeaders(
+ verb string, path string, rawBytes []byte, expectedStatusCode int, headers map[string]string, queryParams ...string,
+) *http.Response {
+ return httptest.DoHTTPReq(ts.T(), decodeJSON, verb, rawBytes, ts.Server.URL+path, headers, expectedStatusCode, queryParams...)
+}
+
+func decodeJSON(r io.Reader, v interface{}) error {
+ return json.UnmarshalRead(r, v)
+}
diff --git a/server/mdm/android/tests/testing_utils.go b/server/mdm/android/tests/testing_utils.go
new file mode 100644
index 0000000000..acdf58378f
--- /dev/null
+++ b/server/mdm/android/tests/testing_utils.go
@@ -0,0 +1,157 @@
+package tests
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "testing"
+
+ "github.com/fleetdm/fleet/v4/server/config"
+ "github.com/fleetdm/fleet/v4/server/datastore/mysql/common_mysql/testing_utils"
+ "github.com/fleetdm/fleet/v4/server/fleet"
+ "github.com/fleetdm/fleet/v4/server/mdm/android"
+ proxy_mock "github.com/fleetdm/fleet/v4/server/mdm/android/mock"
+ "github.com/fleetdm/fleet/v4/server/mdm/android/mysql"
+ "github.com/fleetdm/fleet/v4/server/mdm/android/service"
+ ds_mock "github.com/fleetdm/fleet/v4/server/mock"
+ "github.com/fleetdm/fleet/v4/server/ptr"
+ "github.com/fleetdm/fleet/v4/server/service/middleware/auth"
+ "github.com/fleetdm/fleet/v4/server/service/middleware/endpoint_utils"
+ "github.com/fleetdm/fleet/v4/server/service/middleware/log"
+ kithttp "github.com/go-kit/kit/transport/http"
+ kitlog "github.com/go-kit/log"
+ "github.com/gorilla/mux"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+ "github.com/stretchr/testify/require"
+ "github.com/stretchr/testify/suite"
+ "google.golang.org/api/androidmanagement/v1"
+)
+
+const (
+ EnterpriseSignupURL = "https://enterprise.google.com/signup/android/email?origin=android&thirdPartyToken=B4D779F1C4DD9A440"
+ EnterpriseID = "LC02k5wxw7"
+)
+
+type WithServer struct {
+ suite.Suite
+ DS *mysql.Datastore
+ FleetDS ds_mock.Store
+ Server *httptest.Server
+ Token string
+ AppConfig fleet.AppConfig
+ Proxy proxy_mock.Proxy
+ ProxyCallbackURL string
+}
+
+func (ts *WithServer) SetupSuite(t *testing.T, dbName string) {
+ ts.DS = CreateNamedMySQLDS(t, dbName)
+ ts.createCommonDSMocks()
+
+ ts.Proxy = proxy_mock.Proxy{}
+ ts.createCommonProxyMocks(t)
+
+ fleetSvc := mockService{}
+ logger := kitlog.NewLogfmtLogger(os.Stdout)
+ svc, err := service.NewServiceWithProxy(logger, &ts.FleetDS, &ts.Proxy)
+ require.NoError(t, err)
+
+ ts.Server = runServerForTests(t, logger, &fleetSvc, svc)
+}
+
+func (ts *WithServer) createCommonDSMocks() {
+ ts.FleetDS.GetAndroidDSFunc = func() android.Datastore {
+ return ts.DS
+ }
+ ts.FleetDS.AppConfigFunc = func(_ context.Context) (*fleet.AppConfig, error) {
+ return &ts.AppConfig, nil
+ }
+ ts.FleetDS.SetAndroidEnabledAndConfiguredFunc = func(_ context.Context, configured bool) error {
+ ts.AppConfig.MDM.AndroidEnabledAndConfigured = configured
+ return nil
+ }
+}
+
+func (ts *WithServer) createCommonProxyMocks(t *testing.T) {
+ ts.Proxy.SignupURLsCreateFunc = func(callbackURL string) (*android.SignupDetails, error) {
+ ts.ProxyCallbackURL = callbackURL
+ return &android.SignupDetails{
+ Url: EnterpriseSignupURL,
+ Name: "signupUrls/Cb08124d0999c464f",
+ }, nil
+ }
+ ts.Proxy.EnterprisesCreateFunc = func(ctx context.Context, req android.ProxyEnterprisesCreateRequest) (string, string, error) {
+ return EnterpriseID, "projects/android/topics/ae98ed130-5ce2-4ddb-a90a-191ec76976d5", nil
+ }
+ ts.Proxy.EnterprisesPoliciesPatchFunc = func(enterpriseID string, policyName string, policy *androidmanagement.Policy) error {
+ assert.Equal(t, EnterpriseID, enterpriseID)
+ return nil
+ }
+ ts.Proxy.EnterpriseDeleteFunc = func(enterpriseID string) error {
+ assert.Equal(t, EnterpriseID, enterpriseID)
+ return nil
+ }
+}
+
+func (ts *WithServer) TearDownSuite() {
+ mysql.Close(ts.DS)
+}
+
+type mockService struct {
+ mock.Mock
+ fleet.Service
+}
+
+func (m *mockService) GetSessionByKey(ctx context.Context, sessionKey string) (*fleet.Session, error) {
+ return &fleet.Session{UserID: 1}, nil
+}
+
+func (m *mockService) UserUnauthorized(ctx context.Context, userId uint) (*fleet.User, error) {
+ return &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}, nil
+}
+
+func runServerForTests(t *testing.T, logger kitlog.Logger, fleetSvc fleet.Service, androidSvc android.Service) *httptest.Server {
+
+ fleetAPIOptions := []kithttp.ServerOption{
+ kithttp.ServerBefore(
+ kithttp.PopulateRequestContext,
+ auth.SetRequestsContexts(fleetSvc),
+ ),
+ kithttp.ServerErrorHandler(&endpoint_utils.ErrorHandler{Logger: logger}),
+ kithttp.ServerErrorEncoder(endpoint_utils.EncodeError),
+ kithttp.ServerAfter(
+ kithttp.SetContentType("application/json; charset=utf-8"),
+ log.LogRequestEnd(logger),
+ ),
+ }
+
+ r := mux.NewRouter()
+ service.GetRoutes(fleetSvc, androidSvc)(r, fleetAPIOptions)
+ rootMux := http.NewServeMux()
+ rootMux.HandleFunc("/api/", r.ServeHTTP)
+
+ server := httptest.NewUnstartedServer(rootMux)
+ serverConfig := config.ServerConfig{}
+ server.Config = serverConfig.DefaultHTTPServer(testCtx(), rootMux)
+ require.NotZero(t, server.Config.WriteTimeout)
+ server.Config.Handler = rootMux
+ server.Start()
+ t.Cleanup(func() {
+ server.Close()
+ })
+ return server
+}
+
+func testCtx() context.Context {
+ return context.Background()
+}
+
+func CreateNamedMySQLDS(t *testing.T, name string) *mysql.Datastore {
+ if _, ok := os.LookupEnv("MYSQL_TEST"); !ok {
+ t.Skip("MySQL tests are disabled")
+ }
+ ds := mysql.InitializeDatabase(t, name, new(testing_utils.DatastoreTestOptions))
+ t.Cleanup(func() { mysql.Close(ds) })
+ return ds
+}
diff --git a/server/mdm/scep/Dockerfile b/server/mdm/scep/Dockerfile
index 89e0abca6d..d82f4ff912 100644
--- a/server/mdm/scep/Dockerfile
+++ b/server/mdm/scep/Dockerfile
@@ -1,4 +1,4 @@
-FROM alpine:3@sha256:c5b1261d6d3e43071626931fc004f70149baeba2c8ec672bd4f27761f8e1ad6b
+FROM alpine:3.21.3@sha256:a8560b36e8b8210634f77d9f7f9efd7ffa463e380b75e2e74aff4511df3ef88c
COPY ./scepclient-linux-amd64 /usr/bin/scepclient
COPY ./scepserver-linux-amd64 /usr/bin/scepserver
diff --git a/server/service/endpoint_utils_test.go b/server/service/endpoint_utils_test.go
index 5831f7ddbf..ec0009b73a 100644
--- a/server/service/endpoint_utils_test.go
+++ b/server/service/endpoint_utils_test.go
@@ -16,6 +16,7 @@ import (
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/service/middleware/auth"
"github.com/fleetdm/fleet/v4/server/service/middleware/endpoint_utils"
+ "github.com/fleetdm/fleet/v4/server/service/middleware/log"
"github.com/go-kit/kit/endpoint"
kithttp "github.com/go-kit/kit/transport/http"
kitlog "github.com/go-kit/log"
@@ -292,7 +293,7 @@ func TestEndpointer(t *testing.T) {
kithttp.ServerErrorEncoder(endpoint_utils.EncodeError),
kithttp.ServerAfter(
kithttp.SetContentType("application/json; charset=utf-8"),
- logRequestEnd(kitlog.NewNopLogger()),
+ log.LogRequestEnd(kitlog.NewNopLogger()),
checkLicenseExpiration(svc),
),
}
@@ -412,7 +413,7 @@ func TestEndpointerCustomMiddleware(t *testing.T) {
kithttp.ServerErrorEncoder(endpoint_utils.EncodeError),
kithttp.ServerAfter(
kithttp.SetContentType("application/json; charset=utf-8"),
- logRequestEnd(kitlog.NewNopLogger()),
+ log.LogRequestEnd(kitlog.NewNopLogger()),
checkLicenseExpiration(svc),
),
}
diff --git a/server/service/handler.go b/server/service/handler.go
index f3ede383d4..0cd7de17a0 100644
--- a/server/service/handler.go
+++ b/server/service/handler.go
@@ -11,7 +11,6 @@ import (
eeservice "github.com/fleetdm/fleet/v4/ee/server/service"
"github.com/fleetdm/fleet/v4/server/config"
- "github.com/fleetdm/fleet/v4/server/contexts/logging"
"github.com/fleetdm/fleet/v4/server/contexts/publicip"
"github.com/fleetdm/fleet/v4/server/fleet"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
@@ -26,6 +25,7 @@ import (
scepserver "github.com/fleetdm/fleet/v4/server/mdm/scep/server"
"github.com/fleetdm/fleet/v4/server/service/middleware/auth"
"github.com/fleetdm/fleet/v4/server/service/middleware/endpoint_utils"
+ "github.com/fleetdm/fleet/v4/server/service/middleware/log"
"github.com/fleetdm/fleet/v4/server/service/middleware/mdmconfigured"
"github.com/fleetdm/fleet/v4/server/service/middleware/ratelimit"
kithttp "github.com/go-kit/kit/transport/http"
@@ -42,17 +42,6 @@ import (
microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft"
)
-func logRequestEnd(logger kitlog.Logger) func(context.Context, http.ResponseWriter) context.Context {
- return func(ctx context.Context, w http.ResponseWriter) context.Context {
- logCtx, ok := logging.FromContext(ctx)
- if !ok {
- return ctx
- }
- logCtx.Log(ctx, logger)
- return ctx
- }
-}
-
func checkLicenseExpiration(svc fleet.Service) func(context.Context, http.ResponseWriter) context.Context {
return func(ctx context.Context, w http.ResponseWriter) context.Context {
license, err := svc.License(ctx)
@@ -103,7 +92,7 @@ func MakeHandler(
kithttp.ServerErrorEncoder(endpoint_utils.EncodeError),
kithttp.ServerAfter(
kithttp.SetContentType("application/json; charset=utf-8"),
- logRequestEnd(logger),
+ log.LogRequestEnd(logger),
checkLicenseExpiration(svc),
),
}
diff --git a/server/service/hosts.go b/server/service/hosts.go
index 760b31ce9a..6c48f01438 100644
--- a/server/service/hosts.go
+++ b/server/service/hosts.go
@@ -1057,6 +1057,7 @@ func (svc *Service) RefetchHost(ctx context.Context, id uint) error {
return ctxerr.Wrap(ctx, err, "save host")
}
+ // TODO(android): add android to this list?
if host != nil && (host.Platform == "ios" || host.Platform == "ipados") {
// Get MDM commands already sent
commands, err := svc.ds.GetHostMDMCommands(ctx, host.ID)
diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go
index 9d6476ea7e..6de9502425 100644
--- a/server/service/integration_core_test.go
+++ b/server/service/integration_core_test.go
@@ -27,6 +27,7 @@ import (
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/live_query/live_query_mock"
+ "github.com/fleetdm/fleet/v4/server/mdm/android"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/service/async"
"github.com/fleetdm/fleet/v4/server/service/middleware/endpoint_utils"
@@ -8299,8 +8300,16 @@ func (s *integrationTestSuite) TestPasswordReset() {
require.NotZero(t, createResp.User.ID)
u := *createResp.User
+ // Request password reset when SMTP/SES is not configured
+ res := s.DoRawNoAuth("POST", "/api/latest/fleet/forgot_password", jsonMustMarshal(t, forgotPasswordRequest{Email: "invalid@asd.com"}), http.StatusInternalServerError)
+ res.Body.Close()
+
+ // Configure SMTP
+ var configResp appConfigResponse
+ s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage("{\"smtp_settings\":{\"enable_smtp\":true,\"sender_address\":\"user@example.com\",\"server\":\"127.0.0.1\",\"port\":1025,\"authentication_type\":\"authtype_none\"}}"), http.StatusOK, &configResp)
+
// request forgot password, invalid email
- res := s.DoRawNoAuth("POST", "/api/latest/fleet/forgot_password", jsonMustMarshal(t, forgotPasswordRequest{Email: "invalid@asd.com"}), http.StatusAccepted)
+ res = s.DoRawNoAuth("POST", "/api/latest/fleet/forgot_password", jsonMustMarshal(t, forgotPasswordRequest{Email: "invalid@asd.com"}), http.StatusAccepted)
res.Body.Close()
// TODO: tested manually (adds too much time to the test), works but hitting the rate
@@ -12985,6 +12994,91 @@ func (s *integrationTestSuite) TestSecretVariables() {
assert.Equal(t, "value", secrets[0].Value)
}
+func (s *integrationTestSuite) TestListAndroidHostsInLabel() {
+ t := s.T()
+ ctx := context.Background()
+
+ hostIDs := createAndroidHosts(t, s.ds, 3, nil)
+ notAndroidHost := createOrbitEnrolledHost(t, "darwin", "-4", s.ds)
+
+ // list labels, has the built-in ones, capture All and Android
+ var listResp listLabelsResponse
+ s.DoJSON("GET", "/api/latest/fleet/labels", nil, http.StatusOK, &listResp)
+ var allLblID, androidLblID uint
+ for _, lbl := range listResp.Labels {
+ switch lbl.Name {
+ case fleet.BuiltinLabelNameAllHosts:
+ allLblID = lbl.ID
+ case fleet.BuiltinLabelNameAndroid:
+ androidLblID = lbl.ID
+ }
+ }
+ require.NotZero(t, allLblID)
+ require.NotZero(t, androidLblID)
+
+ err := s.ds.AddLabelsToHost(ctx, notAndroidHost.ID, []uint{allLblID})
+ require.NoError(t, err)
+
+ pluckHostIDs := func(hosts []fleet.HostResponse) []uint {
+ ids := make([]uint, 0, len(hosts))
+ for _, h := range hosts {
+ ids = append(ids, h.ID)
+ }
+ return ids
+ }
+
+ // list hosts in all hosts
+ var listHostsResp listHostsResponse
+ s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", allLblID), nil, http.StatusOK, &listHostsResp)
+ require.Len(t, listHostsResp.Hosts, len(hostIDs)+1)
+ wantIDs := append([]uint{notAndroidHost.ID}, hostIDs...)
+ require.ElementsMatch(t, wantIDs, pluckHostIDs(listHostsResp.Hosts))
+
+ // count hosts in label
+ var countResp countHostsResponse
+ s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusOK, &countResp, "label_id", fmt.Sprint(allLblID))
+ require.Equal(t, len(hostIDs)+1, countResp.Count)
+
+ // list android hosts
+ listHostsResp = listHostsResponse{}
+ s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", androidLblID), nil, http.StatusOK, &listHostsResp)
+ require.Len(t, listHostsResp.Hosts, len(hostIDs))
+ require.ElementsMatch(t, hostIDs, pluckHostIDs(listHostsResp.Hosts))
+
+ countResp = countHostsResponse{}
+ s.DoJSON("GET", "/api/latest/fleet/hosts/count", nil, http.StatusOK, &countResp, "label_id", fmt.Sprint(androidLblID))
+ require.Equal(t, len(hostIDs), countResp.Count)
+}
+
+func createAndroidHosts(t *testing.T, ds *mysql.Datastore, count int, teamID *uint) []uint {
+ ids := make([]uint, 0, count)
+ for i := range count {
+ host := &fleet.AndroidHost{
+ Host: &fleet.Host{
+ Hostname: fmt.Sprintf("hostname%d", i),
+ ComputerName: fmt.Sprintf("computer_name%d", i),
+ Platform: "android",
+ OSVersion: "Android 14",
+ Build: fmt.Sprintf("build%d", i),
+ Memory: 1024,
+ TeamID: teamID,
+ HardwareSerial: uuid.NewString(),
+ },
+ Device: &android.Device{
+ DeviceID: uuid.NewString(),
+ EnterpriseSpecificID: ptr.String(uuid.NewString()),
+ AndroidPolicyID: ptr.Uint(1),
+ LastPolicySyncTime: ptr.Time(time.Time{}),
+ },
+ }
+ host.SetNodeKey(*host.Device.EnterpriseSpecificID)
+ ahost, err := ds.NewAndroidHost(context.Background(), host)
+ require.NoError(t, err)
+ ids = append(ids, ahost.Host.ID)
+ }
+ return ids
+}
+
func (s *integrationTestSuite) TestHostCertificates() {
t := s.T()
ctx := context.Background()
@@ -13011,7 +13105,8 @@ func (s *integrationTestSuite) TestHostCertificates() {
notValidAfterTimes := []time.Time{
now.Add(time.Minute), now.Add(time.Hour),
now.Add(time.Second), now.Add(time.Millisecond),
- now.Add(2 * time.Second)}
+ now.Add(2 * time.Second),
+ }
certs := make([]*fleet.HostCertificateRecord, 0, len(certNames))
for i, name := range certNames {
certs = append(certs, &fleet.HostCertificateRecord{
diff --git a/server/service/integration_desktop_test.go b/server/service/integration_desktop_test.go
index 78773273be..2377b84bb2 100644
--- a/server/service/integration_desktop_test.go
+++ b/server/service/integration_desktop_test.go
@@ -254,7 +254,7 @@ func (s *integrationTestSuite) TestRateLimitOfEndpoints() {
endpoint: "/api/latest/fleet/forgot_password",
verb: "POST",
payload: forgotPasswordRequest{Email: "some@one.com"},
- burst: forgotPasswordRateLimitMaxBurst - 1,
+ burst: forgotPasswordRateLimitMaxBurst - 2,
status: http.StatusAccepted,
},
{
@@ -265,6 +265,12 @@ func (s *integrationTestSuite) TestRateLimitOfEndpoints() {
},
}
+ // Mock working SMTP for password reset
+ config, err := s.ds.AppConfig(context.Background())
+ require.NoError(s.T(), err)
+ config.SMTPSettings.SMTPConfigured = true
+ require.NoError(s.T(), s.ds.SaveAppConfig(context.Background(), config))
+
for _, tCase := range testCases {
b, err := json.Marshal(tCase.payload)
require.NoError(s.T(), err)
@@ -274,6 +280,13 @@ func (s *integrationTestSuite) TestRateLimitOfEndpoints() {
}
s.DoRawWithHeaders(tCase.verb, tCase.endpoint, b, http.StatusTooManyRequests, headers).Body.Close()
}
+
+ // Disable it again because integration tests leak state like a sieve
+ config, err = s.ds.AppConfig(context.Background())
+ require.NoError(s.T(), err)
+ config.SMTPSettings.SMTPConfigured = false
+ require.NoError(s.T(), s.ds.SaveAppConfig(context.Background(), config))
+
}
func (s *integrationTestSuite) TestErrorReporting() {
diff --git a/server/service/middleware/log/log.go b/server/service/middleware/log/log.go
index f1c7e5c1e5..007e2fbb1c 100644
--- a/server/service/middleware/log/log.go
+++ b/server/service/middleware/log/log.go
@@ -2,9 +2,11 @@ package log
import (
"context"
+ "net/http"
"github.com/fleetdm/fleet/v4/server/contexts/logging"
"github.com/go-kit/kit/endpoint"
+ kitlog "github.com/go-kit/log"
)
// Logged wraps an endpoint and adds the error if the context supports it
@@ -24,3 +26,14 @@ func Logged(next endpoint.Endpoint) endpoint.Endpoint {
return res, nil
}
}
+
+func LogRequestEnd(logger kitlog.Logger) func(context.Context, http.ResponseWriter) context.Context {
+ return func(ctx context.Context, w http.ResponseWriter) context.Context {
+ logCtx, ok := logging.FromContext(ctx)
+ if !ok {
+ return ctx
+ }
+ logCtx.Log(ctx, logger)
+ return ctx
+ }
+}
diff --git a/server/service/testing_client.go b/server/service/testing_client.go
index e02e6118a8..a67fb03214 100644
--- a/server/service/testing_client.go
+++ b/server/service/testing_client.go
@@ -26,9 +26,9 @@ import (
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/live_query/live_query_mock"
"github.com/fleetdm/fleet/v4/server/pubsub"
- "github.com/fleetdm/fleet/v4/server/service/middleware/endpoint_utils"
"github.com/fleetdm/fleet/v4/server/sso"
"github.com/fleetdm/fleet/v4/server/test"
+ fleet_httptest "github.com/fleetdm/fleet/v4/server/test/httptest"
"github.com/ghodss/yaml"
kitlog "github.com/go-kit/log"
"github.com/jmoiron/sqlx"
@@ -244,47 +244,11 @@ func (ts *withServer) Do(verb, path string, params interface{}, expectedStatusCo
func (ts *withServer) DoRawWithHeaders(
verb string, path string, rawBytes []byte, expectedStatusCode int, headers map[string]string, queryParams ...string,
) *http.Response {
- t := ts.s.T()
+ return fleet_httptest.DoHTTPReq(ts.s.T(), decodeJSON, verb, rawBytes, ts.server.URL+path, headers, expectedStatusCode, queryParams...)
+}
- requestBody := io.NopCloser(bytes.NewBuffer(rawBytes))
- req, err := http.NewRequest(verb, ts.server.URL+path, requestBody)
- require.NoError(t, err)
- for key, val := range headers {
- req.Header.Add(key, val)
- }
-
- opts := []fleethttp.ClientOpt{}
- if expectedStatusCode >= 300 && expectedStatusCode <= 399 {
- opts = append(opts, fleethttp.WithFollowRedir(false))
- }
- client := fleethttp.NewClient(opts...)
-
- if len(queryParams)%2 != 0 {
- require.Fail(t, "need even number of params: key value")
- }
- if len(queryParams) > 0 {
- q := req.URL.Query()
- for i := 0; i < len(queryParams); i += 2 {
- q.Add(queryParams[i], queryParams[i+1])
- }
- req.URL.RawQuery = q.Encode()
- }
-
- resp, err := client.Do(req)
- require.NoError(t, err)
-
- if resp.StatusCode != expectedStatusCode {
- defer resp.Body.Close()
- var je endpoint_utils.JsonError
- err := json.NewDecoder(resp.Body).Decode(&je)
- if err != nil {
- t.Logf("Error trying to decode response body as Fleet jsonError: %s", err)
- require.Equal(t, expectedStatusCode, resp.StatusCode, fmt.Sprintf("response: %+v", resp))
- }
- require.Equal(t, expectedStatusCode, resp.StatusCode, fmt.Sprintf("Fleet jsonError: %+v", je))
- }
-
- return resp
+func decodeJSON(r io.Reader, v interface{}) error {
+ return json.NewDecoder(r).Decode(v)
}
func (ts *withServer) DoRaw(verb string, path string, rawBytes []byte, expectedStatusCode int, queryParams ...string) *http.Response {
diff --git a/server/service/users.go b/server/service/users.go
index 7843b4b2c2..978619bb14 100644
--- a/server/service/users.go
+++ b/server/service/users.go
@@ -1135,7 +1135,9 @@ func forgotPasswordEndpoint(ctx context.Context, request interface{}, svc fleet.
// Any error returned by the service should not be returned to the
// client to prevent information disclosure (it will be logged in the
// server logs).
- _ = svc.RequestPasswordReset(ctx, req.Email)
+ if err := svc.RequestPasswordReset(ctx, req.Email); errors.Is(err, fleet.ErrPasswordResetNotConfigured) {
+ return forgotPasswordResponse{Err: err}, nil
+ }
return forgotPasswordResponse{}, nil
}
@@ -1150,6 +1152,14 @@ func (svc *Service) RequestPasswordReset(ctx context.Context, email string) erro
time.Sleep(time.Until(start.Add(1 * time.Second)))
}(time.Now())
+ config, err := svc.ds.AppConfig(ctx)
+ if err != nil {
+ return err
+ }
+ if !svc.mailService.CanSendEmail(*config.SMTPSettings) {
+ return fleet.ErrPasswordResetNotConfigured
+ }
+
user, err := svc.ds.UserByEmail(ctx, email)
if err != nil {
return err
@@ -1173,11 +1183,6 @@ func (svc *Service) RequestPasswordReset(ctx context.Context, email string) erro
return err
}
- config, err := svc.ds.AppConfig(ctx)
- if err != nil {
- return err
- }
-
var smtpSettings fleet.SMTPSettings
if config.SMTPSettings != nil {
smtpSettings = *config.SMTPSettings
diff --git a/server/test/httptest/README.md b/server/test/httptest/README.md
new file mode 100644
index 0000000000..28f7a61526
--- /dev/null
+++ b/server/test/httptest/README.md
@@ -0,0 +1,2 @@
+These HTTP test functions are in a separate package to prevent circular dependencies.
+The circular dependency may be caused due to dependency on "github.com/fleetdm/fleet/v4/server/service/middleware/endpoint_utils"
diff --git a/server/test/httptest/http.go b/server/test/httptest/http.go
new file mode 100644
index 0000000000..768121b0f5
--- /dev/null
+++ b/server/test/httptest/http.go
@@ -0,0 +1,55 @@
+package httptest
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "net/http"
+ "testing"
+
+ "github.com/fleetdm/fleet/v4/pkg/fleethttp"
+ "github.com/fleetdm/fleet/v4/server/service/middleware/endpoint_utils"
+ "github.com/stretchr/testify/require"
+)
+
+func DoHTTPReq(t *testing.T, jsonDecoder func(r io.Reader, v interface{}) error, verb string, rawBytes []byte, urlPath string,
+ headers map[string]string, expectedStatusCode int, queryParams ...string) *http.Response {
+ requestBody := io.NopCloser(bytes.NewBuffer(rawBytes))
+ req, err := http.NewRequest(verb, urlPath, requestBody)
+ require.NoError(t, err)
+ for key, val := range headers {
+ req.Header.Add(key, val)
+ }
+
+ opts := []fleethttp.ClientOpt{}
+ if expectedStatusCode >= 300 && expectedStatusCode <= 399 {
+ opts = append(opts, fleethttp.WithFollowRedir(false))
+ }
+ client := fleethttp.NewClient(opts...)
+
+ if len(queryParams)%2 != 0 {
+ require.Fail(t, "need even number of params: key value")
+ }
+ if len(queryParams) > 0 {
+ q := req.URL.Query()
+ for i := 0; i < len(queryParams); i += 2 {
+ q.Add(queryParams[i], queryParams[i+1])
+ }
+ req.URL.RawQuery = q.Encode()
+ }
+
+ resp, err := client.Do(req)
+ require.NoError(t, err)
+
+ if resp.StatusCode != expectedStatusCode {
+ defer resp.Body.Close()
+ var je endpoint_utils.JsonError
+ err := jsonDecoder(resp.Body, &je)
+ if err != nil {
+ t.Logf("Error trying to decode response body as Fleet jsonError: %s", err)
+ require.Equal(t, expectedStatusCode, resp.StatusCode, fmt.Sprintf("response: %+v", resp))
+ }
+ require.Equal(t, expectedStatusCode, resp.StatusCode, fmt.Sprintf("Fleet jsonError: %+v", je))
+ }
+ return resp
+}
diff --git a/server/test/new_objects.go b/server/test/new_objects.go
index f56496faea..0e5d6caa66 100644
--- a/server/test/new_objects.go
+++ b/server/test/new_objects.go
@@ -183,6 +183,13 @@ func AddBuiltinLabels(t *testing.T, ds fleet.Datastore) {
LabelType: fleet.LabelTypeBuiltIn,
LabelMembershipType: fleet.LabelMembershipTypeDynamic,
},
+ {
+ Name: "Android",
+ Platform: "android",
+ Query: "",
+ LabelType: fleet.LabelTypeBuiltIn,
+ LabelMembershipType: fleet.LabelMembershipTypeManual,
+ },
}
names := fleet.ReservedLabelNames()
diff --git a/tools/fleet-docker/Dockerfile b/tools/fleet-docker/Dockerfile
index 506b901628..5f80596651 100644
--- a/tools/fleet-docker/Dockerfile
+++ b/tools/fleet-docker/Dockerfile
@@ -1,4 +1,4 @@
-FROM alpine:3.21@sha256:2c43f33bd1502ec7818bce9eea60e062d04eeadc4aa31cad9dabecb1e48b647b
+FROM alpine:3.21.3@sha256:a8560b36e8b8210634f77d9f7f9efd7ffa463e380b75e2e74aff4511df3ef88c
LABEL maintainer="Fleet Developers"
RUN apk --update add ca-certificates
diff --git a/tools/mdm/migration/mdmproxy/Dockerfile b/tools/mdm/migration/mdmproxy/Dockerfile
index 852f7d31e5..506d7a07b3 100644
--- a/tools/mdm/migration/mdmproxy/Dockerfile
+++ b/tools/mdm/migration/mdmproxy/Dockerfile
@@ -3,7 +3,7 @@ ARG TAG
RUN apk update && apk add --no-cache git
RUN git clone -b $TAG --depth=1 --no-tags --progress --no-recurse-submodules https://github.com/fleetdm/fleet.git && cd /go/fleet/tools/mdm/migration/mdmproxy && go build .
-FROM alpine:3.21@sha256:2c43f33bd1502ec7818bce9eea60e062d04eeadc4aa31cad9dabecb1e48b647b
+FROM alpine:3.21.3@sha256:a8560b36e8b8210634f77d9f7f9efd7ffa463e380b75e2e74aff4511df3ef88c
LABEL maintainer="Fleet Developers"
RUN apk update && apk add --no-cache tini
diff --git a/website/.eslintrc b/website/.eslintrc
index 468ec2e16d..86dbf767d5 100644
--- a/website/.eslintrc
+++ b/website/.eslintrc
@@ -49,6 +49,7 @@
"VantaConnection": true,
"CertificateSigningRequest": true,
"Platform": true,
+ "AdCampaign": true,
// …and any others.
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
},
diff --git a/website/api/controllers/webhooks/receive-from-zapier.js b/website/api/controllers/webhooks/receive-from-zapier.js
new file mode 100644
index 0000000000..66db756158
--- /dev/null
+++ b/website/api/controllers/webhooks/receive-from-zapier.js
@@ -0,0 +1,176 @@
+module.exports = {
+
+
+ friendlyName: 'Receive Zapier events',
+
+
+ description: 'Receive events from Zapier.',
+
+
+ inputs: {
+ eventName: {
+ type: 'string',
+ description: 'The unique identifier for this Zap.',
+ moreInfoUrl: 'https://zapier.com/app/assets/zaps/folders/2035513',
+ required: true,
+ },
+ data: {
+ type: {},
+ description: 'Data associated with this event.',
+ whereToGet: { description: 'Check out the Zap in question and see what it\'s sending via HTTP.' },
+ required: true,
+ },
+ webhookSecret: {
+ type: 'string',
+ description: 'Used to verify that requests are coming from where we think they are.',
+ required: true,
+ },
+ },
+
+
+ exits: {
+ success: { description: 'An event has successfully been received.' },
+ unrecognizedEventName: { description: 'I do not know how to handle that kind of event.', responseType: 'ok' },// TODO: how will zapier react to receiving a bad request response?
+ couldNotMatchLinkedinId: { description: 'A linkedIn company could not be found using the provided linkedIn url', responseType: 'ok' }
+ },
+
+
+ fn: async function ({eventName, data, webhookSecret}) {
+ let assert = require('assert');
+
+ if (!sails.config.custom.zapierWebhookSecret) {
+ throw new Error('No webhook secret configured! (Please set `sails.config.custom.zapierWebhookSecret`.)');
+ }
+
+ if (!sails.config.custom.iqSecret) {
+ throw new Error('No iqSecret configured! (Please set `sails.config.custom.iqSecret`.)');
+ }
+
+ if (sails.config.custom.zapierWebhookSecret !== webhookSecret) {
+ throw new Error('Received unexpected webhook request with webhookSecret set to: '+webhookSecret);
+ }
+ // Search for any campaigns that have a placeholder URN. If there are more than one records with a placeholder URN, throw an error.
+ let adCampaignsWithPlaceholderUrns = await AdCampaign.find({
+ isLatest: true,
+ linkedinCampaignUrn: {startsWith: 'PLACEHOLDER-'}
+ });
+ if(adCampaignsWithPlaceholderUrns.length > 1) {
+ throw new Error(`Consistency violation. When the receive-from-zapier webhook received an event from the ${eventName} zap. More than one adcampaigns with a placeholder campaign URN exist in the database.`);
+ }
+
+ // Zap: https://zapier.com/editor/280954803
+ if(eventName === 'update-placeholder-campaign-urn') {
+ assert(_.isObject(data));
+ assert(_.isString(data.placeholderUrn));
+ assert(_.isString(data.linkedinCampaignUrn));
+
+ let adCampaignWithThisPlaceholderUrn = await AdCampaign.findOne({linkedinCampaignUrn: data.placeholderUrn});
+ if(!adCampaignWithThisPlaceholderUrn) {
+ sails.log.warn(`when the receive-from-zapier webhook received an event to update an AdCampaign record with a non-placeholder linkedinCampaignUrn value (${data.linkedinCampaignUrn}), no record could be found with the specified placeholder (${data.placeholderUrn}).`);
+ }
+ await AdCampaign.updateOne({linkedinCampaignUrn: data.placeholderUrn}).set({
+ linkedinCampaignUrn: data.linkedinCampaignUrn
+ });
+ // Zap: https://zapier.com/editor/281086063
+ } else if (eventName === 'receive-new-customer-data') {
+ assert(_.isObject(data));
+ assert(_.isString(data.newMarketingStage));
+ assert(_.isString(data.name));
+ assert(_.isString(data.website));
+ assert(_.isString(data.linkedinCompanyPageUrl));
+ assert(_.isString(data.persona) && AdCampaign.validate('persona', data.persona));
+
+ // Enrich to obtain linkedin company ID using provided data.
+ // Remove any trailing slashes from the LinkedIn URL.
+ let trailingSlashlessLinkedinCompanyUrl = _.trim(data.linkedinCompanyPageUrl, '/');
+ // Split the LinkedIn url by slashes
+ let splitLinkedinCompanyUrl = trailingSlashlessLinkedinCompanyUrl.split('/');
+ // Grab the last fragment of the URL, we'll use this for the coreSignal API request to
+ let linkedinCompanyIdOrSlug = splitLinkedinCompanyUrl[splitLinkedinCompanyUrl.length - 1];
+ let matchedCompanyPageInfo = await sails.helpers.http.get('https://api.coresignal.com/cdapi/v1/linkedin/company/collect/'+linkedinCompanyIdOrSlug, {}, {
+ Authorization: `Bearer ${sails.config.custom.iqSecret}`,
+ 'content-type': 'application/json'
+ }).intercept((err)=>{
+ sails.log.warn(`When the receive-from-zapier webhook received a request about a Salesforce record, a linkedin company could not be found using the provided linkedIn URL (${data.linkedinCompanyPageUrl})`, err);
+ return 'couldNotMatchLinkedinId';
+ });
+
+ // (FUTURE: make field for this and have it already in CRM so this step isn't necessary)
+ let linkedinCompanyId = matchedCompanyPageInfo.id;
+
+ // Check if we have enough space in our current active campaign.
+ // If so, then use it and update its inventory. Otherwise, prepare to create
+ // a new campaign, and use that instead, updating our set of active campaigns
+ // in the db, including marking the new one as the latest and greatest. Along the way,
+ // communicate with Campaign Manager to update or create the appropriate campaign.
+ let latestCampaign = await AdCampaign.findOne({ isLatest: true, persona: data.persona });
+ if (latestCampaign && latestCampaign.linkedinCompanyIds.length < 100) {
+ // Update ad campaign in Campaign Manager
+ // > For help w/ Linkedin API, see https://github.com/fleetdm/confidential/tree/main/ads
+ let filterCriteriaForLatestCampaign = latestCampaign.linkedinCompanyIds.map((id)=>{
+ return `urn:li:organization:${id}`;
+ });
+ await sails.helpers.http.sendHttpRequest.with({
+ method: 'POST',
+ url: `https://hooks.zapier.com/hooks/catch/3627242/2wdx23r?webhookSecret=${ encodeURIComponent(sails.config.custom.zapierWebhookSecret)}`,
+ body: {
+ campaignGroup: sails.config.custom.linkedinAbmCampaignGroupUrn,
+ name: latestCampaign.name,
+ linkedinCampaignUrn: latestCampaign.linkedinCampaignUrn,
+ targetingCriteria: filterCriteriaForLatestCampaign,
+ }
+ }).retry();
+
+ await AdCampaign.updateOne({ id: latestCampaign.id }).set({
+ linkedinCompanyIds: _.uniq(latestCampaign.linkedinCompanyIds.concat(linkedinCompanyId))
+ });
+ } else {
+
+ // First, mark the old campaign as no longer the latest.
+ // Note: Since we might not have done the first-time setup for
+ // this persona yet, it's possible there won't actually be a
+ // campaign record yet. (In that case, we'll create it momentarily.)
+ if (latestCampaign) {
+ await AdCampaign.updateOne({ id: latestCampaign.id }).set({
+ isLatest: false,
+ });
+ }//fi
+
+ // Create a placeholder linkedinCampaignUrn value to create the record with initially
+ // We'll use this value in a subsequent webhook run that will save update the record with the real linkedinCampaignUrn (once it has been created).
+ // Note: there is a possibility that a new campaign can't be created with only one linkedInCompanyID, (There is a minimum audience size of 300)
+ // In this case, we will treat this new campaign as the latest campaign in the website's database, and send updates for it as new company IDs are added.
+ // When the campaign actually exists in LinkedIn, Zapier will send another event to update the campaign urn in the website's database.
+ let placeholderUrn = 'PLACEHOLDER-'+sails.helpers.strings.random();
+ let nowAt = new Date();
+ let newCampaignName = `${data.persona} - ${nowAt.toISOString().trim('T')[0]} @ ${nowAt.toLocaleString().split(', ')[1]}`;
+ // Now save an incomplete reference to the new LinkedIn campaign.
+ latestCampaign = await AdCampaign.create({
+ isLatest: true,
+ persona: data.persona,
+ name: newCampaignName,
+ linkedinCampaignUrn: placeholderUrn,
+ linkedinCompanyIds: [ linkedinCompanyId ],
+ }).fetch();
+
+ // Then create new ad campaign in Campaign Manager
+ // > For help w/ Linkedin API, see https://github.com/fleetdm/confidential/tree/main/ads
+ await sails.helpers.http.sendHttpRequest.with({
+ method: 'POST',
+ url: `https://hooks.zapier.com/hooks/catch/3627242/2wdx23r?webhookSecret=${ encodeURIComponent(sails.config.custom.zapierWebhookSecret)}`,
+ body: {
+ campaignGroup: sails.config.custom.linkedinAbmCampaignGroupUrn,
+ name: newCampaignName,
+ targetingCriteria: [`urn:li:organization:${linkedinCompanyId}`],
+ linkedinCampaignUrn: placeholderUrn,
+ },
+ }).retry();
+ }
+ } else {
+ throw 'unrecognizedEventName';
+ }
+
+ }
+
+
+};
diff --git a/website/api/models/AdCampaign.js b/website/api/models/AdCampaign.js
new file mode 100644
index 0000000000..6edfd4796e
--- /dev/null
+++ b/website/api/models/AdCampaign.js
@@ -0,0 +1,65 @@
+/**
+ * AdCampaign.js
+ *
+ * @description :: A model definition represents a database table/collection.
+ * @docs :: https://sailsjs.com/docs/concepts/models-and-orm/models
+ */
+
+module.exports = {
+
+ attributes: {
+
+ // ╔═╗╦═╗╦╔╦╗╦╔╦╗╦╦ ╦╔═╗╔═╗
+ // ╠═╝╠╦╝║║║║║ ║ ║╚╗╔╝║╣ ╚═╗
+ // ╩ ╩╚═╩╩ ╩╩ ╩ ╩ ╚╝ ╚═╝╚═╝
+ persona: {
+ type: 'string',
+ isIn: [
+ 'elf.it-major-mdm',
+ // 'elf.it-gap-filler-mdm',
+ // 'elf.it-misc',
+ // 'elf.security-vm',
+ // 'elf.security-misc',
+ // 'santa.it-major-mdm',
+ // 'santa.it-gap-filler-mdm',
+ // 'santa.it-misc',
+ // 'santa.security-vm',
+ // 'santa.security-misc',
+ ],
+ required: true
+ },
+
+ name: {
+ type: 'string',
+ example: 'elf.it-major-mdm - 2024-02-24 @ 6:11pm',
+ required: true,
+ },
+
+ linkedinCampaignUrn: {
+ type: 'string',
+ example: 'urn:li:sponsoredCampaign:379399199',
+ required: true
+ },
+
+ isLatest: {
+ type: 'boolean',
+ description: 'Whether this is the latest and greatest campaign for this persona.',
+ },
+
+ // ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗
+ // ║╣ ║║║╠╩╗║╣ ║║╚═╗
+ // ╚═╝╩ ╩╚═╝╚═╝═╩╝╚═╝
+ linkedinCompanyIds: {
+ type: 'json',
+ example: [ 8482494, 28328 ],
+ defaultsTo: [],
+ },
+
+ // ╔═╗╔═╗╔═╗╔═╗╔═╗╦╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
+ // ╠═╣╚═╗╚═╗║ ║║ ║╠═╣ ║ ║║ ║║║║╚═╗
+ // ╩ ╩╚═╝╚═╝╚═╝╚═╝╩╩ ╩ ╩ ╩╚═╝╝╚╝╚═╝
+
+ },
+
+};
+
diff --git a/website/assets/.eslintrc b/website/assets/.eslintrc
index b23198f97a..24f4a2ca1d 100644
--- a/website/assets/.eslintrc
+++ b/website/assets/.eslintrc
@@ -65,6 +65,7 @@
"VantaConnection": false,
"CertificateSigningRequest": false,
"Platform": false,
+ "AdCampaign": false,
// ...and any other backend globals (e.g. `"Organization": false`)
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
}
diff --git a/website/assets/js/pages/homepage.page.js b/website/assets/js/pages/homepage.page.js
index 0dc9751a2d..67f74322c8 100644
--- a/website/assets/js/pages/homepage.page.js
+++ b/website/assets/js/pages/homepage.page.js
@@ -24,14 +24,13 @@ parasails.registerPage('homepage', {
// ║ ║╠╣ ║╣ ║ ╚╦╝║ ║ ║╣
// ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝
beforeMount: function() {
- //…
if(window.location.hash === '#unsubscribed'){
this.modal = 'unsubscribed';
window.location.hash = '';
}
},
mounted: async function() {
- this.animateHeroTicker();
+ // this.animateHeroTicker();
if(['mdm', 'eo-it', undefined].includes(this.primaryBuyingSituation)){
this.animateBottomTicker();
}
diff --git a/website/assets/styles/pages/homepage.less b/website/assets/styles/pages/homepage.less
index 745d7b5a65..6445dc9489 100644
--- a/website/assets/styles/pages/homepage.less
+++ b/website/assets/styles/pages/homepage.less
@@ -52,7 +52,6 @@
padding-left: 32px;
padding-bottom: 82px;
max-width: 1200px;
- height: 420px;
}
[purpose='ticker-container'] {
overflow: hidden;
@@ -88,7 +87,13 @@
max-width: 640px;
margin-bottom: 16px;
&.vm {
- max-width: unset;
+ max-width: 420px;
+ }
+ &.eo-it {
+ max-width: 420px;
+ }
+ &.eo-security {
+ max-width: 438px;
}
}
p {
diff --git a/website/config/custom.js b/website/config/custom.js
index b9d76f3f22..2f67a6c9b8 100644
--- a/website/config/custom.js
+++ b/website/config/custom.js
@@ -382,6 +382,9 @@ module.exports.custom = {
'qq.com',
],
+ // Zapier:
+ // zapierWebhookSecret: '…',
+
// Contact form:
// slackWebhookUrlForContactForm: '…',
diff --git a/website/config/routes.js b/website/config/routes.js
index b5499bc036..e07facd0f5 100644
--- a/website/config/routes.js
+++ b/website/config/routes.js
@@ -870,6 +870,7 @@ module.exports.routes = {
'GET /learn-more-about/end-user-license-agreement': '/guides/macos-setup-experience#end-user-authentication-and-end-user-license-agreement-eula',
'GET /learn-more-about/end-user-authentication': '/guides/macos-setup-experience#end-user-authentication-and-end-user-license-agreement-eula',
'GET /learn-more-about/policy-templates': '/policies',
+ 'GET /learn-more-about/windows-mdm': '/guides/windows-mdm-setup',
// Sitemap
// =============================================================================================================
@@ -913,6 +914,7 @@ module.exports.routes = {
'POST /api/v1/webhooks/receive-usage-analytics': { action: 'webhooks/receive-usage-analytics', csrf: false },
'/api/v1/webhooks/github': { action: 'webhooks/receive-from-github', csrf: false },
'POST /api/v1/webhooks/receive-from-stripe': { action: 'webhooks/receive-from-stripe', csrf: false },
+ 'POST /api/v1/webhooks/receive-from-zapier': { action: 'webhooks/receive-from-zapier', csrf: false },
'POST /api/v1/get-est-device-certificate': { action: 'get-est-device-certificate', csrf: false},
// ╔═╗╔═╗╦ ╔═╗╔╗╔╔╦╗╔═╗╔═╗╦╔╗╔╔╦╗╔═╗
diff --git a/website/views/pages/homepage.ejs b/website/views/pages/homepage.ejs
index 737c73d3aa..13b9982c18 100644
--- a/website/views/pages/homepage.ejs
+++ b/website/views/pages/homepage.ejs
@@ -6,6 +6,8 @@
<%/* Hero text */%>
+
<%- partial('../partials/primary-tagline.partial.ejs') %>
+ <% /*
One agent for
every
@@ -37,6 +39,7 @@
+ */ %>
Replace the sprawl with <%= primaryBuyingSituation === 'vm'? 'secure, open-source reporting that works the way you want' : primaryBuyingSituation === 'eo-security'? 'open-source telemetry that works the way you want' : 'lightning fast device management that lets employees see what\'s going on' %>.
Learn how
diff --git a/website/views/partials/primary-tagline.partial.ejs b/website/views/partials/primary-tagline.partial.ejs
index 2e549fb754..bb04b6a2dc 100644
--- a/website/views/partials/primary-tagline.partial.ejs
+++ b/website/views/partials/primary-tagline.partial.ejs
@@ -1,6 +1,7 @@
<%=
- (typeof primaryBuyingSituation !== 'undefined' && primaryBuyingSituation === 'vm') ? 'Focus on vulnerabilities, not vendors' // vm
- : (typeof primaryBuyingSituation !== 'undefined' && primaryBuyingSituation === 'eo-security') ? 'Easily get security data'// eo-security
- : (typeof primaryBuyingSituation !== 'undefined' && primaryBuyingSituation === 'eo-it') ? 'One system for every platform' : // eo-it
- 'One system for every OS'// mdm or default (no buying situation)
+ typeof primaryBuyingSituation === 'undefined' ? 'Open device management for everyone' // Default (no buying situation)
+ : primaryBuyingSituation === 'vm' ? 'Focus on vulnerabilities, not vendors' // vm
+ : primaryBuyingSituation === 'eo-security' ? 'Easily get security data'// eo-security
+ : primaryBuyingSituation === 'eo-it' ? 'Untangle your endpoints' : // eo-it
+ 'Open device management for everyone'// mdm
%>