<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Fixes #46643 BaseClient.URL() built request paths as `URLPrefix + path`, overwriting any path already present in BaseURL. Orbit and Fleet Desktop parse the full fleet URL (subpath included) into BaseURL and pass an empty URLPrefix, so the subpath was discarded and every API call 404'd when Fleet was deployed at https://host/subpath. Preserve BaseURL.Path as a prefix on each request. fleetctl is unaffected since it carries the subpath in URLPrefix with an empty BaseURL.Path, and non-subpath deployments are unchanged. # 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 - [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 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Fixed API request URL construction for deployments hosted under a subpath, preventing broken requests and 404 errors. * Preserved query parameters when generating request URLs. * Improved resolution of request paths with and without a leading slash when combined with a base URL subpath. * **Documentation** * Added clearer guidance on how base URL subpaths and additional path prefixes are combined. * **Tests** * Expanded URL-generation coverage to verify correct behavior across subpath and prefix combinations. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
308 lines
8.8 KiB
Go
308 lines
8.8 KiB
Go
package client
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
var ErrInvalidScheme = errors.New("address must start with https:// for remote connections")
|
|
|
|
// HTTPClient interface allows the HTTP methods to be mocked.
|
|
type HTTPClient interface {
|
|
Do(req *http.Request) (*http.Response, error)
|
|
}
|
|
|
|
type BaseClient struct {
|
|
BaseURL *url.URL
|
|
HTTP HTTPClient
|
|
URLPrefix string
|
|
InsecureSkipVerify bool
|
|
// ServerCapabilities is a map of capabilities that the server supports.
|
|
// This map is updated on each response we receive from the server.
|
|
ServerCapabilities fleet.CapabilityMap
|
|
// ClientCapabilities is a map of capabilities that the client supports.
|
|
// This list is given when the client is instantiated and shouldn't be
|
|
// modified afterwards.
|
|
ClientCapabilities fleet.CapabilityMap
|
|
}
|
|
|
|
// ParseResponse processes the status code and parses the response body.
|
|
// It does not close the response body (should be closed by the caller).
|
|
func (bc *BaseClient) ParseResponse(verb, path string, response *http.Response, responseDest any) error {
|
|
switch response.StatusCode {
|
|
case http.StatusNotFound:
|
|
return &NotFoundErr{
|
|
Msg: ExtractServerErrorText(response.Body),
|
|
}
|
|
case http.StatusUnauthorized:
|
|
errText := ExtractServerErrorText(response.Body)
|
|
if strings.Contains(errText, "password reset required") {
|
|
return ErrPasswordResetRequired
|
|
}
|
|
if strings.Contains(errText, "END_USER_AUTH_REQUIRED") {
|
|
return ErrEndUserAuthRequired
|
|
}
|
|
return ErrUnauthenticated
|
|
case http.StatusPaymentRequired:
|
|
return ErrMissingLicense
|
|
default:
|
|
if response.StatusCode >= 200 && response.StatusCode < 300 {
|
|
break
|
|
}
|
|
|
|
e := &StatusCodeErr{
|
|
Code: response.StatusCode,
|
|
Body: ExtractServerErrorText(response.Body),
|
|
}
|
|
return fmt.Errorf("%s %s received status %w", verb, path, e)
|
|
}
|
|
|
|
bc.SetServerCapabilities(response)
|
|
|
|
if responseDest != nil {
|
|
if e, ok := responseDest.(BodyHandler); ok {
|
|
if err := e.Handle(response); err != nil {
|
|
return fmt.Errorf("%s %s error with custom body handler contents: %w", verb, path, err)
|
|
}
|
|
} else if response.StatusCode != http.StatusNoContent {
|
|
b, err := io.ReadAll(response.Body)
|
|
if err != nil {
|
|
return fmt.Errorf("reading response body: %w", err)
|
|
}
|
|
if err := json.Unmarshal(b, &responseDest); err != nil {
|
|
const maxBodyLen = 200
|
|
truncatedBytes, isHTML := TruncateAndDetectHTML(b, maxBodyLen)
|
|
|
|
if isHTML {
|
|
return fmt.Errorf("decode %s %s response: %w, (server returned HTML instead of JSON), body: %s", verb, path, err, truncatedBytes)
|
|
}
|
|
return fmt.Errorf("decode %s %s response: %w, body: %s", verb, path, err, truncatedBytes)
|
|
}
|
|
if e, ok := responseDest.(fleet.Errorer); ok {
|
|
if e.Error() != nil {
|
|
return fmt.Errorf("%s %s error: %w", verb, path, e.Error())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
bc.SetServerCapabilities(response)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (bc *BaseClient) URL(reqPath, rawQuery string) *url.URL {
|
|
u := *bc.BaseURL
|
|
// Preserve any subpath from the base URL (e.g. when Fleet is deployed at
|
|
// https://host/subpath). A subpath can arrive via either BaseURL.Path
|
|
// (Orbit and Fleet Desktop, which parse a single --fleet-url) or URLPrefix
|
|
// (fleetctl, which has a separate --url-prefix config). These two sources
|
|
// are mutually exclusive by configuration convention; setting both
|
|
// concatenates them.
|
|
u.Path = path.Join(bc.BaseURL.Path, bc.URLPrefix, reqPath)
|
|
u.RawQuery = rawQuery
|
|
return &u
|
|
}
|
|
|
|
// SetServerCapabilities updates the server capabilities based on the response
|
|
// from the server.
|
|
func (bc *BaseClient) SetServerCapabilities(response *http.Response) {
|
|
capabilities := response.Header.Get(fleet.CapabilitiesHeader)
|
|
bc.ServerCapabilities.PopulateFromString(capabilities)
|
|
}
|
|
|
|
func (bc *BaseClient) GetServerCapabilities() fleet.CapabilityMap {
|
|
return bc.ServerCapabilities
|
|
}
|
|
|
|
// SetClientCapabilitiesHeader is used to set a header with the client
|
|
// capabilities in the given request.
|
|
//
|
|
// This method is defined in BaseClient because other clients generally have
|
|
// custom implementations of a method to perform the requests to the server.
|
|
func (bc *BaseClient) SetClientCapabilitiesHeader(req *http.Request) {
|
|
if len(bc.ClientCapabilities) == 0 {
|
|
return
|
|
}
|
|
|
|
if req.Header == nil {
|
|
req.Header = http.Header{}
|
|
}
|
|
|
|
req.Header.Set(fleet.CapabilitiesHeader, bc.ClientCapabilities.String())
|
|
}
|
|
|
|
func NewBaseClient(
|
|
addr string,
|
|
insecureSkipVerify bool,
|
|
rootCA, urlPrefix string,
|
|
fleetClientCert *tls.Certificate,
|
|
capabilities fleet.CapabilityMap,
|
|
signerWrapper func(*http.Client) *http.Client,
|
|
) (*BaseClient, error) {
|
|
baseURL, err := url.Parse(addr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parsing URL: %w", err)
|
|
}
|
|
|
|
allowHTTP := insecureSkipVerify || strings.Contains(baseURL.Host, "localhost") || strings.Contains(baseURL.Host, "127.0.0.1")
|
|
if baseURL.Scheme != "https" && !allowHTTP {
|
|
return nil, ErrInvalidScheme
|
|
}
|
|
|
|
rootCAPool := x509.NewCertPool()
|
|
|
|
tlsConfig := &tls.Config{
|
|
// Osquery itself requires >= TLS 1.2.
|
|
// https://github.com/osquery/osquery/blob/9713ad9e28f1cfe6c16a823fb88bd531e39e192d/osquery/remote/transports/tls.cpp#L97-L98
|
|
MinVersion: tls.VersionTLS12,
|
|
}
|
|
|
|
if fleetClientCert != nil {
|
|
tlsConfig.Certificates = []tls.Certificate{*fleetClientCert}
|
|
}
|
|
|
|
switch {
|
|
case rootCA != "":
|
|
// read in the root cert file specified in the context
|
|
certs, err := os.ReadFile(rootCA)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading root CA: %w", err)
|
|
}
|
|
// add certs to pool
|
|
if ok := rootCAPool.AppendCertsFromPEM(certs); !ok {
|
|
return nil, errors.New("failed to add certificates to root CA pool")
|
|
}
|
|
tlsConfig.RootCAs = rootCAPool
|
|
case insecureSkipVerify:
|
|
// Ignoring "G402: TLS InsecureSkipVerify set true", needed for development/testing.
|
|
tlsConfig.InsecureSkipVerify = true //nolint:gosec
|
|
default:
|
|
rootCAPool, err = x509.SystemCertPool()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("loading system cert pool: %w", err)
|
|
}
|
|
tlsConfig.RootCAs = rootCAPool
|
|
}
|
|
|
|
httpClient := fleethttp.NewClient(fleethttp.WithTLSClientConfig(tlsConfig))
|
|
if signerWrapper != nil {
|
|
httpClient = signerWrapper(httpClient)
|
|
}
|
|
client := &BaseClient{
|
|
BaseURL: baseURL,
|
|
HTTP: httpClient,
|
|
InsecureSkipVerify: insecureSkipVerify,
|
|
URLPrefix: urlPrefix,
|
|
ClientCapabilities: capabilities,
|
|
ServerCapabilities: fleet.CapabilityMap{},
|
|
}
|
|
return client, nil
|
|
}
|
|
|
|
// BodyHandler is an interface for custom response body handling.
|
|
type BodyHandler interface {
|
|
Handle(*http.Response) error
|
|
}
|
|
|
|
type FileResponse struct {
|
|
DestPath string
|
|
DestFile string
|
|
DestFilePath string
|
|
SkipMediaType bool
|
|
ProgressFunc func(n int)
|
|
}
|
|
|
|
func (f *FileResponse) Handle(resp *http.Response) error {
|
|
var filename string
|
|
if !f.SkipMediaType {
|
|
_, params, err := mime.ParseMediaType(resp.Header.Get("Content-Disposition"))
|
|
if err != nil {
|
|
return fmt.Errorf("parsing media type from response header: %w", err)
|
|
}
|
|
// Strip any directory components from the server-supplied filename to
|
|
// prevent path traversal.
|
|
filename = filepath.Base(params["filename"])
|
|
}
|
|
|
|
// filepath.Base("") returns "." and filepath.Base("..") returns "..",
|
|
// neither of which is a valid installer filename.
|
|
if filename == "" || filename == "." || filename == ".." {
|
|
filename = f.DestFile
|
|
}
|
|
if filename == "" {
|
|
filename = uuid.NewString()
|
|
}
|
|
|
|
f.DestFilePath = filepath.Join(f.DestPath, filename)
|
|
destFile, err := os.Create(f.DestFilePath)
|
|
if err != nil {
|
|
return fmt.Errorf("creating file: %w", err)
|
|
}
|
|
defer destFile.Close()
|
|
|
|
var respBodyReader io.Reader = resp.Body
|
|
if f.ProgressFunc != nil {
|
|
respBodyReader = &progressReader{
|
|
Reader: respBodyReader,
|
|
progressFunc: f.ProgressFunc,
|
|
}
|
|
}
|
|
|
|
_, err = io.Copy(destFile, respBodyReader)
|
|
if err != nil {
|
|
return fmt.Errorf("copying from http stream to file: %w", err)
|
|
}
|
|
|
|
if err := destFile.Close(); err != nil {
|
|
return fmt.Errorf("closing file after copy: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (f *FileResponse) GetFilePath() string {
|
|
return f.DestFilePath
|
|
}
|
|
|
|
type progressReader struct {
|
|
io.Reader
|
|
progressFunc func(n int)
|
|
}
|
|
|
|
func (pr *progressReader) Read(p []byte) (int, error) {
|
|
n, err := pr.Reader.Read(p)
|
|
pr.progressFunc(n)
|
|
return n, err
|
|
}
|
|
|
|
// DoHTTPRequest performs an HTTP request using the underlying HTTP client.
|
|
func (bc *BaseClient) DoHTTPRequest(req *http.Request) (*http.Response, error) {
|
|
return bc.HTTP.Do(req)
|
|
}
|
|
|
|
// GetRawHTTPClient returns the underlying HTTP client for type assertions (e.g., idle connection cleanup).
|
|
func (bc *BaseClient) GetRawHTTPClient() HTTPClient {
|
|
return bc.HTTP
|
|
}
|
|
|
|
// SetHTTPClient sets the underlying HTTP client (used in tests).
|
|
func (bc *BaseClient) SetHTTPClient(c HTTPClient) {
|
|
bc.HTTP = c
|
|
}
|