fleet/server/service/software_installers.go
Scott Gress e14bfd60fe
Add renameto tags to prepare for deprecating team and query API params (#39847)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** For #39344

# Details

As a first step to deprecating API params like `team_id` in favor of
`fleet_id` and `query_id` in favor of `report_id`, this PR adds
`renameto` tags to all deprecated keys. There is no logic in this PR to
actually use these tags in any way. The logic and test fixes will be in
the next PR, but in the interest of keeping things manageable I'm
pushing this out first.

There were definitely params with "query" in them that we don't want to
change (mainly osquery-related), and I think I kept them all out but
it's worth double-checking here. The team -> fleet changes are pretty
safe in comparison.

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [ ] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.
Deferring changelog to PR with logic changes

## Testing

- [ ] Added/updated automated tests
This should be a no-op.  All existing tests shoud pass.
- [X] QA'd all new/changed functionality manually
2026-02-17 10:00:59 -06:00

1069 lines
38 KiB
Go

package service
import (
"context"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net"
"net/http"
"net/url"
"strconv"
authzctx "github.com/fleetdm/fleet/v4/server/contexts/authz"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
hostctx "github.com/fleetdm/fleet/v4/server/contexts/host"
"github.com/fleetdm/fleet/v4/server/contexts/installersize"
"github.com/fleetdm/fleet/v4/server/contexts/logging"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/platform/endpointer"
platform_http "github.com/fleetdm/fleet/v4/server/platform/http"
"github.com/fleetdm/fleet/v4/server/ptr"
)
type uploadSoftwareInstallerRequest struct {
File *multipart.FileHeader
TeamID *uint
InstallScript string
PreInstallQuery string
PostInstallScript string
SelfService bool
UninstallScript string
LabelsIncludeAny []string
LabelsExcludeAny []string
AutomaticInstall bool
}
type updateSoftwareInstallerRequest struct {
TitleID uint `url:"id"`
File *multipart.FileHeader
TeamID *uint
InstallScript *string
PreInstallQuery *string
PostInstallScript *string
UninstallScript *string
SelfService *bool
LabelsIncludeAny []string
LabelsExcludeAny []string
Categories []string
DisplayName *string
}
type uploadSoftwareInstallerResponse struct {
SoftwarePackage *fleet.SoftwareInstaller `json:"software_package,omitempty"`
Err error `json:"error,omitempty"`
}
// TODO: We parse the whole body before running svc.authz.Authorize.
// An authenticated but unauthorized user could abuse this.
func (updateSoftwareInstallerRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) {
decoded := updateSoftwareInstallerRequest{}
// populate software title ID since we're overriding the decoder that would do it for us
titleID, err := uint32FromRequest(r, "id")
if err != nil {
return nil, endpointer.BadRequestErr("IntFromRequest", err)
}
decoded.TitleID = uint(titleID)
maxInstallerSize := installersize.FromContext(ctx)
err = r.ParseMultipartForm(platform_http.MaxMultipartFormSize)
if err != nil {
var mbe *http.MaxBytesError
if errors.As(err, &mbe) {
return nil, &fleet.BadRequestError{
Message: fmt.Sprintf("The maximum file size is %s.", installersize.Human(maxInstallerSize)),
InternalErr: err,
}
}
var nerr net.Error
if errors.As(err, &nerr) && nerr.Timeout() {
return nil, fleet.NewUserMessageError(
ctxerr.New(ctx, "Couldn't add. Please ensure your internet connection speed is sufficient and stable."),
http.StatusRequestTimeout,
)
}
return nil, &fleet.BadRequestError{
Message: "failed to parse multipart form: " + err.Error(),
InternalErr: err,
}
}
// unlike for uploadSoftwareInstallerRequest, every field is optional, including the file upload
if r.MultipartForm.File["software"] != nil || len(r.MultipartForm.File["software"]) > 0 {
decoded.File = r.MultipartForm.File["software"][0]
if decoded.File.Size > maxInstallerSize {
// Should never happen here since the request's body is limited to the maximum size.
return nil, &fleet.BadRequestError{
Message: fmt.Sprintf("The maximum file size is %s.", installersize.Human(maxInstallerSize)),
}
}
}
// default is no team
val, ok := r.MultipartForm.Value["team_id"]
if ok {
teamID, err := strconv.ParseUint(val[0], 10, 32)
if err != nil {
return nil, &fleet.BadRequestError{Message: fmt.Sprintf("Invalid team_id: %s", val[0])}
}
decoded.TeamID = ptr.Uint(uint(teamID))
}
installScriptMultipart, ok := r.MultipartForm.Value["install_script"]
if ok && len(installScriptMultipart) > 0 {
decoded.InstallScript = &installScriptMultipart[0]
}
preinstallQueryMultipart, ok := r.MultipartForm.Value["pre_install_query"]
if ok && len(preinstallQueryMultipart) > 0 {
decoded.PreInstallQuery = &preinstallQueryMultipart[0]
}
postInstallScriptMultipart, ok := r.MultipartForm.Value["post_install_script"]
if ok && len(postInstallScriptMultipart) > 0 {
decoded.PostInstallScript = &postInstallScriptMultipart[0]
}
uninstallScriptMultipart, ok := r.MultipartForm.Value["uninstall_script"]
if ok && len(uninstallScriptMultipart) > 0 {
decoded.UninstallScript = &uninstallScriptMultipart[0]
}
val, ok = r.MultipartForm.Value["self_service"]
if ok && len(val) > 0 && val[0] != "" {
parsed, err := strconv.ParseBool(val[0])
if err != nil {
return nil, &fleet.BadRequestError{Message: fmt.Sprintf("failed to decode self_service bool in multipart form: %s", err.Error())}
}
decoded.SelfService = &parsed
}
// decode labels and categories
var inclAny, exclAny, categories []string
var existsInclAny, existsExclAny, existsCategories bool
inclAny, existsInclAny = r.MultipartForm.Value[string(fleet.LabelsIncludeAny)]
switch {
case !existsInclAny:
decoded.LabelsIncludeAny = nil
case len(inclAny) == 1 && inclAny[0] == "":
decoded.LabelsIncludeAny = []string{}
default:
decoded.LabelsIncludeAny = inclAny
}
exclAny, existsExclAny = r.MultipartForm.Value[string(fleet.LabelsExcludeAny)]
switch {
case !existsExclAny:
decoded.LabelsExcludeAny = nil
case len(exclAny) == 1 && exclAny[0] == "":
decoded.LabelsExcludeAny = []string{}
default:
decoded.LabelsExcludeAny = exclAny
}
categories, existsCategories = r.MultipartForm.Value["categories"]
switch {
case !existsCategories:
decoded.Categories = nil
case len(categories) == 1 && categories[0] == "":
decoded.Categories = []string{}
default:
decoded.Categories = categories
}
displayNameMultiPart, existsDisplayName := r.MultipartForm.Value["display_name"]
if existsDisplayName && len(displayNameMultiPart) > 0 {
decoded.DisplayName = ptr.String(displayNameMultiPart[0])
if len(*decoded.DisplayName) > fleet.SoftwareTitleDisplayNameMaxLength {
return nil, &fleet.BadRequestError{
Message: "The maximum display name length is 255 characters.",
}
}
}
// Check if scripts are base64 encoded (to bypass WAF rules that block script patterns)
if isScriptsEncoded(r) {
if decoded.InstallScript != nil {
decodedScript, err := decodeBase64Script(*decoded.InstallScript)
if err != nil {
return nil, &fleet.BadRequestError{Message: "invalid base64 encoding for install_script"}
}
decoded.InstallScript = &decodedScript
}
if decoded.UninstallScript != nil {
decodedScript, err := decodeBase64Script(*decoded.UninstallScript)
if err != nil {
return nil, &fleet.BadRequestError{Message: "invalid base64 encoding for uninstall_script"}
}
decoded.UninstallScript = &decodedScript
}
if decoded.PreInstallQuery != nil {
decodedScript, err := decodeBase64Script(*decoded.PreInstallQuery)
if err != nil {
return nil, &fleet.BadRequestError{Message: "invalid base64 encoding for pre_install_query"}
}
decoded.PreInstallQuery = &decodedScript
}
if decoded.PostInstallScript != nil {
decodedScript, err := decodeBase64Script(*decoded.PostInstallScript)
if err != nil {
return nil, &fleet.BadRequestError{Message: "invalid base64 encoding for post_install_script"}
}
decoded.PostInstallScript = &decodedScript
}
}
return &decoded, nil
}
func updateSoftwareInstallerEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*updateSoftwareInstallerRequest)
payload := &fleet.UpdateSoftwareInstallerPayload{
TitleID: req.TitleID,
TeamID: req.TeamID,
InstallScript: req.InstallScript,
PreInstallQuery: req.PreInstallQuery,
PostInstallScript: req.PostInstallScript,
UninstallScript: req.UninstallScript,
SelfService: req.SelfService,
LabelsIncludeAny: req.LabelsIncludeAny,
LabelsExcludeAny: req.LabelsExcludeAny,
Categories: req.Categories,
DisplayName: req.DisplayName,
}
if req.File != nil {
ff, err := req.File.Open()
if err != nil {
return uploadSoftwareInstallerResponse{Err: err}, nil
}
defer ff.Close()
tfr, err := fleet.NewTempFileReader(ff, nil)
if err != nil {
return uploadSoftwareInstallerResponse{Err: err}, nil
}
defer tfr.Close()
payload.InstallerFile = tfr
payload.Filename = req.File.Filename
}
installer, err := svc.UpdateSoftwareInstaller(ctx, payload)
if err != nil {
return uploadSoftwareInstallerResponse{Err: err}, nil
}
return getSoftwareInstallerResponse{SoftwareInstaller: installer}, nil
}
func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet.UpdateSoftwareInstallerPayload) (*fleet.SoftwareInstaller, error) {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)
return nil, fleet.ErrMissingLicense
}
// TODO: We parse the whole body before running svc.authz.Authorize.
// An authenticated but unauthorized user could abuse this.
func (uploadSoftwareInstallerRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) {
decoded := uploadSoftwareInstallerRequest{}
maxInstallerSize := installersize.FromContext(ctx)
err := r.ParseMultipartForm(platform_http.MaxMultipartFormSize)
if err != nil {
var mbe *http.MaxBytesError
if errors.As(err, &mbe) {
return nil, &fleet.BadRequestError{
Message: fmt.Sprintf("The maximum file size is %s.", installersize.Human(maxInstallerSize)),
InternalErr: err,
}
}
var nerr net.Error
if errors.As(err, &nerr) && nerr.Timeout() {
return nil, fleet.NewUserMessageError(
ctxerr.New(ctx, "Couldn't add. Please ensure your internet connection speed is sufficient and stable."),
http.StatusRequestTimeout,
)
}
return nil, &fleet.BadRequestError{
Message: "failed to parse multipart form: " + err.Error(),
InternalErr: err,
}
}
if r.MultipartForm.File["software"] == nil || len(r.MultipartForm.File["software"]) == 0 {
return nil, &fleet.BadRequestError{
Message: "software multipart field is required",
InternalErr: err,
}
}
decoded.File = r.MultipartForm.File["software"][0]
if decoded.File.Size > maxInstallerSize {
// Should never happen here since the request's body is limited to the
// maximum size.
return nil, &fleet.BadRequestError{
Message: fmt.Sprintf("The maximum file size is %s.", installersize.Human(maxInstallerSize)),
}
}
// default is no team
val, ok := r.MultipartForm.Value["team_id"]
if ok {
teamID, err := strconv.ParseUint(val[0], 10, 32)
if err != nil {
return nil, &fleet.BadRequestError{Message: fmt.Sprintf("Invalid team_id: %s", val[0])}
}
decoded.TeamID = ptr.Uint(uint(teamID))
}
val, ok = r.MultipartForm.Value["install_script"]
if ok && len(val) > 0 {
decoded.InstallScript = val[0]
}
val, ok = r.MultipartForm.Value["uninstall_script"]
if ok && len(val) > 0 {
decoded.UninstallScript = val[0]
}
val, ok = r.MultipartForm.Value["pre_install_query"]
if ok && len(val) > 0 {
decoded.PreInstallQuery = val[0]
}
val, ok = r.MultipartForm.Value["post_install_script"]
if ok && len(val) > 0 {
decoded.PostInstallScript = val[0]
}
val, ok = r.MultipartForm.Value["self_service"]
if ok && len(val) > 0 && val[0] != "" {
parsed, err := strconv.ParseBool(val[0])
if err != nil {
return nil, &fleet.BadRequestError{Message: fmt.Sprintf("failed to decode self_service bool in multipart form: %s", err.Error())}
}
decoded.SelfService = parsed
}
// decode labels
var inclAny, exclAny []string
var existsInclAny, existsExclAny bool
inclAny, existsInclAny = r.MultipartForm.Value[string(fleet.LabelsIncludeAny)]
switch {
case !existsInclAny:
decoded.LabelsIncludeAny = nil
case len(inclAny) == 1 && inclAny[0] == "":
decoded.LabelsIncludeAny = []string{}
default:
decoded.LabelsIncludeAny = inclAny
}
exclAny, existsExclAny = r.MultipartForm.Value[string(fleet.LabelsExcludeAny)]
switch {
case !existsExclAny:
decoded.LabelsExcludeAny = nil
case len(exclAny) == 1 && exclAny[0] == "":
decoded.LabelsExcludeAny = []string{}
default:
decoded.LabelsExcludeAny = exclAny
}
val, ok = r.MultipartForm.Value["automatic_install"]
if ok && len(val) > 0 && val[0] != "" {
parsed, err := strconv.ParseBool(val[0])
if err != nil {
return nil, &fleet.BadRequestError{Message: fmt.Sprintf("failed to decode automatic_install bool in multipart form: %s", err.Error())}
}
decoded.AutomaticInstall = parsed
}
// Check if scripts are base64 encoded (to bypass WAF rules that block script patterns)
if isScriptsEncoded(r) {
var err error
if decoded.InstallScript, err = decodeBase64Script(decoded.InstallScript); err != nil {
return nil, &fleet.BadRequestError{Message: "invalid base64 encoding for install_script"}
}
if decoded.UninstallScript, err = decodeBase64Script(decoded.UninstallScript); err != nil {
return nil, &fleet.BadRequestError{Message: "invalid base64 encoding for uninstall_script"}
}
if decoded.PreInstallQuery, err = decodeBase64Script(decoded.PreInstallQuery); err != nil {
return nil, &fleet.BadRequestError{Message: "invalid base64 encoding for pre_install_query"}
}
if decoded.PostInstallScript, err = decodeBase64Script(decoded.PostInstallScript); err != nil {
return nil, &fleet.BadRequestError{Message: "invalid base64 encoding for post_install_script"}
}
}
return &decoded, nil
}
func (r uploadSoftwareInstallerResponse) Error() error { return r.Err }
func uploadSoftwareInstallerEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*uploadSoftwareInstallerRequest)
ff, err := req.File.Open()
if err != nil {
return uploadSoftwareInstallerResponse{Err: err}, nil
}
defer ff.Close()
tfr, err := fleet.NewTempFileReader(ff, nil)
if err != nil {
return uploadSoftwareInstallerResponse{Err: err}, nil
}
defer tfr.Close()
payload := &fleet.UploadSoftwareInstallerPayload{
TeamID: req.TeamID,
InstallScript: req.InstallScript,
PreInstallQuery: req.PreInstallQuery,
PostInstallScript: req.PostInstallScript,
InstallerFile: tfr,
Filename: req.File.Filename,
SelfService: req.SelfService,
UninstallScript: req.UninstallScript,
LabelsIncludeAny: req.LabelsIncludeAny,
LabelsExcludeAny: req.LabelsExcludeAny,
AutomaticInstall: req.AutomaticInstall,
}
installer, err := svc.UploadSoftwareInstaller(ctx, payload)
if err != nil {
return uploadSoftwareInstallerResponse{Err: err}, nil
}
return &uploadSoftwareInstallerResponse{SoftwarePackage: installer}, nil
}
func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) (*fleet.SoftwareInstaller, error) {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)
return nil, fleet.ErrMissingLicense
}
type deleteSoftwareInstallerRequest struct {
TeamID *uint `query:"team_id" renameto:"fleet_id"`
TitleID uint `url:"title_id"`
}
type deleteSoftwareInstallerResponse struct {
Err error `json:"error,omitempty"`
}
func (r deleteSoftwareInstallerResponse) Error() error { return r.Err }
func (r deleteSoftwareInstallerResponse) Status() int { return http.StatusNoContent }
func deleteSoftwareInstallerEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*deleteSoftwareInstallerRequest)
err := svc.DeleteSoftwareInstaller(ctx, req.TitleID, req.TeamID)
if err != nil {
return deleteSoftwareInstallerResponse{Err: err}, nil
}
return deleteSoftwareInstallerResponse{}, nil
}
func (svc *Service) DeleteSoftwareInstaller(ctx context.Context, titleID uint, teamID *uint) error {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)
return fleet.ErrMissingLicense
}
type getSoftwareInstallerRequest struct {
Alt string `query:"alt,optional"`
TeamID *uint `query:"team_id" renameto:"fleet_id"`
TitleID uint `url:"title_id"`
}
type downloadSoftwareInstallerRequest struct {
TitleID uint `url:"title_id"`
Token string `url:"token"`
}
func getSoftwareInstallerEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*getSoftwareInstallerRequest)
payload, err := svc.DownloadSoftwareInstaller(ctx, false, req.Alt, req.TitleID, req.TeamID)
if err != nil {
return orbitDownloadSoftwareInstallerResponse{Err: err}, nil
}
return orbitDownloadSoftwareInstallerResponse{payload: payload}, nil
}
func getSoftwareInstallerTokenEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*getSoftwareInstallerRequest)
token, err := svc.GenerateSoftwareInstallerToken(ctx, req.Alt, req.TitleID, req.TeamID)
if err != nil {
return getSoftwareInstallerTokenResponse{Err: err}, nil
}
return getSoftwareInstallerTokenResponse{Token: token}, nil
}
func downloadSoftwareInstallerEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*downloadSoftwareInstallerRequest)
meta, err := svc.GetSoftwareInstallerTokenMetadata(ctx, req.Token, req.TitleID)
if err != nil {
return orbitDownloadSoftwareInstallerResponse{Err: err}, nil
}
payload, err := svc.DownloadSoftwareInstaller(ctx, true, "media", meta.TitleID, &meta.TeamID)
if err != nil {
return orbitDownloadSoftwareInstallerResponse{Err: err}, nil
}
return orbitDownloadSoftwareInstallerResponse{payload: payload}, nil
}
func (svc *Service) GenerateSoftwareInstallerToken(ctx context.Context, _ string, _ uint, _ *uint) (string, error) {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)
return "", fleet.ErrMissingLicense
}
func (svc *Service) GetSoftwareInstallerTokenMetadata(ctx context.Context, _ string, _ uint) (*fleet.SoftwareInstallerTokenMetadata,
error,
) {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)
return nil, fleet.ErrMissingLicense
}
func (svc *Service) GetSoftwareInstallerMetadata(ctx context.Context, _ bool, _ uint, _ *uint) (*fleet.SoftwareInstaller, error) {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)
return nil, fleet.ErrMissingLicense
}
type getSoftwareInstallerResponse struct {
SoftwareInstaller *fleet.SoftwareInstaller `json:"software_installer,omitempty"`
Err error `json:"error,omitempty"`
}
func (r getSoftwareInstallerResponse) Error() error { return r.Err }
type getSoftwareInstallerTokenResponse struct {
Err error `json:"error,omitempty"`
Token string `json:"token"`
}
func (r getSoftwareInstallerTokenResponse) Error() error { return r.Err }
type orbitDownloadSoftwareInstallerResponse struct {
Err error `json:"error,omitempty"`
// fields used by hijackRender for the response.
payload *fleet.DownloadSoftwareInstallerPayload
}
func (r orbitDownloadSoftwareInstallerResponse) Error() error { return r.Err }
func (r orbitDownloadSoftwareInstallerResponse) HijackRender(ctx context.Context, w http.ResponseWriter) {
w.Header().Set("Content-Length", strconv.Itoa(int(r.payload.Size)))
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment;filename="%s"`, r.payload.Filename))
// OK to just log the error here as writing anything on
// `http.ResponseWriter` sets the status code to 200 (and it can't be
// changed.) Clients should rely on matching content-length with the
// header provided
if n, err := io.Copy(w, r.payload.Installer); err != nil {
logging.WithExtras(ctx, "err", err, "bytes_copied", n)
}
r.payload.Installer.Close()
}
func (svc *Service) DownloadSoftwareInstaller(ctx context.Context, _ bool, _ string, _ uint,
_ *uint) (*fleet.DownloadSoftwareInstallerPayload,
error,
) {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)
return nil, fleet.ErrMissingLicense
}
/////////////////////////////////////////////////////////////////////////////////
// Request to install software in a host
/////////////////////////////////////////////////////////////////////////////////
type installSoftwareRequest struct {
HostID uint `url:"host_id"`
SoftwareTitleID uint `url:"software_title_id"`
}
type installSoftwareResponse struct {
Err error `json:"error,omitempty"`
}
func (r installSoftwareResponse) Error() error { return r.Err }
func (r installSoftwareResponse) Status() int { return http.StatusAccepted }
func installSoftwareTitleEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*installSoftwareRequest)
err := svc.InstallSoftwareTitle(ctx, req.HostID, req.SoftwareTitleID)
if err != nil {
return installSoftwareResponse{Err: err}, nil
}
return installSoftwareResponse{}, nil
}
func (svc *Service) InstallSoftwareTitle(ctx context.Context, hostID uint, softwareTitleID uint) error {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)
return fleet.ErrMissingLicense
}
func (svc *Service) GetVPPTokenIfCanInstallVPPApps(ctx context.Context, appleDevice bool, host *fleet.Host) (string, error) {
return "", fleet.ErrMissingLicense // called downstream of auth checks so doesn't need skipauth
}
func (svc *Service) InstallVPPAppPostValidation(ctx context.Context, host *fleet.Host, vppApp *fleet.VPPApp, token string, opts fleet.HostSoftwareInstallOptions) (string, error) {
return "", fleet.ErrMissingLicense // called downstream of auth checks so doesn't need skipauth
}
////////////////////////////////////////////////////////////////////////////////
// Uninstall software
////////////////////////////////////////////////////////////////////////////////
type uninstallSoftwareRequest struct {
HostID uint `url:"host_id"`
SoftwareTitleID uint `url:"software_title_id"`
}
func uninstallSoftwareTitleEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*uninstallSoftwareRequest)
err := svc.UninstallSoftwareTitle(ctx, req.HostID, req.SoftwareTitleID)
if err != nil {
return installSoftwareResponse{Err: err}, nil
}
return installSoftwareResponse{}, nil
}
func (svc *Service) UninstallSoftwareTitle(ctx context.Context, _ uint, _ uint) error {
// skipauth: No authorization check needed due to implementation returning only license error.
svc.authz.SkipAuthorization(ctx)
return fleet.ErrMissingLicense
}
////////////////////////////////////////////////////////////////////////////////
// Get software uninstall results (host details and self service)
////////////////////////////////////////////////////////////////////////////////
type getSoftwareInstallResultsRequest struct {
InstallUUID string `url:"install_uuid"`
}
type getDeviceSoftwareInstallResultsRequest struct {
Token string `url:"token"`
InstallUUID string `url:"install_uuid"`
}
func (r *getDeviceSoftwareInstallResultsRequest) deviceAuthToken() string {
return r.Token
}
type getSoftwareInstallResultsResponse struct {
Err error `json:"error,omitempty"`
Results *fleet.HostSoftwareInstallerResult `json:"results,omitempty"`
}
func (r getSoftwareInstallResultsResponse) Error() error { return r.Err }
func getDeviceSoftwareInstallResultsEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
_, ok := hostctx.FromContext(ctx)
if !ok {
err := ctxerr.Wrap(ctx, fleet.NewAuthRequiredError("internal error: missing host from request context"))
return getSoftwareInstallResultsResponse{Err: err}, nil
}
req := request.(*getDeviceSoftwareInstallResultsRequest)
results, err := svc.GetSoftwareInstallResults(ctx, req.InstallUUID)
if err != nil {
return getSoftwareInstallResultsResponse{Err: err}, nil
}
return &getSoftwareInstallResultsResponse{Results: results}, nil
}
func getSoftwareInstallResultsEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*getSoftwareInstallResultsRequest)
results, err := svc.GetSoftwareInstallResults(ctx, req.InstallUUID)
if err != nil {
return getSoftwareInstallResultsResponse{Err: err}, nil
}
return &getSoftwareInstallResultsResponse{Results: results}, nil
}
func (svc *Service) GetSoftwareInstallResults(ctx context.Context, resultUUID string) (*fleet.HostSoftwareInstallerResult, error) {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)
return nil, fleet.ErrMissingLicense
}
////////////////////////////////////////////////////////////////////////////////
// Get software uninstall results from My device page
////////////////////////////////////////////////////////////////////////////////
type getDeviceSoftwareUninstallResultsRequest struct {
Token string `url:"token"`
ExecutionID string `url:"execution_id"`
}
func (r *getDeviceSoftwareUninstallResultsRequest) deviceAuthToken() string {
return r.Token
}
func getDeviceSoftwareUninstallResultsEndpoint(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 getSoftwareInstallResultsResponse{Err: err}, nil
}
req := request.(*getDeviceSoftwareUninstallResultsRequest)
scriptResult, err := svc.GetSelfServiceUninstallScriptResult(ctx, host, req.ExecutionID)
if err != nil {
return getScriptResultResponse{Err: err}, nil
}
return setUpGetScriptResultResponse(scriptResult), nil
}
func (svc *Service) GetSelfServiceUninstallScriptResult(ctx context.Context, host *fleet.Host, execID string) (*fleet.HostScriptResult, error) {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)
return nil, fleet.ErrMissingLicense
}
////////////////////////////////////////////////////////////////////////////////
// Batch replace software installers
////////////////////////////////////////////////////////////////////////////////
type batchSetSoftwareInstallersRequest struct {
TeamName string `json:"-" query:"team_name,optional" renameto:"fleet_name"`
DryRun bool `json:"-" query:"dry_run,optional"` // if true, apply validation but do not save changes
Software []*fleet.SoftwareInstallerPayload `json:"software"`
}
type batchSetSoftwareInstallersResponse struct {
RequestUUID string `json:"request_uuid"`
Err error `json:"error,omitempty"`
}
func (r batchSetSoftwareInstallersResponse) Error() error { return r.Err }
func (r batchSetSoftwareInstallersResponse) Status() int { return http.StatusAccepted }
func batchSetSoftwareInstallersEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*batchSetSoftwareInstallersRequest)
requestUUID, err := svc.BatchSetSoftwareInstallers(ctx, req.TeamName, req.Software, req.DryRun)
if err != nil {
return batchSetSoftwareInstallersResponse{Err: err}, nil
}
return batchSetSoftwareInstallersResponse{RequestUUID: requestUUID}, nil
}
func (svc *Service) BatchSetSoftwareInstallers(ctx context.Context, tmName string, payloads []*fleet.SoftwareInstallerPayload, dryRun bool) (string, error) {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)
return "", fleet.ErrMissingLicense
}
type batchSetSoftwareInstallersResultRequest struct {
RequestUUID string `url:"request_uuid"`
TeamName string `query:"team_name,optional" renameto:"fleet_name"`
DryRun bool `query:"dry_run,optional"` // if true, apply validation but do not save changes
}
type batchSetSoftwareInstallersResultResponse struct {
Status string `json:"status"`
Message string `json:"message"`
Packages []fleet.SoftwarePackageResponse `json:"packages"`
Err error `json:"error,omitempty"`
}
func (r batchSetSoftwareInstallersResultResponse) Error() error { return r.Err }
func batchSetSoftwareInstallersResultEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*batchSetSoftwareInstallersResultRequest)
status, message, packages, err := svc.GetBatchSetSoftwareInstallersResult(ctx, req.TeamName, req.RequestUUID, req.DryRun)
if err != nil {
return batchSetSoftwareInstallersResultResponse{Err: err}, nil
}
return batchSetSoftwareInstallersResultResponse{
Status: status,
Message: message,
Packages: packages,
}, nil
}
func (svc *Service) GetBatchSetSoftwareInstallersResult(ctx context.Context, tmName string, requestUUID string, dryRun bool) (string, string, []fleet.SoftwarePackageResponse, error) {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)
return "", "", nil, fleet.ErrMissingLicense
}
//////////////////////////////////////////////////////////////////////////////
// Self Service Install
//////////////////////////////////////////////////////////////////////////////
type fleetSelfServiceSoftwareInstallRequest struct {
Token string `url:"token"`
SoftwareTitleID uint `url:"software_title_id"`
}
func (r *fleetSelfServiceSoftwareInstallRequest) deviceAuthToken() string {
return r.Token
}
type submitSelfServiceSoftwareInstallResponse struct {
Err error `json:"error,omitempty"`
}
func (r submitSelfServiceSoftwareInstallResponse) Error() error { return r.Err }
func (r submitSelfServiceSoftwareInstallResponse) Status() int { return http.StatusAccepted }
func submitSelfServiceSoftwareInstall(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 submitSelfServiceSoftwareInstallResponse{Err: err}, nil
}
req := request.(*fleetSelfServiceSoftwareInstallRequest)
if err := svc.SelfServiceInstallSoftwareTitle(ctx, host, req.SoftwareTitleID); err != nil {
return submitSelfServiceSoftwareInstallResponse{Err: err}, nil
}
return submitSelfServiceSoftwareInstallResponse{}, nil
}
func (svc *Service) SelfServiceInstallSoftwareTitle(ctx context.Context, host *fleet.Host, softwareTitleID uint) error {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)
return fleet.ErrMissingLicense
}
type fleetDeviceSoftwareUninstallRequest struct {
Token string `url:"token"`
SoftwareTitleID uint `url:"software_title_id"`
}
func (r *fleetDeviceSoftwareUninstallRequest) deviceAuthToken() string {
return r.Token
}
type submitDeviceSoftwareUninstallResponse struct {
Err error `json:"error,omitempty"`
}
func (r submitDeviceSoftwareUninstallResponse) Error() error { return r.Err }
func (r submitDeviceSoftwareUninstallResponse) Status() int { return http.StatusAccepted }
func submitDeviceSoftwareUninstall(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 submitDeviceSoftwareUninstallResponse{Err: err}, nil
}
req := request.(*fleetDeviceSoftwareUninstallRequest)
if err := svc.UninstallSoftwareTitle(ctx, host.ID, req.SoftwareTitleID); err != nil {
return submitDeviceSoftwareUninstallResponse{Err: err}, nil
}
return submitDeviceSoftwareUninstallResponse{}, nil
}
func (svc *Service) HasSelfServiceSoftwareInstallers(ctx context.Context, host *fleet.Host) (bool, error) {
alreadyAuthenticated := svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceToken) ||
svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceCertificate) ||
svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceURL)
if !alreadyAuthenticated {
if err := svc.authz.Authorize(ctx, host, fleet.ActionRead); err != nil {
return false, err
}
}
return svc.ds.HasSelfServiceSoftwareInstallers(ctx, host.Platform, host.TeamID)
}
//////////////////////////////////////////////////////////////////////////////
// VPP App Store Apps Batch Install
//////////////////////////////////////////////////////////////////////////////
type batchAssociateAppStoreAppsRequest struct {
TeamName string `json:"-" query:"team_name,optional" renameto:"fleet_name"`
DryRun bool `json:"-" query:"dry_run,optional"`
Apps []fleet.VPPBatchPayload `json:"app_store_apps"`
}
func (b *batchAssociateAppStoreAppsRequest) DecodeBody(ctx context.Context, r io.Reader, u url.Values, c []*x509.Certificate) error {
if err := json.NewDecoder(r).Decode(b); err != nil {
var typeErr *json.UnmarshalTypeError
if errors.As(err, &typeErr) {
return ctxerr.Wrap(ctx, fleet.NewUserMessageError(fmt.Errorf("Couldn't edit software. %q must be a %s, found %s", typeErr.Field, typeErr.Type.String(), typeErr.Value), http.StatusBadRequest))
}
}
return nil
}
type batchAssociateAppStoreAppsResponse struct {
Apps []fleet.VPPAppResponse `json:"app_store_apps"`
Err error `json:"error,omitempty"`
}
func (r batchAssociateAppStoreAppsResponse) Error() error { return r.Err }
func batchAssociateAppStoreAppsEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*batchAssociateAppStoreAppsRequest)
apps, err := svc.BatchAssociateVPPApps(ctx, req.TeamName, req.Apps, req.DryRun)
if err != nil {
return batchAssociateAppStoreAppsResponse{Err: err}, nil
}
return batchAssociateAppStoreAppsResponse{Apps: apps}, nil
}
func (svc *Service) BatchAssociateVPPApps(ctx context.Context, teamName string, payloads []fleet.VPPBatchPayload, dryRun bool) ([]fleet.VPPAppResponse, error) {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)
return nil, fleet.ErrMissingLicense
}
type getInHouseAppManifestRequest struct {
TitleID uint `url:"title_id"`
TeamID *uint `query:"team_id" renameto:"fleet_id"`
}
type getInHouseAppManifestResponse struct {
// Manifest field is used in HijackRender for the response.
Manifest []byte
Err error `json:"error,omitempty"`
}
func (r getInHouseAppManifestResponse) Error() error { return r.Err }
func (r getInHouseAppManifestResponse) HijackRender(ctx context.Context, w http.ResponseWriter) {
// make the browser download the content to a file
w.Header().Add("Content-Disposition", `attachment; filename="in-house-app-manifest.plist"`)
// explicitly set the content length before the write, so the caller can
// detect short writes (if it fails to send the full content properly)
w.Header().Set("Content-Length", strconv.FormatInt(int64(len(r.Manifest)), 10))
// this content type will make macos open the profile with the proper application
w.Header().Set("Content-Type", "application/x-apple-aspen-config; charset=utf-8")
// prevent detection of content, obey the provided content-type
w.Header().Set("X-Content-Type-Options", "nosniff")
if n, err := w.Write(r.Manifest); err != nil {
logging.WithExtras(ctx, "err", err, "written", n)
}
}
func getInHouseAppManifestEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*getInHouseAppManifestRequest)
manifest, err := svc.GetInHouseAppManifest(ctx, req.TitleID, req.TeamID)
if err != nil {
return &getInHouseAppManifestResponse{Err: err}, nil
}
return &getInHouseAppManifestResponse{Manifest: manifest}, nil
}
func (svc *Service) GetInHouseAppManifest(ctx context.Context, titleID uint, teamID *uint) ([]byte, error) {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)
return nil, fleet.ErrMissingLicense
}
type getInHouseAppPackageRequest struct {
TitleID uint `url:"title_id"`
TeamID *uint `query:"team_id" renameto:"fleet_id"`
}
type getInHouseAppPackageResponse struct {
payload *fleet.DownloadSoftwareInstallerPayload
Err error `json:"error,omitempty"`
}
func (r getInHouseAppPackageResponse) HijackRender(ctx context.Context, w http.ResponseWriter) {
w.Header().Set("Content-Length", strconv.Itoa(int(r.payload.Size)))
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment;filename="%s"`, r.payload.Filename))
// OK to just log the error here as writing anything on
// `http.ResponseWriter` sets the status code to 200 (and it can't be
// changed.) Clients should rely on matching content-length with the
// header provided
if n, err := io.Copy(w, r.payload.Installer); err != nil {
logging.WithExtras(ctx, "err", err, "bytes_copied", n)
}
r.payload.Installer.Close()
}
func (r getInHouseAppPackageResponse) Error() error { return r.Err }
func getInHouseAppPackageEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*getInHouseAppPackageRequest)
file, err := svc.GetInHouseAppPackage(ctx, req.TitleID, req.TeamID)
if err != nil {
return &getInHouseAppPackageResponse{Err: err}, nil
}
return &getInHouseAppPackageResponse{payload: file}, nil
}
func (svc *Service) GetInHouseAppPackage(ctx context.Context, titleID uint, teamID *uint) (*fleet.DownloadSoftwareInstallerPayload, error) {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)
return nil, fleet.ErrMissingLicense
}