mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 21:47:20 +00:00
**Related issue:** Resolves #33695 When trying to match package icons, first try to match by hash then by URL. # Checklist for submitter If some of the following don't apply, delete the relevant line. - [X] 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. ## Testing - [X] QA'd all new/changed functionality manually --------- Co-authored-by: Ian Littman <iansltx@gmail.com>
249 lines
7.9 KiB
Go
249 lines
7.9 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/url"
|
|
"time"
|
|
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
)
|
|
|
|
// ListSoftwareVersions retrieves the software versions installed on hosts.
|
|
func (c *Client) ListSoftwareVersions(query string) ([]fleet.Software, error) {
|
|
verb, path := "GET", "/api/latest/fleet/software/versions"
|
|
var responseBody listSoftwareVersionsResponse
|
|
err := c.authenticatedRequestWithQuery(nil, verb, path, &responseBody, query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return responseBody.Software, nil
|
|
}
|
|
|
|
// ListSoftwareTitles retrieves the software titles installed on hosts.
|
|
func (c *Client) ListSoftwareTitles(query string) ([]fleet.SoftwareTitleListResult, error) {
|
|
verb, path := "GET", "/api/latest/fleet/software/titles"
|
|
var responseBody listSoftwareTitlesResponse
|
|
err := c.authenticatedRequestWithQuery(nil, verb, path, &responseBody, query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return responseBody.SoftwareTitles, nil
|
|
}
|
|
|
|
// Get the software titles available for the setup experience.
|
|
func (c *Client) GetSetupExperienceSoftware(platform string, teamID uint) ([]fleet.SoftwareTitleListResult, error) {
|
|
verb, path := "GET", "/api/latest/fleet/setup_experience/software"
|
|
var responseBody getSetupExperienceSoftwareResponse
|
|
query := fmt.Sprintf("platform=%s&team_id=%d", platform, teamID)
|
|
err := c.authenticatedRequestWithQuery(nil, verb, path, &responseBody, query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return responseBody.SoftwareTitles, nil
|
|
}
|
|
|
|
// GetSoftwareTitleByID retrieves a software title by ID.
|
|
//
|
|
//nolint:gocritic // ignore captLocal
|
|
func (c *Client) GetSoftwareTitleByID(ID uint, teamID *uint) (*fleet.SoftwareTitle, error) {
|
|
var query string
|
|
if teamID != nil {
|
|
query = fmt.Sprintf("team_id=%d", *teamID)
|
|
}
|
|
verb, path := "GET", "/api/latest/fleet/software/titles/"+fmt.Sprint(ID)
|
|
var responseBody getSoftwareTitleResponse
|
|
err := c.authenticatedRequestWithQuery(nil, verb, path, &responseBody, query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return responseBody.SoftwareTitle, nil
|
|
}
|
|
|
|
func (c *Client) GetSoftwareTitleIcon(titleID uint, teamID uint) ([]byte, error) {
|
|
verb, path := "GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d/icon", titleID)
|
|
response, err := c.AuthenticatedDo(verb, path, fmt.Sprintf("team_id=%d", teamID), nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s %s: %w", verb, path, err)
|
|
}
|
|
defer response.Body.Close()
|
|
err = c.parseResponse(verb, path, response, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parsing icon response: %w", err)
|
|
}
|
|
if response.StatusCode != http.StatusNoContent {
|
|
b, err := io.ReadAll(response.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading response body: %w", err)
|
|
}
|
|
return b, nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (c *Client) ApplyNoTeamSoftwareInstallers(softwareInstallers []fleet.SoftwareInstallerPayload, opts fleet.ApplySpecOptions) ([]fleet.SoftwarePackageResponse, error) {
|
|
query, err := url.ParseQuery(opts.RawQuery())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return c.applySoftwareInstallers(softwareInstallers, query, opts.DryRun)
|
|
}
|
|
|
|
func (c *Client) applySoftwareInstallers(softwareInstallers []fleet.SoftwareInstallerPayload, query url.Values, dryRun bool) ([]fleet.SoftwarePackageResponse, error) {
|
|
path := "/api/latest/fleet/software/batch"
|
|
var resp batchSetSoftwareInstallersResponse
|
|
if err := c.authenticatedRequestWithQuery(map[string]any{"software": softwareInstallers}, "POST", path, &resp, query.Encode()); err != nil {
|
|
return nil, err
|
|
}
|
|
if dryRun && resp.RequestUUID == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
requestUUID := resp.RequestUUID
|
|
for {
|
|
var resp batchSetSoftwareInstallersResultResponse
|
|
if err := c.authenticatedRequestWithQuery(nil, "GET", path+"/"+requestUUID, &resp, query.Encode()); err != nil {
|
|
return nil, err
|
|
}
|
|
switch {
|
|
case resp.Status == fleet.BatchSetSoftwareInstallersStatusProcessing:
|
|
time.Sleep(1 * time.Second)
|
|
case resp.Status == fleet.BatchSetSoftwareInstallersStatusFailed:
|
|
return nil, errors.New(resp.Message)
|
|
case resp.Status == fleet.BatchSetSoftwareInstallersStatusCompleted:
|
|
return matchPackageIcons(softwareInstallers, resp.Packages), nil
|
|
default:
|
|
return nil, fmt.Errorf("unknown status: %q", resp.Status)
|
|
}
|
|
}
|
|
}
|
|
|
|
// matchPackageIcons hydrates software responses with references to icons in the request payload, so we can track
|
|
// which API calls to make to add/update/delete icons
|
|
func matchPackageIcons(request []fleet.SoftwareInstallerPayload, response []fleet.SoftwarePackageResponse) []fleet.SoftwarePackageResponse {
|
|
// On the client side, software installer entries can have a URL or a hash or both ...
|
|
byURL := make(map[string]*fleet.SoftwareInstallerPayload)
|
|
byHash := make(map[string]*fleet.SoftwareInstallerPayload)
|
|
|
|
for i := range request {
|
|
clientSide := &request[i]
|
|
|
|
if clientSide.URL != "" {
|
|
byURL[clientSide.URL] = clientSide
|
|
}
|
|
if clientSide.SHA256 != "" {
|
|
byHash[clientSide.SHA256] = clientSide
|
|
}
|
|
}
|
|
|
|
for i := range response {
|
|
serverSide := &response[i]
|
|
|
|
// All server side entries have a hash, so first try to match by that
|
|
if clientSide, ok := byHash[serverSide.HashSHA256]; ok {
|
|
serverSide.LocalIconHash = clientSide.IconHash
|
|
serverSide.LocalIconPath = clientSide.IconPath
|
|
continue
|
|
}
|
|
|
|
// ... Then by URL
|
|
if clientSide, ok := byURL[serverSide.URL]; ok {
|
|
serverSide.LocalIconHash = clientSide.IconHash
|
|
serverSide.LocalIconPath = clientSide.IconPath
|
|
}
|
|
}
|
|
|
|
return response
|
|
}
|
|
|
|
func (c *Client) UploadIcon(teamID uint, titleID uint, filename string, iconReader io.Reader) error {
|
|
var buf bytes.Buffer
|
|
writer := multipart.NewWriter(&buf)
|
|
fileWriter, err := writer.CreateFormFile("icon", filename)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err = io.Copy(fileWriter, iconReader); err != nil {
|
|
return err
|
|
}
|
|
// Close the writer before using the buffer
|
|
if err := writer.Close(); err != nil {
|
|
return err
|
|
}
|
|
|
|
return c.putIcon(teamID, titleID, writer, buf)
|
|
}
|
|
|
|
func (c *Client) UpdateIcon(teamID uint, titleID uint, filename string, hash string) error {
|
|
var buf bytes.Buffer
|
|
writer := multipart.NewWriter(&buf)
|
|
if err := writer.WriteField("hash_sha256", hash); err != nil {
|
|
return err
|
|
}
|
|
if err := writer.WriteField("filename", filename); err != nil {
|
|
return err
|
|
}
|
|
// Close the writer before using the buffer
|
|
if err := writer.Close(); err != nil {
|
|
return err
|
|
}
|
|
|
|
return c.putIcon(teamID, titleID, writer, buf)
|
|
}
|
|
|
|
func (c *Client) putIcon(teamID uint, titleID uint, writer *multipart.Writer, buf bytes.Buffer) error {
|
|
response, err := c.doContextWithBodyAndHeaders(
|
|
context.Background(),
|
|
"PUT",
|
|
fmt.Sprintf("/api/latest/fleet/software/titles/%d/icon", titleID),
|
|
fmt.Sprintf("team_id=%d", teamID),
|
|
buf.Bytes(),
|
|
map[string]string{
|
|
"Content-Type": writer.FormDataContentType(),
|
|
"Accept": "application/json",
|
|
"Authorization": fmt.Sprintf("Bearer %s", c.token),
|
|
},
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("do multipart request: %w", err)
|
|
}
|
|
defer response.Body.Close()
|
|
|
|
if response.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("update icon: unexpected status code: %d", response.StatusCode)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) DeleteIcon(teamID uint, titleID uint) error {
|
|
response, err := c.AuthenticatedDo(
|
|
"DELETE",
|
|
fmt.Sprintf("/api/latest/fleet/software/titles/%d/icon", titleID),
|
|
fmt.Sprintf("team_id=%d", teamID),
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("delete icon: %w", err)
|
|
}
|
|
defer response.Body.Close()
|
|
|
|
if response.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("delete icon: unexpected status code: %d", response.StatusCode)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// InstallSoftware triggers a software installation (VPP or software package)
|
|
// on the specified host.
|
|
func (c *Client) InstallSoftware(hostID uint, softwareTitleID uint) error {
|
|
verb, path := "POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", hostID, softwareTitleID)
|
|
var responseBody installSoftwareResponse
|
|
return c.authenticatedRequest(nil, verb, path, &responseBody)
|
|
}
|