mirror of
https://github.com/fleetdm/fleet
synced 2026-05-23 08:58:41 +00:00
Add certificates to host vitals for macOS, iOS, iPadOS (#26663)
This commit is contained in:
commit
0527b1c11f
69 changed files with 2419 additions and 138 deletions
1
changes/23235-host-certificates
Normal file
1
changes/23235-host-certificates
Normal file
|
|
@ -0,0 +1 @@
|
|||
- Added new features to include certificates in host vitals for macOS, iOS, and iPadOS.
|
||||
1
changes/25460-add-list-host-certificates-endpoint
Normal file
1
changes/25460-add-list-host-certificates-endpoint
Normal file
|
|
@ -0,0 +1 @@
|
|||
* Added the list host certificates (and list device's certificates) endpoints.
|
||||
1
changes/issue-25464-ui-host-certs
Normal file
1
changes/issue-25464-ui-host-certs
Normal file
|
|
@ -0,0 +1 @@
|
|||
- add UI for viewing certificate details on the host details and my device pages
|
||||
|
|
@ -21,6 +21,7 @@ import (
|
|||
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/apple/vpp"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/tokenpki"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/testing_utils"
|
||||
"github.com/fleetdm/fleet/v4/server/mock"
|
||||
mdmmock "github.com/fleetdm/fleet/v4/server/mock/mdm"
|
||||
"github.com/fleetdm/fleet/v4/server/ptr"
|
||||
|
|
@ -1623,14 +1624,18 @@ software:
|
|||
require.NoError(t, err)
|
||||
|
||||
// Dry run, global defines software, should fail.
|
||||
_, err = runAppNoChecks([]string{"gitops", "-f", globalFileWithSoftware.Name(), "-f", teamFileBasic.Name(), "-f",
|
||||
_, err = runAppNoChecks([]string{
|
||||
"gitops", "-f", globalFileWithSoftware.Name(), "-f", teamFileBasic.Name(), "-f",
|
||||
noTeamFileBasic.Name(),
|
||||
"--dry-run"})
|
||||
"--dry-run",
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "'software' cannot be set on global file")
|
||||
// Real run, global defines software, should fail.
|
||||
_, err = runAppNoChecks([]string{"gitops", "-f", globalFileWithSoftware.Name(), "-f", teamFileBasic.Name(), "-f",
|
||||
noTeamFileBasic.Name()})
|
||||
_, err = runAppNoChecks([]string{
|
||||
"gitops", "-f", globalFileWithSoftware.Name(), "-f", teamFileBasic.Name(), "-f",
|
||||
noTeamFileBasic.Name(),
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "'software' cannot be set on global file")
|
||||
})
|
||||
|
|
@ -1653,13 +1658,17 @@ software:
|
|||
require.NoError(t, err)
|
||||
|
||||
// Dry run, both global and no-team.yml define controls.
|
||||
_, err = runAppNoChecks([]string{"gitops", "-f", globalFileWithControls.Name(), "-f", teamFileBasic.Name(), "-f",
|
||||
noTeamFileWithControls.Name(), "--dry-run"})
|
||||
_, err = runAppNoChecks([]string{
|
||||
"gitops", "-f", globalFileWithControls.Name(), "-f", teamFileBasic.Name(), "-f",
|
||||
noTeamFileWithControls.Name(), "--dry-run",
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.True(t, strings.Contains(err.Error(), "'controls' cannot be set on both global config and on no-team.yml"))
|
||||
// Real run, both global and no-team.yml define controls.
|
||||
_, err = runAppNoChecks([]string{"gitops", "-f", globalFileWithControls.Name(), "-f", teamFileBasic.Name(), "-f",
|
||||
noTeamFileWithControls.Name()})
|
||||
_, err = runAppNoChecks([]string{
|
||||
"gitops", "-f", globalFileWithControls.Name(), "-f", teamFileBasic.Name(), "-f",
|
||||
noTeamFileWithControls.Name(),
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.True(t, strings.Contains(err.Error(), "'controls' cannot be set on both global config and on no-team.yml"))
|
||||
})
|
||||
|
|
@ -1682,13 +1691,17 @@ software:
|
|||
require.NoError(t, err)
|
||||
|
||||
// Dry run, both global and no-team.yml defines policy with calendar events enabled.
|
||||
_, err = runAppNoChecks([]string{"gitops", "-f", globalFileWithControls.Name(), "-f", teamFileBasic.Name(), "-f",
|
||||
noTeamFilePathPoliciesCalendar.Name(), "--dry-run"})
|
||||
_, err = runAppNoChecks([]string{
|
||||
"gitops", "-f", globalFileWithControls.Name(), "-f", teamFileBasic.Name(), "-f",
|
||||
noTeamFilePathPoliciesCalendar.Name(), "--dry-run",
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.True(t, strings.Contains(err.Error(), "calendar events are not supported on \"No team\" policies: \"Foobar\""), err.Error())
|
||||
// Real run, both global and no-team.yml define controls.
|
||||
_, err = runAppNoChecks([]string{"gitops", "-f", globalFileWithControls.Name(), "-f", teamFileBasic.Name(), "-f",
|
||||
noTeamFilePathPoliciesCalendar.Name()})
|
||||
_, err = runAppNoChecks([]string{
|
||||
"gitops", "-f", globalFileWithControls.Name(), "-f", teamFileBasic.Name(), "-f",
|
||||
noTeamFilePathPoliciesCalendar.Name(),
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.True(t, strings.Contains(err.Error(), "calendar events are not supported on \"No team\" policies: \"Foobar\""), err.Error())
|
||||
})
|
||||
|
|
@ -1707,13 +1720,17 @@ software:
|
|||
require.NoError(t, err)
|
||||
|
||||
// Dry run, controls should be defined somewhere, either in no-team.yml or global.
|
||||
_, err = runAppNoChecks([]string{"gitops", "-f", globalFileWithoutControlsAndSoftwareKeys.Name(), "-f", teamFileBasic.Name(), "-f",
|
||||
noTeamFileWithoutControls.Name(), "--dry-run"})
|
||||
_, err = runAppNoChecks([]string{
|
||||
"gitops", "-f", globalFileWithoutControlsAndSoftwareKeys.Name(), "-f", teamFileBasic.Name(), "-f",
|
||||
noTeamFileWithoutControls.Name(), "--dry-run",
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.True(t, strings.Contains(err.Error(), "'controls' must be set on global config or no-team.yml"))
|
||||
// Real run
|
||||
_, err = runAppNoChecks([]string{"gitops", "-f", globalFileWithoutControlsAndSoftwareKeys.Name(), "-f", teamFileBasic.Name(), "-f",
|
||||
noTeamFileWithoutControls.Name()})
|
||||
_, err = runAppNoChecks([]string{
|
||||
"gitops", "-f", globalFileWithoutControlsAndSoftwareKeys.Name(), "-f", teamFileBasic.Name(), "-f",
|
||||
noTeamFileWithoutControls.Name(),
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.True(t, strings.Contains(err.Error(), "'controls' must be set on global config or no-team.yml"))
|
||||
})
|
||||
|
|
@ -1725,15 +1742,19 @@ software:
|
|||
|
||||
// Dry run, global file without controls and software keys.
|
||||
_ = runAppForTest(t,
|
||||
[]string{"gitops", "-f", globalFileWithoutControlsAndSoftwareKeys.Name(), "-f", teamFileBasic.Name(), "-f",
|
||||
[]string{
|
||||
"gitops", "-f", globalFileWithoutControlsAndSoftwareKeys.Name(), "-f", teamFileBasic.Name(), "-f",
|
||||
noTeamFileBasic.Name(),
|
||||
"--dry-run"})
|
||||
"--dry-run",
|
||||
})
|
||||
assert.Equal(t, fleet.AppConfig{}, *savedAppConfig, "AppConfig should be empty")
|
||||
|
||||
// Real run, global file without controls and software keys.
|
||||
_ = runAppForTest(t,
|
||||
[]string{"gitops", "-f", globalFileWithoutControlsAndSoftwareKeys.Name(), "-f", teamFileBasic.Name(), "-f",
|
||||
noTeamFileBasic.Name()})
|
||||
[]string{
|
||||
"gitops", "-f", globalFileWithoutControlsAndSoftwareKeys.Name(), "-f", teamFileBasic.Name(), "-f",
|
||||
noTeamFileBasic.Name(),
|
||||
})
|
||||
assert.Equal(t, orgName, savedAppConfig.OrgInfo.OrgName)
|
||||
assert.Equal(t, fleetServerURL, savedAppConfig.ServerSettings.ServerURL)
|
||||
assert.Len(t, enrolledSecrets, 1)
|
||||
|
|
@ -1741,7 +1762,6 @@ software:
|
|||
assert.Equal(t, teamName, savedTeam.Name)
|
||||
require.Len(t, enrolledTeamSecrets, 1)
|
||||
assert.Equal(t, secret, enrolledTeamSecrets[0].Secret)
|
||||
|
||||
})
|
||||
|
||||
t.Run("basic global and no-team.yml", func(t *testing.T) {
|
||||
|
|
@ -1907,7 +1927,7 @@ func TestGitOpsFullGlobalAndTeam(t *testing.T) {
|
|||
return 0, nil
|
||||
}
|
||||
|
||||
apnsCert, apnsKey, err := mysql.GenerateTestCertBytes()
|
||||
apnsCert, apnsKey, err := mysql.GenerateTestCertBytes(testing_utils.NewTestMDMAppleCertTemplate())
|
||||
require.NoError(t, err)
|
||||
crt, key, err := apple_mdm.NewSCEPCACertKey()
|
||||
require.NoError(t, err)
|
||||
|
|
@ -2017,7 +2037,6 @@ software:
|
|||
assert.Equal(t, filepath.Base(cspFile.Name()), filepath.Base((*savedAppConfigPtr).MDM.WindowsSettings.CustomSettings.Value[0].Path))
|
||||
assert.True(t, ds.BatchSetScriptsFuncInvoked)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func TestGitOpsTeamSofwareInstallers(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ import (
|
|||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/urfave/cli/v2"
|
||||
|
||||
mdmtesting "github.com/fleetdm/fleet/v4/server/mdm/testing_utils"
|
||||
)
|
||||
|
||||
type withDS struct {
|
||||
|
|
@ -122,7 +124,7 @@ func runServerWithMockedDS(t *testing.T, opts ...*service.TestServerOpts) (*http
|
|||
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
||||
return &fleet.AppConfig{}, nil
|
||||
}
|
||||
apnsCert, apnsKey, err := mysql.GenerateTestCertBytes()
|
||||
apnsCert, apnsKey, err := mysql.GenerateTestCertBytes(mdmtesting.NewTestMDMAppleCertTemplate())
|
||||
require.NoError(t, err)
|
||||
certPEM, keyPEM, tokenBytes, err := mysql.GenerateTestABMAssets(t)
|
||||
require.NoError(t, err)
|
||||
|
|
|
|||
|
|
@ -17,6 +17,23 @@ SELECT 1 FROM osquery_registry WHERE active = true AND registry = 'table' AND na
|
|||
SELECT serial_number, cycle_count, designed_capacity, max_capacity FROM battery
|
||||
```
|
||||
|
||||
## certificates_darwin
|
||||
|
||||
- Platforms: darwin
|
||||
|
||||
- Query:
|
||||
```sql
|
||||
SELECT
|
||||
ca, common_name, subject, issuer,
|
||||
key_algorithm, key_strength, key_usage, signing_algorithm,
|
||||
not_valid_after, not_valid_before,
|
||||
serial, sha1
|
||||
FROM
|
||||
certificates
|
||||
WHERE
|
||||
path = '/Library/Keychains/System.keychain';
|
||||
```
|
||||
|
||||
## chromeos_profile_user_info
|
||||
|
||||
- Platforms: chrome
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import (
|
|||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
|
||||
nanodep_client "github.com/fleetdm/fleet/v4/server/mdm/nanodep/client"
|
||||
mdmtesting "github.com/fleetdm/fleet/v4/server/mdm/testing_utils"
|
||||
"github.com/fleetdm/fleet/v4/server/mock"
|
||||
nanodep_mock "github.com/fleetdm/fleet/v4/server/mock/nanodep"
|
||||
"github.com/fleetdm/fleet/v4/server/ptr"
|
||||
|
|
@ -234,12 +235,13 @@ func TestGetOrCreatePreassignTeam(t *testing.T) {
|
|||
) (updates fleet.MDMProfilesUpdates, err error) {
|
||||
return fleet.MDMProfilesUpdates{}, nil
|
||||
}
|
||||
apnsCert, apnsKey, err := mysql.GenerateTestCertBytes()
|
||||
apnsCert, apnsKey, err := mysql.GenerateTestCertBytes(mdmtesting.NewTestMDMAppleCertTemplate())
|
||||
require.NoError(t, err)
|
||||
certPEM, keyPEM, tokenBytes, err := mysql.GenerateTestABMAssets(t)
|
||||
require.NoError(t, err)
|
||||
ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName,
|
||||
_ sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
|
||||
_ sqlx.QueryerContext,
|
||||
) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
|
||||
return map[fleet.MDMAssetName]fleet.MDMConfigAsset{
|
||||
fleet.MDMAssetABMCert: {Name: fleet.MDMAssetABMCert, Value: certPEM},
|
||||
fleet.MDMAssetABMKey: {Name: fleet.MDMAssetABMKey, Value: keyPEM},
|
||||
|
|
|
|||
47
frontend/__mocks__/certificatesMock.ts
Normal file
47
frontend/__mocks__/certificatesMock.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { IHostCertificate } from "interfaces/certificates";
|
||||
import { IGetHostCertificatesResponse } from "services/entities/hosts";
|
||||
|
||||
const DEFAULT_HOST_CERTIFICATE_MOCK: IHostCertificate = {
|
||||
id: 1,
|
||||
not_valid_after: "2021-08-19T02:02:17Z",
|
||||
not_valid_before: "2021-08-19T02:02:17Z",
|
||||
certificate_authority: true,
|
||||
common_name: "Test Cert",
|
||||
key_algorithm: "rsaEncryption",
|
||||
key_strength: 2048,
|
||||
key_usage: "CRL Sign, Key Cert Sign",
|
||||
serial: "123",
|
||||
signing_algorithm: "sha256WithRSAEncryption",
|
||||
subject: {
|
||||
country: "US",
|
||||
organization: "Test Inc.",
|
||||
organizational_unit: "Test Inc.",
|
||||
common_name: "Test Biz",
|
||||
},
|
||||
issuer: {
|
||||
country: "US",
|
||||
organization: "Test Inc.",
|
||||
organizational_unit: "Test Inc.",
|
||||
common_name: "Test Biz",
|
||||
},
|
||||
};
|
||||
|
||||
export const createMockHostCertificate = (
|
||||
overrides?: Partial<IHostCertificate>
|
||||
): IHostCertificate => {
|
||||
return { ...DEFAULT_HOST_CERTIFICATE_MOCK, ...overrides };
|
||||
};
|
||||
|
||||
const DEFAULT_HOST_CERTIFICATES_RESPONSE_MOCK: IGetHostCertificatesResponse = {
|
||||
certificates: [createMockHostCertificate()],
|
||||
meta: {
|
||||
has_next_results: false,
|
||||
has_previous_results: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const createMockGetHostCertificatesResponse = (
|
||||
overrides?: Partial<IGetHostCertificatesResponse>
|
||||
): IGetHostCertificatesResponse => {
|
||||
return { ...DEFAULT_HOST_CERTIFICATES_RESPONSE_MOCK, ...overrides };
|
||||
};
|
||||
|
|
@ -6,7 +6,7 @@ const meta: Meta<typeof DataSet> = {
|
|||
title: "Components/DataSet",
|
||||
component: DataSet,
|
||||
args: {
|
||||
title: "Data set",
|
||||
title: "Data set title",
|
||||
value: "This is the value",
|
||||
},
|
||||
};
|
||||
|
|
@ -16,3 +16,9 @@ export default meta;
|
|||
type Story = StoryObj<typeof DataSet>;
|
||||
|
||||
export const Basic: Story = {};
|
||||
|
||||
export const HorizontalOrientation: Story = {
|
||||
args: {
|
||||
orientation: "horizontal",
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,15 +6,26 @@ const baseClass = "data-set";
|
|||
interface IDataSetProps {
|
||||
title: React.ReactNode;
|
||||
value: React.ReactNode;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DataSet = ({ title, value, className }: IDataSetProps) => {
|
||||
const classNames = classnames(baseClass, className);
|
||||
const DataSet = ({
|
||||
title,
|
||||
value,
|
||||
orientation = "vertical",
|
||||
className,
|
||||
}: IDataSetProps) => {
|
||||
const classNames = classnames(baseClass, className, {
|
||||
[`${baseClass}__horizontal`]: orientation === "horizontal",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={classNames}>
|
||||
<dt>{title}</dt>
|
||||
<dt>
|
||||
{title}
|
||||
{orientation === "horizontal" && ":"}
|
||||
</dt>
|
||||
<dd>{value}</dd>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
.data-set {
|
||||
font-size: $x-small;
|
||||
|
||||
&__horizontal {
|
||||
display: flex;
|
||||
gap: $pad-small;
|
||||
}
|
||||
|
||||
// ff only
|
||||
@-moz-document url-prefix() {
|
||||
display: flex;
|
||||
|
|
|
|||
24
frontend/interfaces/certificates.ts
Normal file
24
frontend/interfaces/certificates.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
export interface IHostCertificate {
|
||||
id: number;
|
||||
not_valid_after: string;
|
||||
not_valid_before: string;
|
||||
certificate_authority: boolean;
|
||||
common_name: string;
|
||||
key_algorithm: string;
|
||||
key_strength: number;
|
||||
key_usage: string;
|
||||
serial: string;
|
||||
signing_algorithm: string;
|
||||
subject: {
|
||||
country: string;
|
||||
organization: string;
|
||||
organizational_unit: string;
|
||||
common_name: string;
|
||||
};
|
||||
issuer: {
|
||||
country: string;
|
||||
organization: string;
|
||||
organizational_unit: string;
|
||||
common_name: string;
|
||||
};
|
||||
}
|
||||
|
|
@ -128,7 +128,7 @@ export const isLinuxLike = (platform: string) => {
|
|||
);
|
||||
};
|
||||
|
||||
export const isAppleDevice = (platform: string) => {
|
||||
export const isAppleDevice = (platform = "") => {
|
||||
return HOST_APPLE_PLATFORMS.includes(
|
||||
platform as typeof HOST_APPLE_PLATFORMS[number]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,11 @@ import { IDeviceUserResponse, IHostDevice } from "interfaces/host";
|
|||
import createMockHost from "__mocks__/hostMock";
|
||||
import mockServer from "test/mock-server";
|
||||
import { createCustomRenderer } from "test/test-utils";
|
||||
import { customDeviceHandler } from "test/handlers/device-handler";
|
||||
import {
|
||||
customDeviceHandler,
|
||||
defaultDeviceCertificatesHandler,
|
||||
defaultDeviceHandler,
|
||||
} from "test/handlers/device-handler";
|
||||
import DeviceUserPage from "./DeviceUserPage";
|
||||
|
||||
const mockRouter = {
|
||||
|
|
@ -34,12 +38,14 @@ const mockLocation = {
|
|||
|
||||
describe("Device User Page", () => {
|
||||
it("hides the software tab if the device has no software", async () => {
|
||||
mockServer.use(defaultDeviceHandler);
|
||||
mockServer.use(defaultDeviceCertificatesHandler);
|
||||
|
||||
const render = createCustomRenderer({
|
||||
withBackendMock: true,
|
||||
});
|
||||
|
||||
// TODO: fix return type from render
|
||||
const { user } = render(
|
||||
render(
|
||||
<DeviceUserPage
|
||||
router={mockRouter}
|
||||
params={{ device_auth_token: "testToken" }}
|
||||
|
|
@ -51,14 +57,61 @@ describe("Device User Page", () => {
|
|||
await screen.findByText("About");
|
||||
|
||||
expect(screen.queryByText(/Software/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// TODO: Fix this to the new copy
|
||||
// expect(screen.getByText("No software detected")).toBeInTheDocument();
|
||||
it("hides the certificates card if the device has no certificates", async () => {
|
||||
mockServer.use(defaultDeviceHandler);
|
||||
mockServer.use(defaultDeviceCertificatesHandler);
|
||||
|
||||
const render = createCustomRenderer({
|
||||
withBackendMock: true,
|
||||
});
|
||||
|
||||
render(
|
||||
<DeviceUserPage
|
||||
router={mockRouter}
|
||||
params={{ device_auth_token: "testToken" }}
|
||||
location={mockLocation}
|
||||
/>
|
||||
);
|
||||
|
||||
// waiting for the device data to render
|
||||
await screen.findByText("About");
|
||||
|
||||
expect(screen.queryByText(/Certificates/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the certificates card if the device is not an apple device (mac, iphone, ipad)", async () => {
|
||||
const host = createMockHost() as IHostDevice;
|
||||
host.mdm.enrollment_status = "On (manual)";
|
||||
host.platform = "windows";
|
||||
host.dep_assigned_to_fleet = false;
|
||||
|
||||
mockServer.use(customDeviceHandler({ host }));
|
||||
mockServer.use(defaultDeviceCertificatesHandler);
|
||||
|
||||
const render = createCustomRenderer({
|
||||
withBackendMock: true,
|
||||
});
|
||||
|
||||
render(
|
||||
<DeviceUserPage
|
||||
router={mockRouter}
|
||||
params={{ device_auth_token: "testToken" }}
|
||||
location={mockLocation}
|
||||
/>
|
||||
);
|
||||
|
||||
// waiting for the device data to render
|
||||
await screen.findByText("About");
|
||||
|
||||
expect(screen.queryByText(/Certificates/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("MDM enrollment", () => {
|
||||
const setupTest = async (overrides: Partial<IDeviceUserResponse>) => {
|
||||
mockServer.use(customDeviceHandler(overrides));
|
||||
mockServer.use(defaultDeviceCertificatesHandler);
|
||||
|
||||
const render = createCustomRenderer({
|
||||
withBackendMock: true,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ import { Tab, Tabs, TabList, TabPanel } from "react-tabs";
|
|||
import { pick, findIndex } from "lodash";
|
||||
|
||||
import { NotificationContext } from "context/notification";
|
||||
import deviceUserAPI from "services/entities/device_user";
|
||||
import deviceUserAPI, {
|
||||
IGetDeviceCertificatesResponse,
|
||||
} from "services/entities/device_user";
|
||||
import diskEncryptionAPI from "services/entities/disk_encryption";
|
||||
import {
|
||||
IDeviceMappingResponse,
|
||||
|
|
@ -17,6 +19,8 @@ import {
|
|||
import { IHostPolicy } from "interfaces/policy";
|
||||
import { IDeviceGlobalConfig } from "interfaces/config";
|
||||
import { IHostSoftware } from "interfaces/software";
|
||||
import { IHostCertificate } from "interfaces/certificates";
|
||||
import { isAppleDevice } from "interfaces/platform";
|
||||
|
||||
import DeviceUserError from "components/DeviceUserError";
|
||||
// @ts-ignore
|
||||
|
|
@ -31,6 +35,7 @@ import FlashMessage from "components/FlashMessage";
|
|||
import { normalizeEmptyValues } from "utilities/helpers";
|
||||
import PATHS from "router/paths";
|
||||
import {
|
||||
DEFAULT_USE_QUERY_OPTIONS,
|
||||
DOCUMENT_TITLE_SUFFIX,
|
||||
HOST_ABOUT_DATA,
|
||||
HOST_SUMMARY_DATA,
|
||||
|
|
@ -56,6 +61,8 @@ import { parseHostSoftwareQueryParams } from "../cards/Software/HostSoftware";
|
|||
import SelfService from "../cards/Software/SelfService";
|
||||
import SoftwareDetailsModal from "../cards/Software/SoftwareDetailsModal";
|
||||
import DeviceUserBanners from "./components/DeviceUserBanners";
|
||||
import CertificateDetailsModal from "../modals/CertificateDetailsModal";
|
||||
import CertificatesCard from "../cards/Certificates";
|
||||
|
||||
const baseClass = "device-user";
|
||||
|
||||
|
|
@ -71,6 +78,9 @@ const FREE_TAB_PATHS = [
|
|||
PATHS.DEVICE_USER_DETAILS_SOFTWARE,
|
||||
] as const;
|
||||
|
||||
const DEFAULT_CERTIFICATES_PAGE_SIZE = 10;
|
||||
const DEFAULT_CERTIFICATES_PAGE = 0;
|
||||
|
||||
interface IDeviceUserPageProps {
|
||||
location: {
|
||||
pathname: string;
|
||||
|
|
@ -119,6 +129,15 @@ const DeviceUserPage = ({
|
|||
setSelectedSoftwareDetails,
|
||||
] = useState<IHostSoftware | null>(null);
|
||||
|
||||
// certificates states
|
||||
const [
|
||||
selectedCertificate,
|
||||
setSelectedCertificate,
|
||||
] = useState<IHostCertificate | null>(null);
|
||||
const [certificatePage, setCertificatePage] = useState(
|
||||
DEFAULT_CERTIFICATES_PAGE
|
||||
);
|
||||
|
||||
const { data: deviceMapping, refetch: refetchDeviceMapping } = useQuery(
|
||||
["deviceMapping", deviceAuthToken],
|
||||
() =>
|
||||
|
|
@ -146,8 +165,39 @@ const DeviceUserPage = ({
|
|||
}
|
||||
);
|
||||
|
||||
const {
|
||||
data: deviceCertificates,
|
||||
isLoading: isLoadingDeviceCertificates,
|
||||
isError: isErrorDeviceCertificates,
|
||||
refetch: refetchDeviceCertificates,
|
||||
} = useQuery<
|
||||
IGetDeviceCertificatesResponse,
|
||||
Error,
|
||||
IGetDeviceCertificatesResponse,
|
||||
Array<{ scope: string; token: string; page: number; perPage: number }>
|
||||
>(
|
||||
[
|
||||
{
|
||||
scope: "device-certificates",
|
||||
token: deviceAuthToken,
|
||||
page: certificatePage,
|
||||
perPage: DEFAULT_CERTIFICATES_PAGE_SIZE,
|
||||
},
|
||||
],
|
||||
({ queryKey: [{ token, page, perPage }] }) =>
|
||||
deviceUserAPI.getDeviceCertificates(token, page, perPage),
|
||||
{
|
||||
...DEFAULT_USE_QUERY_OPTIONS,
|
||||
// FIXME: is it worth disabling for unsupported platforms? we'd have to workaround the a
|
||||
// catch-22 where we need to know the platform to know if it's supported but we also need to
|
||||
// be able to include the cert refetch in the hosts query hook.
|
||||
enabled: !!deviceUserAPI,
|
||||
}
|
||||
);
|
||||
|
||||
const refetchExtensions = () => {
|
||||
deviceMapping !== null && refetchDeviceMapping();
|
||||
deviceCertificates && refetchDeviceCertificates();
|
||||
};
|
||||
|
||||
const isRefetching = ({
|
||||
|
|
@ -242,6 +292,7 @@ const DeviceUserPage = ({
|
|||
self_service: hasSelfService = false,
|
||||
} = dupResponse || {};
|
||||
const isPremiumTier = license?.tier === "premium";
|
||||
const isAppleHost = isAppleDevice(host?.platform);
|
||||
|
||||
const summaryData = normalizeEmptyValues(pick(host, HOST_SUMMARY_DATA));
|
||||
|
||||
|
|
@ -328,6 +379,10 @@ const DeviceUserPage = ({
|
|||
}
|
||||
};
|
||||
|
||||
const onSelectCertificate = (certificate: IHostCertificate) => {
|
||||
setSelectedCertificate(certificate);
|
||||
};
|
||||
|
||||
const renderDeviceUserPage = () => {
|
||||
const failingPoliciesCount = host?.issues?.failing_policies_count || 0;
|
||||
|
||||
|
|
@ -355,7 +410,7 @@ const DeviceUserPage = ({
|
|||
|
||||
return (
|
||||
<div className="core-wrapper">
|
||||
{!host || isLoadingHost ? (
|
||||
{!host || isLoadingHost || isLoadingDeviceCertificates ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<div className={`${baseClass} main-content`}>
|
||||
|
|
@ -418,12 +473,27 @@ const DeviceUserPage = ({
|
|||
</Tab>
|
||||
)}
|
||||
</TabList>
|
||||
<TabPanel>
|
||||
<TabPanel className={`${baseClass}__details-panel`}>
|
||||
<AboutCard
|
||||
aboutData={aboutData}
|
||||
deviceMapping={deviceMapping}
|
||||
munki={deviceMacAdminsData?.munki}
|
||||
/>
|
||||
{isAppleHost && !!deviceCertificates?.certificates.length && (
|
||||
<CertificatesCard
|
||||
isMyDevicePage
|
||||
data={deviceCertificates}
|
||||
isError={isErrorDeviceCertificates}
|
||||
page={certificatePage}
|
||||
pageSize={DEFAULT_CERTIFICATES_PAGE_SIZE}
|
||||
hostPlatform={host.platform}
|
||||
onSelectCertificate={onSelectCertificate}
|
||||
onNextPage={() => setCertificatePage(certificatePage + 1)}
|
||||
onPreviousPage={() =>
|
||||
setCertificatePage(certificatePage - 1)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</TabPanel>
|
||||
{isPremiumTier && isSoftwareEnabled && hasSelfService && (
|
||||
<TabPanel>
|
||||
|
|
@ -524,6 +594,12 @@ const DeviceUserPage = ({
|
|||
hideInstallDetails
|
||||
/>
|
||||
)}
|
||||
{selectedCertificate && (
|
||||
<CertificateDetailsModal
|
||||
certificate={selectedCertificate}
|
||||
onExit={() => setSelectedCertificate(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -27,6 +27,12 @@
|
|||
transform: scale(0.5);
|
||||
}
|
||||
|
||||
&__details-panel {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: $pad-large;
|
||||
}
|
||||
|
||||
&__error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import activitiesAPI, {
|
|||
IHostPastActivitiesResponse,
|
||||
IHostUpcomingActivitiesResponse,
|
||||
} from "services/entities/activities";
|
||||
import hostAPI from "services/entities/hosts";
|
||||
import hostAPI, { IGetHostCertificatesResponse } from "services/entities/hosts";
|
||||
import teamAPI, { ILoadTeamsResponse } from "services/entities/teams";
|
||||
|
||||
import {
|
||||
|
|
@ -32,6 +32,7 @@ import { IQueryStats } from "interfaces/query_stats";
|
|||
import { IHostSoftware } from "interfaces/software";
|
||||
import { ITeam } from "interfaces/team";
|
||||
import { IHostUpcomingActivity } from "interfaces/activity";
|
||||
import { IHostCertificate } from "interfaces/certificates";
|
||||
|
||||
import { normalizeEmptyValues, wrapFleetHelper } from "utilities/helpers";
|
||||
import permissions from "utilities/permissions";
|
||||
|
|
@ -40,6 +41,7 @@ import {
|
|||
HOST_SUMMARY_DATA,
|
||||
HOST_ABOUT_DATA,
|
||||
HOST_OSQUERY_DATA,
|
||||
DEFAULT_USE_QUERY_OPTIONS,
|
||||
} from "utilities/constants";
|
||||
|
||||
import { isAndroid, isIPadOrIPhone } from "interfaces/platform";
|
||||
|
|
@ -73,10 +75,12 @@ import PoliciesCard from "../cards/Policies";
|
|||
import QueriesCard from "../cards/Queries";
|
||||
import PacksCard from "../cards/Packs";
|
||||
import PolicyDetailsModal from "../cards/Policies/HostPoliciesTable/PolicyDetailsModal";
|
||||
import UnenrollMdmModal from "./modals/UnenrollMdmModal";
|
||||
import CertificatesCard from "../cards/Certificates";
|
||||
|
||||
import TransferHostModal from "../../components/TransferHostModal";
|
||||
import DeleteHostModal from "../../components/DeleteHostModal";
|
||||
|
||||
import UnenrollMdmModal from "./modals/UnenrollMdmModal";
|
||||
import DiskEncryptionKeyModal from "./modals/DiskEncryptionKeyModal";
|
||||
import HostActionsDropdown from "./HostActionsDropdown/HostActionsDropdown";
|
||||
import OSSettingsModal from "../OSSettingsModal";
|
||||
|
|
@ -95,6 +99,7 @@ import SoftwareDetailsModal from "../cards/Software/SoftwareDetailsModal";
|
|||
import { parseHostSoftwareQueryParams } from "../cards/Software/HostSoftware";
|
||||
import { getErrorMessage } from "./helpers";
|
||||
import CancelActivityModal from "./modals/CancelActivityModal";
|
||||
import CertificateDetailsModal from "../modals/CertificateDetailsModal";
|
||||
|
||||
const baseClass = "host-details";
|
||||
|
||||
|
|
@ -129,6 +134,8 @@ interface IHostDetailsSubNavItem {
|
|||
}
|
||||
|
||||
const DEFAULT_ACTIVITY_PAGE_SIZE = 8;
|
||||
const DEFAULT_CERTIFICATES_PAGE_SIZE = 10;
|
||||
const DEFAULT_CERTIFICATES_PAGE = 0;
|
||||
|
||||
const HostDetailsPage = ({
|
||||
router,
|
||||
|
|
@ -165,6 +172,7 @@ const HostDetailsPage = ({
|
|||
const [showLockHostModal, setShowLockHostModal] = useState(false);
|
||||
const [showUnlockHostModal, setShowUnlockHostModal] = useState(false);
|
||||
const [showWipeModal, setShowWipeModal] = useState(false);
|
||||
|
||||
// Used in activities to show run script details modal
|
||||
const [scriptExecutionId, setScriptExecutiontId] = useState("");
|
||||
const [selectedPolicy, setSelectedPolicy] = useState<IHostPolicy | null>(
|
||||
|
|
@ -209,6 +217,15 @@ const HostDetailsPage = ({
|
|||
>("past");
|
||||
const [activityPage, setActivityPage] = useState(0);
|
||||
|
||||
// certificates states
|
||||
const [
|
||||
selectedCertificate,
|
||||
setSelectedCertificate,
|
||||
] = useState<IHostCertificate | null>(null);
|
||||
const [certificatePage, setCertificatePage] = useState(
|
||||
DEFAULT_CERTIFICATES_PAGE
|
||||
);
|
||||
|
||||
const { data: teams } = useQuery<ILoadTeamsResponse, Error, ITeam[]>(
|
||||
"teams",
|
||||
() => teamAPI.loadAll(),
|
||||
|
|
@ -264,10 +281,41 @@ const HostDetailsPage = ({
|
|||
}
|
||||
);
|
||||
|
||||
const {
|
||||
data: hostCertificates,
|
||||
isLoading: isLoadingHostCertificates,
|
||||
isError: isErrorHostCertificates,
|
||||
refetch: refetchHostCertificates,
|
||||
} = useQuery<
|
||||
IGetHostCertificatesResponse,
|
||||
Error,
|
||||
IGetHostCertificatesResponse,
|
||||
Array<{ scope: string; hostId: number; page: number; perPage: number }>
|
||||
>(
|
||||
[
|
||||
{
|
||||
scope: "host-certificates",
|
||||
hostId: hostIdFromURL,
|
||||
page: certificatePage,
|
||||
perPage: DEFAULT_CERTIFICATES_PAGE_SIZE,
|
||||
},
|
||||
],
|
||||
({ queryKey: [{ hostId, page, perPage }] }) =>
|
||||
hostAPI.getHostCertificates(hostId, page, perPage),
|
||||
{
|
||||
...DEFAULT_USE_QUERY_OPTIONS,
|
||||
// FIXME: is it worth disabling for unsupported platforms? we'd have to workaround the a
|
||||
// catch-22 where we need to know the platform to know if it's supported but we also need to
|
||||
// be able to include the cert refetch in the hosts query hook.
|
||||
enabled: !!hostIdFromURL,
|
||||
}
|
||||
);
|
||||
|
||||
const refetchExtensions = () => {
|
||||
deviceMapping !== null && refetchDeviceMapping();
|
||||
macadmins !== null && refetchMacadmins();
|
||||
mdm?.enrollment_status !== null && refetchMdm();
|
||||
hostCertificates && refetchHostCertificates();
|
||||
};
|
||||
|
||||
const {
|
||||
|
|
@ -710,6 +758,10 @@ const HostDetailsPage = ({
|
|||
setSelectedCancelActivity(activity);
|
||||
};
|
||||
|
||||
const onSelectCertificate = (certificate: IHostCertificate) => {
|
||||
setSelectedCertificate(certificate);
|
||||
};
|
||||
|
||||
const renderActionDropdown = () => {
|
||||
if (!host) {
|
||||
return null;
|
||||
|
|
@ -734,7 +786,8 @@ const HostDetailsPage = ({
|
|||
!host ||
|
||||
isLoadingHost ||
|
||||
pastActivitiesIsLoading ||
|
||||
upcomingActivitiesIsLoading
|
||||
upcomingActivitiesIsLoading ||
|
||||
isLoadingHostCertificates
|
||||
) {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
|
@ -799,11 +852,12 @@ const HostDetailsPage = ({
|
|||
name: host?.mdm.macos_setup?.bootstrap_package_name,
|
||||
};
|
||||
|
||||
const isIosOrIpadosHost =
|
||||
host.platform === "ios" || host.platform === "ipados";
|
||||
const isDarwinHost = host.platform === "darwin";
|
||||
const isIosOrIpadosHost = isIPadOrIPhone(host.platform);
|
||||
|
||||
const detailsPanelClass = classNames(`${baseClass}__details-panel`, {
|
||||
[`${baseClass}__details-panel--ios-grid`]: isIosOrIpadosHost,
|
||||
[`${baseClass}__details-panel--macos-grid`]: isDarwinHost,
|
||||
});
|
||||
|
||||
return (
|
||||
|
|
@ -909,6 +963,21 @@ const HostDetailsPage = ({
|
|||
hostUsersEnabled={featuresConfig?.enable_host_users}
|
||||
/>
|
||||
)}
|
||||
{(isIosOrIpadosHost || isDarwinHost) &&
|
||||
!!hostCertificates?.certificates.length && (
|
||||
<CertificatesCard
|
||||
data={hostCertificates}
|
||||
hostPlatform={host.platform}
|
||||
onSelectCertificate={onSelectCertificate}
|
||||
isError={isErrorHostCertificates}
|
||||
page={certificatePage}
|
||||
pageSize={DEFAULT_CERTIFICATES_PAGE_SIZE}
|
||||
onNextPage={() => setCertificatePage(certificatePage + 1)}
|
||||
onPreviousPage={() =>
|
||||
setCertificatePage(certificatePage - 1)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<SoftwareCard
|
||||
|
|
@ -925,7 +994,7 @@ const HostDetailsPage = ({
|
|||
hostTeamId={host.team_id || 0}
|
||||
hostMDMEnrolled={host.mdm.connected_to_fleet}
|
||||
/>
|
||||
{host?.platform === "darwin" && macadmins?.munki?.version && (
|
||||
{isDarwinHost && macadmins?.munki?.version && (
|
||||
<MunkiIssuesCard
|
||||
isLoading={isLoadingHost}
|
||||
munkiIssues={macadmins.munki_issues}
|
||||
|
|
@ -1095,6 +1164,12 @@ const HostDetailsPage = ({
|
|||
onCancel={() => setSelectedCancelActivity(null)}
|
||||
/>
|
||||
)}
|
||||
{selectedCertificate && (
|
||||
<CertificateDetailsModal
|
||||
certificate={selectedCertificate}
|
||||
onExit={() => setSelectedCertificate(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</MainContent>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
}
|
||||
|
||||
@media screen and (min-width: $break-md) {
|
||||
// default grid to show for non macos, ios, or ipados hosts.
|
||||
&__details-panel.react-tabs__tab-panel--selected {
|
||||
// Must be selected to show grid
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
|
@ -14,18 +15,29 @@
|
|||
"activity agent-options"
|
||||
"activity labels"
|
||||
"users users";
|
||||
grid-auto-flow: column;
|
||||
}
|
||||
|
||||
// No agent options card for i(Pad)OS, so extend Labels card vertically
|
||||
// No agent options card for i(Pad)OS, so extend Labels card vertically.
|
||||
// We also add the certs card to the grid layout on mac hosts
|
||||
&__details-panel--ios-grid.react-tabs__tab-panel--selected {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-areas:
|
||||
"about about"
|
||||
"activity labels";
|
||||
grid-auto-flow: column;
|
||||
"activity labels"
|
||||
"certs certs";
|
||||
}
|
||||
|
||||
|
||||
// We add the certs card to the grid layout on mac hosts
|
||||
&__details-panel--macos-grid.react-tabs__tab-panel--selected {
|
||||
grid-template-areas:
|
||||
"about about"
|
||||
"activity agent-options"
|
||||
"activity labels"
|
||||
"users users"
|
||||
"certs certs";
|
||||
}
|
||||
|
||||
|
||||
.about-card {
|
||||
grid-area: about;
|
||||
}
|
||||
|
|
@ -45,6 +57,10 @@
|
|||
.users-card {
|
||||
grid-area: users;
|
||||
}
|
||||
|
||||
.certificates-card {
|
||||
grid-area: certs;
|
||||
}
|
||||
}
|
||||
|
||||
.about-card,
|
||||
|
|
|
|||
|
|
@ -217,10 +217,10 @@ const About = ({
|
|||
<Card
|
||||
borderRadiusSize="xxlarge"
|
||||
includeShadow
|
||||
paddingSize="large"
|
||||
paddingSize="xxlarge"
|
||||
className={baseClass}
|
||||
>
|
||||
<p className="card__header">About</p>
|
||||
<h2>About</h2>
|
||||
<div className="info-flex">
|
||||
<DataSet
|
||||
title="Added to Fleet"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
.about-card {
|
||||
h2 {
|
||||
font-size: $medium;
|
||||
margin: 0 0 $pad-large;
|
||||
}
|
||||
|
||||
.truncated-tooltip {
|
||||
.about-card__device-mapping__source {
|
||||
|
|
@ -52,7 +56,7 @@
|
|||
.component__tooltip-wrapper__element {
|
||||
max-width: 350px;
|
||||
}
|
||||
|
||||
|
||||
@media (min-width: $break-md) {
|
||||
max-height: 250px;
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
position: relative;
|
||||
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
font-size: $medium;
|
||||
margin: 0 0 $pad-large;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
import React from "react";
|
||||
|
||||
import { IHostCertificate } from "interfaces/certificates";
|
||||
import { HostPlatform } from "interfaces/platform";
|
||||
import { IGetHostCertificatesResponse } from "services/entities/hosts";
|
||||
|
||||
import Card from "components/Card";
|
||||
import DataError from "components/DataError";
|
||||
|
||||
import CertificatesTable from "./CertificatesTable";
|
||||
|
||||
const baseClass = "certificates-card";
|
||||
|
||||
interface ICertificatesProps {
|
||||
data: IGetHostCertificatesResponse;
|
||||
hostPlatform: HostPlatform;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
isError: boolean;
|
||||
isMyDevicePage?: boolean;
|
||||
onSelectCertificate: (certificate: IHostCertificate) => void;
|
||||
onNextPage: () => void;
|
||||
onPreviousPage: () => void;
|
||||
}
|
||||
|
||||
const CertificatesCard = ({
|
||||
data,
|
||||
hostPlatform,
|
||||
isError,
|
||||
page,
|
||||
pageSize,
|
||||
isMyDevicePage = false,
|
||||
onSelectCertificate,
|
||||
onNextPage,
|
||||
onPreviousPage,
|
||||
}: ICertificatesProps) => {
|
||||
const renderContent = () => {
|
||||
if (isError) return <DataError />;
|
||||
|
||||
return (
|
||||
<CertificatesTable
|
||||
data={data}
|
||||
showHelpText={!isMyDevicePage && hostPlatform === "darwin"}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
onSelectCertificate={onSelectCertificate}
|
||||
onNextPage={onNextPage}
|
||||
onPreviousPage={onPreviousPage}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={baseClass}
|
||||
borderRadiusSize="xxlarge"
|
||||
includeShadow
|
||||
paddingSize="xxlarge"
|
||||
>
|
||||
<h2>Certificates</h2>
|
||||
{renderContent()}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default CertificatesCard;
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import React, { useCallback } from "react";
|
||||
|
||||
import { IHostCertificate } from "interfaces/certificates";
|
||||
import { IGetHostCertificatesResponse } from "services/entities/hosts";
|
||||
|
||||
import TableContainer from "components/TableContainer";
|
||||
import CustomLink from "components/CustomLink";
|
||||
import TableCount from "components/TableContainer/TableCount";
|
||||
import { ITableQueryData } from "components/TableContainer/TableContainer";
|
||||
|
||||
import generateTableConfig from "./CertificatesTableConfig";
|
||||
|
||||
const baseClass = "certificates-table";
|
||||
|
||||
interface ICertificatesTableProps {
|
||||
data: IGetHostCertificatesResponse;
|
||||
showHelpText: boolean;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
onSelectCertificate: (certificate: IHostCertificate) => void;
|
||||
onNextPage: () => void;
|
||||
onPreviousPage: () => void;
|
||||
}
|
||||
|
||||
const CertificatesTable = ({
|
||||
data,
|
||||
showHelpText,
|
||||
page,
|
||||
pageSize,
|
||||
onSelectCertificate,
|
||||
onNextPage,
|
||||
onPreviousPage,
|
||||
}: ICertificatesTableProps) => {
|
||||
const tableConfig = generateTableConfig();
|
||||
|
||||
const onClickTableRow = (row: any) => {
|
||||
onSelectCertificate(row.original);
|
||||
};
|
||||
|
||||
const onQueryChange = useCallback(
|
||||
async (newTableQuery: ITableQueryData) => {
|
||||
console.log(newTableQuery);
|
||||
|
||||
if (page === newTableQuery.pageIndex) return;
|
||||
|
||||
if (newTableQuery.pageIndex > page) {
|
||||
onNextPage();
|
||||
} else {
|
||||
onPreviousPage();
|
||||
}
|
||||
},
|
||||
[onNextPage, onPreviousPage, page]
|
||||
);
|
||||
|
||||
const helpText = showHelpText ? (
|
||||
<p>
|
||||
Showing certificates in the system keychain. To get all certificates, you
|
||||
can query the certificates table.{" "}
|
||||
<CustomLink
|
||||
text="Learn more"
|
||||
url="https://fleetdm.com/learn-more-about/certificates-query"
|
||||
newTab
|
||||
/>
|
||||
</p>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<TableContainer
|
||||
className={baseClass}
|
||||
columnConfigs={tableConfig}
|
||||
data={data.certificates}
|
||||
emptyComponent={() => null}
|
||||
isAllPagesSelected={false}
|
||||
showMarkAllPages={false}
|
||||
isLoading={false}
|
||||
disableMultiRowSelect
|
||||
onSelectSingleRow={onClickTableRow}
|
||||
renderTableHelpText={() => helpText}
|
||||
renderCount={() => (
|
||||
<TableCount name="certificates" count={data.certificates.length} />
|
||||
)}
|
||||
pageSize={pageSize}
|
||||
defaultPageIndex={page}
|
||||
onQueryChange={onQueryChange}
|
||||
disableNextPage={data?.meta.has_next_results === false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
export default CertificatesTable;
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import React from "react";
|
||||
import { Column } from "react-table";
|
||||
|
||||
import { IHostCertificate } from "interfaces/certificates";
|
||||
import { monthDayYearFormat } from "utilities/date_format";
|
||||
import { hasExpired, willExpireWithinXDays } from "utilities/helpers";
|
||||
|
||||
import HeaderCell from "components/TableContainer/DataTable/HeaderCell/HeaderCell";
|
||||
import TextCell from "components/TableContainer/DataTable/TextCell";
|
||||
import ViewAllHostsLink from "components/ViewAllHostsLink";
|
||||
import StatusIndicator from "components/StatusIndicator";
|
||||
import { IIndicatorValue } from "components/StatusIndicator/StatusIndicator";
|
||||
|
||||
type IHostCertificatesTableConfig = Column<IHostCertificate>;
|
||||
|
||||
const generateTableConfig = (): IHostCertificatesTableConfig[] => {
|
||||
return [
|
||||
{
|
||||
accessor: "common_name",
|
||||
Header: (cellProps) => (
|
||||
<HeaderCell value="Name" isSortedDesc={cellProps.column.isSortedDesc} />
|
||||
),
|
||||
Cell: (cellProps) => <TextCell value={cellProps.cell.value} />,
|
||||
},
|
||||
{
|
||||
accessor: "not_valid_after",
|
||||
Header: (cellProps) => (
|
||||
<HeaderCell
|
||||
value="Expires"
|
||||
isSortedDesc={cellProps.column.isSortedDesc}
|
||||
/>
|
||||
),
|
||||
Cell: (cellProps) => {
|
||||
let status: IIndicatorValue = "success";
|
||||
if (hasExpired(cellProps.value)) {
|
||||
status = "error";
|
||||
} else if (willExpireWithinXDays(cellProps.value, 30)) {
|
||||
status = "warning";
|
||||
}
|
||||
return (
|
||||
<StatusIndicator
|
||||
value={monthDayYearFormat(cellProps.value)}
|
||||
indicator={status}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "",
|
||||
id: "view-all-hosts",
|
||||
disableSortBy: true,
|
||||
Cell: () => {
|
||||
return (
|
||||
<ViewAllHostsLink
|
||||
className="view-cert-details"
|
||||
noLink
|
||||
rowHover
|
||||
excludeChevron
|
||||
customText="View details"
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export default generateTableConfig;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.certificates-table {
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./CertificatesTable";
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
.certificates-card {
|
||||
h2 {
|
||||
font-size: $medium;
|
||||
margin: 0 0 $pad-large;
|
||||
}
|
||||
|
||||
}
|
||||
1
frontend/pages/hosts/details/cards/Certificates/index.ts
Normal file
1
frontend/pages/hosts/details/cards/Certificates/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./Certificates";
|
||||
|
|
@ -37,10 +37,10 @@ const Labels = ({ onLabelClick, labels }: ILabelsProps): JSX.Element => {
|
|||
<Card
|
||||
borderRadiusSize="xxlarge"
|
||||
includeShadow
|
||||
largePadding
|
||||
paddingSize="xxlarge"
|
||||
className={classNames}
|
||||
>
|
||||
<p className="card__header">Labels</p>
|
||||
<h2>Labels</h2>
|
||||
{labels.length === 0 ? (
|
||||
<p className="info-flex__item">
|
||||
No labels are associated with this host.
|
||||
|
|
|
|||
7
frontend/pages/hosts/details/cards/Labels/__styles.scss
Normal file
7
frontend/pages/hosts/details/cards/Labels/__styles.scss
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
.labels-card {
|
||||
|
||||
h2 {
|
||||
font-size: $medium;
|
||||
margin: 0 0 $pad-large;
|
||||
}
|
||||
}
|
||||
|
|
@ -63,11 +63,11 @@ const Users = ({
|
|||
<Card
|
||||
borderRadiusSize="xxlarge"
|
||||
includeShadow
|
||||
largePadding
|
||||
paddingSize="xxlarge"
|
||||
className={baseClass}
|
||||
>
|
||||
<>
|
||||
<p className="card__header">Users</p>
|
||||
<h2 className="card__header">Users</h2>
|
||||
{users?.length ? (
|
||||
<TableContainer
|
||||
columnConfigs={tableHeaders}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
.users-card {
|
||||
h2 {
|
||||
font-size: $medium;
|
||||
margin: 0 0 $pad-large;
|
||||
}
|
||||
|
||||
.data-table-block {
|
||||
.data-table__table {
|
||||
thead {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,227 @@
|
|||
import React from "react";
|
||||
|
||||
import { IHostCertificate } from "interfaces/certificates";
|
||||
|
||||
import Modal from "components/Modal";
|
||||
import DataSet from "components/DataSet";
|
||||
import Button from "components/buttons/Button";
|
||||
import { monthDayYearFormat } from "utilities/date_format";
|
||||
|
||||
const baseClass = "certificate-details-modal";
|
||||
|
||||
interface ICertificateDetailsModalProps {
|
||||
certificate: IHostCertificate;
|
||||
onExit: () => void;
|
||||
}
|
||||
|
||||
const CertificateDetailsModal = ({
|
||||
certificate,
|
||||
onExit,
|
||||
}: ICertificateDetailsModalProps) => {
|
||||
// Destructure the certificate object so we can check for presence of values
|
||||
const {
|
||||
subject: {
|
||||
country: subjectCountry,
|
||||
organization: subjectOrganization,
|
||||
organizational_unit: subjectOrganizationalUnit,
|
||||
common_name: subjectCommonName,
|
||||
},
|
||||
issuer: {
|
||||
country: issuerCountry,
|
||||
organization: issuerOrganization,
|
||||
organizational_unit: issuerOrganizationalUnit,
|
||||
common_name: issuerCommonName,
|
||||
},
|
||||
not_valid_before,
|
||||
not_valid_after,
|
||||
key_algorithm,
|
||||
key_strength,
|
||||
key_usage,
|
||||
serial,
|
||||
certificate_authority,
|
||||
signing_algorithm,
|
||||
} = certificate;
|
||||
|
||||
const showSubjectSection = Boolean(
|
||||
subjectCountry ||
|
||||
subjectOrganization ||
|
||||
subjectOrganizationalUnit ||
|
||||
subjectCommonName
|
||||
);
|
||||
const showIssuerNameSection = Boolean(
|
||||
issuerCommonName ||
|
||||
issuerCountry ||
|
||||
issuerOrganization ||
|
||||
issuerOrganizationalUnit
|
||||
);
|
||||
const showValidityPeriodSection = Boolean(
|
||||
not_valid_before || not_valid_after
|
||||
);
|
||||
const showKeyInfoSection = Boolean(
|
||||
key_algorithm || key_strength || key_usage || serial
|
||||
);
|
||||
const showSignatureSection = Boolean(signing_algorithm);
|
||||
|
||||
return (
|
||||
<Modal className={baseClass} title="Certificate details" onExit={onExit}>
|
||||
<>
|
||||
<div className={`${baseClass}__content`}>
|
||||
{showSubjectSection && (
|
||||
<div className={`${baseClass}__section`}>
|
||||
<h3>Subject Name</h3>
|
||||
<dl>
|
||||
{subjectCountry && (
|
||||
<DataSet
|
||||
title="Country or region"
|
||||
value={subjectCountry}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
)}
|
||||
{subjectOrganization && (
|
||||
<DataSet
|
||||
title="Organization"
|
||||
value={subjectOrganization}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
)}
|
||||
{subjectOrganizationalUnit && (
|
||||
<DataSet
|
||||
title="Organizational unit"
|
||||
value={subjectOrganizationalUnit}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
)}
|
||||
{subjectCommonName && (
|
||||
<DataSet
|
||||
title="Common name"
|
||||
value={subjectCommonName}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
{showIssuerNameSection && (
|
||||
<div className={`${baseClass}__section`}>
|
||||
<h3>Issuer name</h3>
|
||||
<dl>
|
||||
{issuerCountry && (
|
||||
<DataSet
|
||||
title="Country or region"
|
||||
value={issuerCountry}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
)}
|
||||
{issuerOrganization && (
|
||||
<DataSet
|
||||
title="Organization"
|
||||
value={issuerOrganization}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
)}
|
||||
{issuerOrganizationalUnit && (
|
||||
<DataSet
|
||||
title="Organizational unit"
|
||||
value={issuerOrganizationalUnit}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
)}
|
||||
{issuerCommonName && (
|
||||
<DataSet
|
||||
title="Common name"
|
||||
value={issuerCommonName}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
{showValidityPeriodSection && (
|
||||
<div className={`${baseClass}__section`}>
|
||||
<h3>Validity period</h3>
|
||||
<dl>
|
||||
{not_valid_before && (
|
||||
<DataSet
|
||||
title="Not valid before"
|
||||
value={monthDayYearFormat(not_valid_before)}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
)}
|
||||
{not_valid_after && (
|
||||
<DataSet
|
||||
title="Not valid after"
|
||||
value={monthDayYearFormat(not_valid_after)}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
{showKeyInfoSection && (
|
||||
<div className={`${baseClass}__section`}>
|
||||
<h3>Key info</h3>
|
||||
<dl>
|
||||
{key_algorithm && (
|
||||
<DataSet
|
||||
title="Algorithm"
|
||||
value={key_algorithm}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
)}
|
||||
{key_strength && (
|
||||
<DataSet
|
||||
title="Key size"
|
||||
value={key_strength}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
)}
|
||||
{key_usage && (
|
||||
<DataSet
|
||||
title="Key usage"
|
||||
value={key_usage}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
)}
|
||||
{serial && (
|
||||
<DataSet
|
||||
title="Serial number"
|
||||
value={serial}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
{/* will always show this section */}
|
||||
<div className={`${baseClass}__section`}>
|
||||
<h3>Basic constraints</h3>
|
||||
<dl>
|
||||
<DataSet
|
||||
title="Certificate authority"
|
||||
value={certificate_authority ? "Yes" : "No"}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
</dl>
|
||||
</div>
|
||||
{showSignatureSection && (
|
||||
<div className={`${baseClass}__section`}>
|
||||
<h3>Signature</h3>
|
||||
<dl>
|
||||
<DataSet
|
||||
title="Algorithm"
|
||||
value={signing_algorithm}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-cta-wrap">
|
||||
<Button onClick={onExit}>Done</Button>
|
||||
</div>
|
||||
</>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CertificateDetailsModal;
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
.certificate-details-modal {
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $pad-xlarge;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: $small;
|
||||
}
|
||||
|
||||
&__section {
|
||||
display: flex;
|
||||
gap: $pad-small;
|
||||
flex-direction: column;
|
||||
padding-top: $pad-xlarge;
|
||||
border-top: 1px solid $ui-fleet-black-10;
|
||||
|
||||
&:first-child {
|
||||
padding-top: 0;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
dl {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $pad-xsmall;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./CertificateDetailsModal";
|
||||
|
|
@ -1,8 +1,11 @@
|
|||
import { IDeviceUserResponse } from "interfaces/host";
|
||||
import { IDeviceSoftware } from "interfaces/software";
|
||||
import { IHostCertificate } from "interfaces/certificates";
|
||||
import sendRequest from "services";
|
||||
import endpoints from "utilities/endpoints";
|
||||
import { buildQueryStringFromParams } from "utilities/url";
|
||||
import { createMockGetHostCertificatesResponse } from "__mocks__/certificatesMock";
|
||||
|
||||
import { IHostSoftwareQueryParams } from "./hosts";
|
||||
|
||||
export type ILoadHostDetailsExtension = "device_mapping" | "macadmins";
|
||||
|
|
@ -27,6 +30,14 @@ interface IGetDeviceDetailsRequest {
|
|||
exclude_software?: boolean;
|
||||
}
|
||||
|
||||
export interface IGetDeviceCertificatesResponse {
|
||||
certificates: IHostCertificate[];
|
||||
meta: {
|
||||
has_next_results: boolean;
|
||||
has_previous_results: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
loadHostDetails: ({
|
||||
token,
|
||||
|
|
@ -74,4 +85,20 @@ export default {
|
|||
|
||||
return sendRequest("POST", path);
|
||||
},
|
||||
|
||||
getDeviceCertificates: (
|
||||
deviceToken: string,
|
||||
page = 0,
|
||||
perPage = 10
|
||||
): Promise<IGetDeviceCertificatesResponse> => {
|
||||
const { DEVICE_CERTIFICATES } = endpoints;
|
||||
const path = `${DEVICE_CERTIFICATES(
|
||||
deviceToken
|
||||
)}?${buildQueryStringFromParams({
|
||||
page,
|
||||
per_page: perPage,
|
||||
})}`;
|
||||
|
||||
return sendRequest("GET", path);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -23,6 +23,11 @@ import {
|
|||
} from "interfaces/mdm";
|
||||
import { IMunkiIssuesAggregate } from "interfaces/macadmins";
|
||||
import { PlatformValueOptions, PolicyResponse } from "utilities/constants";
|
||||
import { IHostCertificate } from "interfaces/certificates";
|
||||
import {
|
||||
createMockGetHostCertificatesResponse,
|
||||
createMockHostCertificate,
|
||||
} from "__mocks__/certificatesMock";
|
||||
|
||||
export interface ISortOption {
|
||||
key: string;
|
||||
|
|
@ -171,6 +176,14 @@ export interface IHostSoftwareQueryKey extends IHostSoftwareQueryParams {
|
|||
softwareUpdatedAt?: string;
|
||||
}
|
||||
|
||||
export interface IGetHostCertificatesResponse {
|
||||
certificates: IHostCertificate[];
|
||||
meta: {
|
||||
has_next_results: boolean;
|
||||
has_previous_results: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export type ILoadHostDetailsExtension = "device_mapping" | "macadmins";
|
||||
|
||||
const LABEL_PREFIX = "labels/";
|
||||
|
|
@ -579,6 +592,7 @@ export default {
|
|||
HOST_SOFTWARE_PACKAGE_INSTALL(hostId, softwareId)
|
||||
);
|
||||
},
|
||||
|
||||
uninstallHostSoftwarePackage: (hostId: number, softwareId: number) => {
|
||||
const { HOST_SOFTWARE_PACKAGE_UNINSTALL } = endpoints;
|
||||
return sendRequest(
|
||||
|
|
@ -586,4 +600,18 @@ export default {
|
|||
HOST_SOFTWARE_PACKAGE_UNINSTALL(hostId, softwareId)
|
||||
);
|
||||
},
|
||||
|
||||
getHostCertificates: (
|
||||
hostId: number,
|
||||
page = 0,
|
||||
perPage = 10
|
||||
): Promise<IGetHostCertificatesResponse> => {
|
||||
const { HOST_CERTIFICATES } = endpoints;
|
||||
const path = `${HOST_CERTIFICATES(hostId)}?${buildQueryStringFromParams({
|
||||
page,
|
||||
per_page: perPage,
|
||||
})}`;
|
||||
|
||||
return sendRequest("GET", path);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,9 +6,11 @@ import createMockDeviceUser, {
|
|||
import createMockHost from "__mocks__/hostMock";
|
||||
import createMockLicense from "__mocks__/licenseMock";
|
||||
import createMockMacAdmins from "__mocks__/macAdminsMock";
|
||||
import { createMockHostCertificate } from "__mocks__/certificatesMock";
|
||||
import { baseUrl } from "test/test-utils";
|
||||
import { IDeviceUserResponse } from "interfaces/host";
|
||||
import { IGetDeviceSoftwareResponse } from "services/entities/device_user";
|
||||
import { IGetHostCertificatesResponse } from "services/entities/hosts";
|
||||
|
||||
export const defaultDeviceHandler = http.get(baseUrl("/device/:token"), () => {
|
||||
return HttpResponse.json({
|
||||
|
|
@ -63,3 +65,16 @@ export const customDeviceSoftwareHandler = (
|
|||
http.get(baseUrl("/device/:token/software"), () => {
|
||||
return HttpResponse.json(createMockDeviceSoftwareResponse(overrides));
|
||||
});
|
||||
|
||||
export const defaultDeviceCertificatesHandler = http.get(
|
||||
baseUrl("/device/:token/certificates"),
|
||||
() => {
|
||||
return HttpResponse.json<IGetHostCertificatesResponse>({
|
||||
certificates: [createMockHostCertificate()],
|
||||
meta: {
|
||||
has_next_results: false,
|
||||
has_previous_results: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -40,6 +40,9 @@ export default {
|
|||
DEVICE_TRIGGER_LINUX_DISK_ENCRYPTION_KEY_ESCROW: (token: string): string => {
|
||||
return `/${API_VERSION}/fleet/device/${token}/mdm/linux/trigger_escrow`;
|
||||
},
|
||||
DEVICE_CERTIFICATES: (token: string): string => {
|
||||
return `/${API_VERSION}/fleet/device/${token}/certificates`;
|
||||
},
|
||||
|
||||
// Host endpoints
|
||||
HOST_SUMMARY: `/${API_VERSION}/fleet/host_summary`,
|
||||
|
|
@ -61,6 +64,8 @@ export default {
|
|||
`/${API_VERSION}/fleet/hosts/${hostId}/software/${softwareId}/install`,
|
||||
HOST_SOFTWARE_PACKAGE_UNINSTALL: (hostId: number, softwareId: number) =>
|
||||
`/${API_VERSION}/fleet/hosts/${hostId}/software/${softwareId}/uninstall`,
|
||||
HOST_CERTIFICATES: (id: number) =>
|
||||
`/${API_VERSION}/fleet/hosts/${id}/certificates`,
|
||||
|
||||
INVITES: `/${API_VERSION}/fleet/invites`,
|
||||
INVITE_VERIFY: (token: string) => `/${API_VERSION}/fleet/invites/${token}`,
|
||||
|
|
|
|||
|
|
@ -627,6 +627,13 @@ export const hasLicenseExpired = (expiration: string): boolean => {
|
|||
return isAfter(new Date(), new Date(expiration));
|
||||
};
|
||||
|
||||
// just a rename of hasLicenseExpired so that it can be used in other contexts.
|
||||
// TODO: change hasLicenseExpired instances to hasExpired
|
||||
/**
|
||||
* determines if a date has expired. This will check against the current date and time.
|
||||
*/
|
||||
export const hasExpired = hasLicenseExpired;
|
||||
|
||||
/**
|
||||
* determines if a date will expire within "x" number of days. If the date has
|
||||
* has already expired, this function will return false.
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
|
||||
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
|
||||
|
|
@ -739,6 +740,28 @@ func (c *TestAppleMDMClient) AcknowledgeInstalledApplicationList(udid, cmdUUID s
|
|||
return c.sendAndDecodeCommandResponse(payload)
|
||||
}
|
||||
|
||||
func (c *TestAppleMDMClient) AcknowledgeCertificateList(udid, cmdUUID string, certTemplates []*x509.Certificate) (*mdm.Command, error) {
|
||||
var certList []fleet.MDMAppleCertificateListItem
|
||||
for _, cert := range certTemplates {
|
||||
b, _, err := mysql.GenerateTestCertBytes(cert)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
certList = append(certList, fleet.MDMAppleCertificateListItem{
|
||||
CommonName: cert.Subject.CommonName,
|
||||
Data: b,
|
||||
})
|
||||
}
|
||||
cmd := map[string]any{
|
||||
"CommandUUID": cmdUUID,
|
||||
"UDID": udid,
|
||||
"Status": "Acknowledged",
|
||||
"CertificateList": certList,
|
||||
}
|
||||
|
||||
return c.sendAndDecodeCommandResponse(cmd)
|
||||
}
|
||||
|
||||
func (c *TestAppleMDMClient) GetBootstrapToken() ([]byte, error) {
|
||||
payload := map[string]any{
|
||||
"MessageType": "GetBootstrapToken",
|
||||
|
|
|
|||
192
server/datastore/mysql/host_certificates.go
Normal file
192
server/datastore/mysql/host_certificates.go
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/go-kit/kit/log/level"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
func (ds *Datastore) ListHostCertificates(ctx context.Context, hostID uint, opts fleet.ListOptions) ([]*fleet.HostCertificateRecord, *fleet.PaginationMetadata, error) {
|
||||
return listHostCertsDB(ctx, ds.reader(ctx), hostID, opts)
|
||||
}
|
||||
|
||||
func (ds *Datastore) UpdateHostCertificates(ctx context.Context, hostID uint, certs []*fleet.HostCertificateRecord) error {
|
||||
incomingBySHA1 := make(map[string]*fleet.HostCertificateRecord, len(certs))
|
||||
for _, cert := range certs {
|
||||
if cert.HostID != hostID {
|
||||
// caller should ensure this does not happen
|
||||
level.Debug(ds.logger).Log("msg", fmt.Sprintf("host certificates: host ID does not match provided certificate: %d %d", hostID, cert.HostID))
|
||||
}
|
||||
if _, ok := incomingBySHA1[strings.ToUpper(hex.EncodeToString(cert.SHA1Sum))]; ok {
|
||||
// TODO: sha1 is broken so this could be a sign of a problem, how should we handle?
|
||||
level.Info(ds.logger).Log("msg", "host certificates: host has multiple certificates with the same SHA1, only the first will be recorded", "host_id", hostID, "sha1", string(cert.SHA1Sum))
|
||||
continue
|
||||
}
|
||||
incomingBySHA1[strings.ToUpper(hex.EncodeToString(cert.SHA1Sum))] = cert
|
||||
}
|
||||
|
||||
// get existing certs for this host; we'll use the reader because we expect certs to change
|
||||
// infrequently and they will be eventually consistent
|
||||
existingCerts, _, err := listHostCertsDB(ctx, ds.reader(ctx), hostID, fleet.ListOptions{}) // requesting unpaginated results with default limit of 1 million
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "list host certificates for update")
|
||||
}
|
||||
existingBySHA1 := make(map[string]*fleet.HostCertificateRecord, len(existingCerts))
|
||||
for _, ec := range existingCerts {
|
||||
existingBySHA1[strings.ToUpper(hex.EncodeToString(ec.SHA1Sum))] = ec
|
||||
}
|
||||
|
||||
toInsert := make([]*fleet.HostCertificateRecord, 0, len(incomingBySHA1))
|
||||
// toUpdate := make([]*fleet.HostCertificateRecord, 0, len(incomingBySHA1))
|
||||
for sha1, incoming := range incomingBySHA1 {
|
||||
if _, ok := existingBySHA1[sha1]; ok {
|
||||
// TODO: should we always update existing records? skipping updates reduces db load but
|
||||
// osquery is using sha1 so we consider subtleties
|
||||
level.Debug(ds.logger).Log("msg", fmt.Sprintf("host certificates: already exists: %s", sha1), "host_id", hostID) // TODO: silence this log after initial rollout period
|
||||
} else {
|
||||
toInsert = append(toInsert, incoming)
|
||||
}
|
||||
}
|
||||
|
||||
toDelete := make([]uint, 0, len(existingBySHA1))
|
||||
for sha1, existing := range existingBySHA1 {
|
||||
if _, ok := incomingBySHA1[sha1]; !ok {
|
||||
toDelete = append(toDelete, existing.ID)
|
||||
}
|
||||
}
|
||||
|
||||
return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
|
||||
if err := insertHostCertsDB(ctx, tx, toInsert); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "insert host certs")
|
||||
}
|
||||
if err := softDeleteHostCertsDB(ctx, tx, hostID, toDelete); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "soft delete host certs")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func listHostCertsDB(ctx context.Context, tx sqlx.QueryerContext, hostID uint, opts fleet.ListOptions) ([]*fleet.HostCertificateRecord, *fleet.PaginationMetadata, error) {
|
||||
stmt := `
|
||||
SELECT
|
||||
id,
|
||||
sha1_sum,
|
||||
host_id,
|
||||
created_at,
|
||||
deleted_at,
|
||||
not_valid_before,
|
||||
not_valid_after,
|
||||
certificate_authority,
|
||||
common_name,
|
||||
key_algorithm,
|
||||
key_strength,
|
||||
key_usage,
|
||||
serial,
|
||||
signing_algorithm,
|
||||
subject_country,
|
||||
subject_org,
|
||||
subject_org_unit,
|
||||
subject_common_name,
|
||||
issuer_country,
|
||||
issuer_org,
|
||||
issuer_org_unit,
|
||||
issuer_common_name
|
||||
FROM
|
||||
host_certificates
|
||||
WHERE
|
||||
host_id = ?
|
||||
AND deleted_at IS NULL`
|
||||
|
||||
args := []interface{}{hostID}
|
||||
stmtPaged, args := appendListOptionsWithCursorToSQL(stmt, args, &opts)
|
||||
|
||||
var certs []*fleet.HostCertificateRecord
|
||||
if err := sqlx.SelectContext(ctx, tx, &certs, stmtPaged, args...); err != nil {
|
||||
return nil, nil, ctxerr.Wrap(ctx, err, "selecting host certificates")
|
||||
}
|
||||
|
||||
var metaData *fleet.PaginationMetadata
|
||||
if opts.IncludeMetadata {
|
||||
metaData = &fleet.PaginationMetadata{HasPreviousResults: opts.Page > 0}
|
||||
if len(certs) > int(opts.PerPage) { //nolint:gosec // dismiss G115
|
||||
metaData.HasNextResults = true
|
||||
certs = certs[:len(certs)-1]
|
||||
}
|
||||
}
|
||||
return certs, metaData, nil
|
||||
}
|
||||
|
||||
func insertHostCertsDB(ctx context.Context, tx sqlx.ExtContext, certs []*fleet.HostCertificateRecord) error {
|
||||
if len(certs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
stmt := `
|
||||
INSERT INTO host_certificates (
|
||||
host_id,
|
||||
sha1_sum,
|
||||
not_valid_before,
|
||||
not_valid_after,
|
||||
certificate_authority,
|
||||
common_name,
|
||||
key_algorithm,
|
||||
key_strength,
|
||||
key_usage,
|
||||
serial,
|
||||
signing_algorithm,
|
||||
subject_country,
|
||||
subject_org,
|
||||
subject_org_unit,
|
||||
subject_common_name,
|
||||
issuer_country,
|
||||
issuer_org,
|
||||
issuer_org_unit,
|
||||
issuer_common_name
|
||||
) VALUES %s`
|
||||
|
||||
placeholders := make([]string, 0, len(certs))
|
||||
args := make([]interface{}, 0, len(certs)*19)
|
||||
for _, cert := range certs {
|
||||
placeholders = append(placeholders, "(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)")
|
||||
args = append(args,
|
||||
cert.HostID, cert.SHA1Sum, cert.NotValidBefore, cert.NotValidAfter, cert.CertificateAuthority, cert.CommonName,
|
||||
cert.KeyAlgorithm, cert.KeyStrength, cert.KeyUsage, cert.Serial, cert.SigningAlgorithm,
|
||||
cert.SubjectCountry, cert.SubjectOrganization, cert.SubjectOrganizationalUnit, cert.SubjectCommonName,
|
||||
cert.IssuerCountry, cert.IssuerOrganization, cert.IssuerOrganizationalUnit, cert.IssuerCommonName)
|
||||
}
|
||||
|
||||
stmt = fmt.Sprintf(stmt, strings.Join(placeholders, ","))
|
||||
|
||||
if _, err := tx.ExecContext(ctx, stmt, args...); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "inserting host certificates")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func softDeleteHostCertsDB(ctx context.Context, tx sqlx.ExtContext, hostID uint, toDelete []uint) error {
|
||||
// TODO: consider whether we should hard delete certs after a certain period of time if we are seeing
|
||||
// the table grow too large with soft deleted records
|
||||
|
||||
if len(toDelete) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
stmt := `UPDATE host_certificates SET deleted_at = NOW(6) WHERE host_id = ? AND id IN (?)`
|
||||
stmt, args, err := sqlx.In(stmt, hostID, toDelete)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "building soft delete query")
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx, stmt, args...); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "soft deleting host certificates")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
120
server/datastore/mysql/host_certificates_test.go
Normal file
120
server/datastore/mysql/host_certificates_test.go
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestHostCertificates(t *testing.T) {
|
||||
ds := CreateMySQLDS(t)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
fn func(t *testing.T, ds *Datastore)
|
||||
}{
|
||||
{"UpdateAndList", testUpdateAndListHostCertificates},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
defer TruncateTables(t, ds)
|
||||
c.fn(t, ds)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testUpdateAndListHostCertificates(t *testing.T, ds *Datastore) {
|
||||
expected1 := x509.Certificate{
|
||||
Subject: pkix.Name{
|
||||
Country: []string{"US"},
|
||||
CommonName: "test.example.com",
|
||||
Organization: []string{"Org 1"},
|
||||
|
||||
OrganizationalUnit: []string{"Engineering"},
|
||||
},
|
||||
Issuer: pkix.Name{
|
||||
Country: []string{"US"},
|
||||
CommonName: "issuer.test.example.com",
|
||||
Organization: []string{"Issuer 1"},
|
||||
},
|
||||
SerialNumber: big.NewInt(1337),
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
|
||||
SignatureAlgorithm: x509.SHA256WithRSA,
|
||||
NotBefore: time.Now().Add(-time.Hour).Truncate(time.Second).UTC(),
|
||||
NotAfter: time.Now().Add(24 * time.Hour).Truncate(time.Second).UTC(),
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
|
||||
expected2 := x509.Certificate{
|
||||
Subject: pkix.Name{
|
||||
Country: []string{"US"},
|
||||
CommonName: "another.test.example.com",
|
||||
Organization: []string{"Org 2"},
|
||||
OrganizationalUnit: []string{"Engineering"},
|
||||
},
|
||||
SerialNumber: big.NewInt(1337),
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
|
||||
SignatureAlgorithm: x509.SHA256WithRSA,
|
||||
NotBefore: time.Now().Add(-2 * time.Hour).Truncate(time.Second).UTC(),
|
||||
NotAfter: time.Now().Add(48 * time.Hour).Truncate(time.Second).UTC(),
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
|
||||
payload := []*fleet.HostCertificateRecord{
|
||||
generateTestHostCertificateRecord(t, 1, &expected1),
|
||||
generateTestHostCertificateRecord(t, 1, &expected2),
|
||||
}
|
||||
|
||||
require.NoError(t, ds.UpdateHostCertificates(context.Background(), 1, payload))
|
||||
|
||||
// verify that we saved the records correctly
|
||||
certs, _, err := ds.ListHostCertificates(context.Background(), 1, fleet.ListOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, certs, 2)
|
||||
// default ordering is by common name ascending
|
||||
require.Equal(t, expected2.Subject.CommonName, certs[0].CommonName)
|
||||
require.Equal(t, expected2.Subject.CommonName, certs[0].SubjectCommonName)
|
||||
require.Equal(t, expected1.Subject.CommonName, certs[1].CommonName)
|
||||
require.Equal(t, expected1.Subject.CommonName, certs[1].SubjectCommonName)
|
||||
|
||||
// order by not_valid_after descending
|
||||
certs2, _, err := ds.ListHostCertificates(context.Background(), 1, fleet.ListOptions{OrderKey: "not_valid_after", OrderDirection: fleet.OrderAscending})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, certs2, 2)
|
||||
require.Equal(t, expected1.Subject.CommonName, certs2[0].CommonName)
|
||||
require.Equal(t, expected1.Subject.CommonName, certs2[0].SubjectCommonName)
|
||||
require.Equal(t, expected2.Subject.CommonName, certs2[1].CommonName)
|
||||
require.Equal(t, expected2.Subject.CommonName, certs2[1].SubjectCommonName)
|
||||
|
||||
// simulate removal of a certificate
|
||||
require.NoError(t, ds.UpdateHostCertificates(context.Background(), 1, []*fleet.HostCertificateRecord{payload[1]}))
|
||||
certs3, _, err := ds.ListHostCertificates(context.Background(), 1, fleet.ListOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, certs3, 1)
|
||||
require.Equal(t, expected2.Subject.CommonName, certs3[0].CommonName)
|
||||
require.Equal(t, expected2.Subject.CommonName, certs3[0].SubjectCommonName)
|
||||
}
|
||||
|
||||
func generateTestHostCertificateRecord(t *testing.T, hostID uint, template *x509.Certificate) *fleet.HostCertificateRecord {
|
||||
b, _, err := GenerateTestCertBytes(template)
|
||||
require.NoError(t, err)
|
||||
|
||||
block, _ := pem.Decode(b)
|
||||
|
||||
parsed, err := x509.ParseCertificate(block.Bytes)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, parsed)
|
||||
|
||||
return fleet.NewHostCertificateRecord(hostID, parsed)
|
||||
}
|
||||
|
|
@ -542,6 +542,7 @@ var hostRefs = []string{
|
|||
"host_mdm_actions",
|
||||
"host_calendar_events",
|
||||
"upcoming_activities",
|
||||
"host_certificates",
|
||||
"android_devices",
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package mysql
|
|||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
|
|
@ -7055,6 +7056,13 @@ func testHostsDeleteHosts(t *testing.T, ds *Datastore) {
|
|||
require.NoError(t, err)
|
||||
require.True(t, added)
|
||||
|
||||
// Add a host certificate
|
||||
require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, []*fleet.HostCertificateRecord{{
|
||||
HostID: host.ID,
|
||||
CommonName: "foo",
|
||||
SHA1Sum: sha1.New().Sum([]byte("foo")),
|
||||
}}))
|
||||
|
||||
// create an android device from this host
|
||||
_, err = ds.writer(context.Background()).Exec(`
|
||||
INSERT INTO android_devices (host_id, device_id)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
package tables
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
func init() {
|
||||
MigrationClient.AddMigration(Up_20250226000000, Down_20250226000000)
|
||||
}
|
||||
|
||||
func Up_20250226000000(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
CREATE TABLE host_certificates (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
host_id INT UNSIGNED NOT NULL,
|
||||
not_valid_after DATETIME(6) NOT NULL,
|
||||
not_valid_before DATETIME(6) NOT NULL,
|
||||
certificate_authority TINYINT(1) NOT NULL,
|
||||
common_name VARCHAR(255) NOT NULL,
|
||||
key_algorithm VARCHAR(255) NOT NULL,
|
||||
key_strength INT NOT NULL,
|
||||
key_usage VARCHAR(255) NOT NULL,
|
||||
serial VARCHAR(255) NOT NULL,
|
||||
signing_algorithm VARCHAR(255) NOT NULL,
|
||||
subject_country VARCHAR(2) NOT NULL,
|
||||
subject_org VARCHAR(255) NOT NULL,
|
||||
subject_org_unit VARCHAR(255) NOT NULL,
|
||||
subject_common_name VARCHAR(255) NOT NULL,
|
||||
issuer_country VARCHAR(2) NOT NULL,
|
||||
issuer_org VARCHAR(255) NOT NULL,
|
||||
issuer_org_unit VARCHAR(255) NOT NULL,
|
||||
issuer_common_name VARCHAR(255) NOT NULL,
|
||||
sha1_sum BINARY(20) NOT NULL,
|
||||
created_at DATETIME(6) NOT NULL DEFAULT NOW(6),
|
||||
deleted_at DATETIME(6) NULL DEFAULT NULL,
|
||||
|
||||
PRIMARY KEY (id),
|
||||
INDEX idx_host_certs_hid_cn (host_id, common_name),
|
||||
INDEX idx_host_certs_not_valid_after (host_id, not_valid_after)
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;`)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func Down_20250226000000(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -5,15 +5,13 @@ import (
|
|||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"database/sql"
|
||||
"encoding/asn1"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
|
|
@ -32,6 +30,7 @@ import (
|
|||
"github.com/fleetdm/fleet/v4/server/datastore/mysql/common_mysql/testing_utils"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
nanodep_client "github.com/fleetdm/fleet/v4/server/mdm/nanodep/client"
|
||||
mdmtesting "github.com/fleetdm/fleet/v4/server/mdm/testing_utils"
|
||||
"github.com/go-kit/log"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
|
@ -642,7 +641,7 @@ func CreateAndSetABMToken(t testing.TB, ds *Datastore, orgName string) *fleet.AB
|
|||
}
|
||||
|
||||
func SetTestABMAssets(t testing.TB, ds *Datastore, orgName string) *fleet.ABMToken {
|
||||
apnsCert, apnsKey, err := GenerateTestCertBytes()
|
||||
apnsCert, apnsKey, err := GenerateTestCertBytes(mdmtesting.NewTestMDMAppleCertTemplate())
|
||||
require.NoError(t, err)
|
||||
|
||||
certPEM, keyPEM, tokenBytes, err := GenerateTestABMAssets(t)
|
||||
|
|
@ -673,7 +672,7 @@ func SetTestABMAssets(t testing.TB, ds *Datastore, orgName string) *fleet.ABMTok
|
|||
}
|
||||
|
||||
func GenerateTestABMAssets(t testing.TB) ([]byte, []byte, []byte, error) {
|
||||
certPEM, keyPEM, err := GenerateTestCertBytes()
|
||||
certPEM, keyPEM, err := GenerateTestCertBytes(mdmtesting.NewTestMDMAppleCertTemplate())
|
||||
require.NoError(t, err)
|
||||
|
||||
testBMToken := &nanodep_client.OAuth1Tokens{
|
||||
|
|
@ -712,32 +711,17 @@ func GenerateTestABMAssets(t testing.TB) ([]byte, []byte, []byte, error) {
|
|||
return certPEM, keyPEM, []byte(tokenBytes), nil
|
||||
}
|
||||
|
||||
// TODO: move to mdmcrypto?
|
||||
func GenerateTestCertBytes() ([]byte, []byte, error) {
|
||||
func GenerateTestCertBytes(template *x509.Certificate) ([]byte, []byte, error) {
|
||||
if template == nil {
|
||||
return nil, nil, errors.New("template is nil")
|
||||
}
|
||||
|
||||
priv, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
template := x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{
|
||||
Organization: []string{"Test Org"},
|
||||
ExtraNames: []pkix.AttributeTypeAndValue{
|
||||
{
|
||||
Type: asn1.ObjectIdentifier{0, 9, 2342, 19200300, 100, 1, 1},
|
||||
Value: "com.apple.mgmt.Example",
|
||||
},
|
||||
},
|
||||
},
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().Add(365 * 24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
|
||||
certBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
|
||||
certBytes, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -365,6 +365,9 @@ type Datastore interface {
|
|||
// IsHostConnectedToFleetMDM verifies if the host has an active Fleet MDM enrollment with this server
|
||||
IsHostConnectedToFleetMDM(ctx context.Context, host *Host) (bool, error)
|
||||
|
||||
ListHostCertificates(ctx context.Context, hostID uint, opts ListOptions) ([]*HostCertificateRecord, *PaginationMetadata, error)
|
||||
UpdateHostCertificates(ctx context.Context, hostID uint, certs []*HostCertificateRecord) error
|
||||
|
||||
// AreHostsConnectedToFleetMDM checks each host MDM enrollment with
|
||||
// this server and returns a map indexed by the host uuid and a boolean
|
||||
// indicating if the enrollment is active.
|
||||
|
|
|
|||
226
server/fleet/host_certificates.go
Normal file
226
server/fleet/host_certificates.go
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
package fleet
|
||||
|
||||
import (
|
||||
"crypto/sha1" // nolint:gosec // used for compatibility with existing osquery certificates table schema
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HostCertificateRecord is the database model for a host certificate.
|
||||
type HostCertificateRecord struct {
|
||||
ID uint `json:"-" db:"id"`
|
||||
HostID uint `json:"-" db:"host_id"`
|
||||
|
||||
// SHA1Sum is a SHA-1 hash of the DER encoded certificate.
|
||||
SHA1Sum []byte `json:"-" db:"sha1_sum"`
|
||||
|
||||
// CreatedAt is the time the certificate was recorded by Fleet (i.e. certificate initially
|
||||
// reported to Fleet).
|
||||
CreatedAt time.Time `json:"-" db:"created_at"`
|
||||
// DeletedAt is the time the certificate was soft deleted by Fleet (i.e. previously reported to
|
||||
// Fleet certificate is subsequently not reported).
|
||||
DeletedAt *time.Time `json:"-" db:"deleted_at"`
|
||||
|
||||
// The following fields are extracted from the certificate.
|
||||
NotValidAfter time.Time `json:"-" db:"not_valid_after"`
|
||||
NotValidBefore time.Time `json:"-" db:"not_valid_before"`
|
||||
CertificateAuthority bool `json:"-" db:"certificate_authority"`
|
||||
CommonName string `json:"-" db:"common_name"`
|
||||
KeyAlgorithm string `json:"-" db:"key_algorithm"`
|
||||
KeyStrength int `json:"-" db:"key_strength"`
|
||||
KeyUsage string `json:"-" db:"key_usage"`
|
||||
Serial string `json:"-" db:"serial"`
|
||||
SigningAlgorithm string `json:"-" db:"signing_algorithm"`
|
||||
|
||||
SubjectCountry string `json:"-" db:"subject_country"`
|
||||
SubjectOrganization string `json:"-" db:"subject_org"`
|
||||
SubjectOrganizationalUnit string `json:"-" db:"subject_org_unit"`
|
||||
SubjectCommonName string `json:"-" db:"subject_common_name"`
|
||||
IssuerCountry string `json:"-" db:"issuer_country"`
|
||||
IssuerOrganization string `json:"-" db:"issuer_org"`
|
||||
IssuerOrganizationalUnit string `json:"-" db:"issuer_org_unit"`
|
||||
IssuerCommonName string `json:"-" db:"issuer_common_name"`
|
||||
}
|
||||
|
||||
func NewHostCertificateRecord(
|
||||
hostID uint,
|
||||
cert *x509.Certificate,
|
||||
) *HostCertificateRecord {
|
||||
hash := sha1.Sum(cert.Raw) // nolint:gosec
|
||||
|
||||
return &HostCertificateRecord{
|
||||
HostID: hostID,
|
||||
SHA1Sum: hash[:], // nolint:gosec
|
||||
NotValidAfter: cert.NotAfter,
|
||||
NotValidBefore: cert.NotBefore,
|
||||
CertificateAuthority: cert.IsCA,
|
||||
// TODO: we need to define methodology for determining common name analogous to osquery,
|
||||
// which seems to preferentially use Subject.CommonName for this value:
|
||||
// https://github.com/osquery/osquery/blob/16bb01508eeca6d663b6d4f7e15034306be0fc3d/osquery/tables/system/posix/openssl_utils.cpp#L253
|
||||
CommonName: cert.Subject.CommonName,
|
||||
KeyAlgorithm: cert.PublicKeyAlgorithm.String(),
|
||||
// TODO: we need to define methodology for determining key strength analogous to osquery,
|
||||
// which describes this value as "Key size used for RSA/DSA, or curve name":
|
||||
// https://github.com/osquery/osquery/blob/16bb01508eeca6d663b6d4f7e15034306be0fc3d/osquery/tables/system/posix/openssl_utils.cpp#L337
|
||||
KeyStrength: 0, // TODO: add key strength here
|
||||
// TODO: we need to define methodology for determining key usage analogous to osquery, which
|
||||
// describes this as "Certificate key usage and extended key usage":
|
||||
// https://github.com/osquery/osquery/blob/16bb01508eeca6d663b6d4f7e15034306be0fc3d/osquery/tables/system/posix/openssl_utils.cpp#L166
|
||||
KeyUsage: "",
|
||||
Serial: cert.SerialNumber.String(),
|
||||
SigningAlgorithm: cert.SignatureAlgorithm.String(),
|
||||
SubjectCommonName: cert.Subject.CommonName,
|
||||
SubjectCountry: firstOrEmpty(cert.Subject.Country), // TODO: confirm methodology
|
||||
SubjectOrganization: firstOrEmpty(cert.Subject.Organization), // TODO: confirm methodology
|
||||
SubjectOrganizationalUnit: firstOrEmpty(cert.Subject.OrganizationalUnit), // TODO: confirm methodology
|
||||
IssuerCommonName: cert.Issuer.CommonName,
|
||||
IssuerCountry: firstOrEmpty(cert.Issuer.Country), // TODO: confirm methodology
|
||||
IssuerOrganization: firstOrEmpty(cert.Issuer.Organization), // TODO: confirm methodology
|
||||
IssuerOrganizationalUnit: firstOrEmpty(cert.Issuer.OrganizationalUnit), // TODO: confirm methodology
|
||||
}
|
||||
}
|
||||
|
||||
// ToPayload fills a HostCertificatePayload with the fields of a
|
||||
// HostCertificateRecord. The HostCertificatePayload is used in API responses.
|
||||
func (r *HostCertificateRecord) ToPayload() *HostCertificatePayload {
|
||||
subject := &HostCertificateNameDetails{
|
||||
CommonName: r.SubjectCommonName,
|
||||
Country: r.SubjectCountry,
|
||||
Organization: r.SubjectOrganization,
|
||||
OrganizationalUnit: r.SubjectOrganizationalUnit,
|
||||
}
|
||||
issuer := &HostCertificateNameDetails{
|
||||
CommonName: r.IssuerCommonName,
|
||||
Country: r.IssuerCountry,
|
||||
Organization: r.IssuerOrganization,
|
||||
OrganizationalUnit: r.IssuerOrganizationalUnit,
|
||||
}
|
||||
return &HostCertificatePayload{
|
||||
ID: r.ID,
|
||||
NotValidAfter: r.NotValidAfter,
|
||||
NotValidBefore: r.NotValidBefore,
|
||||
CertificateAuthority: r.CertificateAuthority,
|
||||
CommonName: r.CommonName,
|
||||
KeyAlgorithm: r.KeyAlgorithm,
|
||||
KeyStrength: r.KeyStrength,
|
||||
KeyUsage: r.KeyUsage,
|
||||
Serial: r.Serial,
|
||||
SigningAlgorithm: r.SigningAlgorithm,
|
||||
Subject: subject,
|
||||
Issuer: issuer,
|
||||
}
|
||||
}
|
||||
|
||||
// HostCertificatePayload is the JSON model for API endpoints that return host certificates.
|
||||
type HostCertificatePayload struct {
|
||||
ID uint `json:"id"`
|
||||
NotValidAfter time.Time `json:"not_valid_after"`
|
||||
NotValidBefore time.Time `json:"not_valid_before"`
|
||||
CertificateAuthority bool `json:"certificate_authority"`
|
||||
CommonName string `json:"common_name"`
|
||||
KeyAlgorithm string `json:"key_algorithm"`
|
||||
KeyStrength int `json:"key_strength"`
|
||||
KeyUsage string `json:"key_usage"`
|
||||
Serial string `json:"serial"`
|
||||
SigningAlgorithm string `json:"signing_algorithm"`
|
||||
|
||||
Subject *HostCertificateNameDetails `json:"subject,omitempty"`
|
||||
Issuer *HostCertificateNameDetails `json:"issuer,omitempty"`
|
||||
}
|
||||
|
||||
type HostCertificateNameDetails struct {
|
||||
CommonName string `json:"common_name"`
|
||||
Country string `json:"country"`
|
||||
Organization string `json:"organization"`
|
||||
OrganizationalUnit string `json:"organizational_unit"`
|
||||
}
|
||||
|
||||
// MDMAppleCertificateListResponse is the plist model for a certificate list response.
|
||||
// https://developer.apple.com/documentation/devicemanagement/certificatelistresponse
|
||||
type MDMAppleCertificateListResponse struct {
|
||||
CertificateList []MDMAppleCertificateListItem `plist:"CertificateList"`
|
||||
CommandUUID string `plist:"CommandUUID"`
|
||||
EnrollmentID string `plist:"EnrollmentID"`
|
||||
EnrollmentUserID string `plist:"EnrollmentUserID"`
|
||||
ErrorChain []MDMAppleErrorChainItem `plist:"ErrorChain"`
|
||||
NotOnConsole bool `plist:"NotOnConsole"`
|
||||
Status string `plist:"Status"`
|
||||
UDID string `plist:"UDID"`
|
||||
UserID string `plist:"UserID"`
|
||||
UserLongName string `plist:"UserLongName"`
|
||||
UserShortName string `plist:"UserShortName"`
|
||||
}
|
||||
|
||||
// MDMAppleCertificateListItem is the plist model for a certificate.
|
||||
// https://developer.apple.com/documentation/devicemanagement/certificatelistresponse/certificatelistitem
|
||||
type MDMAppleCertificateListItem struct {
|
||||
CommonName string `plist:"CommonName"`
|
||||
// Data is the DER-encoded certificate.
|
||||
Data []byte `plist:"Data"`
|
||||
IsIdentity bool `plist:"IsIdentity"`
|
||||
}
|
||||
|
||||
func (c *MDMAppleCertificateListItem) Parse(hostID uint) (*HostCertificateRecord, error) {
|
||||
cert, err := x509.ParseCertificate(c.Data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewHostCertificateRecord(hostID, cert), nil
|
||||
}
|
||||
|
||||
// MdmAppleErrorChainItem is the plist model for an error chain item.
|
||||
// https://developer.apple.com/documentation/devicemanagement/certificatelistresponse/errorchainitem
|
||||
type MDMAppleErrorChainItem struct {
|
||||
ErrorCode int `plist:"ErrorCode"`
|
||||
ErrorDomain string `plist:"ErrorDomain"`
|
||||
LocalizedDescription string `plist:"LocalizedDescription"`
|
||||
USEnglishDescription string `plist:"USEnglishDescription"`
|
||||
}
|
||||
|
||||
// ExtractDetailsFromOsqueryDistinguishedName parses a distinguished name and returns the country,
|
||||
// organization, and organizational unit. It assumes provided string follows the formatting used by
|
||||
// osquery `certificates` table[1], which appears to follow the style used by openSSL for `-subj`
|
||||
// values). Key-value pairs are assumed to be separated by forward slashes, for example:
|
||||
// "/C=US/O=Fleet Device Management Inc./OU=Fleet Device Management Inc./CN=FleetDM".
|
||||
//
|
||||
// See https://osquery.io/schema/5.15.0/#certificates
|
||||
func ExtractDetailsFromOsqueryDistinguishedName(str string) (*HostCertificateNameDetails, error) {
|
||||
str = strings.TrimSpace(str)
|
||||
str = strings.Trim(str, "/")
|
||||
|
||||
if !strings.Contains(str, "/") {
|
||||
return nil, errors.New("invalid format, wrong separator")
|
||||
}
|
||||
|
||||
parts := strings.Split(str, "/")
|
||||
|
||||
var details HostCertificateNameDetails
|
||||
for _, part := range parts {
|
||||
kv := strings.Split(part, "=")
|
||||
if len(kv) != 2 {
|
||||
return nil, errors.New("invalid distinguished name, wrong key value pair format")
|
||||
}
|
||||
|
||||
switch strings.ToUpper(kv[0]) {
|
||||
case "C":
|
||||
details.Country = strings.Trim(kv[1], " ")
|
||||
case "O":
|
||||
details.Organization = strings.Trim(kv[1], " ")
|
||||
case "OU":
|
||||
details.OrganizationalUnit = strings.Trim(kv[1], " ")
|
||||
case "CN":
|
||||
details.CommonName = strings.Trim(kv[1], " ")
|
||||
}
|
||||
}
|
||||
|
||||
return &details, nil
|
||||
}
|
||||
|
||||
func firstOrEmpty(s []string) string {
|
||||
if len(s) > 0 {
|
||||
return s[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
105
server/fleet/host_certificates_test.go
Normal file
105
server/fleet/host_certificates_test.go
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
package fleet
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestExtractHostCertificateNameDetails(t *testing.T) {
|
||||
expected := HostCertificateNameDetails{
|
||||
Country: "US",
|
||||
Organization: "Fleet Device Management Inc.",
|
||||
OrganizationalUnit: "Fleet Device Management Inc.",
|
||||
CommonName: "FleetDM",
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
expected *HostCertificateNameDetails
|
||||
err bool
|
||||
}{
|
||||
{
|
||||
name: "valid",
|
||||
input: "/C=US/O=Fleet Device Management Inc./OU=Fleet Device Management Inc./CN=FleetDM",
|
||||
expected: &expected,
|
||||
},
|
||||
{
|
||||
name: "valid with different order",
|
||||
input: "/O=Fleet Device Management Inc./OU=Fleet Device Management Inc./CN=FleetDM/C=US",
|
||||
expected: &expected,
|
||||
},
|
||||
{
|
||||
name: "valid with missing key",
|
||||
input: "/C=US/O=Fleet Device Management Inc./CN=FleetDM ",
|
||||
expected: &HostCertificateNameDetails{
|
||||
Country: "US",
|
||||
Organization: "Fleet Device Management Inc.",
|
||||
OrganizationalUnit: "",
|
||||
CommonName: "FleetDM",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid with additional keyr",
|
||||
input: "/C=US/O=Fleet Device Management Inc./OU=Fleet Device Management Inc./CN=FleetDM/L=SomeCity",
|
||||
expected: &expected,
|
||||
},
|
||||
{
|
||||
name: "invalid format with extra slash",
|
||||
input: "/C=US/O=Fleet Device Management Inc./OU=Fleet Device Management Inc./CN=FleetDM/invalid",
|
||||
err: true,
|
||||
},
|
||||
{
|
||||
name: "invalid format with wrong separator",
|
||||
input: "C=US,O=Fleet Device Management Inc.,OU=Fleet Device Management Inc.,CN=FleetDM",
|
||||
err: true,
|
||||
},
|
||||
{
|
||||
name: "invalid format with extra equal",
|
||||
input: "/C=US=/O=Fleet Device Management Inc./OU=Fleet Device Management Inc./CN=FleetDM",
|
||||
err: true,
|
||||
},
|
||||
{
|
||||
name: "invalid format with malformed key values",
|
||||
input: "/C=US/O/OU=Fleet Device Management Inc./=/CN=FleetDM",
|
||||
err: true,
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
input: "",
|
||||
err: true,
|
||||
},
|
||||
{
|
||||
name: "missing value",
|
||||
input: "/C=US/O=Fleet Device Management Inc./OU=Fleet Device Management Inc./CN=",
|
||||
expected: &HostCertificateNameDetails{
|
||||
Country: "US",
|
||||
Organization: "Fleet Device Management Inc.",
|
||||
OrganizationalUnit: "Fleet Device Management Inc.",
|
||||
CommonName: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing first slash",
|
||||
input: "C=US/O=Fleet Device Management Inc./OU=Fleet Device Management Inc./CN=FleetDM",
|
||||
expected: &expected,
|
||||
},
|
||||
{
|
||||
name: "trailing slash",
|
||||
input: "/C=US/O=Fleet Device Management Inc./OU=Fleet Device Management Inc./CN=FleetDM/",
|
||||
expected: &expected,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
actual, err := ExtractDetailsFromOsqueryDistinguishedName(tc.input)
|
||||
if tc.err {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.expected, actual)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -801,6 +801,7 @@ const (
|
|||
RefetchBaseCommandUUIDPrefix = "REFETCH-"
|
||||
RefetchDeviceCommandUUIDPrefix = RefetchBaseCommandUUIDPrefix + "DEVICE-"
|
||||
RefetchAppsCommandUUIDPrefix = RefetchBaseCommandUUIDPrefix + "APPS-"
|
||||
RefetchCertsCommandUUIDPrefix = RefetchBaseCommandUUIDPrefix + "CERTS-"
|
||||
)
|
||||
|
||||
// VPPTokenInfo is the representation of the VPP token that we send out via API.
|
||||
|
|
|
|||
|
|
@ -435,6 +435,9 @@ type Service interface {
|
|||
// the specified host.
|
||||
ListHostSoftware(ctx context.Context, hostID uint, opts HostSoftwareTitleListOptions) ([]*HostSoftwareWithInstaller, *PaginationMetadata, error)
|
||||
|
||||
// ListHostCertificates lists the certificates installed on the specified host.
|
||||
ListHostCertificates(ctx context.Context, hostID uint, opts ListOptions) ([]*HostCertificatePayload, *PaginationMetadata, error)
|
||||
|
||||
// /////////////////////////////////////////////////////////////////////////////
|
||||
// AppConfigService provides methods for configuring the Fleet application
|
||||
|
||||
|
|
|
|||
|
|
@ -1211,7 +1211,7 @@ func IOSiPadOSRefetch(ctx context.Context, ds fleet.Datastore, commander *MDMApp
|
|||
logger.Log("msg", "sending commands to refetch", "count", len(devices), "lookup-duration", time.Since(start))
|
||||
commandUUID := uuid.NewString()
|
||||
|
||||
hostMDMCommands := make([]fleet.HostMDMCommand, 0, 2*len(devices))
|
||||
hostMDMCommands := make([]fleet.HostMDMCommand, 0, 3*len(devices))
|
||||
installedAppsUUIDs := make([]string, 0, len(devices))
|
||||
for _, device := range devices {
|
||||
if !slices.Contains(device.CommandsAlreadySent, fleet.RefetchAppsCommandUUIDPrefix) {
|
||||
|
|
@ -1229,6 +1229,23 @@ func IOSiPadOSRefetch(ctx context.Context, ds fleet.Datastore, commander *MDMApp
|
|||
}
|
||||
}
|
||||
|
||||
certsListUUIDs := make([]string, 0, len(devices))
|
||||
for _, device := range devices {
|
||||
if !slices.Contains(device.CommandsAlreadySent, fleet.RefetchCertsCommandUUIDPrefix) {
|
||||
certsListUUIDs = append(certsListUUIDs, device.UUID)
|
||||
hostMDMCommands = append(hostMDMCommands, fleet.HostMDMCommand{
|
||||
HostID: device.HostID,
|
||||
CommandType: fleet.RefetchCertsCommandUUIDPrefix,
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(certsListUUIDs) > 0 {
|
||||
err = commander.CertificateList(ctx, certsListUUIDs, fleet.RefetchCertsCommandUUIDPrefix+commandUUID)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "send CertificateList commands to ios and ipados devices")
|
||||
}
|
||||
}
|
||||
|
||||
// DeviceInformation is last because the refetch response clears the refetch_requested flag
|
||||
deviceInfoUUIDs := make([]string, 0, len(devices))
|
||||
for _, device := range devices {
|
||||
|
|
|
|||
|
|
@ -336,6 +336,34 @@ func (svc *MDMAppleCommander) InstalledApplicationList(ctx context.Context, host
|
|||
return svc.EnqueueCommand(ctx, hostUUIDs, raw)
|
||||
}
|
||||
|
||||
// CertificateList sends the homonym [command][1] to the device to get a list of installed
|
||||
// certificates on the device.
|
||||
//
|
||||
// Note that user-enrolled devices ignore the [ManagedOnly][2] value set below and will always
|
||||
// include only managed certificates. This is a limitation imposed by Apple.
|
||||
//
|
||||
// [1]: https://developer.apple.com/documentation/devicemanagement/certificatelistcommand
|
||||
// [2]: https://developer.apple.com/documentation/devicemanagement/certificatelistcommand/command-data.dictionary
|
||||
func (svc *MDMAppleCommander) CertificateList(ctx context.Context, hostUUIDs []string, cmdUUID string) error {
|
||||
raw := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CommandUUID</key>
|
||||
<string>%s</string>
|
||||
<key>Command</key>
|
||||
<dict>
|
||||
<key>MangedOnly</key>
|
||||
<false/>
|
||||
<key>RequestType</key>
|
||||
<string>CertificateList</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>`, cmdUUID)
|
||||
|
||||
return svc.EnqueueCommand(ctx, hostUUIDs, raw)
|
||||
}
|
||||
|
||||
// EnqueueCommand takes care of enqueuing the commands and sending push
|
||||
// notifications to the devices.
|
||||
//
|
||||
|
|
@ -352,7 +380,8 @@ func (svc *MDMAppleCommander) EnqueueCommand(ctx context.Context, hostUUIDs []st
|
|||
}
|
||||
|
||||
func (svc *MDMAppleCommander) enqueueAndNotify(ctx context.Context, hostUUIDs []string, cmd *mdm.Command,
|
||||
subtype mdm.CommandSubtype) error {
|
||||
subtype mdm.CommandSubtype,
|
||||
) error {
|
||||
if _, err := svc.storage.EnqueueCommand(ctx, hostUUIDs,
|
||||
&mdm.CommandWithSubtype{Command: *cmd, Subtype: subtype}); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "enqueuing command")
|
||||
|
|
@ -367,7 +396,8 @@ func (svc *MDMAppleCommander) enqueueAndNotify(ctx context.Context, hostUUIDs []
|
|||
// EnqueueCommandInstallProfileWithSecrets is a special case of EnqueueCommand that does not expand secret variables.
|
||||
// Secret variables are expanded when the command is sent to the device, and secrets are never stored in the database unencrypted.
|
||||
func (svc *MDMAppleCommander) EnqueueCommandInstallProfileWithSecrets(ctx context.Context, hostUUIDs []string,
|
||||
rawCommand mobileconfig.Mobileconfig, commandUUID string) error {
|
||||
rawCommand mobileconfig.Mobileconfig, commandUUID string,
|
||||
) error {
|
||||
cmd := &mdm.Command{
|
||||
CommandUUID: commandUUID,
|
||||
Raw: []byte(rawCommand),
|
||||
|
|
|
|||
29
server/mdm/testing_utils/testing_utils.go
Normal file
29
server/mdm/testing_utils/testing_utils.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package testing_utils
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/asn1"
|
||||
"math/big"
|
||||
"time"
|
||||
)
|
||||
|
||||
func NewTestMDMAppleCertTemplate() *x509.Certificate {
|
||||
return &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{
|
||||
Organization: []string{"Test Org"},
|
||||
ExtraNames: []pkix.AttributeTypeAndValue{
|
||||
{
|
||||
Type: asn1.ObjectIdentifier{0, 9, 2342, 19200300, 100, 1, 1},
|
||||
Value: "com.apple.mgmt.Example",
|
||||
},
|
||||
},
|
||||
},
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().Add(365 * 24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
}
|
||||
|
|
@ -274,6 +274,10 @@ type CleanupHostMDMAppleProfilesFunc func(ctx context.Context) error
|
|||
|
||||
type IsHostConnectedToFleetMDMFunc func(ctx context.Context, host *fleet.Host) (bool, error)
|
||||
|
||||
type ListHostCertificatesFunc func(ctx context.Context, hostID uint, opts fleet.ListOptions) ([]*fleet.HostCertificateRecord, *fleet.PaginationMetadata, error)
|
||||
|
||||
type UpdateHostCertificatesFunc func(ctx context.Context, hostID uint, certs []*fleet.HostCertificateRecord) error
|
||||
|
||||
type AreHostsConnectedToFleetMDMFunc func(ctx context.Context, hosts []*fleet.Host) (map[string]bool, error)
|
||||
|
||||
type AggregatedMunkiVersionFunc func(ctx context.Context, teamID *uint) ([]fleet.AggregatedMunkiVersion, time.Time, error)
|
||||
|
|
@ -1617,6 +1621,12 @@ type DataStore struct {
|
|||
IsHostConnectedToFleetMDMFunc IsHostConnectedToFleetMDMFunc
|
||||
IsHostConnectedToFleetMDMFuncInvoked bool
|
||||
|
||||
ListHostCertificatesFunc ListHostCertificatesFunc
|
||||
ListHostCertificatesFuncInvoked bool
|
||||
|
||||
UpdateHostCertificatesFunc UpdateHostCertificatesFunc
|
||||
UpdateHostCertificatesFuncInvoked bool
|
||||
|
||||
AreHostsConnectedToFleetMDMFunc AreHostsConnectedToFleetMDMFunc
|
||||
AreHostsConnectedToFleetMDMFuncInvoked bool
|
||||
|
||||
|
|
@ -3948,6 +3958,20 @@ func (s *DataStore) IsHostConnectedToFleetMDM(ctx context.Context, host *fleet.H
|
|||
return s.IsHostConnectedToFleetMDMFunc(ctx, host)
|
||||
}
|
||||
|
||||
func (s *DataStore) ListHostCertificates(ctx context.Context, hostID uint, opts fleet.ListOptions) ([]*fleet.HostCertificateRecord, *fleet.PaginationMetadata, error) {
|
||||
s.mu.Lock()
|
||||
s.ListHostCertificatesFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.ListHostCertificatesFunc(ctx, hostID, opts)
|
||||
}
|
||||
|
||||
func (s *DataStore) UpdateHostCertificates(ctx context.Context, hostID uint, certs []*fleet.HostCertificateRecord) error {
|
||||
s.mu.Lock()
|
||||
s.UpdateHostCertificatesFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.UpdateHostCertificatesFunc(ctx, hostID, certs)
|
||||
}
|
||||
|
||||
func (s *DataStore) AreHostsConnectedToFleetMDM(ctx context.Context, hosts []*fleet.Host) (map[string]bool, error) {
|
||||
s.mu.Lock()
|
||||
s.AreHostsConnectedToFleetMDMFuncInvoked = true
|
||||
|
|
|
|||
|
|
@ -3030,44 +3030,102 @@ func (svc *MDMAppleCheckinAndCommandService) handleRefetch(r *mdm.Request, cmdRe
|
|||
return nil, ctxerr.Wrap(ctx, err, "failed to get host by identifier")
|
||||
}
|
||||
|
||||
if strings.HasPrefix(cmdResult.CommandUUID, fleet.RefetchAppsCommandUUIDPrefix) {
|
||||
// We remove pending command first in case there is an error processing the results, so that we don't prevent another refetch.
|
||||
err = svc.ds.RemoveHostMDMCommand(ctx, fleet.HostMDMCommand{
|
||||
HostID: host.ID,
|
||||
CommandType: fleet.RefetchAppsCommandUUIDPrefix,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "remove refetch apps command")
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(cmdResult.CommandUUID, fleet.RefetchAppsCommandUUIDPrefix):
|
||||
return svc.handleRefetchAppsResults(ctx, host, cmdResult)
|
||||
|
||||
if host.Platform != "ios" && host.Platform != "ipados" {
|
||||
return nil, ctxerr.New(ctx, "refetch apps command sent to non-iOS/non-iPadOS host")
|
||||
}
|
||||
source := "ios_apps"
|
||||
if host.Platform == "ipados" {
|
||||
source = "ipados_apps"
|
||||
}
|
||||
case strings.HasPrefix(cmdResult.CommandUUID, fleet.RefetchCertsCommandUUIDPrefix):
|
||||
return svc.handleRefetchCertsResults(ctx, host, cmdResult)
|
||||
|
||||
response := cmdResult.Raw
|
||||
software, err := unmarshalAppList(ctx, response, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = svc.ds.UpdateHostSoftware(ctx, host.ID, software)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "update host software")
|
||||
}
|
||||
case strings.HasPrefix(cmdResult.CommandUUID, fleet.RefetchDeviceCommandUUIDPrefix):
|
||||
return svc.handleRefetchDeviceResults(ctx, host, cmdResult)
|
||||
|
||||
return nil, nil
|
||||
default:
|
||||
// This should never happen, but just in case we'll return an error.
|
||||
return nil, ctxerr.New(ctx, fmt.Sprintf("unknown refetch command type %s", cmdResult.CommandUUID))
|
||||
}
|
||||
}
|
||||
|
||||
func (svc *MDMAppleCheckinAndCommandService) handleRefetchAppsResults(ctx context.Context, host *fleet.Host, cmdResult *mdm.CommandResults) (*mdm.Command, error) {
|
||||
if !strings.HasPrefix(cmdResult.CommandUUID, fleet.RefetchAppsCommandUUIDPrefix) {
|
||||
// Caller should have checked this, but just in case we'll return an error.
|
||||
return nil, ctxerr.New(ctx, fmt.Sprintf("expected REFETCH-APPS- prefix but got %s", cmdResult.CommandUUID))
|
||||
}
|
||||
|
||||
// Otherwise, the command has prefix fleet.RefetchDeviceCommandUUIDPrefix, which is a refetch device command.
|
||||
// We remove pending command first in case there is an error processing the results, so that we don't prevent another refetch.
|
||||
err = svc.ds.RemoveHostMDMCommand(ctx, fleet.HostMDMCommand{
|
||||
if err := svc.ds.RemoveHostMDMCommand(ctx, fleet.HostMDMCommand{
|
||||
HostID: host.ID,
|
||||
CommandType: fleet.RefetchAppsCommandUUIDPrefix,
|
||||
}); err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "remove refetch apps command")
|
||||
}
|
||||
|
||||
if host.Platform != "ios" && host.Platform != "ipados" {
|
||||
return nil, ctxerr.New(ctx, "refetch apps command sent to non-iOS/non-iPadOS host")
|
||||
}
|
||||
source := "ios_apps"
|
||||
if host.Platform == "ipados" {
|
||||
source = "ipados_apps"
|
||||
}
|
||||
|
||||
response := cmdResult.Raw
|
||||
software, err := unmarshalAppList(ctx, response, source)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "unmarshal app list")
|
||||
}
|
||||
_, err = svc.ds.UpdateHostSoftware(ctx, host.ID, software)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "update host software")
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (svc *MDMAppleCheckinAndCommandService) handleRefetchCertsResults(ctx context.Context, host *fleet.Host, cmdResult *mdm.CommandResults) (*mdm.Command, error) {
|
||||
if !strings.HasPrefix(cmdResult.CommandUUID, fleet.RefetchCertsCommandUUIDPrefix) {
|
||||
// Caller should have checked this, but just in case we'll return an error.
|
||||
return nil, ctxerr.New(ctx, fmt.Sprintf("expected REFETCH-CERTS- prefix but got %s", cmdResult.CommandUUID))
|
||||
}
|
||||
|
||||
// We remove pending command first in case there is an error processing the results, so that we don't prevent another refetch.
|
||||
if err := svc.ds.RemoveHostMDMCommand(ctx, fleet.HostMDMCommand{
|
||||
HostID: host.ID,
|
||||
CommandType: fleet.RefetchCertsCommandUUIDPrefix,
|
||||
}); err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "refetch certs: remove refetch command")
|
||||
}
|
||||
|
||||
var listResp fleet.MDMAppleCertificateListResponse
|
||||
if err := plist.Unmarshal(cmdResult.Raw, &listResp); err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "refetch certs: unmarshal certificate list command result")
|
||||
}
|
||||
payload := make([]*fleet.HostCertificateRecord, 0, len(listResp.CertificateList))
|
||||
for _, cert := range listResp.CertificateList {
|
||||
parsed, err := cert.Parse(host.ID)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "refetch certs: parse certificate")
|
||||
}
|
||||
payload = append(payload, parsed)
|
||||
}
|
||||
|
||||
if err := svc.ds.UpdateHostCertificates(ctx, host.ID, payload); err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "refetch certs: update host certificates")
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (svc *MDMAppleCheckinAndCommandService) handleRefetchDeviceResults(ctx context.Context, host *fleet.Host, cmdResult *mdm.CommandResults) (*mdm.Command, error) {
|
||||
if !strings.HasPrefix(cmdResult.CommandUUID, fleet.RefetchDeviceCommandUUIDPrefix) {
|
||||
// Caller should have checked this, but just in case we'll return an error.
|
||||
return nil, ctxerr.New(ctx, fmt.Sprintf("expected REFETCH-DEVICE- prefix but got %s", cmdResult.CommandUUID))
|
||||
}
|
||||
|
||||
// We remove pending command first in case there is an error processing the results, so that we don't prevent another refetch.
|
||||
if err := svc.ds.RemoveHostMDMCommand(ctx, fleet.HostMDMCommand{
|
||||
HostID: host.ID,
|
||||
CommandType: fleet.RefetchDeviceCommandUUIDPrefix,
|
||||
})
|
||||
if err != nil {
|
||||
}); err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "remove refetch device command")
|
||||
}
|
||||
|
||||
|
|
@ -3075,7 +3133,7 @@ func (svc *MDMAppleCheckinAndCommandService) handleRefetch(r *mdm.Request, cmdRe
|
|||
QueryResponses map[string]interface{} `plist:"QueryResponses"`
|
||||
}
|
||||
if err := plist.Unmarshal(cmdResult.Raw, &deviceInformationResponse); err != nil {
|
||||
return nil, ctxerr.Wrap(r.Context, err, "failed to unmarshal device information command result")
|
||||
return nil, ctxerr.Wrap(ctx, err, "failed to unmarshal device information command result")
|
||||
}
|
||||
deviceName := deviceInformationResponse.QueryResponses["DeviceName"].(string)
|
||||
deviceCapacity := deviceInformationResponse.QueryResponses["DeviceCapacity"].(float64)
|
||||
|
|
@ -3103,26 +3161,25 @@ func (svc *MDMAppleCheckinAndCommandService) handleRefetch(r *mdm.Request, cmdRe
|
|||
host.HardwareModel = productName
|
||||
host.DetailUpdatedAt = time.Now()
|
||||
host.RefetchRequested = false
|
||||
if err := svc.ds.UpdateHost(r.Context, host); err != nil {
|
||||
return nil, ctxerr.Wrap(r.Context, err, "failed to update host")
|
||||
if err := svc.ds.UpdateHost(ctx, host); err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "failed to update host")
|
||||
}
|
||||
if err := svc.ds.SetOrUpdateHostDisksSpace(r.Context, host.ID, availableDeviceCapacity, 100*availableDeviceCapacity/deviceCapacity,
|
||||
if err := svc.ds.SetOrUpdateHostDisksSpace(ctx, host.ID, availableDeviceCapacity, 100*availableDeviceCapacity/deviceCapacity,
|
||||
deviceCapacity); err != nil {
|
||||
return nil, ctxerr.Wrap(r.Context, err, "failed to update host storage")
|
||||
return nil, ctxerr.Wrap(ctx, err, "failed to update host storage")
|
||||
}
|
||||
if err := svc.ds.UpdateHostOperatingSystem(r.Context, host.ID, fleet.OperatingSystem{
|
||||
if err := svc.ds.UpdateHostOperatingSystem(ctx, host.ID, fleet.OperatingSystem{
|
||||
Name: osVersionPrefix,
|
||||
Version: osVersion,
|
||||
Platform: platform,
|
||||
}); err != nil {
|
||||
return nil, ctxerr.Wrap(r.Context, err, "failed to update host operating system")
|
||||
return nil, ctxerr.Wrap(ctx, err, "failed to update host operating system")
|
||||
}
|
||||
|
||||
if host.MDM.EnrollmentStatus != nil && *host.MDM.EnrollmentStatus == "Pending" {
|
||||
// Since the device has been refetched, we can assume it's enrolled.
|
||||
err = svc.ds.UpdateMDMData(ctx, host.ID, true)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(r.Context, err, "failed to update MDM data")
|
||||
if err := svc.ds.UpdateMDMData(ctx, host.ID, true); err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "failed to update MDM data")
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
|
|
|
|||
|
|
@ -55,6 +55,8 @@ import (
|
|||
"github.com/smallstep/pkcs7"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
mdmtesting "github.com/fleetdm/fleet/v4/server/mdm/testing_utils"
|
||||
)
|
||||
|
||||
type nopProfileMatcher struct{}
|
||||
|
|
@ -218,7 +220,7 @@ func setupAppleMDMService(t *testing.T, license *fleet.LicenseInfo) (fleet.Servi
|
|||
ds.ExpandEmbeddedSecretsAndUpdatedAtFunc = func(ctx context.Context, document string) (string, *time.Time, error) {
|
||||
return document, nil, nil
|
||||
}
|
||||
apnsCert, apnsKey, err := mysql.GenerateTestCertBytes()
|
||||
apnsCert, apnsKey, err := mysql.GenerateTestCertBytes(mdmtesting.NewTestMDMAppleCertTemplate())
|
||||
require.NoError(t, err)
|
||||
crt, key, err := apple_mdm.NewSCEPCACertKey()
|
||||
require.NoError(t, err)
|
||||
|
|
@ -3618,7 +3620,7 @@ func setupTest(t *testing.T) (context.Context, kitlog.Logger, *mock.Store, *conf
|
|||
AppleSCEPCert: "./testdata/server.pem",
|
||||
AppleSCEPKey: "./testdata/server.key",
|
||||
}
|
||||
apnsCert, apnsKey, err := mysql.GenerateTestCertBytes()
|
||||
apnsCert, apnsKey, err := mysql.GenerateTestCertBytes(mdmtesting.NewTestMDMAppleCertTemplate())
|
||||
require.NoError(t, err)
|
||||
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
||||
appCfg := &fleet.AppConfig{}
|
||||
|
|
@ -3826,7 +3828,7 @@ func TestRenewSCEPCertificatesBranches(t *testing.T) {
|
|||
}
|
||||
|
||||
appleStorage.RetrievePushCertFunc = func(ctx context.Context, topic string) (*tls.Certificate, string, error) {
|
||||
apnsCert, apnsKey, err := mysql.GenerateTestCertBytes()
|
||||
apnsCert, apnsKey, err := mysql.GenerateTestCertBytes(mdmtesting.NewTestMDMAppleCertTemplate())
|
||||
require.NoError(t, err)
|
||||
cert, err := tls.X509KeyPair(apnsCert, apnsKey)
|
||||
return &cert, "", err
|
||||
|
|
|
|||
|
|
@ -685,3 +685,49 @@ func getDeviceSoftwareEndpoint(ctx context.Context, request interface{}, svc fle
|
|||
}
|
||||
return getDeviceSoftwareResponse{Software: res, Meta: meta, Count: int(meta.TotalResults)}, nil //nolint:gosec // dismiss G115
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// List Current Device's Certificates
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type listDeviceCertificatesRequest struct {
|
||||
Token string `url:"token"`
|
||||
fleet.ListOptions
|
||||
}
|
||||
|
||||
func (r *listDeviceCertificatesRequest) ValidateRequest() error {
|
||||
if r.ListOptions.OrderKey != "" && !listHostCertificatesSortCols[r.ListOptions.OrderKey] {
|
||||
return badRequest("invalid order key")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *listDeviceCertificatesRequest) deviceAuthToken() string {
|
||||
return r.Token
|
||||
}
|
||||
|
||||
type listDeviceCertificatesResponse struct {
|
||||
Certificates []*fleet.HostCertificatePayload `json:"certificates"`
|
||||
Meta *fleet.PaginationMetadata `json:"meta,omitempty"`
|
||||
Err error `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (r listDeviceCertificatesResponse) Error() error { return r.Err }
|
||||
|
||||
func listDeviceCertificatesEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
||||
host, ok := hostctx.FromContext(ctx)
|
||||
if !ok {
|
||||
err := ctxerr.Wrap(ctx, fleet.NewAuthRequiredError("internal error: missing host from request context"))
|
||||
return listDevicePoliciesResponse{Err: err}, nil
|
||||
}
|
||||
|
||||
req := request.(*listDeviceCertificatesRequest)
|
||||
res, meta, err := svc.ListHostCertificates(ctx, host.ID, req.ListOptions)
|
||||
if err != nil {
|
||||
return listDeviceCertificatesResponse{Err: err}, nil
|
||||
}
|
||||
if res == nil {
|
||||
res = []*fleet.HostCertificatePayload{}
|
||||
}
|
||||
return listDeviceCertificatesResponse{Certificates: res, Meta: meta}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -396,6 +396,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
|
|||
ue.POST("/api/_version_/fleet/hosts/{id:[0-9]+}/labels", addLabelsToHostEndpoint, addLabelsToHostRequest{})
|
||||
ue.DELETE("/api/_version_/fleet/hosts/{id:[0-9]+}/labels", removeLabelsFromHostEndpoint, removeLabelsFromHostRequest{})
|
||||
ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/software", getHostSoftwareEndpoint, getHostSoftwareRequest{})
|
||||
ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/certificates", listHostCertificatesEndpoint, listHostCertificatesRequest{})
|
||||
|
||||
ue.GET("/api/_version_/fleet/hosts/summary/mdm", getHostMDMSummary, getHostMDMSummaryRequest{})
|
||||
ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/mdm", getHostMDM, getHostMDMRequest{})
|
||||
|
|
@ -797,6 +798,9 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
|
|||
de.WithCustomMiddleware(
|
||||
errorLimiter.Limit("install_self_service", desktopQuota),
|
||||
).POST("/api/_version_/fleet/device/{token}/software/install/{software_title_id}", submitSelfServiceSoftwareInstall, fleetSelfServiceSoftwareInstallRequest{})
|
||||
de.WithCustomMiddleware(
|
||||
errorLimiter.Limit("get_device_certificates", desktopQuota),
|
||||
).GET("/api/_version_/fleet/device/{token}/certificates", listDeviceCertificatesEndpoint, listDeviceCertificatesRequest{})
|
||||
|
||||
// mdm-related endpoints available via device authentication
|
||||
demdm := de.WithCustomMiddleware(mdmConfiguredMiddleware.VerifyAppleMDM())
|
||||
|
|
|
|||
|
|
@ -1066,15 +1066,18 @@ func (svc *Service) RefetchHost(ctx context.Context, id uint) error {
|
|||
}
|
||||
doAppRefetch := true
|
||||
doDeviceInfoRefetch := true
|
||||
doCertsRefetch := true
|
||||
for _, cmd := range commands {
|
||||
switch cmd.CommandType {
|
||||
case fleet.RefetchDeviceCommandUUIDPrefix:
|
||||
doDeviceInfoRefetch = false
|
||||
case fleet.RefetchAppsCommandUUIDPrefix:
|
||||
doAppRefetch = false
|
||||
case fleet.RefetchCertsCommandUUIDPrefix:
|
||||
doCertsRefetch = false
|
||||
}
|
||||
}
|
||||
if !doAppRefetch && !doDeviceInfoRefetch {
|
||||
if !doAppRefetch && !doDeviceInfoRefetch && !doCertsRefetch {
|
||||
// Nothing to do.
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1082,7 +1085,7 @@ func (svc *Service) RefetchHost(ctx context.Context, id uint) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hostMDMCommands := make([]fleet.HostMDMCommand, 0, 2)
|
||||
hostMDMCommands := make([]fleet.HostMDMCommand, 0, 3)
|
||||
cmdUUID := uuid.NewString()
|
||||
if doAppRefetch {
|
||||
err = svc.mdmAppleCommander.InstalledApplicationList(ctx, []string{host.UUID}, fleet.RefetchAppsCommandUUIDPrefix+cmdUUID)
|
||||
|
|
@ -1094,6 +1097,15 @@ func (svc *Service) RefetchHost(ctx context.Context, id uint) error {
|
|||
CommandType: fleet.RefetchAppsCommandUUIDPrefix,
|
||||
})
|
||||
}
|
||||
if doCertsRefetch {
|
||||
if err := svc.mdmAppleCommander.CertificateList(ctx, []string{host.UUID}, fleet.RefetchCertsCommandUUIDPrefix+cmdUUID); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "refetch certs with MDM")
|
||||
}
|
||||
hostMDMCommands = append(hostMDMCommands, fleet.HostMDMCommand{
|
||||
HostID: host.ID,
|
||||
CommandType: fleet.RefetchCertsCommandUUIDPrefix,
|
||||
})
|
||||
}
|
||||
if doDeviceInfoRefetch {
|
||||
// DeviceInformation is last because the refetch response clears the refetch_requested flag
|
||||
err = svc.mdmAppleCommander.DeviceInformation(ctx, []string{host.UUID}, fleet.RefetchDeviceCommandUUIDPrefix+cmdUUID)
|
||||
|
|
@ -2704,3 +2716,77 @@ func (svc *Service) ListHostSoftware(ctx context.Context, hostID uint, opts flee
|
|||
software, meta, err := svc.ds.ListHostSoftware(ctx, host, opts)
|
||||
return software, meta, ctxerr.Wrap(ctx, err, "list host software")
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// Host Certificates
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type listHostCertificatesRequest struct {
|
||||
ID uint `url:"id"`
|
||||
fleet.ListOptions
|
||||
}
|
||||
|
||||
var listHostCertificatesSortCols = map[string]bool{
|
||||
"common_name": true,
|
||||
"not_valid_after": true,
|
||||
}
|
||||
|
||||
func (r *listHostCertificatesRequest) ValidateRequest() error {
|
||||
if r.ListOptions.OrderKey != "" && !listHostCertificatesSortCols[r.ListOptions.OrderKey] {
|
||||
return badRequest("invalid order key")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type listHostCertificatesResponse struct {
|
||||
Certificates []*fleet.HostCertificatePayload `json:"certificates"`
|
||||
Meta *fleet.PaginationMetadata `json:"meta,omitempty"`
|
||||
Err error `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (r listHostCertificatesResponse) Error() error { return r.Err }
|
||||
|
||||
func listHostCertificatesEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
||||
req := request.(*listHostCertificatesRequest)
|
||||
res, meta, err := svc.ListHostCertificates(ctx, req.ID, req.ListOptions)
|
||||
if err != nil {
|
||||
return listHostCertificatesResponse{Err: err}, nil
|
||||
}
|
||||
if res == nil {
|
||||
res = []*fleet.HostCertificatePayload{}
|
||||
}
|
||||
return listHostCertificatesResponse{Certificates: res, Meta: meta}, nil
|
||||
}
|
||||
|
||||
func (svc *Service) ListHostCertificates(ctx context.Context, hostID uint, opts fleet.ListOptions) ([]*fleet.HostCertificatePayload, *fleet.PaginationMetadata, error) {
|
||||
if !svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceToken) {
|
||||
host, err := svc.ds.HostLite(ctx, hostID)
|
||||
if err != nil {
|
||||
svc.authz.SkipAuthorization(ctx)
|
||||
return nil, nil, ctxerr.Wrap(ctx, err, "failed to load host")
|
||||
}
|
||||
if err := svc.authz.Authorize(ctx, host, fleet.ActionRead); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// query/after not supported, always include pagination info
|
||||
opts.MatchQuery = ""
|
||||
opts.After = ""
|
||||
opts.IncludeMetadata = true
|
||||
// default sort order is common name ascending
|
||||
if opts.OrderKey == "" {
|
||||
opts.OrderKey = "common_name"
|
||||
}
|
||||
|
||||
certs, meta, err := svc.ds.ListHostCertificates(ctx, hostID, opts)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
payload := make([]*fleet.HostCertificatePayload, 0, len(certs))
|
||||
for _, cert := range certs {
|
||||
payload = append(payload, cert.ToPayload())
|
||||
}
|
||||
return payload, meta, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -665,6 +665,9 @@ func TestHostAuth(t *testing.T) {
|
|||
ds.IsHostConnectedToFleetMDMFunc = func(ctx context.Context, host *fleet.Host) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
ds.ListHostCertificatesFunc = func(ctx context.Context, hostID uint, opts fleet.ListOptions) ([]*fleet.HostCertificateRecord, *fleet.PaginationMetadata, error) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
|
|
@ -812,6 +815,11 @@ func TestHostAuth(t *testing.T) {
|
|||
|
||||
_, _, err = svc.ListHostSoftware(ctx, 2, fleet.HostSoftwareTitleListOptions{})
|
||||
checkAuthErr(t, tt.shouldFailGlobalRead, err)
|
||||
|
||||
_, _, err = svc.ListHostCertificates(ctx, 1, fleet.ListOptions{})
|
||||
checkAuthErr(t, tt.shouldFailTeamRead, err)
|
||||
_, _, err = svc.ListHostCertificates(ctx, 2, fleet.ListOptions{})
|
||||
checkAuthErr(t, tt.shouldFailGlobalRead, err)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package service
|
|||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha1" // nolint: gosec
|
||||
"database/sql"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
|
|
@ -13077,3 +13078,129 @@ func createAndroidHosts(t *testing.T, ds *mysql.Datastore, count int, teamID *ui
|
|||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func (s *integrationTestSuite) TestHostCertificates() {
|
||||
t := s.T()
|
||||
ctx := context.Background()
|
||||
|
||||
token := "good_token"
|
||||
host := createOrbitEnrolledHost(t, "linux", "host1", s.ds)
|
||||
createDeviceTokenForHost(t, s.ds, host.ID, token)
|
||||
|
||||
// no certificate at the moment
|
||||
var certResp listHostCertificatesResponse
|
||||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/certificates", host.ID), nil, http.StatusOK, &certResp)
|
||||
require.Empty(t, certResp.Certificates)
|
||||
|
||||
certResp = listHostCertificatesResponse{}
|
||||
res := s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/certificates", nil, http.StatusOK)
|
||||
err := json.NewDecoder(res.Body).Decode(&certResp)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, certResp.Certificates)
|
||||
|
||||
// create some certs for that host
|
||||
certNames := []string{"a", "b", "c", "d", "e"}
|
||||
now := time.Now()
|
||||
// sorting by not_valid_after should get us "d", "c", "e", "a", "b"
|
||||
notValidAfterTimes := []time.Time{
|
||||
now.Add(time.Minute), now.Add(time.Hour),
|
||||
now.Add(time.Second), now.Add(time.Millisecond),
|
||||
now.Add(2 * time.Second),
|
||||
}
|
||||
certs := make([]*fleet.HostCertificateRecord, 0, len(certNames))
|
||||
for i, name := range certNames {
|
||||
certs = append(certs, &fleet.HostCertificateRecord{
|
||||
HostID: host.ID,
|
||||
CommonName: name,
|
||||
SHA1Sum: sha1.New().Sum([]byte(name)), // nolint: gosec
|
||||
SubjectCountry: "s" + name,
|
||||
IssuerCountry: "i" + name,
|
||||
NotValidAfter: notValidAfterTimes[i],
|
||||
})
|
||||
}
|
||||
require.NoError(t, s.ds.UpdateHostCertificates(ctx, host.ID, certs))
|
||||
|
||||
// list all certs
|
||||
certResp = listHostCertificatesResponse{}
|
||||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/certificates", host.ID), nil, http.StatusOK, &certResp)
|
||||
require.Len(t, certResp.Certificates, len(certNames))
|
||||
for i, cert := range certResp.Certificates {
|
||||
want := certNames[i]
|
||||
require.Equal(t, want, cert.CommonName)
|
||||
require.NotNil(t, cert.Subject)
|
||||
require.Equal(t, "s"+want, cert.Subject.Country)
|
||||
require.NotNil(t, cert.Issuer)
|
||||
require.Equal(t, "i"+want, cert.Issuer.Country)
|
||||
}
|
||||
|
||||
certResp = listHostCertificatesResponse{}
|
||||
res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/certificates", nil, http.StatusOK)
|
||||
err = json.NewDecoder(res.Body).Decode(&certResp)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, certResp.Certificates, len(certNames))
|
||||
for i, cert := range certResp.Certificates {
|
||||
want := certNames[i]
|
||||
require.Equal(t, want, cert.CommonName)
|
||||
require.NotNil(t, cert.Subject)
|
||||
require.Equal(t, "s"+want, cert.Subject.Country)
|
||||
require.NotNil(t, cert.Issuer)
|
||||
require.Equal(t, "i"+want, cert.Issuer.Country)
|
||||
}
|
||||
|
||||
// non-existing host
|
||||
certResp = listHostCertificatesResponse{}
|
||||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/certificates", host.ID+1000), nil, http.StatusNotFound, &certResp)
|
||||
// for the device endpoint, the token is the authentication so if it doesn't
|
||||
// exist, the endpoint is unauthorized.
|
||||
certResp = listHostCertificatesResponse{}
|
||||
s.DoRawNoAuth("GET", "/api/latest/fleet/device/NO-SUCH-TOKEN/certificates", nil, http.StatusUnauthorized)
|
||||
|
||||
pluckCertNames := func(certs []*fleet.HostCertificatePayload) []string {
|
||||
names := make([]string, 0, len(certs))
|
||||
for _, cert := range certs {
|
||||
names = append(names, cert.CommonName)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// fails if order_key is invalid
|
||||
certResp = listHostCertificatesResponse{}
|
||||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/certificates", host.ID), nil, http.StatusBadRequest, &certResp, "order_key", "no-such-column")
|
||||
|
||||
certResp = listHostCertificatesResponse{}
|
||||
res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/certificates", nil, http.StatusBadRequest, "order_key", "no-such-column")
|
||||
require.Contains(t, extractServerErrorText(res.Body), "invalid order key")
|
||||
|
||||
// test the pagination options
|
||||
cases := []struct {
|
||||
queryParams []string
|
||||
wantNames []string
|
||||
wantMeta fleet.PaginationMetadata
|
||||
}{
|
||||
{queryParams: []string{"page", "0", "per_page", "2"}, wantNames: []string{"a", "b"}, wantMeta: fleet.PaginationMetadata{HasNextResults: true}},
|
||||
{queryParams: []string{"page", "1", "per_page", "2"}, wantNames: []string{"c", "d"}, wantMeta: fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: true}},
|
||||
{queryParams: []string{"page", "2", "per_page", "2"}, wantNames: []string{"e"}, wantMeta: fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true}},
|
||||
{queryParams: []string{"page", "3", "per_page", "2"}, wantNames: []string{}, wantMeta: fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true}},
|
||||
{queryParams: []string{"page", "0", "per_page", "4", "order_direction", "desc"}, wantNames: []string{"e", "d", "c", "b"}, wantMeta: fleet.PaginationMetadata{HasNextResults: true}},
|
||||
{queryParams: []string{"page", "1", "per_page", "4", "order_direction", "desc"}, wantNames: []string{"a"}, wantMeta: fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true}},
|
||||
{queryParams: []string{"page", "0", "per_page", "3", "order_key", "not_valid_after"}, wantNames: []string{"d", "c", "e"}, wantMeta: fleet.PaginationMetadata{HasNextResults: true}},
|
||||
{queryParams: []string{"page", "1", "per_page", "3", "order_key", "not_valid_after"}, wantNames: []string{"a", "b"}, wantMeta: fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(strings.Join(c.queryParams, "_"), func(t *testing.T) {
|
||||
certResp = listHostCertificatesResponse{}
|
||||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/certificates", host.ID), nil, http.StatusOK, &certResp, c.queryParams...)
|
||||
require.Len(t, certResp.Certificates, len(c.wantNames))
|
||||
require.Equal(t, c.wantNames, pluckCertNames(certResp.Certificates))
|
||||
require.Equal(t, c.wantMeta, *certResp.Meta)
|
||||
|
||||
certResp = listHostCertificatesResponse{}
|
||||
res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/certificates", nil, http.StatusOK, c.queryParams...)
|
||||
err = json.NewDecoder(res.Body).Decode(&certResp)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, certResp.Certificates, len(c.wantNames))
|
||||
require.Equal(t, c.wantNames, pluckCertNames(certResp.Certificates))
|
||||
require.Equal(t, c.wantMeta, *certResp.Meta)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ import (
|
|||
"github.com/fleetdm/fleet/v4/server/mdm/scep/depot"
|
||||
filedepot "github.com/fleetdm/fleet/v4/server/mdm/scep/depot/file"
|
||||
scepserver "github.com/fleetdm/fleet/v4/server/mdm/scep/server"
|
||||
mdmtesting "github.com/fleetdm/fleet/v4/server/mdm/testing_utils"
|
||||
"github.com/fleetdm/fleet/v4/server/ptr"
|
||||
"github.com/fleetdm/fleet/v4/server/service/mock"
|
||||
"github.com/fleetdm/fleet/v4/server/service/osquery_utils"
|
||||
|
|
@ -10730,6 +10731,8 @@ func (s *integrationMDMTestSuite) TestEnrollAfterDEPSyncIOSIPadOS() {
|
|||
func (s *integrationMDMTestSuite) TestRefetchIOSIPadOS() {
|
||||
t := s.T()
|
||||
|
||||
testCerts := []*x509.Certificate{mdmtesting.NewTestMDMAppleCertTemplate()}
|
||||
|
||||
// Try to refetch host that is not MDM enrolled
|
||||
serialNumber := mdmtest.RandSerialNumber()
|
||||
fleetHost, err := s.ds.NewHost(context.Background(), &fleet.Host{
|
||||
|
|
@ -10750,7 +10753,7 @@ func (s *integrationMDMTestSuite) TestRefetchIOSIPadOS() {
|
|||
|
||||
// Refetch host
|
||||
_ = s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/refetch", host.ID), nil, http.StatusOK)
|
||||
const commandsSentPerRefetch = 2
|
||||
const commandsSentPerRefetch = 3
|
||||
commandsSent := commandsSentPerRefetch
|
||||
|
||||
var hostResp getHostResponse
|
||||
|
|
@ -10763,6 +10766,7 @@ func (s *integrationMDMTestSuite) TestRefetchIOSIPadOS() {
|
|||
require.Len(t, commands, commandsSent)
|
||||
assert.ElementsMatch(t, []fleet.HostMDMCommand{
|
||||
{HostID: host.ID, CommandType: fleet.RefetchAppsCommandUUIDPrefix},
|
||||
{HostID: host.ID, CommandType: fleet.RefetchCertsCommandUUIDPrefix},
|
||||
{HostID: host.ID, CommandType: fleet.RefetchDeviceCommandUUIDPrefix},
|
||||
}, commands)
|
||||
|
||||
|
|
@ -10789,6 +10793,9 @@ func (s *integrationMDMTestSuite) TestRefetchIOSIPadOS() {
|
|||
cmd, err = mdmClient.AcknowledgeInstalledApplicationList(mdmClient.UUID, cmd.CommandUUID,
|
||||
[]fleet.Software{expectedSoftware[0].Software})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "CertificateList", cmd.Command.RequestType)
|
||||
cmd, err = mdmClient.AcknowledgeCertificateList(mdmClient.UUID, cmd.CommandUUID, testCerts)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "DeviceInformation", cmd.Command.RequestType)
|
||||
_, err = mdmClient.AcknowledgeDeviceInformation(mdmClient.UUID, cmd.CommandUUID, deviceName, "iPhone SE")
|
||||
require.NoError(t, err)
|
||||
|
|
@ -10808,6 +10815,8 @@ func (s *integrationMDMTestSuite) TestRefetchIOSIPadOS() {
|
|||
}
|
||||
assert.ElementsMatch(t, expectedSoftware, hostResp.Host.Software)
|
||||
|
||||
// TODO: add test for GET /hosts/:id/certificates endpoint, should match up with testCerts
|
||||
|
||||
// Install the same app for iPadOS
|
||||
hostIPad, mdmClientIPad := s.createAppleMobileHostThenEnrollMDM("ipados")
|
||||
|
||||
|
|
@ -10839,6 +10848,9 @@ func (s *integrationMDMTestSuite) TestRefetchIOSIPadOS() {
|
|||
cmd, err = mdmClientIPad.AcknowledgeInstalledApplicationList(mdmClientIPad.UUID, cmd.CommandUUID,
|
||||
[]fleet.Software{expectedSoftware[0].Software})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "CertificateList", cmd.Command.RequestType)
|
||||
cmd, err = mdmClientIPad.AcknowledgeCertificateList(mdmClientIPad.UUID, cmd.CommandUUID, testCerts)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "DeviceInformation", cmd.Command.RequestType)
|
||||
cmd, err = mdmClientIPad.AcknowledgeDeviceInformation(mdmClientIPad.UUID, cmd.CommandUUID, deviceNameIPad, "iPad 10")
|
||||
require.NoError(t, err)
|
||||
|
|
@ -10855,6 +10867,8 @@ func (s *integrationMDMTestSuite) TestRefetchIOSIPadOS() {
|
|||
}
|
||||
assert.ElementsMatch(t, expectedSoftware, hostResp.Host.Software)
|
||||
|
||||
// TODO: add test for GET /hosts/:id/certificates endpoint, should match up with testCerts
|
||||
|
||||
hostsCountTs := time.Now().UTC()
|
||||
require.NoError(t, s.ds.SyncHostsSoftware(context.Background(), hostsCountTs))
|
||||
ctx := context.Background()
|
||||
|
|
@ -10917,6 +10931,9 @@ func (s *integrationMDMTestSuite) TestRefetchIOSIPadOS() {
|
|||
require.Equal(t, "InstalledApplicationList", cmd.Command.RequestType)
|
||||
cmd, err = mdmClient.AcknowledgeInstalledApplicationList(mdmClient.UUID, cmd.CommandUUID, []fleet.Software{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "CertificateList", cmd.Command.RequestType)
|
||||
cmd, err = mdmClient.AcknowledgeCertificateList(mdmClient.UUID, cmd.CommandUUID, []*x509.Certificate{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "DeviceInformation", cmd.Command.RequestType)
|
||||
const deviceNameRenamed = "My new iPhone"
|
||||
cmd, err = mdmClient.AcknowledgeDeviceInformation(mdmClient.UUID, cmd.CommandUUID, deviceNameRenamed, "iPhone SE")
|
||||
|
|
@ -10930,6 +10947,8 @@ func (s *integrationMDMTestSuite) TestRefetchIOSIPadOS() {
|
|||
assert.Equal(t, deviceNameRenamed, hostResp.Host.ComputerName)
|
||||
assert.Empty(t, hostResp.Host.Software)
|
||||
|
||||
// TODO: add test for GET /hosts/:id/certificates endpoint, should be empty
|
||||
|
||||
// Mark host as unenrolled and refetch.
|
||||
require.NoError(t, s.ds.UpdateMDMData(ctx, host.ID, false))
|
||||
hostResp = getHostResponse{}
|
||||
|
|
@ -10976,6 +10995,8 @@ func (s *integrationMDMTestSuite) TestRefetchIOSIPadOS() {
|
|||
require.Equal(t, "InstalledApplicationList", cmd.Command.RequestType)
|
||||
cmd, err = mdmClient.AcknowledgeInstalledApplicationList(mdmClient.UUID, cmd.CommandUUID, []fleet.Software{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "CertificateList", cmd.Command.RequestType)
|
||||
cmd, err = mdmClient.AcknowledgeCertificateList(mdmClient.UUID, cmd.CommandUUID, []*x509.Certificate{})
|
||||
require.Equal(t, "DeviceInformation", cmd.Command.RequestType)
|
||||
cmd, err = mdmClient.AcknowledgeDeviceInformation(mdmClient.UUID, cmd.CommandUUID, deviceNameRenamed, "iPhone SE")
|
||||
require.NoError(t, err)
|
||||
|
|
@ -10988,6 +11009,8 @@ func (s *integrationMDMTestSuite) TestRefetchIOSIPadOS() {
|
|||
require.NotNil(t, hostResp.Host.MDM.EnrollmentStatus)
|
||||
assert.Equal(t, "On (automatic)", *hostResp.Host.MDM.EnrollmentStatus)
|
||||
|
||||
// TODO: add test for GET /hosts/:id/certificates endpoint, should be empty
|
||||
|
||||
// list commands should return all the commands we sent
|
||||
var listCmdResp listMDMAppleCommandsResponse
|
||||
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/commands", nil, http.StatusOK, &listCmdResp)
|
||||
|
|
|
|||
|
|
@ -301,6 +301,12 @@ type RequestDecoder interface {
|
|||
DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error)
|
||||
}
|
||||
|
||||
// A value that implements requestValidator is called after having the values
|
||||
// decoded into it to apply further validations.
|
||||
type requestValidator interface {
|
||||
ValidateRequest() error
|
||||
}
|
||||
|
||||
// MakeDecoder creates a decoder for the type for the struct passed on. If the
|
||||
// struct has at least 1 json tag it'll unmarshall the body. If the struct has
|
||||
// a `url` tag with value list_options it'll gather fleet.ListOptions from the
|
||||
|
|
@ -439,6 +445,11 @@ func MakeDecoder(
|
|||
}
|
||||
}
|
||||
|
||||
if rv, ok := v.Interface().(requestValidator); ok {
|
||||
if err := rv.ValidateRequest(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return v.Interface(), nil
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -693,6 +693,20 @@ var extraDetailQueries = map[string]DetailQuery{
|
|||
Platforms: []string{"windows"},
|
||||
DirectIngestFunc: directIngestDiskEncryption,
|
||||
},
|
||||
"certificates_darwin": {
|
||||
Query: `
|
||||
SELECT
|
||||
ca, common_name, subject, issuer,
|
||||
key_algorithm, key_strength, key_usage, signing_algorithm,
|
||||
not_valid_after, not_valid_before,
|
||||
serial, sha1
|
||||
FROM
|
||||
certificates
|
||||
WHERE
|
||||
path = '/Library/Keychains/System.keychain';`,
|
||||
Platforms: []string{"darwin"},
|
||||
DirectIngestFunc: directIngestHostCertificates,
|
||||
},
|
||||
}
|
||||
|
||||
// mdmQueries are used by the Fleet server to compliment certain MDM
|
||||
|
|
@ -2382,3 +2396,57 @@ func directIngestWindowsProfiles(
|
|||
}
|
||||
return microsoft_mdm.VerifyHostMDMProfiles(ctx, logger, ds, host, rawResponse)
|
||||
}
|
||||
|
||||
func directIngestHostCertificates(
|
||||
ctx context.Context,
|
||||
logger log.Logger,
|
||||
host *fleet.Host,
|
||||
ds fleet.Datastore,
|
||||
rows []map[string]string,
|
||||
) error {
|
||||
if len(rows) == 0 {
|
||||
// if there are no results, it probably may indicate a problem so we log it
|
||||
level.Debug(logger).Log("component", "service", "method", "directIngestHostCertificates", "msg", "no rows returned", "host_id", host.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
certs := make([]*fleet.HostCertificateRecord, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
csum, err := hex.DecodeString(row["sha1"])
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "directIngestHostCertificates: decoding sha1")
|
||||
}
|
||||
subject, err := fleet.ExtractDetailsFromOsqueryDistinguishedName(row["subject"])
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "directIngestHostCertificates: extracting subject details")
|
||||
}
|
||||
issuer, err := fleet.ExtractDetailsFromOsqueryDistinguishedName(row["issuer"])
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "directIngestHostCertificates: extracting issuer details")
|
||||
}
|
||||
|
||||
certs = append(certs, &fleet.HostCertificateRecord{
|
||||
HostID: host.ID,
|
||||
SHA1Sum: csum,
|
||||
NotValidAfter: time.Unix(cast.ToInt64(row["not_valid_after"]), 0).UTC(),
|
||||
NotValidBefore: time.Unix(cast.ToInt64(row["not_valid_before"]), 0).UTC(),
|
||||
CertificateAuthority: cast.ToBool(row["ca"]),
|
||||
CommonName: row["common_name"],
|
||||
KeyAlgorithm: row["key_algorithm"],
|
||||
KeyStrength: cast.ToInt(row["key_strength"]),
|
||||
KeyUsage: row["key_usage"],
|
||||
Serial: row["serial"],
|
||||
SigningAlgorithm: row["signing_algorithm"],
|
||||
SubjectCountry: subject.Country,
|
||||
SubjectOrganizationalUnit: subject.OrganizationalUnit,
|
||||
SubjectOrganization: subject.Organization,
|
||||
SubjectCommonName: subject.CommonName,
|
||||
IssuerCountry: issuer.Country,
|
||||
IssuerOrganizationalUnit: issuer.OrganizationalUnit,
|
||||
IssuerOrganization: issuer.Organization,
|
||||
IssuerCommonName: issuer.CommonName,
|
||||
})
|
||||
}
|
||||
|
||||
return ds.UpdateHostCertificates(ctx, host.ID, certs)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -290,13 +290,14 @@ func TestGetDetailQueries(t *testing.T) {
|
|||
"disk_encryption_linux",
|
||||
"disk_encryption_windows",
|
||||
"chromeos_profile_user_info",
|
||||
"certificates_darwin",
|
||||
}
|
||||
|
||||
require.Len(t, queriesNoConfig, len(baseQueries))
|
||||
sortedKeysCompare(t, queriesNoConfig, baseQueries)
|
||||
|
||||
queriesWithoutWinOSVuln := GetDetailQueries(context.Background(), config.FleetConfig{Vulnerabilities: config.VulnerabilitiesConfig{DisableWinOSVulnerabilities: true}}, nil, nil)
|
||||
require.Len(t, queriesWithoutWinOSVuln, 25)
|
||||
require.Len(t, queriesWithoutWinOSVuln, 26)
|
||||
|
||||
queriesWithUsers := GetDetailQueries(context.Background(), config.FleetConfig{App: config.AppConfig{EnableScheduledQueryStats: true}}, nil, &fleet.Features{EnableHostUsers: true})
|
||||
qs := baseQueries
|
||||
|
|
@ -2220,6 +2221,57 @@ func TestIngestNetworkInterface(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestDirectIngestHostCertificates(t *testing.T) {
|
||||
ds := new(mock.Store)
|
||||
ctx := context.Background()
|
||||
logger := log.NewNopLogger()
|
||||
host := &fleet.Host{ID: 1}
|
||||
|
||||
row1 := map[string]string{
|
||||
"ca": "0",
|
||||
"common_name": "Cert 1 Common Name",
|
||||
"issuer": "/C=US/O=Issuer 1 Inc./CN=Issuer 1 Common Name",
|
||||
"subject": "/C=US/O=Subject 1 Inc./OU=Subject 1 Org Unit/CN=Subject 1 Common Name",
|
||||
"key_algorithm": "rsaEncryption",
|
||||
"key_strength": "2048",
|
||||
"key_usage": "Data Encipherment, Key Encipherment, Digital Signature",
|
||||
"serial": "123abc",
|
||||
"signing_algorithm": "sha256WithRSAEncryption",
|
||||
"not_valid_after": "1822755797",
|
||||
"not_valid_before": "1770228826",
|
||||
"sha1": "9c1e9c00d8120c1a9d96274d2a17c38ffa30fd31",
|
||||
}
|
||||
|
||||
ds.UpdateHostCertificatesFunc = func(ctx context.Context, hostID uint, certs []*fleet.HostCertificateRecord) error {
|
||||
require.Equal(t, host.ID, hostID)
|
||||
require.Len(t, certs, 1)
|
||||
require.Equal(t, "9c1e9c00d8120c1a9d96274d2a17c38ffa30fd31", hex.EncodeToString(certs[0].SHA1Sum))
|
||||
require.Equal(t, "Cert 1 Common Name", certs[0].CommonName)
|
||||
require.Equal(t, "Subject 1 Common Name", certs[0].SubjectCommonName)
|
||||
require.Equal(t, "Subject 1 Inc.", certs[0].SubjectOrganization)
|
||||
require.Equal(t, "Subject 1 Org Unit", certs[0].SubjectOrganizationalUnit)
|
||||
require.Equal(t, "US", certs[0].SubjectCountry)
|
||||
require.Equal(t, "Issuer 1 Common Name", certs[0].IssuerCommonName)
|
||||
require.Equal(t, "Issuer 1 Inc.", certs[0].IssuerOrganization)
|
||||
require.Empty(t, certs[0].IssuerOrganizationalUnit)
|
||||
require.Equal(t, "US", certs[0].IssuerCountry)
|
||||
require.Equal(t, "rsaEncryption", certs[0].KeyAlgorithm)
|
||||
require.Equal(t, 2048, certs[0].KeyStrength)
|
||||
require.Equal(t, "Data Encipherment, Key Encipherment, Digital Signature", certs[0].KeyUsage)
|
||||
require.Equal(t, "123abc", certs[0].Serial)
|
||||
require.Equal(t, "sha256WithRSAEncryption", certs[0].SigningAlgorithm)
|
||||
require.Equal(t, int64(1822755797), certs[0].NotValidAfter.Unix())
|
||||
require.Equal(t, int64(1770228826), certs[0].NotValidBefore.Unix())
|
||||
require.False(t, certs[0].CertificateAuthority)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
err := directIngestHostCertificates(ctx, logger, host, ds, []map[string]string{row1})
|
||||
require.NoError(t, err)
|
||||
require.True(t, ds.UpdateHostCertificatesFuncInvoked)
|
||||
}
|
||||
|
||||
func TestGenerateSQLForAllExists(t *testing.T) {
|
||||
// Combine two queries
|
||||
query1 := "SELECT 1 WHERE foo = bar"
|
||||
|
|
|
|||
|
|
@ -257,8 +257,8 @@ func (ts *withServer) DoRaw(verb string, path string, rawBytes []byte, expectedS
|
|||
}, queryParams...)
|
||||
}
|
||||
|
||||
func (ts *withServer) DoRawNoAuth(verb string, path string, rawBytes []byte, expectedStatusCode int) *http.Response {
|
||||
return ts.DoRawWithHeaders(verb, path, rawBytes, expectedStatusCode, nil)
|
||||
func (ts *withServer) DoRawNoAuth(verb string, path string, rawBytes []byte, expectedStatusCode int, queryParams ...string) *http.Response {
|
||||
return ts.DoRawWithHeaders(verb, path, rawBytes, expectedStatusCode, nil, queryParams...)
|
||||
}
|
||||
|
||||
func (ts *withServer) DoJSON(verb, path string, params interface{}, expectedStatusCode int, v interface{}, queryParams ...string) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue