2018-05-04 16:53:21 +00:00
|
|
|
package service
|
|
|
|
|
|
|
|
import (
|
|
|
|
"bytes"
|
2021-08-24 12:50:03 +00:00
|
|
|
"context"
|
2018-05-04 16:53:21 +00:00
|
|
|
"encoding/json"
|
2021-11-22 14:13:26 +00:00
|
|
|
"errors"
|
2018-05-04 16:53:21 +00:00
|
|
|
"fmt"
|
2021-02-03 02:55:16 +00:00
|
|
|
"io"
|
2018-05-04 16:53:21 +00:00
|
|
|
"net/http"
|
2021-02-03 02:55:16 +00:00
|
|
|
"os"
|
|
|
|
"time"
|
2018-05-04 16:53:21 +00:00
|
|
|
|
2022-08-05 22:07:32 +00:00
|
|
|
"github.com/fleetdm/fleet/v4/pkg/spec"
|
2021-11-22 14:13:26 +00:00
|
|
|
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
2021-08-26 13:28:53 +00:00
|
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
2018-05-04 16:53:21 +00:00
|
|
|
)
|
|
|
|
|
2022-06-01 23:05:05 +00:00
|
|
|
// Client is used to consume Fleet APIs from Go code
|
2018-05-04 16:53:21 +00:00
|
|
|
type Client struct {
|
2022-06-01 23:05:05 +00:00
|
|
|
*baseClient
|
2022-06-07 20:00:09 +00:00
|
|
|
addr string
|
|
|
|
token string
|
|
|
|
customHeaders map[string]string
|
2021-08-26 13:28:53 +00:00
|
|
|
|
|
|
|
writer io.Writer
|
2018-05-04 16:53:21 +00:00
|
|
|
}
|
|
|
|
|
2021-02-03 02:55:16 +00:00
|
|
|
type ClientOption func(*Client) error
|
|
|
|
|
|
|
|
func NewClient(addr string, insecureSkipVerify bool, rootCA, urlPrefix string, options ...ClientOption) (*Client, error) {
|
|
|
|
// TODO #265 refactor all optional parameters to functional options
|
|
|
|
// API breaking change, needs a major version release
|
add headers denoting capabilities between fleet server / desktop / orbit (#7833)
This adds a new mechanism to allow us to handle compatibility issues between Orbit, Fleet Server and Fleet Desktop.
The general idea is to _always_ send a custom header of the form:
```
fleet-capabilities-header = "X-Fleet-Capabilities:" capabilities
capabilities = capability * (,)
capability = string
```
Both from the server to the clients (Orbit, Fleet Desktop) and vice-versa. For an example, see: https://github.com/fleetdm/fleet/commit/8c0bbdd291f54e03e19766bcdfead0fb8067f60c
Also, the following applies:
- Backwards compat: if the header is not present, assume that orbit/fleet doesn't have the capability
- The current capabilities endpoint will be removed
### Motivation
This solution is trying to solve the following problems:
- We have three independent processes communicating with each other (Fleet Desktop, Orbit and Fleet Server). Each process can be updated independently, and therefore we need a way for each process to know what features are supported by its peers.
- We originally implemented a dedicated API endpoint in the server that returned a list of the capabilities (or "features") enabled, we found this, and any other server-only solution (like API versioning) to be insufficient because:
- There are cases in which the server also needs to know which features are supported by its clients
- Clients needed to poll for changes to detect if the capabilities supported by the server change, by sending the capabilities on each request we have a much cleaner way to handling different responses.
- We are also introducing an unauthenticated endpoint to get the server features, this gives us flexibility if we need to implement different authentication mechanisms, and was one of the pitfalls of the first implementation.
Related to https://github.com/fleetdm/fleet/issues/7929
2022-09-26 10:53:53 +00:00
|
|
|
baseClient, err := newBaseClient(addr, insecureSkipVerify, rootCA, urlPrefix, fleet.CapabilityMap{})
|
2022-06-01 23:05:05 +00:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
2018-10-01 22:23:46 +00:00
|
|
|
}
|
|
|
|
|
2021-02-03 02:55:16 +00:00
|
|
|
client := &Client{
|
2022-06-01 23:05:05 +00:00
|
|
|
baseClient: baseClient,
|
|
|
|
addr: addr,
|
2021-02-03 02:55:16 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
for _, option := range options {
|
|
|
|
err := option(client)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return client, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func EnableClientDebug() ClientOption {
|
|
|
|
return func(c *Client) error {
|
|
|
|
httpClient, ok := c.http.(*http.Client)
|
|
|
|
if !ok {
|
|
|
|
return errors.New("client is not *http.Client")
|
|
|
|
}
|
|
|
|
httpClient.Transport = &logRoundTripper{roundtripper: httpClient.Transport}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
2018-05-04 16:53:21 +00:00
|
|
|
}
|
|
|
|
|
2021-08-26 13:28:53 +00:00
|
|
|
func SetClientWriter(w io.Writer) ClientOption {
|
|
|
|
return func(c *Client) error {
|
|
|
|
c.writer = w
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-06-07 20:00:09 +00:00
|
|
|
// WithCustomHeaders sets custom headers to be sent with every request made
|
|
|
|
// with the client.
|
|
|
|
func WithCustomHeaders(headers map[string]string) ClientOption {
|
|
|
|
return func(c *Client) error {
|
|
|
|
// clone the map to prevent any changes in the original affecting the client
|
|
|
|
m := make(map[string]string, len(headers))
|
|
|
|
for k, v := range headers {
|
|
|
|
m[k] = v
|
|
|
|
}
|
|
|
|
c.customHeaders = m
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-10-05 22:53:54 +00:00
|
|
|
func (c *Client) doContextWithBodyAndHeaders(ctx context.Context, verb, path, rawQuery string, bodyBytes []byte, headers map[string]string) (*http.Response, error) {
|
2021-08-24 12:50:03 +00:00
|
|
|
request, err := http.NewRequestWithContext(
|
|
|
|
ctx,
|
2018-05-04 16:53:21 +00:00
|
|
|
verb,
|
2020-11-05 04:45:16 +00:00
|
|
|
c.url(path, rawQuery).String(),
|
2018-05-07 19:44:40 +00:00
|
|
|
bytes.NewBuffer(bodyBytes),
|
2018-05-04 16:53:21 +00:00
|
|
|
)
|
|
|
|
if err != nil {
|
2021-11-22 14:13:26 +00:00
|
|
|
return nil, ctxerr.Wrap(ctx, err, "creating request object")
|
2018-05-04 16:53:21 +00:00
|
|
|
}
|
2022-06-07 20:00:09 +00:00
|
|
|
|
|
|
|
// set the custom headers first, they should not override the actual headers
|
|
|
|
// we set explicitly.
|
|
|
|
for k, v := range c.customHeaders {
|
|
|
|
request.Header.Set(k, v)
|
|
|
|
}
|
2018-05-04 16:53:21 +00:00
|
|
|
for k, v := range headers {
|
|
|
|
request.Header.Set(k, v)
|
|
|
|
}
|
|
|
|
|
2021-08-26 13:28:53 +00:00
|
|
|
resp, err := c.http.Do(request)
|
|
|
|
if err != nil {
|
2021-11-22 14:13:26 +00:00
|
|
|
return nil, ctxerr.Wrap(ctx, err, "do request")
|
2021-08-26 13:28:53 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if resp.Header.Get(fleet.HeaderLicenseKey) == fleet.HeaderLicenseValueExpired {
|
|
|
|
fleet.WriteExpiredLicenseBanner(c.writer)
|
|
|
|
}
|
|
|
|
|
|
|
|
return resp, nil
|
2018-05-04 16:53:21 +00:00
|
|
|
}
|
|
|
|
|
2022-10-05 22:53:54 +00:00
|
|
|
func (c *Client) doContextWithHeaders(ctx context.Context, verb, path, rawQuery string, params interface{}, headers map[string]string) (*http.Response, error) {
|
|
|
|
var bodyBytes []byte
|
|
|
|
var err error
|
|
|
|
if params != nil {
|
|
|
|
bodyBytes, err = json.Marshal(params)
|
|
|
|
if err != nil {
|
|
|
|
return nil, ctxerr.Wrap(ctx, err, "marshaling json")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return c.doContextWithBodyAndHeaders(ctx, verb, path, rawQuery, bodyBytes, headers)
|
|
|
|
}
|
|
|
|
|
2020-11-05 04:45:16 +00:00
|
|
|
func (c *Client) Do(verb, path, rawQuery string, params interface{}) (*http.Response, error) {
|
2021-08-24 12:50:03 +00:00
|
|
|
return c.DoContext(context.Background(), verb, path, rawQuery, params)
|
|
|
|
}
|
|
|
|
|
|
|
|
func (c *Client) DoContext(ctx context.Context, verb, path, rawQuery string, params interface{}) (*http.Response, error) {
|
2018-05-04 16:53:21 +00:00
|
|
|
headers := map[string]string{
|
|
|
|
"Content-type": "application/json",
|
|
|
|
"Accept": "application/json",
|
|
|
|
}
|
|
|
|
|
2021-08-24 12:50:03 +00:00
|
|
|
return c.doContextWithHeaders(ctx, verb, path, rawQuery, params, headers)
|
2018-05-04 16:53:21 +00:00
|
|
|
}
|
|
|
|
|
2020-11-05 04:45:16 +00:00
|
|
|
func (c *Client) AuthenticatedDo(verb, path, rawQuery string, params interface{}) (*http.Response, error) {
|
2018-05-04 16:53:21 +00:00
|
|
|
if c.token == "" {
|
|
|
|
return nil, errors.New("authentication token is empty")
|
|
|
|
}
|
|
|
|
|
|
|
|
headers := map[string]string{
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
"Accept": "application/json",
|
|
|
|
"Authorization": fmt.Sprintf("Bearer %s", c.token),
|
|
|
|
}
|
|
|
|
|
2021-08-24 12:50:03 +00:00
|
|
|
return c.doContextWithHeaders(context.Background(), verb, path, rawQuery, params, headers)
|
2018-05-04 16:53:21 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func (c *Client) SetToken(t string) {
|
|
|
|
c.token = t
|
|
|
|
}
|
|
|
|
|
2021-02-03 02:55:16 +00:00
|
|
|
// http.RoundTripper that will log debug information about the request and
|
|
|
|
// response, including paths, timing, and body.
|
|
|
|
//
|
|
|
|
// Inspired by https://stackoverflow.com/a/39528716/491710 and
|
|
|
|
// github.com/motemen/go-loghttp
|
|
|
|
type logRoundTripper struct {
|
|
|
|
roundtripper http.RoundTripper
|
|
|
|
}
|
|
|
|
|
|
|
|
// RoundTrip implements http.RoundTripper
|
|
|
|
func (l *logRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
|
|
// Log request
|
|
|
|
fmt.Fprintf(os.Stderr, "%s %s\n", req.Method, req.URL)
|
|
|
|
reqBody, err := req.GetBody()
|
|
|
|
if err != nil {
|
|
|
|
fmt.Fprintf(os.Stderr, "GetBody error: %v\n", err)
|
|
|
|
} else {
|
|
|
|
defer reqBody.Close()
|
|
|
|
if _, err := io.Copy(os.Stderr, reqBody); err != nil {
|
|
|
|
fmt.Fprintf(os.Stderr, "Copy body error: %v\n", err)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
fmt.Fprintf(os.Stderr, "\n")
|
|
|
|
|
|
|
|
// Perform request using underlying roundtripper
|
|
|
|
start := time.Now()
|
|
|
|
res, err := l.roundtripper.RoundTrip(req)
|
|
|
|
if err != nil {
|
|
|
|
fmt.Fprintf(os.Stderr, "RoundTrip error: %v", err)
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
// Log response
|
2021-05-17 17:29:50 +00:00
|
|
|
took := time.Since(start).Truncate(time.Millisecond)
|
2021-02-03 02:55:16 +00:00
|
|
|
fmt.Fprintf(os.Stderr, "%s %s %s (%s)\n", res.Request.Method, res.Request.URL, res.Status, took)
|
|
|
|
|
|
|
|
resBody := &bytes.Buffer{}
|
|
|
|
resBodyReader := io.TeeReader(res.Body, resBody)
|
|
|
|
if _, err := io.Copy(os.Stderr, resBodyReader); err != nil {
|
|
|
|
fmt.Fprintf(os.Stderr, "Read body error: %v", err)
|
|
|
|
return nil, err
|
|
|
|
}
|
2021-09-15 19:27:53 +00:00
|
|
|
res.Body = io.NopCloser(resBody)
|
2021-02-03 02:55:16 +00:00
|
|
|
|
|
|
|
return res, nil
|
|
|
|
}
|
2021-07-16 18:28:13 +00:00
|
|
|
|
2021-09-14 13:58:48 +00:00
|
|
|
func (c *Client) authenticatedRequestWithQuery(params interface{}, verb string, path string, responseDest interface{}, query string) error {
|
|
|
|
response, err := c.AuthenticatedDo(verb, path, query, params)
|
2021-07-16 18:28:13 +00:00
|
|
|
if err != nil {
|
2021-11-22 14:13:26 +00:00
|
|
|
return fmt.Errorf("%s %s: %w", verb, path, err)
|
2021-07-16 18:28:13 +00:00
|
|
|
}
|
|
|
|
defer response.Body.Close()
|
|
|
|
|
2022-06-01 23:05:05 +00:00
|
|
|
return c.parseResponse(verb, path, response, responseDest)
|
2021-07-16 18:28:13 +00:00
|
|
|
}
|
2021-09-14 13:58:48 +00:00
|
|
|
|
|
|
|
func (c *Client) authenticatedRequest(params interface{}, verb string, path string, responseDest interface{}) error {
|
|
|
|
return c.authenticatedRequestWithQuery(params, verb, path, responseDest, "")
|
|
|
|
}
|
2022-08-05 22:07:32 +00:00
|
|
|
|
|
|
|
// ApplyGroup applies the given spec group to Fleet.
|
2022-09-19 17:53:44 +00:00
|
|
|
func (c *Client) ApplyGroup(ctx context.Context, specs *spec.Group, logf func(format string, args ...interface{}), opts fleet.ApplySpecOptions) error {
|
2022-08-05 22:07:32 +00:00
|
|
|
logfn := func(format string, args ...interface{}) {
|
|
|
|
if logf != nil {
|
|
|
|
logf(format, args...)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if len(specs.Queries) > 0 {
|
2022-09-19 17:53:44 +00:00
|
|
|
if opts.DryRun {
|
|
|
|
logfn("[!] ignoring queries, dry run mode only supported for 'config' and 'team' specs\n")
|
|
|
|
} else {
|
|
|
|
if err := c.ApplyQueries(specs.Queries); err != nil {
|
|
|
|
return fmt.Errorf("applying queries: %w", err)
|
|
|
|
}
|
|
|
|
logfn("[+] applied %d queries\n", len(specs.Queries))
|
2022-08-05 22:07:32 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if len(specs.Labels) > 0 {
|
2022-09-19 17:53:44 +00:00
|
|
|
if opts.DryRun {
|
|
|
|
logfn("[!] ignoring labels, dry run mode only supported for 'config' and 'team' specs\n")
|
|
|
|
} else {
|
|
|
|
if err := c.ApplyLabels(specs.Labels); err != nil {
|
|
|
|
return fmt.Errorf("applying labels: %w", err)
|
|
|
|
}
|
|
|
|
logfn("[+] applied %d labels\n", len(specs.Labels))
|
2022-08-05 22:07:32 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if len(specs.Policies) > 0 {
|
2022-09-19 17:53:44 +00:00
|
|
|
if opts.DryRun {
|
|
|
|
logfn("[!] ignoring policies, dry run mode only supported for 'config' and 'team' specs\n")
|
|
|
|
} else {
|
|
|
|
if err := c.ApplyPolicies(specs.Policies); err != nil {
|
|
|
|
return fmt.Errorf("applying policies: %w", err)
|
|
|
|
}
|
|
|
|
logfn("[+] applied %d policies\n", len(specs.Policies))
|
2022-08-05 22:07:32 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if len(specs.Packs) > 0 {
|
2022-09-19 17:53:44 +00:00
|
|
|
if opts.DryRun {
|
|
|
|
logfn("[!] ignoring packs, dry run mode only supported for 'config' and 'team' specs\n")
|
|
|
|
} else {
|
|
|
|
if err := c.ApplyPacks(specs.Packs); err != nil {
|
|
|
|
return fmt.Errorf("applying packs: %w", err)
|
|
|
|
}
|
|
|
|
logfn("[+] applied %d packs\n", len(specs.Packs))
|
2022-08-05 22:07:32 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if specs.AppConfig != nil {
|
2022-09-19 17:53:44 +00:00
|
|
|
if err := c.ApplyAppConfig(specs.AppConfig, opts); err != nil {
|
2022-08-05 22:07:32 +00:00
|
|
|
return fmt.Errorf("applying fleet config: %w", err)
|
|
|
|
}
|
2022-09-19 17:53:44 +00:00
|
|
|
if opts.DryRun {
|
|
|
|
logfn("[+] would've applied fleet config\n")
|
|
|
|
} else {
|
|
|
|
logfn("[+] applied fleet config\n")
|
|
|
|
}
|
2022-08-05 22:07:32 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if specs.EnrollSecret != nil {
|
2022-09-19 17:53:44 +00:00
|
|
|
if opts.DryRun {
|
|
|
|
logfn("[!] ignoring enroll secrets, dry run mode only supported for 'config' and 'team' specs\n")
|
|
|
|
} else {
|
|
|
|
if err := c.ApplyEnrollSecretSpec(specs.EnrollSecret); err != nil {
|
|
|
|
return fmt.Errorf("applying enroll secrets: %w", err)
|
|
|
|
}
|
|
|
|
logfn("[+] applied enroll secrets\n")
|
2022-08-05 22:07:32 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if len(specs.Teams) > 0 {
|
2022-09-19 17:53:44 +00:00
|
|
|
if err := c.ApplyTeams(specs.Teams, opts); err != nil {
|
2022-08-05 22:07:32 +00:00
|
|
|
return fmt.Errorf("applying teams: %w", err)
|
|
|
|
}
|
2022-09-19 17:53:44 +00:00
|
|
|
if opts.DryRun {
|
|
|
|
logfn("[+] would've applied %d teams\n", len(specs.Teams))
|
|
|
|
} else {
|
|
|
|
logfn("[+] applied %d teams\n", len(specs.Teams))
|
|
|
|
}
|
2022-08-05 22:07:32 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if specs.UsersRoles != nil {
|
2022-09-19 17:53:44 +00:00
|
|
|
if opts.DryRun {
|
|
|
|
logfn("[!] ignoring user roles, dry run mode only supported for 'config' and 'team' specs\n")
|
|
|
|
} else {
|
|
|
|
if err := c.ApplyUsersRoleSecretSpec(specs.UsersRoles); err != nil {
|
|
|
|
return fmt.Errorf("applying user roles: %w", err)
|
|
|
|
}
|
|
|
|
logfn("[+] applied user roles\n")
|
2022-08-05 22:07:32 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|