fleet/client/device_client.go
Lucas Manuel Rodriguez 0b8c29198b
Make orbit and Fleet Desktop not depend on server/service/ packages (#42231)
Resolves #40396.

No changes file because there should be no user visible changes.

## Testing

- [x] QA'd all new/changed functionality manually

## fleetd/orbit/Fleet Desktop

- [x] Verified compatibility with the latest released version of Fleet
(see [Must
rule](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/workflows/fleetd-development-and-release-strategy.md))
- [X] Verified that fleetd runs on macOS, Linux and Windows
- [X] Verified auto-update works from the released version of component
to the new version (see [tools/tuf/test](../tools/tuf/test/README.md))
2026-03-26 10:59:42 -03:00

280 lines
8.9 KiB
Go

package client
import (
"bytes"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"github.com/fleetdm/fleet/v4/pkg/retry"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/rs/zerolog/log"
)
// DeviceClient is used consume the `device/...` endpoints and meant to be used by Fleet Desktop
type DeviceClient struct {
*BaseClient
// fleetAlternativeBrowserHostFromServer serves a similar purpose as fleetAlternativeBrowserHost, but this value is set
// on the Fleet server and takes precedence over it.
fleetAlternativeBrowserHostFromServer string
// fleetAlternativeBrowserHost is an alternative host to use for the Fleet Desktop URLs generated for the browser.
//
// This is needed when the host that Orbit will connect to is different from the host that will connect via the browser.
fleetAlternativeBrowserHost string
// if set and a request fails with ErrUnauthenticated, the client will call
// this function to get a fresh token and retry if it returns a different,
// non-empty token.
invalidTokenRetryFunc func() string
}
// NewDeviceClient instantiates a new client to perform requests against device endpoints.
func NewDeviceClient(addr string, insecureSkipVerify bool, rootCA string, fleetClientCrt *tls.Certificate, fleetAlternativeBrowserHost string) (*DeviceClient, error) {
capabilities := fleet.CapabilityMap{}
baseClient, err := NewBaseClient(addr, insecureSkipVerify, rootCA, "", fleetClientCrt, capabilities, nil)
if err != nil {
return nil, err
}
return &DeviceClient{
BaseClient: baseClient,
fleetAlternativeBrowserHost: fleetAlternativeBrowserHost,
}, nil
}
// WithInvalidTokenRetry sets the function to call if a request fails with ErrUnauthenticated.
func (dc *DeviceClient) WithInvalidTokenRetry(fn func() string) {
log.Debug().Msg("setting invalid token retry hook")
dc.invalidTokenRetryFunc = fn
}
// request performs the request, resolving the pathFmt that should contain a %s
// verb to be replaced with the token, or no verb at all if the token is "-"
// (the pathFmt is used as-is as path). It will retry if the request fails due
// to an invalid token and the invalidTokenRetryFunc field is set.
func (dc *DeviceClient) request(verb, pathFmt, token, query string, params any, responseDest any) error {
const maxAttempts = 4
var attempt int
for {
attempt++
path := pathFmt
if token != "-" {
path = fmt.Sprintf(pathFmt, token)
}
reqErr := dc.requestAttempt(verb, path, query, params, responseDest)
if attempt >= maxAttempts || dc.invalidTokenRetryFunc == nil || token == "-" || !errors.Is(reqErr, ErrUnauthenticated) {
// no retry possible, return the result
if reqErr != nil {
log.Debug().Msgf("not retrying API error; attempt=%d, hook set=%t, token unset=%t, error is auth=%t",
attempt, dc.invalidTokenRetryFunc != nil, token == "-", errors.Is(reqErr, ErrUnauthenticated))
}
return reqErr
}
delay := time.Duration(attempt) * time.Second
log.Debug().Msgf("retrying API error in %s", delay)
time.Sleep(delay)
newToken := dc.invalidTokenRetryFunc()
log.Debug().Msgf("retrying API error; token is different=%t", newToken != "" && newToken != token)
if newToken != "" {
token = newToken
}
}
}
func (dc *DeviceClient) requestAttempt(verb string, path string, query string, params any, responseDest any) error {
var bodyBytes []byte
var err error
if params != nil {
bodyBytes, err = json.Marshal(params)
if err != nil {
return fmt.Errorf("making request json marshalling : %w", err)
}
}
request, err := http.NewRequest(
verb,
dc.URL(path, query).String(),
bytes.NewBuffer(bodyBytes),
)
if err != nil {
return err
}
dc.SetClientCapabilitiesHeader(request)
response, err := dc.DoHTTPRequest(request)
if err != nil {
return fmt.Errorf("%s %s: %w", verb, path, err)
}
defer response.Body.Close()
return dc.ParseResponse(verb, path, response, responseDest)
}
// getAlternativeBrowserHostSetting overrides portions of the provided URL based on the values of
// fleetAlternativeBrowserHostFromServer or fleetAlternativeBrowserHost
func (dc *DeviceClient) getAlternativeBrowserHostSetting() string {
if dc.fleetAlternativeBrowserHostFromServer != "" {
return dc.fleetAlternativeBrowserHostFromServer
}
return dc.fleetAlternativeBrowserHost
}
// BrowserTransparencyURL returns a URL for the browser that the server
// will use to redirect to the transparency URL configured by the user.
func (dc *DeviceClient) BrowserTransparencyURL(token string) string {
transparencyURL := dc.BaseClient.URL("/api/latest/fleet/device/"+token+"/transparency", "")
if altHost := dc.getAlternativeBrowserHostSetting(); altHost != "" {
transparencyURL.Host = altHost
}
return transparencyURL.String()
}
// BrowserSelfServiceURL returns the "Self-service" URL for the browser.
func (dc *DeviceClient) BrowserSelfServiceURL(token string) string {
selfServiceURL := dc.BaseClient.URL("/device/"+token+"/self-service", "")
if altHost := dc.getAlternativeBrowserHostSetting(); altHost != "" {
selfServiceURL.Host = altHost
}
return selfServiceURL.String()
}
// BrowserDeviceURL returns the "My device" URL for the browser.
func (dc *DeviceClient) BrowserDeviceURL(token string) string {
deviceURL := dc.BaseClient.URL("/device/"+token, "")
if altHost := dc.getAlternativeBrowserHostSetting(); altHost != "" {
deviceURL.Host = altHost
}
return deviceURL.String()
}
// BrowserPoliciesURL returns the "Policies" URL for the browser.
func (dc *DeviceClient) BrowserPoliciesURL(token string) string {
policiesURL := dc.BaseClient.URL(fmt.Sprintf(`/device/%s/policies`, token), "")
if altHost := dc.getAlternativeBrowserHostSetting(); altHost != "" {
policiesURL.Host = altHost
}
return policiesURL.String()
}
// CheckToken checks if a token is valid by making an authenticated request to the server.
func (dc *DeviceClient) CheckToken(token string) error {
verb, path := "HEAD", "/api/latest/fleet/device/%s/ping"
err := dc.request(verb, path, token, "", nil, nil)
if IsNotFoundErr(err) {
_, err = dc.DesktopSummary(token)
}
return err
}
// Ping sends a ping to the server using the device/ping endpoint.
func (dc *DeviceClient) Ping() error {
verb, path := "HEAD", "/api/fleet/device/ping"
err := dc.request(verb, path, "-", "", nil, nil)
if err == nil || IsNotFoundErr(err) {
return nil
}
return err
}
// listDevicePoliciesResponse is a local response type for deserializing the device policies response.
// Definition duplicated for now (orbit should not depend server/service).
type listDevicePoliciesResponse struct {
Err error `json:"error,omitempty"`
Policies []*fleet.HostPolicy `json:"policies"`
}
func (r listDevicePoliciesResponse) Error() error { return r.Err }
func (dc *DeviceClient) getListDevicePolicies(token string) ([]*fleet.HostPolicy, error) {
verb, path := "GET", "/api/latest/fleet/device/%s/policies"
var responseBody listDevicePoliciesResponse
err := dc.request(verb, path, token, "", nil, &responseBody)
return responseBody.Policies, err
}
// fleetDesktopResponse is a local response type for deserializing the desktop summary response.
type fleetDesktopResponse struct {
Err error `json:"error,omitempty"`
fleet.DesktopSummary
}
func (r fleetDesktopResponse) Error() error { return r.Err }
func (dc *DeviceClient) getMinDesktopPayload(token string) (fleetDesktopResponse, error) {
verb, path := "GET", "/api/latest/fleet/device/%s/desktop"
var r fleetDesktopResponse
err := dc.request(verb, path, token, "", nil, &r)
return r, err
}
func (dc *DeviceClient) DesktopSummary(token string) (*fleetDesktopResponse, error) {
r, err := dc.getMinDesktopPayload(token)
if err == nil {
r.FailingPolicies = new(uintValueOrZero(r.FailingPolicies))
dc.fleetAlternativeBrowserHostFromServer = r.AlternativeBrowserHost
return &r, nil
}
if IsNotFoundErr(err) {
policies, err := dc.getListDevicePolicies(token)
if err != nil {
return nil, err
}
var failingPolicies uint
for _, policy := range policies {
if policy.Response != "pass" {
failingPolicies++
}
}
return &fleetDesktopResponse{
DesktopSummary: fleet.DesktopSummary{
FailingPolicies: new(failingPolicies),
},
}, nil
}
return nil, err
}
func (dc *DeviceClient) MigrateMDM(token string) error {
verb, path := "POST", "/api/latest/fleet/device/%s/migrate_mdm"
return dc.request(verb, path, token, "", nil, nil)
}
// fleetdErrorRequest is a local request type for the error reporting endpoint.
type fleetdErrorRequest struct {
FleetdError fleet.FleetdError `json:"error"`
}
func (dc *DeviceClient) ReportError(token string, fleetdErr fleet.FleetdError) error {
verb, path := "POST", "/api/latest/fleet/device/%s/debug/errors"
req := fleetdErrorRequest{FleetdError: fleetdErr}
return retry.Do(
func() error {
err := dc.request(verb, path, token, "", req, nil)
if err != nil {
return err
}
return nil
},
retry.WithMaxAttempts(3),
retry.WithInterval(15*time.Second),
)
}
func uintValueOrZero(v *uint) uint {
if v == nil {
return 0
}
return *v
}