mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
**Related issue:** Resolves #41379 # 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] Added/updated automated tests - [ ] Where appropriate, [automated tests simulate multiple hosts and test for host isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing) (updates to one hosts's records do not affect another) - [ ] 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] If the change applies to only one platform, confirmed that `runtime.GOOS` is used as needed to isolate changes - [ ] Verified that fleetd runs on macOS, Linux and Windows - [ ] Verified auto-update works from the released version of component to the new version (see [tools/tuf/test](../tools/tuf/test/README.md)) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added EUA token support to Orbit enrollment workflow * Introduced `--eua-token` CLI flag for Windows MDM enrollment * Windows MSI packages now support EUA_TOKEN property (Orbit v1.55.0+) * **Tests** * Added tests for EUA token handling in enrollment and Windows packaging * **Documentation** * Added changelog entry documenting EUA token inclusion in enrollment requests <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
778 lines
24 KiB
Go
778 lines
24 KiB
Go
package client
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"mime"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptrace"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/fleetdm/fleet/v4/orbit/pkg/constant"
|
|
"github.com/fleetdm/fleet/v4/orbit/pkg/logging"
|
|
"github.com/fleetdm/fleet/v4/orbit/pkg/luks"
|
|
"github.com/fleetdm/fleet/v4/orbit/pkg/platform"
|
|
"github.com/fleetdm/fleet/v4/pkg/retry"
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
// OrbitClient exposes the Orbit API to communicate with the Fleet server.
|
|
type OrbitClient struct {
|
|
*BaseClient
|
|
nodeKeyFilePath string
|
|
enrollSecret string
|
|
hostInfo fleet.OrbitHostInfo
|
|
|
|
enrolledMu sync.Mutex
|
|
enrolled bool
|
|
|
|
lastRecordedErrMu sync.Mutex
|
|
lastRecordedErr error
|
|
|
|
configCache configCache
|
|
onGetConfigErrFns *OnGetConfigErrFuncs
|
|
lastNetErrOnGetConfigLogged time.Time
|
|
|
|
lastIdleConnectionsCleanupMu sync.Mutex
|
|
lastIdleConnectionsCleanup time.Time
|
|
|
|
// TestNodeKey is used for testing only.
|
|
TestNodeKey string
|
|
|
|
// Interfaces that will receive updated configs
|
|
ConfigReceivers []fleet.OrbitConfigReceiver
|
|
// How frequently a new config will be fetched
|
|
ReceiverUpdateInterval time.Duration
|
|
// receiverUpdateContext used by ExecuteConfigReceivers to cancel the update loop.
|
|
receiverUpdateContext context.Context
|
|
// receiverUpdateCancelFunc is used to cancel receiverUpdateContext.
|
|
receiverUpdateCancelFunc context.CancelFunc
|
|
|
|
// euaToken is a one-time Fleet-signed JWT from Windows MDM enrollment,
|
|
// sent during orbit enrollment to link the IdP account without prompting.
|
|
euaToken string
|
|
|
|
// hostIdentityCertPath is the file path to the host identity certificate issued using SCEP.
|
|
//
|
|
// If set then it will be deleted on HTTP 401 errors from Fleet and it will cause ExecuteConfigReceivers
|
|
// to terminate to trigger a restart.
|
|
hostIdentityCertPath string
|
|
|
|
// initiatedIdpAuth is a flag indicating whether a window has been opened
|
|
// to the sign-on page for the organization's Identity Provider.
|
|
initiatedIdpAuth bool
|
|
|
|
// openSSOWindow is a function that opens a browser window to the SSO URL.
|
|
openSSOWindow func() error
|
|
}
|
|
|
|
// time-to-live for config cache
|
|
const configCacheTTL = 3 * time.Second
|
|
|
|
type configCache struct {
|
|
mu sync.Mutex
|
|
lastUpdated time.Time
|
|
config *fleet.OrbitConfig
|
|
err error
|
|
}
|
|
|
|
func (oc *OrbitClient) SetOpenSSOWindowFunc(f func() error) {
|
|
oc.openSSOWindow = f
|
|
}
|
|
|
|
func (oc *OrbitClient) request(verb string, path string, params any, resp any) error {
|
|
return oc.requestWithExternal(verb, path, params, resp, false)
|
|
}
|
|
|
|
// requestWithExternal is used to make requests to Fleet or external URLs. If external is true, the pathOrURL
|
|
// is used as the full URL to make the request to.
|
|
func (oc *OrbitClient) requestWithExternal(verb string, pathOrURL string, params any, resp any, external bool) 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)
|
|
}
|
|
}
|
|
|
|
oc.closeIdleConnections()
|
|
|
|
ctx := context.Background()
|
|
if os.Getenv("FLEETD_TEST_HTTPTRACE") == "1" {
|
|
ctx = httptrace.WithClientTrace(ctx, testStdoutHTTPTracer)
|
|
}
|
|
|
|
var request *http.Request
|
|
if external {
|
|
request, err = http.NewRequestWithContext(
|
|
ctx,
|
|
verb,
|
|
pathOrURL,
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
parsedURL, err := url.Parse(pathOrURL)
|
|
if err != nil {
|
|
return fmt.Errorf("parsing URL: %w", err)
|
|
}
|
|
|
|
request, err = http.NewRequestWithContext(
|
|
ctx,
|
|
verb,
|
|
oc.URL(parsedURL.Path, parsedURL.RawQuery).String(),
|
|
bytes.NewBuffer(bodyBytes),
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
oc.SetClientCapabilitiesHeader(request)
|
|
}
|
|
response, err := oc.DoHTTPRequest(request)
|
|
if err != nil {
|
|
oc.setLastRecordedError(err)
|
|
return fmt.Errorf("%s %s: %w", verb, pathOrURL, err)
|
|
}
|
|
defer response.Body.Close()
|
|
|
|
if err := oc.ParseResponse(verb, pathOrURL, response, resp); err != nil {
|
|
oc.setLastRecordedError(err)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// OnGetConfigErrFuncs defines functions to be executed on GetConfig errors.
|
|
type OnGetConfigErrFuncs struct {
|
|
// OnNetErrFunc receives network and 5XX errors on GetConfig requests.
|
|
// These errors are rate limited to once every 5 minutes.
|
|
OnNetErrFunc func(err error)
|
|
// DebugErrFunc receives all errors on GetConfig requests.
|
|
DebugErrFunc func(err error)
|
|
}
|
|
|
|
var (
|
|
netErrInterval = 5 * time.Minute
|
|
configRetryOnNetworkError = 30 * time.Second
|
|
defaultOrbitConfigReceiverInterval = 30 * time.Second
|
|
)
|
|
|
|
// NewOrbitClient creates a new OrbitClient.
|
|
//
|
|
// - rootDir is the Orbit's root directory, where the Orbit node key is loaded-from/stored.
|
|
// - addr is the address of the Fleet server.
|
|
// - orbitHostInfo is the host system information used for enrolling to Fleet.
|
|
// - onGetConfigErrFns can be used to handle errors in the GetConfig request.
|
|
func NewOrbitClient(
|
|
rootDir string,
|
|
addr string,
|
|
rootCA string,
|
|
insecureSkipVerify bool,
|
|
enrollSecret string,
|
|
fleetClientCert *tls.Certificate,
|
|
orbitHostInfo fleet.OrbitHostInfo,
|
|
onGetConfigErrFns *OnGetConfigErrFuncs,
|
|
httpSignerWrapper func(*http.Client) *http.Client,
|
|
hostIdentityCertPath string,
|
|
) (*OrbitClient, error) {
|
|
orbitCapabilities := fleet.GetOrbitClientCapabilities()
|
|
bc, err := NewBaseClient(addr, insecureSkipVerify, rootCA, "", fleetClientCert, orbitCapabilities, httpSignerWrapper)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
nodeKeyFilePath := filepath.Join(rootDir, constant.OrbitNodeKeyFileName)
|
|
ctx, cancelFunc := context.WithCancel(context.Background())
|
|
|
|
return &OrbitClient{
|
|
nodeKeyFilePath: nodeKeyFilePath,
|
|
BaseClient: bc,
|
|
enrollSecret: enrollSecret,
|
|
hostInfo: orbitHostInfo,
|
|
enrolled: false,
|
|
onGetConfigErrFns: onGetConfigErrFns,
|
|
lastIdleConnectionsCleanup: time.Now(),
|
|
ReceiverUpdateInterval: defaultOrbitConfigReceiverInterval,
|
|
receiverUpdateContext: ctx,
|
|
receiverUpdateCancelFunc: cancelFunc,
|
|
hostIdentityCertPath: hostIdentityCertPath,
|
|
}, nil
|
|
}
|
|
|
|
// SetEUAToken sets a one-time EUA token to include in the enrollment request.
|
|
func (oc *OrbitClient) SetEUAToken(token string) {
|
|
oc.euaToken = token
|
|
}
|
|
|
|
// TriggerOrbitRestart triggers a orbit process restart.
|
|
func (oc *OrbitClient) TriggerOrbitRestart(reason string) {
|
|
log.Info().Msgf("orbit restart triggered: %s", reason)
|
|
oc.receiverUpdateCancelFunc()
|
|
}
|
|
|
|
// RestartTriggered returns true if any of the config receivers triggered an orbit restart.
|
|
func (oc *OrbitClient) RestartTriggered() bool {
|
|
select {
|
|
case <-oc.receiverUpdateContext.Done():
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// closeIdleConnections attempts to close idle connections from the pool every 55 minutes.
|
|
//
|
|
// Some load balancers (e.g. AWS ELB) have a maximum lifetime for a connection
|
|
// (no matter if the connection is active or not) and will forcefully close the
|
|
// connection causing errors in the client (e.g. https://github.com/fleetdm/fleet/issues/18783).
|
|
// To prevent these errors, we will attempt to cleanup idle connections every 55
|
|
// minutes to not let these connection grow too old. (AWS ELB's default value for maximum
|
|
// lifetime of a connection is 3600 seconds.)
|
|
func (oc *OrbitClient) closeIdleConnections() {
|
|
oc.lastIdleConnectionsCleanupMu.Lock()
|
|
defer oc.lastIdleConnectionsCleanupMu.Unlock()
|
|
|
|
if time.Since(oc.lastIdleConnectionsCleanup) < 55*time.Minute {
|
|
return
|
|
}
|
|
|
|
oc.lastIdleConnectionsCleanup = time.Now()
|
|
|
|
rawClient := oc.GetRawHTTPClient()
|
|
c, ok := rawClient.(*http.Client)
|
|
if !ok {
|
|
return
|
|
}
|
|
t, ok := c.Transport.(*http.Transport)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
t.CloseIdleConnections()
|
|
}
|
|
|
|
func (oc *OrbitClient) RunConfigReceivers() error {
|
|
config, err := oc.GetConfig()
|
|
if err != nil {
|
|
return fmt.Errorf("RunConfigReceivers get config: %w", err)
|
|
}
|
|
|
|
var errs []error
|
|
var errMu sync.Mutex
|
|
var wg sync.WaitGroup
|
|
wg.Add(len(oc.ConfigReceivers))
|
|
|
|
for _, receiver := range oc.ConfigReceivers {
|
|
go func() {
|
|
defer func() {
|
|
if err := recover(); err != nil {
|
|
errMu.Lock()
|
|
errs = append(errs, fmt.Errorf("panic occured in receiver: %v", err))
|
|
errMu.Unlock()
|
|
}
|
|
wg.Done()
|
|
}()
|
|
|
|
err := receiver.Run(config)
|
|
if err != nil {
|
|
errMu.Lock()
|
|
errs = append(errs, err)
|
|
errMu.Unlock()
|
|
}
|
|
}()
|
|
}
|
|
|
|
wg.Wait()
|
|
|
|
if len(errs) != 0 {
|
|
return errors.Join(errs...)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (oc *OrbitClient) RegisterConfigReceiver(cr fleet.OrbitConfigReceiver) {
|
|
oc.ConfigReceivers = append(oc.ConfigReceivers, cr)
|
|
}
|
|
|
|
func (oc *OrbitClient) ExecuteConfigReceivers() error {
|
|
ticker := time.NewTicker(oc.ReceiverUpdateInterval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-oc.receiverUpdateContext.Done():
|
|
return nil
|
|
case <-ticker.C:
|
|
if err := oc.RunConfigReceivers(); err != nil {
|
|
log.Error().Err(err).Msg("running config receivers")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (oc *OrbitClient) InterruptConfigReceivers(err error) {
|
|
oc.receiverUpdateCancelFunc()
|
|
}
|
|
|
|
// GetConfig returns the Orbit config fetched from Fleet server for this instance of OrbitClient.
|
|
// Since this method is called in multiple places, we use a cache with configCacheTTL time-to-live
|
|
// to reduce traffic to the Fleet server.
|
|
// Upon network errors, this method will retry the get config request (every 30 seconds).
|
|
func (oc *OrbitClient) GetConfig() (*fleet.OrbitConfig, error) {
|
|
oc.configCache.mu.Lock()
|
|
defer oc.configCache.mu.Unlock()
|
|
|
|
// If time-to-live passed, we update the config cache
|
|
now := time.Now()
|
|
if now.After(oc.configCache.lastUpdated.Add(configCacheTTL)) {
|
|
verb, path := "POST", "/api/fleet/orbit/config"
|
|
var (
|
|
resp fleet.OrbitConfig
|
|
err error
|
|
)
|
|
// Retry until we don't get a network error or a 5XX error.
|
|
_ = retry.Do(func() error {
|
|
err = oc.authenticatedRequest(verb, path, &fleet.OrbitGetConfigRequest{}, &resp)
|
|
var (
|
|
netErr net.Error
|
|
statusCodeErr *StatusCodeErr
|
|
)
|
|
if err != nil && oc.onGetConfigErrFns != nil && oc.onGetConfigErrFns.DebugErrFunc != nil {
|
|
oc.onGetConfigErrFns.DebugErrFunc(err)
|
|
}
|
|
if errors.As(err, &netErr) || (errors.As(err, &statusCodeErr) && statusCodeErr.StatusCode() >= 500) {
|
|
now := time.Now()
|
|
if oc.onGetConfigErrFns != nil && oc.onGetConfigErrFns.OnNetErrFunc != nil && now.After(oc.lastNetErrOnGetConfigLogged.Add(netErrInterval)) {
|
|
oc.onGetConfigErrFns.OnNetErrFunc(err)
|
|
oc.lastNetErrOnGetConfigLogged = now
|
|
}
|
|
return err // retry on network or server 5XX errors
|
|
}
|
|
return nil
|
|
}, retry.WithInterval(configRetryOnNetworkError))
|
|
oc.configCache.config = &resp
|
|
oc.configCache.err = err
|
|
oc.configCache.lastUpdated = now
|
|
}
|
|
return oc.configCache.config, oc.configCache.err
|
|
}
|
|
|
|
// SetOrUpdateDeviceToken sends a request to the server to set or update the device token.
|
|
func (oc *OrbitClient) SetOrUpdateDeviceToken(deviceAuthToken string) error {
|
|
verb, path := "POST", "/api/fleet/orbit/device_token"
|
|
params := fleet.SetOrUpdateDeviceTokenRequest{
|
|
DeviceAuthToken: deviceAuthToken,
|
|
}
|
|
var resp fleet.SetOrUpdateDeviceTokenResponse
|
|
if err := oc.authenticatedRequest(verb, path, ¶ms, &resp); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SetOrUpdateDeviceMappingEmail sends a request to the server to set or update the device mapping email.
|
|
func (oc *OrbitClient) SetOrUpdateDeviceMappingEmail(email string) error {
|
|
verb, path := "PUT", "/api/fleet/orbit/device_mapping"
|
|
params := fleet.OrbitPutDeviceMappingRequest{
|
|
Email: email,
|
|
}
|
|
var resp fleet.OrbitPutDeviceMappingResponse
|
|
if err := oc.authenticatedRequest(verb, path, ¶ms, &resp); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetHostScript returns the script fetched from Fleet server to run on this host.
|
|
func (oc *OrbitClient) GetHostScript(execID string) (*fleet.HostScriptResult, error) {
|
|
verb, path := "POST", "/api/fleet/orbit/scripts/request"
|
|
var resp fleet.OrbitGetScriptResponse
|
|
if err := oc.authenticatedRequest(verb, path, &fleet.OrbitGetScriptRequest{
|
|
ExecutionID: execID,
|
|
}, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
return resp.HostScriptResult, nil
|
|
}
|
|
|
|
// SaveHostScriptResult saves the result of running the script on this host.
|
|
func (oc *OrbitClient) SaveHostScriptResult(result *fleet.HostScriptResultPayload) error {
|
|
verb, path := "POST", "/api/fleet/orbit/scripts/result"
|
|
var resp fleet.OrbitPostScriptResultResponse
|
|
if err := oc.authenticatedRequest(verb, path, &fleet.OrbitPostScriptResultRequest{
|
|
HostScriptResultPayload: result,
|
|
}, &resp); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (oc *OrbitClient) GetInstallerDetails(installId string) (*fleet.SoftwareInstallDetails, error) {
|
|
verb, path := "POST", "/api/fleet/orbit/software_install/details"
|
|
var resp fleet.OrbitGetSoftwareInstallResponse
|
|
if err := oc.authenticatedRequest(verb, path, &fleet.OrbitGetSoftwareInstallRequest{
|
|
InstallUUID: installId,
|
|
}, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
return resp.SoftwareInstallDetails, nil
|
|
}
|
|
|
|
func (oc *OrbitClient) SaveInstallerResult(payload *fleet.HostSoftwareInstallResultPayload) error {
|
|
verb, path := "POST", "/api/fleet/orbit/software_install/result"
|
|
var resp fleet.OrbitPostSoftwareInstallResultResponse
|
|
if err := oc.authenticatedRequest(verb, path, &fleet.OrbitPostSoftwareInstallResultRequest{
|
|
HostSoftwareInstallResultPayload: payload,
|
|
}, &resp); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (oc *OrbitClient) DownloadSoftwareInstaller(installerID uint, downloadDirectory string, progressFunc func(n int)) (string, error) {
|
|
verb, path := "POST", "/api/fleet/orbit/software_install/package?alt=media"
|
|
resp := FileResponse{
|
|
DestPath: downloadDirectory,
|
|
ProgressFunc: progressFunc,
|
|
}
|
|
if err := oc.authenticatedRequest(verb, path, &fleet.OrbitDownloadSoftwareInstallerRequest{
|
|
InstallerID: installerID,
|
|
}, &resp); err != nil {
|
|
return "", err
|
|
}
|
|
return resp.GetFilePath(), nil
|
|
}
|
|
|
|
func (oc *OrbitClient) DownloadSoftwareInstallerFromURL(url string, filename string, downloadDirectory string, progressFunc func(int)) (string, error) {
|
|
resp := FileResponse{
|
|
DestPath: downloadDirectory,
|
|
DestFile: filename,
|
|
SkipMediaType: true,
|
|
ProgressFunc: progressFunc,
|
|
}
|
|
if err := oc.requestWithExternal("GET", url, nil, &resp, true); err != nil {
|
|
return "", err
|
|
}
|
|
return resp.GetFilePath(), nil
|
|
}
|
|
|
|
// NullFileResponse discards downloaded file content.
|
|
type NullFileResponse struct{}
|
|
|
|
func (f *NullFileResponse) Handle(resp *http.Response) error {
|
|
_, _, err := mime.ParseMediaType(resp.Header.Get("Content-Disposition"))
|
|
if err != nil {
|
|
return fmt.Errorf("parsing media type from response header: %w", err)
|
|
}
|
|
_, err = io.Copy(io.Discard, resp.Body)
|
|
if err != nil {
|
|
return fmt.Errorf("copying from http stream to io.Discard: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DownloadAndDiscardSoftwareInstaller downloads the software installer and discards it.
|
|
// This method is used during load testing by osquery-perf.
|
|
func (oc *OrbitClient) DownloadAndDiscardSoftwareInstaller(installerID uint) error {
|
|
verb, path := "POST", "/api/fleet/orbit/software_install/package?alt=media"
|
|
resp := NullFileResponse{}
|
|
return oc.authenticatedRequest(verb, path, &fleet.OrbitDownloadSoftwareInstallerRequest{
|
|
InstallerID: installerID,
|
|
}, &resp)
|
|
}
|
|
|
|
// Ping sends a ping request to the orbit/ping endpoint.
|
|
func (oc *OrbitClient) Ping() error {
|
|
verb, path := "HEAD", "/api/fleet/orbit/ping"
|
|
err := oc.request(verb, path, nil, nil)
|
|
if err == nil || IsNotFoundErr(err) {
|
|
// notFound is ok, it means an old server without the capabilities header
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (oc *OrbitClient) enroll() (string, error) {
|
|
verb, path := "POST", "/api/fleet/orbit/enroll"
|
|
params := fleet.EnrollOrbitRequest{
|
|
EnrollSecret: oc.enrollSecret,
|
|
HardwareUUID: oc.hostInfo.HardwareUUID,
|
|
HardwareSerial: oc.hostInfo.HardwareSerial,
|
|
Hostname: oc.hostInfo.Hostname,
|
|
Platform: oc.hostInfo.Platform,
|
|
PlatformLike: oc.hostInfo.PlatformLike,
|
|
OsqueryIdentifier: oc.hostInfo.OsqueryIdentifier,
|
|
ComputerName: oc.hostInfo.ComputerName,
|
|
HardwareModel: oc.hostInfo.HardwareModel,
|
|
EUAToken: oc.euaToken,
|
|
}
|
|
var resp fleet.EnrollOrbitResponse
|
|
err := oc.request(verb, path, params, &resp)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return resp.OrbitNodeKey, nil
|
|
}
|
|
|
|
// enrollLock helps protect the enrolling process in case mutliple OrbitClients
|
|
// want to re-enroll at the same time.
|
|
var enrollLock sync.Mutex
|
|
|
|
// getNodeKeyOrEnroll attempts to read the orbit node key if the file exists on disk
|
|
// otherwise it enrolls the host with Fleet and saves the node key to disk
|
|
func (oc *OrbitClient) getNodeKeyOrEnroll() (string, error) {
|
|
if oc.TestNodeKey != "" {
|
|
return oc.TestNodeKey, nil
|
|
}
|
|
|
|
enrollLock.Lock()
|
|
defer enrollLock.Unlock()
|
|
|
|
orbitNodeKey, err := os.ReadFile(oc.nodeKeyFilePath)
|
|
switch {
|
|
case err == nil:
|
|
return string(orbitNodeKey), nil
|
|
case errors.Is(err, fs.ErrNotExist):
|
|
// OK, if there's no orbit node key, proceed to enroll.
|
|
default:
|
|
return "", fmt.Errorf("read orbit node key file: %w", err)
|
|
}
|
|
var orbitNodeKey_ string
|
|
if err := retry.Do(
|
|
func() error {
|
|
orbitNodeKey_, err = oc.enrollAndWriteNodeKeyFile()
|
|
return err
|
|
},
|
|
// The below configuration means the following retry intervals (exponential backoff):
|
|
// 10s, 20s, 40s, 80s, 160s and then return the failure (max attempts = 6)
|
|
// thus executing no more than ~6 enroll request failures every ~5 minutes.
|
|
retry.WithInterval(orbitEnrollRetryInterval()),
|
|
retry.WithMaxAttempts(constant.OrbitEnrollMaxRetries),
|
|
retry.WithBackoffMultiplier(constant.OrbitEnrollBackoffMultiplier),
|
|
retry.WithErrorFilter(func(err error) (errorOutcome retry.ErrorOutcome) {
|
|
log.Info().Err(err).Msg("orbit enroll attempt failed")
|
|
switch {
|
|
case IsNotFoundErr(err):
|
|
// Do not retry if the endpoint does not exist.
|
|
return retry.ErrorOutcomeDoNotRetry
|
|
case errors.Is(err, ErrEndUserAuthRequired):
|
|
// If we get an ErrEndUserAuthRequired error, then the user
|
|
// needs to authenticate with the identity provider.
|
|
//
|
|
// Open a browser window to the sign-on page and
|
|
// then keep retrying until they authenticate.
|
|
log.Debug().Msg("enroll unauthenticated, waiting for end-user to authenticate via SSO")
|
|
if !oc.initiatedIdpAuth {
|
|
if oc.openSSOWindow == nil {
|
|
log.Error().Msg("SSO window open function not set")
|
|
return retry.ErrorOutcomeNormalRetry
|
|
}
|
|
log.Debug().Msg("opening SSO window")
|
|
openWindowErr := oc.openSSOWindow()
|
|
if openWindowErr != nil {
|
|
log.Error().Err(openWindowErr).Msg("opening SSO window")
|
|
return retry.ErrorOutcomeNormalRetry
|
|
}
|
|
oc.initiatedIdpAuth = true
|
|
}
|
|
// Sleep for 20 seconds, making the total retry interval 30 seconds
|
|
time.Sleep(20 * time.Second)
|
|
return retry.ErrorOutcomeResetAttempts
|
|
default:
|
|
logging.LogErrIfEnvNotSet(constant.SilenceEnrollLogErrorEnvVar, err, "enroll failed, retrying")
|
|
return retry.ErrorOutcomeNormalRetry
|
|
}
|
|
}),
|
|
); err != nil {
|
|
if IsNotFoundErr(err) {
|
|
return "", errors.New("enroll endpoint does not exist")
|
|
}
|
|
return "", fmt.Errorf("orbit node key enroll failed, attempts=%d", constant.OrbitEnrollMaxRetries)
|
|
}
|
|
return orbitNodeKey_, nil
|
|
}
|
|
|
|
// GetNodeKey gets the orbit node key from file.
|
|
func (oc *OrbitClient) GetNodeKey() (string, error) {
|
|
orbitNodeKey, err := os.ReadFile(oc.nodeKeyFilePath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(orbitNodeKey), nil
|
|
}
|
|
|
|
func (oc *OrbitClient) enrollAndWriteNodeKeyFile() (string, error) {
|
|
orbitNodeKey, err := oc.enroll()
|
|
if err != nil {
|
|
return "", fmt.Errorf("enroll request: %w", err)
|
|
}
|
|
|
|
if runtime.GOOS == "windows" {
|
|
// creating the secret file with empty content
|
|
if err := os.WriteFile(oc.nodeKeyFilePath, nil, constant.DefaultFileMode); err != nil {
|
|
return "", fmt.Errorf("create orbit node key file: %w", err)
|
|
}
|
|
// restricting file access
|
|
if err := platform.ChmodRestrictFile(oc.nodeKeyFilePath); err != nil {
|
|
return "", fmt.Errorf("apply ACLs: %w", err)
|
|
}
|
|
}
|
|
|
|
// writing raw key material to the acl-ready secret file
|
|
if err := os.WriteFile(oc.nodeKeyFilePath, []byte(orbitNodeKey), constant.DefaultFileMode); err != nil {
|
|
return "", fmt.Errorf("write orbit node key file: %w", err)
|
|
}
|
|
|
|
return orbitNodeKey, nil
|
|
}
|
|
|
|
func (oc *OrbitClient) authenticatedRequest(verb string, path string, params any, resp any) error {
|
|
nodeKey, err := oc.getNodeKeyOrEnroll()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
s := params.(fleet.SetOrbitNodeKeyer)
|
|
s.SetOrbitNodeKey(nodeKey)
|
|
|
|
err = oc.request(verb, path, params, resp)
|
|
switch {
|
|
case err == nil:
|
|
oc.setEnrolled(true)
|
|
return nil
|
|
case errors.Is(err, ErrUnauthenticated):
|
|
if err := os.Remove(oc.nodeKeyFilePath); err != nil {
|
|
log.Info().Err(err).Msg("remove orbit node key")
|
|
}
|
|
oc.setEnrolled(false)
|
|
|
|
if oc.hostIdentityCertPath != "" {
|
|
if err := os.Remove(oc.hostIdentityCertPath); err != nil {
|
|
log.Info().Err(err).Msg("remove orbit host identity cert")
|
|
}
|
|
log.Info().Msg("removed orbit host identity cert, triggering a restart")
|
|
oc.receiverUpdateCancelFunc()
|
|
}
|
|
return err
|
|
default:
|
|
return err
|
|
}
|
|
}
|
|
|
|
func (oc *OrbitClient) Enrolled() bool {
|
|
oc.enrolledMu.Lock()
|
|
defer oc.enrolledMu.Unlock()
|
|
return oc.enrolled
|
|
}
|
|
|
|
func (oc *OrbitClient) setEnrolled(v bool) {
|
|
oc.enrolledMu.Lock()
|
|
defer oc.enrolledMu.Unlock()
|
|
oc.enrolled = v
|
|
}
|
|
|
|
func (oc *OrbitClient) LastRecordedError() error {
|
|
oc.lastRecordedErrMu.Lock()
|
|
defer oc.lastRecordedErrMu.Unlock()
|
|
return oc.lastRecordedErr
|
|
}
|
|
|
|
func (oc *OrbitClient) setLastRecordedError(err error) {
|
|
oc.lastRecordedErrMu.Lock()
|
|
defer oc.lastRecordedErrMu.Unlock()
|
|
oc.lastRecordedErr = fmt.Errorf("%s: %w", time.Now().UTC().Format("2006-01-02T15:04:05Z"), err)
|
|
}
|
|
|
|
func orbitEnrollRetryInterval() time.Duration {
|
|
interval := os.Getenv("FLEETD_ENROLL_RETRY_INTERVAL")
|
|
if interval != "" {
|
|
d, err := time.ParseDuration(interval)
|
|
if err == nil {
|
|
return d
|
|
}
|
|
}
|
|
return constant.OrbitEnrollRetrySleep
|
|
}
|
|
|
|
// SetOrUpdateDiskEncryptionKey sends a request to the server to set or update the disk encryption keys.
|
|
func (oc *OrbitClient) SetOrUpdateDiskEncryptionKey(diskEncryptionStatus fleet.OrbitHostDiskEncryptionKeyPayload) error {
|
|
verb, path := "POST", "/api/fleet/orbit/disk_encryption_key"
|
|
var resp fleet.OrbitPostDiskEncryptionKeyResponse
|
|
if err := oc.authenticatedRequest(verb, path, &fleet.OrbitPostDiskEncryptionKeyRequest{
|
|
EncryptionKey: diskEncryptionStatus.EncryptionKey,
|
|
ClientError: diskEncryptionStatus.ClientError,
|
|
}, &resp); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
const httpTraceTimeFormat = "2006-01-02T15:04:05Z"
|
|
|
|
var testStdoutHTTPTracer = &httptrace.ClientTrace{
|
|
ConnectStart: func(network, addr string) {
|
|
fmt.Printf(
|
|
"httptrace: %s: ConnectStart: %s, %s\n",
|
|
time.Now().UTC().Format(httpTraceTimeFormat), network, addr,
|
|
)
|
|
},
|
|
ConnectDone: func(network, addr string, err error) {
|
|
fmt.Printf(
|
|
"httptrace: %s: ConnectDone: %s, %s, err='%s'\n",
|
|
time.Now().UTC().Format(httpTraceTimeFormat), network, addr, err,
|
|
)
|
|
},
|
|
}
|
|
|
|
// GetSetupExperienceStatus checks the status of the setup experience for this host.
|
|
func (oc *OrbitClient) GetSetupExperienceStatus(resetFailedSetupSteps bool) (*fleet.SetupExperienceStatusPayload, error) {
|
|
verb, path := "POST", "/api/fleet/orbit/setup_experience/status"
|
|
var resp fleet.GetOrbitSetupExperienceStatusResponse
|
|
err := oc.authenticatedRequest(verb, path, &fleet.GetOrbitSetupExperienceStatusRequest{
|
|
ResetFailedSetupSteps: resetFailedSetupSteps,
|
|
}, &resp)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return resp.Results, nil
|
|
}
|
|
|
|
func (oc *OrbitClient) SendLinuxKeyEscrowResponse(lr luks.LuksResponse) error {
|
|
verb, path := "POST", "/api/fleet/orbit/luks_data"
|
|
var resp fleet.OrbitPostLUKSResponse
|
|
if err := oc.authenticatedRequest(verb, path, &fleet.OrbitPostLUKSRequest{
|
|
Passphrase: lr.Passphrase,
|
|
KeySlot: lr.KeySlot,
|
|
Salt: lr.Salt,
|
|
ClientError: lr.Err,
|
|
}, &resp); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (oc *OrbitClient) InitiateSetupExperience() (fleet.SetupExperienceInitResult, error) {
|
|
verb, path := "POST", "/api/fleet/orbit/setup_experience/init"
|
|
var resp fleet.OrbitSetupExperienceInitResponse
|
|
if err := oc.authenticatedRequest(verb, path, &fleet.OrbitSetupExperienceInitRequest{}, &resp); err != nil {
|
|
return fleet.SetupExperienceInitResult{}, err
|
|
}
|
|
return resp.Result, nil
|
|
}
|