From cdd364a41b9af06b92e4e2e4a89d17325cba7e0b Mon Sep 17 00:00:00 2001 From: Roberto Dip Date: Mon, 15 Aug 2022 19:09:15 -0300 Subject: [PATCH 01/11] adjust installers endpoint to avoid AJAX downloads --- docs/Contributing/API-for-contributors.md | 13 ++-- .../DownloadInstallers/DownloadInstallers.tsx | 66 +++++++------------ server/contexts/token/token.go | 8 ++- server/service/handler.go | 2 +- server/service/installer.go | 34 +++++++--- server/service/integration_core_test.go | 4 +- server/service/integration_sandbox_test.go | 24 ++++--- 7 files changed, 80 insertions(+), 71 deletions(-) diff --git a/docs/Contributing/API-for-contributors.md b/docs/Contributing/API-for-contributors.md index 78136599461..ccac4d2b9e6 100644 --- a/docs/Contributing/API-for-contributors.md +++ b/docs/Contributing/API-for-contributors.md @@ -1717,15 +1717,16 @@ Redirects to the transparency URL. Downloads a pre-built fleet-osquery installer with the given parameters. -`GET /api/_version_/fleet/download_installer/{kind}` +`POST /api/_version_/fleet/download_installer/{kind}` #### Parameters -| Name | Type | In | Description | -| ------------- | ------- | ----- | ------------------------------------------------------------------ | -| kind | string | path | The installer kind: pkg, msi, deb or rpm. | -| enroll_secret | string | query | The global enroll secret. | -| desktop | boolean | query | Set to `true` to ask for an installer that includes Fleet Desktop. | +| Name | Type | In | Description | +| ------------- | ------- | ---------------------- | ------------------------------------------------------------------ | +| kind | string | path | The installer kind: pkg, msi, deb or rpm. | +| enroll_secret | string | x-www-form-urlencoded | The global enroll secret. | +| token | string | x-www-form-urlencoded | The Authentication token. | +| desktop | boolean | x-www-form-urlencoded | Set to `true` to ask for an installer that includes Fleet Desktop. | ##### Default response diff --git a/frontend/components/AddHostsModal/DownloadInstallers/DownloadInstallers.tsx b/frontend/components/AddHostsModal/DownloadInstallers/DownloadInstallers.tsx index 316e5d4a857..f06cd9efc7a 100644 --- a/frontend/components/AddHostsModal/DownloadInstallers/DownloadInstallers.tsx +++ b/frontend/components/AddHostsModal/DownloadInstallers/DownloadInstallers.tsx @@ -1,5 +1,4 @@ -import React, { useState } from "react"; -import FileSaver from "file-saver"; +import React, { FormEvent, useRef, useState } from "react"; import { IInstallerPlatform, @@ -7,7 +6,9 @@ import { INSTALLER_PLATFORM_BY_TYPE, INSTALLER_TYPE_BY_PLATFORM, } from "interfaces/installer"; -import installerAPI from "services/entities/installers"; +import ENDPOINTS from "utilities/endpoints"; +import local from "utilities/local"; +import URL_PREFIX from "router/url_prefix"; import Button from "components/buttons/Button"; import Checkbox from "components/forms/fields/Checkbox"; @@ -65,36 +66,22 @@ const DownloadInstallers = ({ onCancel, }: IDownloadInstallersProps): JSX.Element => { const [includeDesktop, setIncludeDesktop] = useState(true); - const [isDownloadError, setIsDownloadError] = useState(false); const [isDownloading, setIsDownloading] = useState(false); const [isDownloadSuccess, setIsDownloadSuccess] = useState(false); const [selectedInstaller, setSelectedInstaller] = useState< IInstallerType | undefined >(); + const formEl = useRef(null); + const path = `${ENDPOINTS.DOWNLOAD_INSTALLER}/${selectedInstaller}`; + const { origin } = global.window.location; + const url = `${origin}${URL_PREFIX}/api${path}`; + const token = local.getItem("auth_token"); - const downloadInstaller = async (installerType?: IInstallerType) => { - if (!installerType) { - // do nothing - return; - } + const downloadInstaller = () => { setIsDownloading(true); - try { - const blob: BlobPart = await installerAPI.downloadInstaller({ - enrollSecret, - installerType, - includeDesktop, - }); - const filename = `fleet-osquery.${installerType}`; - const file = new global.window.File([blob], filename, { - type: "application/octet-stream", - }); - FileSaver.saveAs(file); - setIsDownloadSuccess(true); - } catch { - setIsDownloadError(true); - } finally { - setIsDownloading(false); - } + formEl.current?.submit(); + setIsDownloadSuccess(true); + setIsDownloading(false); }; const onClickSelector = (type: IInstallerType) => { @@ -109,14 +96,6 @@ const DownloadInstallers = ({ setSelectedInstaller(type); }; - if (isDownloadError) { - return ( -
- -
- ); - } - if (isDownloadSuccess) { const installerPlatform = (selectedInstaller && @@ -171,13 +150,18 @@ const DownloadInstallers = ({ - +
+ + + + +
); }; diff --git a/server/contexts/token/token.go b/server/contexts/token/token.go index 4c23807a43f..3eb8759313e 100644 --- a/server/contexts/token/token.go +++ b/server/contexts/token/token.go @@ -16,12 +16,16 @@ const tokenKey key = 0 type Token string // FromHTTPRequest extracts an Authorization -// from an HTTP header if present. +// from an HTTP request if present. func FromHTTPRequest(r *http.Request) Token { headers := r.Header.Get("Authorization") headerParts := strings.Split(headers, " ") if len(headerParts) != 2 || strings.ToUpper(headerParts[0]) != "BEARER" { - return "" + if err := r.ParseForm(); err != nil { + return "" + } + + return Token(r.FormValue("token")) } return Token(headerParts[1]) } diff --git a/server/service/handler.go b/server/service/handler.go index 7d92053ee58..0a68e85a897 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -360,7 +360,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC ue.GET("/api/_version_/fleet/activities", listActivitiesEndpoint, listActivitiesRequest{}) - ue.GET("/api/_version_/fleet/download_installer/{kind}", getInstallerEndpoint, installerRequest{}) + ue.POST("/api/_version_/fleet/download_installer/{kind}", getInstallerEndpoint, getInstallerRequest{}) ue.GET("/api/_version_/fleet/packs/{id:[0-9]+}/scheduled", getScheduledQueriesInPackEndpoint, getScheduledQueriesInPackRequest{}) ue.EndingAtVersion("v1").POST("/api/_version_/fleet/schedule", scheduleQueryEndpoint, scheduleQueryRequest{}) diff --git a/server/service/installer.go b/server/service/installer.go index b670d4264da..db144ffaeee 100644 --- a/server/service/installer.go +++ b/server/service/installer.go @@ -3,6 +3,7 @@ package service import ( "context" "errors" + "fmt" "io" "net/http" "strconv" @@ -10,24 +11,39 @@ import ( "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/contexts/logging" "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/gorilla/mux" ) -type installerRequest struct { - Kind string `url:"kind"` - EnrollSecret string `query:"enroll_secret"` - Desktop bool `query:"desktop,optional"` -} - //////////////////////////////////////////////////////////////////////////////// // Retrieve an Orbit installer from storage //////////////////////////////////////////////////////////////////////////////// +type getInstallerRequest struct { + Kind string + EnrollSecret string + Desktop bool +} + +func (getInstallerRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) { + k, ok := mux.Vars(r)["kind"] + if !ok { + return "", errBadRoute + } + + return getInstallerRequest{ + Kind: k, + EnrollSecret: r.FormValue("enroll_secret"), + Desktop: r.FormValue("desktop") == "true", + }, nil +} + type getInstallerResponse struct { Err error `json:"error,omitempty"` // file fields below are used in hijackRender for the response fileReader io.ReadCloser fileLength int64 + fileExt string } func (r getInstallerResponse) error() error { return r.Err } @@ -35,7 +51,7 @@ func (r getInstallerResponse) error() error { return r.Err } func (r getInstallerResponse) hijackRender(ctx context.Context, w http.ResponseWriter) { w.Header().Set("Content-Length", strconv.FormatInt(r.fileLength, 10)) w.Header().Set("Content-Type", "application/octet-stream") - w.Header().Set("Content-Disposition", "attachment") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment;filename="fleet-osquery.%s"`, r.fileExt)) // OK to just log the error here as writing anything on // `http.ResponseWriter` sets the status code to 200 (and it can't be @@ -49,7 +65,7 @@ func (r getInstallerResponse) hijackRender(ctx context.Context, w http.ResponseW } func getInstallerEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (interface{}, error) { - req := request.(*installerRequest) + req := request.(getInstallerRequest) fileReader, fileLength, err := svc.GetInstaller(ctx, fleet.Installer{ EnrollSecret: req.EnrollSecret, @@ -61,7 +77,7 @@ func getInstallerEndpoint(ctx context.Context, request interface{}, svc fleet.Se return getInstallerResponse{Err: err}, nil } - return getInstallerResponse{fileReader: fileReader, fileLength: fileLength}, nil + return getInstallerResponse{fileReader: fileReader, fileLength: fileLength, fileExt: req.Kind}, nil } // GetInstaller retrieves a blob containing the installer binary diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 6492c6bb35b..5d10879bd50 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -5112,8 +5112,8 @@ func (s *integrationTestSuite) TestSandboxEndpoints() { require.NotEqual(t, http.StatusOK, res.StatusCode) // installers endpoint is not enabled - validURL := installerURL(enrollSecret, "pkg", false) - s.Do("GET", validURL, nil, http.StatusInternalServerError) + url, installersBody := installerReq(enrollSecret, "pkg", s.token, "false") + s.DoRaw("GET", url, installersBody, http.StatusInternalServerError) } func (s *integrationTestSuite) TestGetHostBatteries() { diff --git a/server/service/integration_sandbox_test.go b/server/service/integration_sandbox_test.go index 5ebe8ca88ff..9ff5f7b5acb 100644 --- a/server/service/integration_sandbox_test.go +++ b/server/service/integration_sandbox_test.go @@ -75,9 +75,9 @@ func (s *integrationSandboxTestSuite) TestDemoLogin() { func (s *integrationSandboxTestSuite) TestInstallerGet() { t := s.T() - validURL := installerURL(enrollSecret, "pkg", false) + validURL, formBody := installerReq(enrollSecret, "pkg", s.token, "false") - r := s.Do("GET", validURL, nil, http.StatusOK) + r := s.DoRaw("POST", validURL, formBody, http.StatusOK) body, err := io.ReadAll(r.Body) require.NoError(t, err) require.Equal(t, "mock", string(body)) @@ -92,16 +92,20 @@ func (s *integrationSandboxTestSuite) TestInstallerGet() { s.token = s.cachedAdminToken // wrong enroll secret - s.Do("GET", installerURL("wrong-enroll", "pkg", false), nil, http.StatusInternalServerError) + wrongURL, wrongFormBody := installerReq("wrong-enroll", "pkg", s.token, "false") + s.Do("GET", wrongURL, wrongFormBody, http.StatusInternalServerError) // non-existent package - s.Do("GET", installerURL(enrollSecret, "exe", false), nil, http.StatusNotFound) + wrongURL, wrongFormBody = installerReq("wrong-enroll", "exe", s.token, "false") + s.Do("GET", wrongURL, wrongFormBody, http.StatusInternalServerError) + s.Do("GET", wrongURL, wrongFormBody, http.StatusNotFound) } -func installerURL(secret, kind string, desktop bool) string { - url := fmt.Sprintf("/api/latest/fleet/download_installer/%s?enroll_secret=%s", kind, secret) - if desktop { - url = url + "&desktop=1" - } - return url +func installerReq(secret, kind, token, desktop string) (string, []byte) { + path := fmt.Sprintf("/api/latest/fleet/download_installer/%s?enroll_secret=%s", kind, secret) + formBody := make(url.Values) + formBody.Set("token", token) + formBody.Set("enroll_secret", secret) + formBody.Set("desktop", string(desktop)) + return path, []byte(formBody.Encode()) } From a9c56b11422fb07de5fb791acebd031c5db258a4 Mon Sep 17 00:00:00 2001 From: Roberto Dip Date: Mon, 15 Aug 2022 19:22:15 -0300 Subject: [PATCH 02/11] lint --- server/service/integration_sandbox_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/service/integration_sandbox_test.go b/server/service/integration_sandbox_test.go index 9ff5f7b5acb..4a7b93f84ee 100644 --- a/server/service/integration_sandbox_test.go +++ b/server/service/integration_sandbox_test.go @@ -106,6 +106,6 @@ func installerReq(secret, kind, token, desktop string) (string, []byte) { formBody := make(url.Values) formBody.Set("token", token) formBody.Set("enroll_secret", secret) - formBody.Set("desktop", string(desktop)) + formBody.Set("desktop", desktop) return path, []byte(formBody.Encode()) } From 6c6d451946bb10daea851a626604db68acf3efdb Mon Sep 17 00:00:00 2001 From: Roberto Dip Date: Mon, 15 Aug 2022 19:31:56 -0300 Subject: [PATCH 03/11] Update docs/Contributing/API-for-contributors.md Co-authored-by: gillespi314 <73313222+gillespi314@users.noreply.github.com> --- docs/Contributing/API-for-contributors.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/Contributing/API-for-contributors.md b/docs/Contributing/API-for-contributors.md index ccac4d2b9e6..04587bf3123 100644 --- a/docs/Contributing/API-for-contributors.md +++ b/docs/Contributing/API-for-contributors.md @@ -1725,7 +1725,7 @@ Downloads a pre-built fleet-osquery installer with the given parameters. | ------------- | ------- | ---------------------- | ------------------------------------------------------------------ | | kind | string | path | The installer kind: pkg, msi, deb or rpm. | | enroll_secret | string | x-www-form-urlencoded | The global enroll secret. | -| token | string | x-www-form-urlencoded | The Authentication token. | +| token | string | x-www-form-urlencoded | The authentication token. | | desktop | boolean | x-www-form-urlencoded | Set to `true` to ask for an installer that includes Fleet Desktop. | ##### Default response From ab4c3e4dbc93e567a2764c0989ccb71990b9aa5a Mon Sep 17 00:00:00 2001 From: Roberto Dip Date: Mon, 15 Aug 2022 20:40:34 -0300 Subject: [PATCH 04/11] better installer handling --- docs/Contributing/API-for-contributors.md | 28 +++++++ .../DownloadInstallers/DownloadInstallers.tsx | 52 +++++++++++-- frontend/services/entities/installers.ts | 9 +-- frontend/services/index.ts | 2 +- server/datastore/s3/installer.go | 19 +++++ server/fleet/datastore.go | 1 + server/fleet/service.go | 1 + server/mock/datastore_installers.go | 10 +++ server/service/endpoint_utils.go | 4 + server/service/handler.go | 1 + server/service/installer.go | 63 ++++++++++++++++ server/service/installer_test.go | 73 +++++++++++++++++++ server/service/integration_sandbox_test.go | 28 +++++++ 13 files changed, 278 insertions(+), 13 deletions(-) diff --git a/docs/Contributing/API-for-contributors.md b/docs/Contributing/API-for-contributors.md index 04587bf3123..e10fb9c5d31 100644 --- a/docs/Contributing/API-for-contributors.md +++ b/docs/Contributing/API-for-contributors.md @@ -32,6 +32,7 @@ Unlike the [Fleet REST API documentation](../Using-Fleet/REST-API.md), only the - [Get device's policies](#get-devices-policies) - [Get device's API features](#get-devices-api-features) - [Get device's transparency URL](#get-devices-transparency-url) +- [Check if an installer exists](#check-if-an-installer-exists) - [Download an installer](#download-an-installer) ### Get queries spec @@ -1747,4 +1748,31 @@ If an installer with the provided parameters is found, the installer is returned This error occurs if an installer with the provided parameters doesn't exist. +### Check if an installer exists + +Checks if a pre-built fleet-osquery installer with the given parameters exists. + +`HEAD /api/_version_/fleet/download_installer/{enroll_secret}/{kind}` + +#### Parameters + +| Name | Type | In | Description | +| ------------- | ------- | ----- | ------------------------------------------------------------------ | +| enroll_secret | string | path | The global enroll secret. | +| kind | string | path | The installer kind: pkg, msi, deb or rpm. | +| desktop | boolean | query | Set to `true` to ask for an installer that includes Fleet Desktop. | + +##### Default response + +`Status: 200` + +If an installer with the provided parameters is found. + +##### Installer doesn't exist + +`Status: 400` + +If an installer with the provided parameters doesn't exist. + + diff --git a/frontend/components/AddHostsModal/DownloadInstallers/DownloadInstallers.tsx b/frontend/components/AddHostsModal/DownloadInstallers/DownloadInstallers.tsx index f06cd9efc7a..ee963166b7d 100644 --- a/frontend/components/AddHostsModal/DownloadInstallers/DownloadInstallers.tsx +++ b/frontend/components/AddHostsModal/DownloadInstallers/DownloadInstallers.tsx @@ -9,6 +9,7 @@ import { import ENDPOINTS from "utilities/endpoints"; import local from "utilities/local"; import URL_PREFIX from "router/url_prefix"; +import installerAPI from "services/entities/installers"; import Button from "components/buttons/Button"; import Checkbox from "components/forms/fields/Checkbox"; @@ -67,21 +68,45 @@ const DownloadInstallers = ({ }: IDownloadInstallersProps): JSX.Element => { const [includeDesktop, setIncludeDesktop] = useState(true); const [isDownloading, setIsDownloading] = useState(false); + const [isDownloadError, setIsDownloadError] = useState(false); const [isDownloadSuccess, setIsDownloadSuccess] = useState(false); const [selectedInstaller, setSelectedInstaller] = useState< IInstallerType | undefined >(); - const formEl = useRef(null); const path = `${ENDPOINTS.DOWNLOAD_INSTALLER}/${selectedInstaller}`; const { origin } = global.window.location; const url = `${origin}${URL_PREFIX}/api${path}`; const token = local.getItem("auth_token"); - const downloadInstaller = () => { + const downloadInstaller = async (event: React.FormEvent) => { + if (!selectedInstaller) { + // do nothing + return; + } + console.log(event); + event.preventDefault(); + event.persist(); + setIsDownloading(true); - formEl.current?.submit(); - setIsDownloadSuccess(true); - setIsDownloading(false); + try { + await installerAPI.checkInstallerExistence({ + enrollSecret, + includeDesktop, + installerType: selectedInstaller, + }); + + // For some reason only Firefox fails with NS_ERROR_FAILURE unless we + // push back this operation to the bottom of the event queue + setTimeout(() => { + (event.target as HTMLFormElement).submit(); + setIsDownloadSuccess(true); + }); + } catch (error) { + console.log(error); + setIsDownloadError(true); + } finally { + setIsDownloading(false); + } }; const onClickSelector = (type: IInstallerType) => { @@ -96,6 +121,14 @@ const DownloadInstallers = ({ setSelectedInstaller(type); }; + if (isDownloadError) { + return ( +
+ +
+ ); + } + if (isDownloadSuccess) { const installerPlatform = (selectedInstaller && @@ -150,14 +183,19 @@ const DownloadInstallers = ({ -
+ diff --git a/frontend/services/entities/installers.ts b/frontend/services/entities/installers.ts index 8405961a7d8..31823ea07fc 100644 --- a/frontend/services/entities/installers.ts +++ b/frontend/services/entities/installers.ts @@ -3,25 +3,24 @@ import { IInstallerType } from "interfaces/installer"; import sendRequest from "services"; import ENDPOINTS from "utilities/endpoints"; -export interface IDownloadInstallerRequestParams { +export interface ICheckInstallerExistenceRequestParams { enrollSecret: string; includeDesktop: boolean; installerType: IInstallerType; } export default { - downloadInstaller: ({ + checkInstallerExistence: ({ enrollSecret, includeDesktop, installerType, - }: IDownloadInstallerRequestParams): Promise => { + }: ICheckInstallerExistenceRequestParams): Promise => { const path = `${ ENDPOINTS.DOWNLOAD_INSTALLER }/${installerType}?desktop=${includeDesktop}&enroll_secret=${encodeURIComponent( enrollSecret )}`; - console.log("path: ", path); - return sendRequest("GET", path, undefined, "blob"); + return sendRequest("HEAD", path, undefined); }, }; diff --git a/frontend/services/index.ts b/frontend/services/index.ts index 0cad1f853fa..418f590c88b 100644 --- a/frontend/services/index.ts +++ b/frontend/services/index.ts @@ -7,7 +7,7 @@ import local from "utilities/local"; import URL_PREFIX from "router/url_prefix"; const sendRequest = async ( - method: "GET" | "POST" | "PATCH" | "DELETE", + method: "GET" | "POST" | "PATCH" | "DELETE" | "HEAD", path: string, data?: unknown, responseType: AxiosResponseType = "json" diff --git a/server/datastore/s3/installer.go b/server/datastore/s3/installer.go index 8d61789cb9a..8d25ee4cb91 100644 --- a/server/datastore/s3/installer.go +++ b/server/datastore/s3/installer.go @@ -74,6 +74,25 @@ func (i *InstallerStore) Put(ctx context.Context, installer fleet.Installer) (st return key, err } +// Exists checks if an installer exists in the S3 bucket +func (i *InstallerStore) Exists(ctx context.Context, installer fleet.Installer) (bool, error) { + key := i.keyForInstaller(installer) + _, err := i.s3client.HeadObject(&s3.HeadObjectInput{Bucket: &i.bucket, Key: &key}) + + if err != nil { + if aerr, ok := err.(awserr.Error); ok { + switch aerr.Code() { + case s3.ErrCodeNoSuchKey, s3.ErrCodeNoSuchBucket, "NotFound": + return false, nil + } + } + + return false, ctxerr.Wrap(ctx, err, "checking existence on file store") + } + + return true, nil +} + // keyForInstaller builds an S3 key to search for the installer func (i *InstallerStore) keyForInstaller(installer fleet.Installer) string { file := fmt.Sprintf("%s.%s", executable, installer.Kind) diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 89d48cbea5a..fc7e72b5d25 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -30,6 +30,7 @@ type CarveStore interface { type InstallerStore interface { Get(ctx context.Context, installer Installer) (io.ReadCloser, int64, error) Put(ctx context.Context, installer Installer) (string, error) + Exists(ctx context.Context, installer Installer) (bool, error) } // Datastore combines all the interfaces in the Fleet DAL diff --git a/server/fleet/service.go b/server/fleet/service.go index 8eae7d46b88..2f44fd42140 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -485,4 +485,5 @@ type Service interface { // Installers GetInstaller(ctx context.Context, installer Installer) (io.ReadCloser, int64, error) + CheckInstallerExistence(ctx context.Context, installer Installer) error } diff --git a/server/mock/datastore_installers.go b/server/mock/datastore_installers.go index 2ccd5d19a5d..74472aea1a0 100644 --- a/server/mock/datastore_installers.go +++ b/server/mock/datastore_installers.go @@ -15,12 +15,17 @@ type GetFunc func(ctx context.Context, installer fleet.Installer) (io.ReadCloser type PutFunc func(ctx context.Context, installer fleet.Installer) (string, error) +type ExistsFunc func(ctx context.Context, installer fleet.Installer) (bool, error) + type InstallerStore struct { GetFunc GetFunc GetFuncInvoked bool PutFunc PutFunc PutFuncInvoked bool + + ExistsFunc ExistsFunc + ExistsFuncInvoked bool } func (s *InstallerStore) Get(ctx context.Context, installer fleet.Installer) (io.ReadCloser, int64, error) { @@ -32,3 +37,8 @@ func (s *InstallerStore) Put(ctx context.Context, installer fleet.Installer) (st s.PutFuncInvoked = true return s.PutFunc(ctx, installer) } + +func (s *InstallerStore) Exists(ctx context.Context, installer fleet.Installer) (bool, error) { + s.ExistsFuncInvoked = true + return s.ExistsFunc(ctx, installer) +} diff --git a/server/service/endpoint_utils.go b/server/service/endpoint_utils.go index 87e13c580bb..a322c279b44 100644 --- a/server/service/endpoint_utils.go +++ b/server/service/endpoint_utils.go @@ -363,6 +363,10 @@ func (e *authEndpointer) DELETE(path string, f handlerFunc, v interface{}) { e.handleEndpoint(path, f, v, "DELETE") } +func (e *authEndpointer) HEAD(path string, f handlerFunc, v interface{}) { + e.handleEndpoint(path, f, v, "HEAD") +} + // PathHandler registers a handler for the verb and path. The pathHandler is // a function that receives the actual path to which it will be mounted, and // returns the actual http.Handler that will handle this endpoint. This is for diff --git a/server/service/handler.go b/server/service/handler.go index 0a68e85a897..0ffa0196bc6 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -361,6 +361,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC ue.GET("/api/_version_/fleet/activities", listActivitiesEndpoint, listActivitiesRequest{}) ue.POST("/api/_version_/fleet/download_installer/{kind}", getInstallerEndpoint, getInstallerRequest{}) + ue.HEAD("/api/_version_/fleet/download_installer/{kind}", checkInstallerEndpoint, checkInstallerRequest{}) ue.GET("/api/_version_/fleet/packs/{id:[0-9]+}/scheduled", getScheduledQueriesInPackEndpoint, getScheduledQueriesInPackRequest{}) ue.EndingAtVersion("v1").POST("/api/_version_/fleet/schedule", scheduleQueryEndpoint, scheduleQueryRequest{}) diff --git a/server/service/installer.go b/server/service/installer.go index db144ffaeee..67f006e0b80 100644 --- a/server/service/installer.go +++ b/server/service/installer.go @@ -106,3 +106,66 @@ func (svc *Service) GetInstaller(ctx context.Context, installer fleet.Installer) return reader, length, nil } + +//////////////////////////////////////////////////////////////////////////////// +// Check if a prebuilt Orbit installer is available +//////////////////////////////////////////////////////////////////////////////// + +type checkInstallerRequest struct { + Kind string `url:"kind"` + Desktop bool `query:"desktop,optional"` + EnrollSecret string `query:"enroll_secret"` +} + +type checkInstallerResponse struct { + Err error `json:"error,omitempty"` +} + +func (r checkInstallerResponse) error() error { return r.Err } + +func checkInstallerEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (interface{}, error) { + req := request.(*checkInstallerRequest) + + err := svc.CheckInstallerExistence(ctx, fleet.Installer{ + EnrollSecret: req.EnrollSecret, + Kind: req.Kind, + Desktop: req.Desktop, + }) + + if err != nil { + return checkInstallerResponse{Err: err}, nil + } + + return checkInstallerResponse{}, nil +} + +// CheckInstallerExistence checks if an installer exists in the configured storage +func (svc *Service) CheckInstallerExistence(ctx context.Context, installer fleet.Installer) error { + if err := svc.authz.Authorize(ctx, &fleet.EnrollSecret{}, fleet.ActionRead); err != nil { + return err + } + + if !svc.SandboxEnabled() { + return errors.New("this endpoint only enabled in demo mode") + } + + if svc.installerStore == nil { + return ctxerr.New(ctx, "installer storage has not been configured") + } + + _, err := svc.ds.VerifyEnrollSecret(ctx, installer.EnrollSecret) + if err != nil { + return ctxerr.Wrap(ctx, err, "cannot find a matching enroll secret") + } + + exists, err := svc.installerStore.Exists(ctx, installer) + if err != nil { + return ctxerr.Wrap(ctx, err, "checking installer existence") + } + + if !exists { + return notFoundError{} + } + + return nil +} diff --git a/server/service/installer_test.go b/server/service/installer_test.go index 7d2673aacc2..5cbc5087803 100644 --- a/server/service/installer_test.go +++ b/server/service/installer_test.go @@ -99,3 +99,76 @@ func TestGetInstaller(t *testing.T) { require.True(t, is.GetFuncInvoked) }) } +func TestCheckInstallerExistence(t *testing.T) { + t.Run("unauthorized access is not allowed", func(t *testing.T) { + _, _, _, svc := setup(t) + err := svc.CheckInstallerExistence(context.Background(), fleet.Installer{}) + require.Error(t, err) + require.Contains(t, err.Error(), authz.ForbiddenErrorMessage) + }) + + t.Run("errors if store is not configured", func(t *testing.T) { + ctx, ds, _, _ := setup(t) + svc := newTestService(t, ds, nil, nil, &TestServerOpts{Is: nil}) + err := svc.CheckInstallerExistence(ctx, fleet.Installer{}) + require.Error(t, err) + require.ErrorContains(t, err, "installer storage has not been configured") + }) + + t.Run("errors if the provided enroll secret cannot be found", func(t *testing.T) { + ctx, ds, _, svc := setup(t) + ds.VerifyEnrollSecretFunc = func(ctx context.Context, enrollSecret string) (*fleet.EnrollSecret, error) { + return nil, notFoundError{} + } + err := svc.CheckInstallerExistence(ctx, fleet.Installer{}) + require.Error(t, err) + require.ErrorAs(t, err, ¬FoundError{}) + require.True(t, ds.VerifyEnrollSecretFuncInvoked) + }) + + t.Run("errors if there's a problem verifying the enroll secret", func(t *testing.T) { + ctx, ds, _, svc := setup(t) + ds.VerifyEnrollSecretFunc = func(ctx context.Context, enrollSecret string) (*fleet.EnrollSecret, error) { + return nil, ctxerr.New(ctx, "test error") + } + err := svc.CheckInstallerExistence(ctx, fleet.Installer{}) + require.Error(t, err) + require.ErrorContains(t, err, "test error") + require.True(t, ds.VerifyEnrollSecretFuncInvoked) + }) + + t.Run("errors if there's a problem checking the blob storage", func(t *testing.T) { + ctx, ds, is, svc := setup(t) + is.ExistsFunc = func(ctx context.Context, installer fleet.Installer) (bool, error) { + return false, ctxerr.New(ctx, "test error") + } + err := svc.CheckInstallerExistence(ctx, fleet.Installer{}) + require.Error(t, err) + require.ErrorContains(t, err, "test error") + require.True(t, ds.VerifyEnrollSecretFuncInvoked) + require.True(t, is.ExistsFuncInvoked) + }) + + t.Run("errors with not found if the installer is not in the storage", func(t *testing.T) { + ctx, ds, is, svc := setup(t) + is.ExistsFunc = func(ctx context.Context, installer fleet.Installer) (bool, error) { + return false, nil + } + err := svc.CheckInstallerExistence(ctx, fleet.Installer{}) + require.Error(t, err) + require.ErrorAs(t, err, ¬FoundError{}) + require.True(t, ds.VerifyEnrollSecretFuncInvoked) + require.True(t, is.ExistsFuncInvoked) + }) + + t.Run("returns no errors if the installer exists", func(t *testing.T) { + ctx, ds, is, svc := setup(t) + is.ExistsFunc = func(ctx context.Context, installer fleet.Installer) (bool, error) { + return true, nil + } + err := svc.CheckInstallerExistence(ctx, fleet.Installer{}) + require.NoError(t, err) + require.True(t, ds.VerifyEnrollSecretFuncInvoked) + require.True(t, is.ExistsFuncInvoked) + }) +} diff --git a/server/service/integration_sandbox_test.go b/server/service/integration_sandbox_test.go index 4a7b93f84ee..ed249c6857d 100644 --- a/server/service/integration_sandbox_test.go +++ b/server/service/integration_sandbox_test.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "net/url" + "os" "testing" "github.com/fleetdm/fleet/v4/server/config" @@ -101,6 +102,33 @@ func (s *integrationSandboxTestSuite) TestInstallerGet() { s.Do("GET", wrongURL, wrongFormBody, http.StatusNotFound) } +func (s *integrationSandboxTestSuite) TestInstallerHeadCheck() { + // make sure FLEET_DEMO is not set + os.Unsetenv("FLEET_DEMO") + validURL := fmt.Sprintf("/api/latest/fleet/download_installer/%s/%s", enrollSecret, "pkg") + s.Do("HEAD", validURL, nil, http.StatusInternalServerError) + + os.Setenv("FLEET_DEMO", "1") + defer os.Unsetenv("FLEET_DEMO") + + // works when FLEET_DEMO is set + s.Do("HEAD", validURL, nil, http.StatusOK) + + // unauthorized requests + s.DoRawNoAuth("GET", validURL, nil, http.StatusUnauthorized) + s.token = "invalid" + s.Do("GET", validURL, nil, http.StatusUnauthorized) + s.token = s.cachedAdminToken + + // wrong enroll secret + invalidURL := fmt.Sprintf("/api/latest/fleet/download_installer/%s/%s", "wrong-enroll", "pkg") + s.Do("HEAD", invalidURL, nil, http.StatusInternalServerError) + + // non-existent package + invalidURL = fmt.Sprintf("/api/latest/fleet/download_installer/%s/%s", enrollSecret, "exe") + s.Do("HEAD", invalidURL, nil, http.StatusNotFound) +} + func installerReq(secret, kind, token, desktop string) (string, []byte) { path := fmt.Sprintf("/api/latest/fleet/download_installer/%s?enroll_secret=%s", kind, secret) formBody := make(url.Values) From bd3ec48d3c2bbd08984b6c9962b4345695feb3d8 Mon Sep 17 00:00:00 2001 From: Roberto Dip Date: Mon, 15 Aug 2022 20:44:45 -0300 Subject: [PATCH 05/11] remove console.log --- .../AddHostsModal/DownloadInstallers/DownloadInstallers.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/frontend/components/AddHostsModal/DownloadInstallers/DownloadInstallers.tsx b/frontend/components/AddHostsModal/DownloadInstallers/DownloadInstallers.tsx index ee963166b7d..af54a148744 100644 --- a/frontend/components/AddHostsModal/DownloadInstallers/DownloadInstallers.tsx +++ b/frontend/components/AddHostsModal/DownloadInstallers/DownloadInstallers.tsx @@ -83,7 +83,6 @@ const DownloadInstallers = ({ // do nothing return; } - console.log(event); event.preventDefault(); event.persist(); @@ -102,7 +101,6 @@ const DownloadInstallers = ({ setIsDownloadSuccess(true); }); } catch (error) { - console.log(error); setIsDownloadError(true); } finally { setIsDownloading(false); From 7225d8d88e170a031dce8cb00db1c7171cf40424 Mon Sep 17 00:00:00 2001 From: Roberto Dip Date: Mon, 15 Aug 2022 20:48:17 -0300 Subject: [PATCH 06/11] test fixes --- server/service/integration_sandbox_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/service/integration_sandbox_test.go b/server/service/integration_sandbox_test.go index ed249c6857d..9f1c82b6f5b 100644 --- a/server/service/integration_sandbox_test.go +++ b/server/service/integration_sandbox_test.go @@ -84,7 +84,7 @@ func (s *integrationSandboxTestSuite) TestInstallerGet() { require.Equal(t, "mock", string(body)) require.Equal(t, "application/octet-stream", r.Header.Get("Content-Type")) require.Equal(t, "4", r.Header.Get("Content-Length")) - require.Equal(t, "attachment", r.Header.Get("Content-Disposition")) + require.Equal(t, `attachment;filename="fleet-osquery.pkg"`, r.Header.Get("Content-Disposition")) // unauthorized requests s.DoRawNoAuth("GET", validURL, nil, http.StatusUnauthorized) @@ -105,7 +105,7 @@ func (s *integrationSandboxTestSuite) TestInstallerGet() { func (s *integrationSandboxTestSuite) TestInstallerHeadCheck() { // make sure FLEET_DEMO is not set os.Unsetenv("FLEET_DEMO") - validURL := fmt.Sprintf("/api/latest/fleet/download_installer/%s/%s", enrollSecret, "pkg") + validURL := fmt.Sprintf("/api/latest/fleet/download_installer/%s?enroll_secret=%s", enrollSecret, "pkg") s.Do("HEAD", validURL, nil, http.StatusInternalServerError) os.Setenv("FLEET_DEMO", "1") @@ -121,11 +121,11 @@ func (s *integrationSandboxTestSuite) TestInstallerHeadCheck() { s.token = s.cachedAdminToken // wrong enroll secret - invalidURL := fmt.Sprintf("/api/latest/fleet/download_installer/%s/%s", "wrong-enroll", "pkg") + invalidURL := fmt.Sprintf("/api/latest/fleet/download_installer/%s?enroll_secret=%s", "wrong-enroll", "pkg") s.Do("HEAD", invalidURL, nil, http.StatusInternalServerError) // non-existent package - invalidURL = fmt.Sprintf("/api/latest/fleet/download_installer/%s/%s", enrollSecret, "exe") + invalidURL = fmt.Sprintf("/api/latest/fleet/download_installer/%s?enroll_secret=%s", enrollSecret, "exe") s.Do("HEAD", invalidURL, nil, http.StatusNotFound) } From ec25d7c4c99fb78a0bba35dd7ff314e6b700e2f9 Mon Sep 17 00:00:00 2001 From: Roberto Dip Date: Mon, 15 Aug 2022 21:06:22 -0300 Subject: [PATCH 07/11] fix tests and add comments --- .../DownloadInstallers/DownloadInstallers.tsx | 6 ++++++ server/service/integration_sandbox_test.go | 13 ++++++------- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/frontend/components/AddHostsModal/DownloadInstallers/DownloadInstallers.tsx b/frontend/components/AddHostsModal/DownloadInstallers/DownloadInstallers.tsx index af54a148744..b191909483d 100644 --- a/frontend/components/AddHostsModal/DownloadInstallers/DownloadInstallers.tsx +++ b/frontend/components/AddHostsModal/DownloadInstallers/DownloadInstallers.tsx @@ -83,11 +83,16 @@ const DownloadInstallers = ({ // do nothing return; } + + // Prevent the submit behavior, as we want to control when the POST is + // actually performed. event.preventDefault(); event.persist(); setIsDownloading(true); try { + // First check if the installer exists, no need to save the result of + // this operation as any status other than 200 will throw an error await installerAPI.checkInstallerExistence({ enrollSecret, includeDesktop, @@ -97,6 +102,7 @@ const DownloadInstallers = ({ // For some reason only Firefox fails with NS_ERROR_FAILURE unless we // push back this operation to the bottom of the event queue setTimeout(() => { + // Submit the form now that we know that is safe to do so. (event.target as HTMLFormElement).submit(); setIsDownloadSuccess(true); }); diff --git a/server/service/integration_sandbox_test.go b/server/service/integration_sandbox_test.go index 9f1c82b6f5b..9ad79e72822 100644 --- a/server/service/integration_sandbox_test.go +++ b/server/service/integration_sandbox_test.go @@ -87,19 +87,18 @@ func (s *integrationSandboxTestSuite) TestInstallerGet() { require.Equal(t, `attachment;filename="fleet-osquery.pkg"`, r.Header.Get("Content-Disposition")) // unauthorized requests - s.DoRawNoAuth("GET", validURL, nil, http.StatusUnauthorized) + s.DoRawNoAuth("POST", validURL, nil, http.StatusUnauthorized) s.token = "invalid" - s.Do("GET", validURL, nil, http.StatusUnauthorized) + s.Do("POST", validURL, nil, http.StatusUnauthorized) s.token = s.cachedAdminToken // wrong enroll secret wrongURL, wrongFormBody := installerReq("wrong-enroll", "pkg", s.token, "false") - s.Do("GET", wrongURL, wrongFormBody, http.StatusInternalServerError) + s.Do("POST", wrongURL, wrongFormBody, http.StatusInternalServerError) // non-existent package wrongURL, wrongFormBody = installerReq("wrong-enroll", "exe", s.token, "false") - s.Do("GET", wrongURL, wrongFormBody, http.StatusInternalServerError) - s.Do("GET", wrongURL, wrongFormBody, http.StatusNotFound) + s.Do("POST", wrongURL, wrongFormBody, http.StatusNotFound) } func (s *integrationSandboxTestSuite) TestInstallerHeadCheck() { @@ -115,9 +114,9 @@ func (s *integrationSandboxTestSuite) TestInstallerHeadCheck() { s.Do("HEAD", validURL, nil, http.StatusOK) // unauthorized requests - s.DoRawNoAuth("GET", validURL, nil, http.StatusUnauthorized) + s.DoRawNoAuth("HEAD", validURL, nil, http.StatusUnauthorized) s.token = "invalid" - s.Do("GET", validURL, nil, http.StatusUnauthorized) + s.Do("HEAD", validURL, nil, http.StatusUnauthorized) s.token = s.cachedAdminToken // wrong enroll secret From 871e890d5d6888f9cde298e21a1aabe11bf48bd3 Mon Sep 17 00:00:00 2001 From: Roberto Dip Date: Mon, 15 Aug 2022 22:37:14 -0300 Subject: [PATCH 08/11] fix tests --- server/service/installer_test.go | 4 ++- server/service/integration_core_test.go | 4 +-- server/service/integration_sandbox_test.go | 37 ++++++++++++---------- 3 files changed, 25 insertions(+), 20 deletions(-) diff --git a/server/service/installer_test.go b/server/service/installer_test.go index 5cbc5087803..d712106a2ee 100644 --- a/server/service/installer_test.go +++ b/server/service/installer_test.go @@ -109,7 +109,9 @@ func TestCheckInstallerExistence(t *testing.T) { t.Run("errors if store is not configured", func(t *testing.T) { ctx, ds, _, _ := setup(t) - svc := newTestService(t, ds, nil, nil, &TestServerOpts{Is: nil}) + cfg := config.TestConfig() + cfg.Server.SandboxEnabled = true + svc := newTestServiceWithConfig(t, ds, cfg, nil, nil, &TestServerOpts{Is: nil, FleetConfig: &cfg}) err := svc.CheckInstallerExistence(ctx, fleet.Installer{}) require.Error(t, err) require.ErrorContains(t, err, "installer storage has not been configured") diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 5d10879bd50..fb28ee2a37a 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -5112,8 +5112,8 @@ func (s *integrationTestSuite) TestSandboxEndpoints() { require.NotEqual(t, http.StatusOK, res.StatusCode) // installers endpoint is not enabled - url, installersBody := installerReq(enrollSecret, "pkg", s.token, "false") - s.DoRaw("GET", url, installersBody, http.StatusInternalServerError) + url, installersBody := installerGETReq(enrollSecret, "pkg", s.token, false) + s.DoRaw("POST", url, installersBody, http.StatusInternalServerError) } func (s *integrationTestSuite) TestGetHostBatteries() { diff --git a/server/service/integration_sandbox_test.go b/server/service/integration_sandbox_test.go index 9ad79e72822..beb60038857 100644 --- a/server/service/integration_sandbox_test.go +++ b/server/service/integration_sandbox_test.go @@ -6,7 +6,6 @@ import ( "io" "net/http" "net/url" - "os" "testing" "github.com/fleetdm/fleet/v4/server/config" @@ -76,7 +75,7 @@ func (s *integrationSandboxTestSuite) TestDemoLogin() { func (s *integrationSandboxTestSuite) TestInstallerGet() { t := s.T() - validURL, formBody := installerReq(enrollSecret, "pkg", s.token, "false") + validURL, formBody := installerGETReq(enrollSecret, "pkg", s.token, false) r := s.DoRaw("POST", validURL, formBody, http.StatusOK) body, err := io.ReadAll(r.Body) @@ -93,24 +92,16 @@ func (s *integrationSandboxTestSuite) TestInstallerGet() { s.token = s.cachedAdminToken // wrong enroll secret - wrongURL, wrongFormBody := installerReq("wrong-enroll", "pkg", s.token, "false") + wrongURL, wrongFormBody := installerGETReq("wrong-enroll", "pkg", s.token, false) s.Do("POST", wrongURL, wrongFormBody, http.StatusInternalServerError) // non-existent package - wrongURL, wrongFormBody = installerReq("wrong-enroll", "exe", s.token, "false") + wrongURL, wrongFormBody = installerGETReq(enrollSecret, "exe", s.token, false) s.Do("POST", wrongURL, wrongFormBody, http.StatusNotFound) } func (s *integrationSandboxTestSuite) TestInstallerHeadCheck() { - // make sure FLEET_DEMO is not set - os.Unsetenv("FLEET_DEMO") - validURL := fmt.Sprintf("/api/latest/fleet/download_installer/%s?enroll_secret=%s", enrollSecret, "pkg") - s.Do("HEAD", validURL, nil, http.StatusInternalServerError) - - os.Setenv("FLEET_DEMO", "1") - defer os.Unsetenv("FLEET_DEMO") - - // works when FLEET_DEMO is set + validURL := installerURL(enrollSecret, "pkg", false) s.Do("HEAD", validURL, nil, http.StatusOK) // unauthorized requests @@ -120,19 +111,31 @@ func (s *integrationSandboxTestSuite) TestInstallerHeadCheck() { s.token = s.cachedAdminToken // wrong enroll secret - invalidURL := fmt.Sprintf("/api/latest/fleet/download_installer/%s?enroll_secret=%s", "wrong-enroll", "pkg") + invalidURL := installerURL("wrong-enroll", "pkg", false) s.Do("HEAD", invalidURL, nil, http.StatusInternalServerError) // non-existent package - invalidURL = fmt.Sprintf("/api/latest/fleet/download_installer/%s?enroll_secret=%s", enrollSecret, "exe") + invalidURL = installerURL(enrollSecret, "exe", false) s.Do("HEAD", invalidURL, nil, http.StatusNotFound) } -func installerReq(secret, kind, token, desktop string) (string, []byte) { +func installerURL(secret, kind string, desktop bool) string { path := fmt.Sprintf("/api/latest/fleet/download_installer/%s?enroll_secret=%s", kind, secret) + if desktop { + path += "&desktop=1" + } + return path +} + +func installerGETReq(secret, kind, token string, desktop bool) (string, []byte) { + path := installerURL(secret, kind, desktop) + d := "0" + if desktop { + d = "1" + } formBody := make(url.Values) formBody.Set("token", token) formBody.Set("enroll_secret", secret) - formBody.Set("desktop", desktop) + formBody.Set("desktop", d) return path, []byte(formBody.Encode()) } From b2ff8aaabf76c882be340a185f5c1d394d5cae22 Mon Sep 17 00:00:00 2001 From: Roberto Dip Date: Tue, 16 Aug 2022 08:21:33 -0300 Subject: [PATCH 09/11] adjust download to prevent tab flashing --- .../DownloadInstallers/DownloadInstallers.tsx | 90 +++++++++++++------ .../DownloadInstallers/_styles.scss | 8 ++ 2 files changed, 73 insertions(+), 25 deletions(-) diff --git a/frontend/components/AddHostsModal/DownloadInstallers/DownloadInstallers.tsx b/frontend/components/AddHostsModal/DownloadInstallers/DownloadInstallers.tsx index b191909483d..0d06f0d66f7 100644 --- a/frontend/components/AddHostsModal/DownloadInstallers/DownloadInstallers.tsx +++ b/frontend/components/AddHostsModal/DownloadInstallers/DownloadInstallers.tsx @@ -1,4 +1,4 @@ -import React, { FormEvent, useRef, useState } from "react"; +import React, { FunctionComponent, useState } from "react"; import { IInstallerPlatform, @@ -30,6 +30,17 @@ interface IDownloadInstallersProps { onCancel: () => void; } +interface IDownloadFormProps { + url: string; + onSubmit: (event: React.FormEvent) => void; + token: string | null; + enrollSecret: string; + includeDesktop: boolean; + selectedInstaller: string | undefined; + isDownloading: boolean; + isDownloadSuccess: boolean; +} + const baseClass = "download-installers"; const displayOrder = [ @@ -62,6 +73,41 @@ const displayIcon = (platform: IInstallerPlatform, isSelected: boolean) => { } }; +const DownloadForm: FunctionComponent = ({ + url, + onSubmit, + token, + enrollSecret, + includeDesktop, + selectedInstaller, + isDownloading, + isDownloadSuccess, +}) => { + return ( + + + + +