Merge branch 'main' into feat-23235-host-certificates

This commit is contained in:
gillespi314
2025-02-26 12:43:19 -06:00
102 changed files with 1897 additions and 358 deletions
+1 -1
View File
@@ -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
@@ -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
+7
View File
@@ -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**.
<meta name="articleTitle" value="Windows MDM setup">
<meta name="authorFullName" value="Noah Talerman">
<meta name="authorGitHubUsername" value="noahtalerman">
+1
View File
@@ -0,0 +1 @@
- Fixed password authentication getting disabled when SMTP isn't configured
@@ -48,6 +48,31 @@ const displayIncompatibilityText = (err: Error) => {
}
};
// const tipContent = (
// <>
// Estimated compatibility based on the <br />
// tables used in the query. Querying <br />
// iPhones, iPads, and Android hosts is not <br />
// supported.
// </>
// );
// TODO(android): replace with the above tipContent when Android feature flag is removed
const tipContent = (
<>
Estimated compatibility based on the <br />
tables used in the query. Check the <br />
table documentation (schema) to verify <br />
compatibility of individual columns.
<br />
<br />
Only live queries are supported on ChromeOS.
<br />
<br />
Querying iPhones & iPads is not supported.
</>
);
const PlatformCompatibility = ({
compatiblePlatforms,
error,
@@ -84,22 +109,7 @@ const PlatformCompatibility = ({
return (
<div className={baseClass}>
<b>
<TooltipWrapper
tipContent={
<>
Estimated compatibility based on the <br />
tables used in the query. Check the <br />
table documentation (schema) to verify <br />
compatibility of individual columns.
<br />
<br />
Only live queries are supported on ChromeOS.
<br />
<br />
Querying iPhones & iPads is not supported.
</>
}
>
<TooltipWrapper tipContent={tipContent}>
Compatible with:
</TooltipWrapper>
</b>
+30
View File
@@ -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 (
<svg
width={ICON_SIZES[size]}
height={ICON_SIZES[size]}
viewBox="0 0 37 36"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M36.2229 27.609C36.2264 27.6311 36.2299 27.6532 36.2334 27.6755L36.2308 27.6771C36.2708 27.9395 36.3052 28.2038 36.334 28.4697H0.333984C0.362614 28.2048 0.397264 27.9406 0.437906 27.6773L0.441441 27.6544C0.451523 27.589 0.461687 27.5231 0.472641 27.4579C0.547458 27.0082 0.638975 26.5615 0.747 26.1186C1.09124 24.714 1.60318 23.356 2.2718 22.0737C2.65903 21.3295 3.09792 20.6134 3.58523 19.9305C4.47919 18.6776 5.52815 17.5429 6.70711 16.5534C7.3662 16.0006 8.06372 15.4953 8.79441 15.0413C8.79303 15.0386 8.7914 15.0359 8.78975 15.0332C8.7882 15.0307 8.78663 15.0281 8.78527 15.0255C8.60378 14.7119 8.42216 14.3985 8.24054 14.0851C8.05879 13.7715 7.87703 13.4578 7.69542 13.144C7.59975 12.979 7.50411 12.814 7.40846 12.649C7.14896 12.2014 6.88945 11.7538 6.62948 11.3061C6.37513 10.8658 6.12018 10.4259 5.86462 9.98624C5.8043 9.88189 5.75719 9.77389 5.72077 9.66336C5.59985 9.28573 5.61806 8.8773 5.77209 8.51192C5.80163 8.44077 5.83692 8.37144 5.87644 8.30478C6.01706 8.06962 6.21412 7.87324 6.44977 7.73342C6.66437 7.60538 6.90529 7.52786 7.1543 7.50674C7.4671 7.47933 7.78122 7.54172 8.0598 7.68659C8.33838 7.83146 8.56986 8.05279 8.72705 8.32461C8.98116 8.76435 9.23625 9.20492 9.49191 9.64452L10.5573 11.4825C10.747 11.8104 10.9371 12.138 11.1271 12.4657C11.3007 12.7651 11.4744 13.0645 11.6478 13.364C11.6703 13.4037 11.6933 13.4433 11.7164 13.4829C11.7306 13.5074 11.7449 13.5318 11.7589 13.5563C11.9646 13.4752 12.1718 13.3982 12.3805 13.3254C14.1903 12.6954 16.1302 12.3442 18.1492 12.3241C18.2118 12.3233 18.2732 12.3233 18.3354 12.3233C20.6062 12.3233 22.7786 12.7409 24.7811 13.5045C24.8326 13.5241 24.8836 13.5446 24.9351 13.5652L24.9459 13.5695C24.9738 13.5204 25.0021 13.4718 25.0302 13.4233C25.0419 13.4033 25.0535 13.3832 25.0651 13.3632C25.2549 13.0353 25.4449 12.7077 25.6349 12.38C25.8086 12.0806 25.9823 11.7812 26.1557 11.4816L27.2211 9.64382L27.238 9.61462C27.4869 9.18471 27.7363 8.75391 27.9858 8.32391C28.1165 8.09796 28.299 7.90625 28.5183 7.76452C28.7375 7.62278 28.9872 7.53506 29.2469 7.50857C29.3506 7.49806 29.455 7.49722 29.5588 7.50603C29.8074 7.52787 30.0478 7.60524 30.2625 7.73244C30.5665 7.91219 30.8043 8.18522 30.9407 8.51094C31.0404 8.74712 31.0838 9.00322 31.0677 9.25905C31.0515 9.51489 30.9762 9.76348 30.8476 9.98525C30.5937 10.4248 30.3386 10.8656 30.0829 11.3052L29.0175 13.1428C28.8348 13.4587 28.6517 13.7742 28.4687 14.0898C28.288 14.4013 28.1074 14.7128 27.927 15.0244C27.9235 15.0302 27.9203 15.0361 27.9171 15.0421C27.9139 15.048 27.9108 15.0539 27.9073 15.0596C29.0636 15.781 30.135 16.6304 31.1013 17.5915C32.4247 18.9062 33.5371 20.4175 34.399 22.0718C35.2795 23.7602 35.8867 25.5775 36.1981 27.4561C36.2067 27.507 36.2147 27.5578 36.2229 27.609ZM27.9322 21.0133C28.5237 21.9021 28.4189 23.0116 27.6987 23.4911C26.9788 23.9706 25.9154 23.6388 25.3243 22.7497C24.7329 21.8609 24.8378 20.7513 25.5581 20.2718C26.2781 19.7924 27.3412 20.1241 27.9322 21.0133ZM11.1423 20.275C11.8626 20.7546 11.9675 21.8639 11.376 22.7528C10.785 23.6419 9.72172 23.9737 9.00144 23.4942C8.28144 23.0147 8.17667 21.9053 8.76772 21.0161C9.35919 20.1274 10.4219 19.7955 11.1423 20.275Z"
fill={COLORS[color]}
/>
</svg>
);
};
export default Android;
+2
View File
@@ -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,
+28 -10
View File
@@ -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<Platform, "ios" | "ipados">;
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");
}
+1 -1
View File
@@ -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";
+25 -1
View File
@@ -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 = (
<>
<PlatformHostCounts
androidDevEnabled={!!config?.android_enabled}
currentTeamId={teamIdForApi}
macCount={macCount}
windowsCount={windowsCount}
@@ -547,6 +562,7 @@ const DashboardPage = ({ router, location }: IDashboardProps): JSX.Element => {
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 && <div className={`${baseClass}__section`}>{MDMCard}</div>}
</>
);
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<CustomOptionType>) => {
const selectedPlatformOption = PLATFORM_DROPDOWN_OPTIONS.find(
(platform) => platform.value === option?.value
@@ -1340,7 +1340,7 @@ const GlobalActivityItem = ({
const hasDetails = ACTIVITIES_WITH_DETAILS.has(activity.type);
const renderActivityPrefix = () => {
const DEFAULT_ACTOR_DISPLAY = <b>{activity.actor_full_name} </b>;
const DEFAULT_ACTOR_DISPLAY = <b>{activity.actor_full_name ?? "Fleet"} </b>;
switch (activity.type) {
case ActivityType.UserLoggedIn:
+5 -14
View File
@@ -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;
@@ -66,7 +66,8 @@ const MetricsHostCounts = ({
{selectedPlatform === "all" && TotalHostsCard}
{isPremiumTier &&
selectedPlatform !== "ios" &&
selectedPlatform !== "ipados" && (
selectedPlatform !== "ipados" &&
selectedPlatform !== "android" && (
<>
{MissingHostsCard}
{LowDiskSpaceHostsCard}
@@ -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 (
<HostCountCard
iconName="android"
count={androidCount}
title="Android"
path={PATHS.MANAGE_HOSTS_LABEL(androidLabelId).concat(
teamId !== undefined ? `?team_id=${teamId}` : ""
)}
/>
);
};
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)}
</>
);
}
@@ -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<OSUpdatesSupportedPlatform | null>(null);
] = useState<OSUpdatesTargetPlatform | null>(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 (
<div className={baseClass}>
@@ -131,9 +134,11 @@ const OSUpdates = ({ router, teamIdForApi, queryParams }: IOSUpdates) => {
refetchTeamConfig={refetchTeamConfig}
/>
</div>
<div className={`${baseClass}__nudge-preview`}>
<EndUserOSRequirementPreview platform={selectedPlatform} />
</div>
{!isAndroid(selectedPlatform) && (
<div className={`${baseClass}__nudge-preview`}>
<EndUserOSRequirementPreview platform={selectedPlatform} />
</div>
)}
</div>
</div>
);
@@ -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 (
<div className={baseClass}>
<TabsWrapper>
<Tabs
defaultIndex={PLATFORM_BY_INDEX.indexOf(selectedPlatform)}
defaultIndex={platformByIndex.indexOf(selectedPlatform)}
onSelect={onTabChange}
>
<TabList>
@@ -76,6 +84,11 @@ const PlatformTabs = ({
<Tab key="iPadOS" data-text="iPadOS">
iPadOS
</Tab>
{isAndroidMdmEnabled && (
<Tab key="Android" data-text="Android">
Android
</Tab>
)}
</TabList>
<TabPanel>
<AppleOSTargetForm
@@ -122,6 +135,19 @@ const PlatformTabs = ({
refetchTeamConfig={refetchTeamConfig}
/>
</TabPanel>
{isAndroidMdmEnabled && (
<TabPanel>
<div className={`${baseClass}__coming-soon`}>
<p>
<b>Android updates are coming soon.</b>
</p>
<p>
Need to encourage installation of Android updates?{" "}
<CustomLink url={SUPPORT_LINK} text="Let us know" newTab />
</p>
</div>
</TabPanel>
)}
</Tabs>
</TabsWrapper>
</div>
@@ -2,4 +2,10 @@
.react-tabs__tab-list {
margin-bottom: $pad-large;
}
&__coming-soon {
p {
margin: 0;
padding-bottom: $pad-small;
}
}
}
@@ -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 <Spinner />;
}
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 (
<PlatformTabs
<WindowsTargetForm
currentTeamId={currentTeamId}
defaultMacOSVersion={defaultMacOSVersion}
defaultMacOSDeadline={defaultMacOSDeadline}
defaultIOSVersion={defaultIOSVersion}
defaultIOSDeadline={defaultIOSDeadline}
defaultIPadOSVersion={defaultIPadOSOSVersion}
defaultIPadOSDeadline={defaultIPadOSDeadline}
defaultWindowsDeadlineDays={defaultWindowsDeadlineDays}
defaultWindowsGracePeriodDays={defaultWindowsGracePeriodDays}
selectedPlatform={selectedPlatform}
onSelectPlatform={onSelectPlatform}
defaultDeadlineDays={defaultWindowsDeadlineDays}
defaultGracePeriodDays={defaultWindowsGracePeriodDays}
refetchAppConfig={refetchAppConfig}
refetchTeamConfig={refetchTeamConfig}
isWindowsMdmEnabled={isWindowsMdmEnabled}
/>
);
}
return (
<WindowsTargetForm
<PlatformTabs
currentTeamId={currentTeamId}
defaultDeadlineDays={defaultWindowsDeadlineDays}
defaultGracePeriodDays={defaultWindowsGracePeriodDays}
defaultMacOSVersion={defaultMacOSVersion}
defaultMacOSDeadline={defaultMacOSDeadline}
defaultIOSVersion={defaultIOSVersion}
defaultIOSDeadline={defaultIOSDeadline}
defaultIPadOSVersion={defaultIPadOSOSVersion}
defaultIPadOSDeadline={defaultIPadOSDeadline}
defaultWindowsDeadlineDays={defaultWindowsDeadlineDays}
defaultWindowsGracePeriodDays={defaultWindowsGracePeriodDays}
selectedPlatform={selectedPlatform}
onSelectPlatform={onSelectPlatform}
refetchAppConfig={refetchAppConfig}
refetchTeamConfig={refetchTeamConfig}
isWindowsMdmEnabled={isWindowsMdmEnabled}
isAndroidMdmEnabled={isAndroidMdmEnabled}
/>
);
};
@@ -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"
@@ -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?
<TextCell value={cellProps.cell.value} />
),
},
@@ -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 <TextCell value={cellProps.cell.value} />;
@@ -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 <TextCell value={cellProps.cell.value} />;
@@ -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?
<TextCell
value={{ timeString: cellProps.cell.value }}
formatter={HumanTimeDiffWithFleetLaunchCutoff}
@@ -509,10 +499,7 @@ const allHostTableHeaders: IHostTableColumnConfig[] = [
accessor: "seen_time",
id: "seen_time",
Cell: (cellProps: IHostTableStringCellProps) => {
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) => (
<TextCell value={cellProps.cell.value} />
),
Cell: (cellProps: IHostTableStringCellProps) => {
// TODO(android): is iOS/iPadOS supported?
if (isAndroid(cellProps.row.original.platform)) {
return NotSupported;
}
return <TextCell value={cellProps.cell.value} />;
},
},
{
title: "Serial number",
@@ -622,9 +609,13 @@ const allHostTableHeaders: IHostTableColumnConfig[] = [
),
accessor: "hardware_serial",
id: "hardware_serial",
Cell: (cellProps: IHostTableStringCellProps) => (
<TextCell value={cellProps.cell.value} />
),
Cell: (cellProps: IHostTableStringCellProps) => {
// TODO(android): is iOS/iPadOS supported?
if (isAndroid(cellProps.row.original.platform)) {
return NotSupported;
}
return <TextCell value={cellProps.cell.value} />;
},
},
{
title: "Hardware model",
@@ -77,7 +77,11 @@ const DeleteHostModal = ({
newTab
/>
</li>
<li>iOS and iPadOS hosts will re-appear unless MDM is turned off.</li>
<li>
{/* 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.
</li>
</ul>
<div className="modal-cta-wrap">
<Button
@@ -2,7 +2,12 @@ import React from "react";
import { cloneDeep } from "lodash";
import { IDropdownOption } from "interfaces/dropdownOption";
import { isLinuxLike, isAppleDevice } from "interfaces/platform";
import {
isLinuxLike,
isAppleDevice,
isMobilePlatform,
isAndroid,
} from "interfaces/platform";
import { isScriptSupportedPlatform } from "interfaces/script";
import {
@@ -96,6 +101,7 @@ const canEditMdm = (config: IHostActionConfigOptions) => {
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 = ({
@@ -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
@@ -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 (
<DataSet title="Hardware model" value={aboutData.hardware_model} />
);
}
return (
<>
<DataSet title="Hardware model" value={aboutData.hardware_model} />
@@ -205,6 +211,8 @@ const About = ({
);
};
// TODO(android): confirm visible fields using actual android device data
return (
<Card
borderRadiusSize="xxlarge"
@@ -222,7 +230,7 @@ const About = ({
/>
}
/>
{!isIosOrIpadosHost && (
{!isIosOrIpadosHost && !isAndroidHost && (
<DataSet
title="Last restarted"
value={
@@ -21,7 +21,7 @@ const InstalledSoftwareActivityItem = ({
const actorDisplayName = self_service ? (
<span>End user</span>
) : (
<b>{actorName}</b>
<b>{actorName ?? "Fleet"}</b>
);
return (
@@ -26,7 +26,7 @@ const RanScriptActivityItem = ({
isSoloActivity={isSoloActivity}
hideCancel={hideCancel}
>
<b>{activity.actor_full_name}</b>
<b>{activity.actor_full_name ?? "Fleet"}</b>
<>
{" "}
{ranScriptPrefix}{" "}
@@ -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 (
<DataSet title="Operating system" value={summaryData.os_version} />
@@ -384,12 +390,12 @@ const HostSummary = ({
};
const renderAgentSummary = () => {
if (isChromeHost) {
return <DataSet title="Agent" value={summaryData.osquery_version} />;
if (isIosOrIpadosHost || isAndroidHost) {
return null;
}
if (isIosOrIpadosHost) {
return null;
if (isChromeHost) {
return <DataSet title="Agent" value={summaryData.osquery_version} />;
}
if (summaryData.orbit_version !== DEFAULT_EMPTY_CELL_VALUE) {
@@ -499,7 +505,7 @@ const HostSummary = ({
largePadding
className={`${baseClass}-card`}
>
{!isIosOrIpadosHost && (
{!isIosOrIpadosHost && !isAndroidHost && (
<DataSet
title="Status"
value={
@@ -515,6 +521,7 @@ const HostSummary = ({
)}
{summaryData.issues?.total_issues_count > 0 &&
!isIosOrIpadosHost &&
!isAndroidHost &&
renderIssues()}
{isPremiumTier && renderHostTeam()}
{/* Rendering of OS Settings data */}
@@ -532,17 +539,19 @@ const HostSummary = ({
}
/>
)}
{bootstrapPackageData?.status && !isIosOrIpadosHost && (
<DataSet
title="Bootstrap package"
value={
<BootstrapPackageIndicator
status={bootstrapPackageData.status}
onClick={toggleBootstrapPackageModal}
/>
}
/>
)}
{bootstrapPackageData?.status &&
!isIosOrIpadosHost &&
!isAndroidHost && (
<DataSet
title="Bootstrap package"
value={
<BootstrapPackageIndicator
status={bootstrapPackageData.status}
onClick={toggleBootstrapPackageModal}
/>
}
/>
)}
{!isChromeHost && renderDiskSpaceSummary()}
{renderDiskEncryptionSummary()}
{!isIosOrIpadosHost && (
@@ -555,7 +564,7 @@ const HostSummary = ({
<DataSet title="Processor type" value={summaryData.cpu_type} />
)}
{renderOperatingSystemSummary()}
{!isIosOrIpadosHost && renderAgentSummary()}
{renderAgentSummary()}
{isPremiumTier &&
// TODO - refactor normalizeEmptyValues pattern
!!summaryData.maintenance_window &&
@@ -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 (
<EmptyTable
header={<>Policies are not supported for this host</>}
info={
<>
Interested in detecting device health issues on Android hosts?{" "}
<CustomLink url={SUPPORT_LINK} text="Let us know" newTab />
</>
}
/>
);
}
if (policies.length === 0) {
return (
<EmptyTable
@@ -1,5 +1,6 @@
import React, { useCallback, useMemo } from "react";
import { isAndroid } from "interfaces/platform";
import { IQueryStats } from "interfaces/query_stats";
import { SUPPORT_LINK } from "utilities/constants";
import TableContainer from "components/TableContainer";
@@ -74,6 +75,20 @@ const HostQueries = ({
);
}
if (isAndroid(hostPlatform)) {
return (
<EmptyTable
header="Queries are not supported for this host"
info={
<>
Interested in querying Android hosts?{" "}
<CustomLink url={SUPPORT_LINK} text="Let us know" newTab />
</>
}
/>
);
}
return (
<EmptyTable
header="No queries are scheduled to run on this host"
@@ -12,7 +12,7 @@ import deviceAPI, {
IGetDeviceSoftwareResponse,
} from "services/entities/device_user";
import { IHostSoftware, ISoftware } from "interfaces/software";
import { HostPlatform, isIPadOrIPhone } from "interfaces/platform";
import { HostPlatform, isAndroid, isIPadOrIPhone } from "interfaces/platform";
import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
import { NotificationContext } from "context/notification";
import { AppContext } from "context/app";
@@ -35,7 +35,7 @@ export interface ITableSoftware extends Omit<ISoftware, "vulnerabilities"> {
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,
}
@@ -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 (
<EmptyTable
header="Software is not supported for this host"
info={
<>
Interested in viewing software for Android hosts?{" "}
<CustomLink url={SUPPORT_LINK} text="Let us know" newTab />
</>
}
/>
);
}
return (
<div className={baseClass}>
<TableContainer
+1
View File
@@ -166,6 +166,7 @@ const routes = (
<Route path="chrome" component={DashboardPage} />
<Route path="ios" component={DashboardPage} />
<Route path="ipados" component={DashboardPage} />
<Route path="android" component={DashboardPage} />
</Route>
<Route path="settings" component={AuthAnyAdminRoutes}>
<IndexRedirect to="organization/info" />
+1
View File
@@ -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
+11 -19
View File
@@ -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<string, DisplayPlatform> = {
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<PlatformLabelNameFromAPI, keyof typeof PLATFORM_TYPE_ICONS> => {
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<PlatformLabelOptions, "ChromeOS" | "iOS" | "iPadOS">;
value: Exclude<PlatformValueOptions, "chrome" | "ios" | "ipados"> | "";
}
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";
+2 -1
View File
@@ -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
+4
View File
@@ -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=
@@ -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
@@ -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;
@@ -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
@@ -0,0 +1 @@
- Ensure only one copy of fleet desktop is running at a time
+42
View File
@@ -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() {
+8 -9
View File
@@ -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):
+5 -3
View File
@@ -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
+6 -6
View File
@@ -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) {
+26 -15
View File
@@ -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
+3 -3
View File
@@ -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)
}
+1 -1
View File
@@ -1027,5 +1027,5 @@ allow {
allow {
object.type == "android_enterprise"
subject.global_role == admin
action == write
action == [read, write][_]
}
+49 -2
View File
@@ -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
}
+27
View File
@@ -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 {
+1 -1
View File
@@ -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"`
+1
View File
@@ -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
)
@@ -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
}
File diff suppressed because one or more lines are too long
+3
View File
@@ -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
+3 -2
View File
@@ -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 ""
+2
View File
@@ -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: {},
}
}
+1
View File
@@ -770,6 +770,7 @@ func MDMPlatform(hostPlatform string) string {
return "darwin"
case "windows":
return "windows"
// TODO(android): add android to this list?
}
return ""
}
+1
View File
@@ -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(
+1 -1
View File
@@ -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)
+4
View File
@@ -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"
+113
View File
@@ -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)
}
@@ -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
}
}
+77
View File
@@ -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)
}
+23
View File
@@ -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
}
}
+1 -1
View File
@@ -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 {
+3 -3
View File
@@ -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))
@@ -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)
+22
View File
@@ -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
}
+20
View File
@@ -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
}
@@ -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
}
+8 -7
View File
@@ -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{})
}
+9 -7
View File
@@ -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)
}
+1 -1
View File
@@ -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 {
+56 -29
View File
@@ -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) {
+5
View File
@@ -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.
@@ -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)
}
+49
View File
@@ -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)
}
+157
View File
@@ -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
}
+1 -1
View File
@@ -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
+3 -2
View File
@@ -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),
),
}
+2 -13
View File
@@ -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),
),
}
+1
View File
@@ -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)
+97 -2
View File
@@ -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{
+14 -1
View File
@@ -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() {
+13
View File
@@ -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
}
}
+5 -41
View File
@@ -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 {
+11 -6
View File
@@ -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
+2
View File
@@ -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"
+55
View File
@@ -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
}
+7
View File
@@ -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()
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1
View File
@@ -49,6 +49,7 @@
"VantaConnection": true,
"CertificateSigningRequest": true,
"Platform": true,
"AdCampaign": true,
// …and any others.
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
},
+176
View File
@@ -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';
}
}
};
+65
View File
@@ -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: [],
},
// ╔═╗╔═╗╔═╗╔═╗╔═╗╦╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
// ╠═╣╚═╗╚═╗║ ║║ ║╠═╣ ║ ║║ ║║║║╚═╗
// ╩ ╩╚═╝╚═╝╚═╝╚═╝╩╩ ╩ ╩ ╩╚═╝╝╚╝╚═╝
},
};
+1
View File
@@ -65,6 +65,7 @@
"VantaConnection": false,
"CertificateSigningRequest": false,
"Platform": false,
"AdCampaign": false,
// ...and any other backend globals (e.g. `"Organization": false`)
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
}
+1 -2
View File
@@ -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();
}
+7 -2
View File
@@ -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 {
+3
View File
@@ -382,6 +382,9 @@ module.exports.custom = {
'qq.com',
],
// Zapier:
// zapierWebhookSecret: '…',
// Contact form:
// slackWebhookUrlForContactForm: '…',
+2
View File
@@ -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},
// ╔═╗╔═╗╦ ╔═╗╔╗╔╔╦╗╔═╗╔═╗╦╔╗╔╔╦╗╔═╗

Some files were not shown because too many files have changed in this diff Show More