diff --git a/docs/Contributing/API-for-contributors.md b/docs/Contributing/API-for-contributors.md index 78136599461..fd840ae88fe 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 @@ -1717,15 +1718,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 @@ -1746,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/{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. | + +##### 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 316e5d4a857..0d06f0d66f7 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, { FunctionComponent, useState } from "react"; import { IInstallerPlatform, @@ -7,6 +6,9 @@ import { INSTALLER_PLATFORM_BY_TYPE, INSTALLER_TYPE_BY_PLATFORM, } from "interfaces/installer"; +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"; @@ -28,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 = [ @@ -60,37 +73,81 @@ const displayIcon = (platform: IInstallerPlatform, isSelected: boolean) => { } }; +const DownloadForm: FunctionComponent = ({ + url, + onSubmit, + token, + enrollSecret, + includeDesktop, + selectedInstaller, + isDownloading, + isDownloadSuccess, +}) => { + return ( + + + + + + {!isDownloadSuccess && ( + + {isDownloading ? : "Download installer"} + + )} + + ); +}; + const DownloadInstallers = ({ enrollSecret, onCancel, }: IDownloadInstallersProps): JSX.Element => { const [includeDesktop, setIncludeDesktop] = useState(true); - const [isDownloadError, setIsDownloadError] = useState(false); const [isDownloading, setIsDownloading] = useState(false); + const [isDownloadError, setIsDownloadError] = useState(false); const [isDownloadSuccess, setIsDownloadSuccess] = useState(false); const [selectedInstaller, setSelectedInstaller] = useState< IInstallerType | undefined >(); + 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) { + const downloadInstaller = async (event: React.FormEvent) => { + if (!selectedInstaller) { // 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 { - const blob: BlobPart = await installerAPI.downloadInstaller({ + // 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, - installerType, includeDesktop, + installerType: selectedInstaller, }); - const filename = `fleet-osquery.${installerType}`; - const file = new global.window.File([blob], filename, { - type: "application/octet-stream", - }); - FileSaver.saveAs(file); + + (event.target as HTMLFormElement).submit(); setIsDownloadSuccess(true); - } catch { + } catch (error) { setIsDownloadError(true); } finally { setIsDownloading(false); @@ -109,6 +166,20 @@ const DownloadInstallers = ({ setSelectedInstaller(type); }; + const form = ( + + ); + if (isDownloadError) { return ( @@ -128,6 +199,7 @@ const DownloadInstallers = ({ You’re almost there {`Run the installer on a ${installerPlatform}laptop, workstation, or sever to add it to Fleet.`} Got it + {form} ); } @@ -171,13 +243,7 @@ const DownloadInstallers = ({ > - downloadInstaller(selectedInstaller)} - > - {isDownloading ? : "Download installer"} - + {form} ); }; diff --git a/frontend/components/AddHostsModal/DownloadInstallers/_styles.scss b/frontend/components/AddHostsModal/DownloadInstallers/_styles.scss index 48aff6b09ba..c0b3952649e 100644 --- a/frontend/components/AddHostsModal/DownloadInstallers/_styles.scss +++ b/frontend/components/AddHostsModal/DownloadInstallers/_styles.scss @@ -4,6 +4,10 @@ padding: 0; padding-bottom: 20px; + iframe { + display: none; + } + p { padding-top: $pad-small; padding-bottom: $pad-medium; @@ -97,6 +101,10 @@ margin: 0; padding-bottom: 24px; } + + iframe { + display: none; + } } &__error { 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/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/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 7d92053ee58..0ffa0196bc6 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -360,7 +360,8 @@ 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.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 b670d4264da..67f006e0b80 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 @@ -90,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..d712106a2ee 100644 --- a/server/service/installer_test.go +++ b/server/service/installer_test.go @@ -99,3 +99,78 @@ 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) + 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") + }) + + 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_core_test.go b/server/service/integration_core_test.go index 6492c6bb35b..309c6749a1e 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 := installerPOSTReq(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 5ebe8ca88ff..02f7448eb9c 100644 --- a/server/service/integration_sandbox_test.go +++ b/server/service/integration_sandbox_test.go @@ -75,33 +75,67 @@ func (s *integrationSandboxTestSuite) TestDemoLogin() { func (s *integrationSandboxTestSuite) TestInstallerGet() { t := s.T() - validURL := installerURL(enrollSecret, "pkg", false) + validURL, formBody := installerPOSTReq(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)) 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) + 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 - s.Do("GET", installerURL("wrong-enroll", "pkg", false), nil, http.StatusInternalServerError) + wrongURL, wrongFormBody := installerPOSTReq("wrong-enroll", "pkg", s.token, false) + s.Do("POST", wrongURL, wrongFormBody, http.StatusInternalServerError) // non-existent package - s.Do("GET", installerURL(enrollSecret, "exe", false), nil, http.StatusNotFound) + wrongURL, wrongFormBody = installerPOSTReq(enrollSecret, "exe", s.token, false) + s.Do("POST", wrongURL, wrongFormBody, http.StatusNotFound) +} + +func (s *integrationSandboxTestSuite) TestInstallerHeadCheck() { + validURL := installerURL(enrollSecret, "pkg", false) + s.Do("HEAD", validURL, nil, http.StatusOK) + + // unauthorized requests + s.DoRawNoAuth("HEAD", validURL, nil, http.StatusUnauthorized) + s.token = "invalid" + s.Do("HEAD", validURL, nil, http.StatusUnauthorized) + s.token = s.cachedAdminToken + + // wrong enroll secret + invalidURL := installerURL("wrong-enroll", "pkg", false) + s.Do("HEAD", invalidURL, nil, http.StatusInternalServerError) + + // non-existent package + invalidURL = installerURL(enrollSecret, "exe", false) + s.Do("HEAD", invalidURL, nil, http.StatusNotFound) } func installerURL(secret, kind string, desktop bool) string { - url := fmt.Sprintf("/api/latest/fleet/download_installer/%s?enroll_secret=%s", kind, secret) + path := fmt.Sprintf("/api/latest/fleet/download_installer/%s?enroll_secret=%s", kind, secret) + if desktop { + path += "&desktop=1" + } + return path +} + +func installerPOSTReq(secret, kind, token string, desktop bool) (string, []byte) { + path := installerURL(secret, kind, desktop) + d := "0" if desktop { - url = url + "&desktop=1" + d = "1" } - return url + formBody := make(url.Values) + formBody.Set("token", token) + formBody.Set("enroll_secret", secret) + formBody.Set("desktop", d) + return path, []byte(formBody.Encode()) }
{`Run the installer on a ${installerPlatform}laptop, workstation, or sever to add it to Fleet.`}