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"
2023-02-15 18:01:44 +00:00
"path/filepath"
2021-02-03 02:55:16 +00:00
"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
2023-04-07 20:31:02 +00:00
func ( c * Client ) CheckMDMEnabled ( ) error {
appCfg , err := c . GetAppConfig ( )
if err != nil {
return err
}
if ! appCfg . MDM . EnabledAndConfigured {
return errors . New ( "MDM features aren't turned on. Use `fleetctl generate mdm-apple` and then `fleet serve` with `mdm` configuration to turn on MDM features." )
}
return nil
}
2022-08-05 22:07:32 +00:00
// ApplyGroup applies the given spec group to Fleet.
2023-02-22 16:14:53 +00:00
func ( c * Client ) ApplyGroup (
ctx context . Context ,
specs * spec . Group ,
baseDir string ,
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 {
2023-02-22 16:14:53 +00:00
// If set, override the team in all the policies.
if opts . TeamForPolicies != "" {
for _ , policySpec := range specs . Policies {
policySpec . Team = opts . TeamForPolicies
}
}
2022-09-19 17:53:44 +00:00
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 {
2023-02-15 18:01:44 +00:00
if macosCustomSettings := extractAppCfgMacOSCustomSettings ( specs . AppConfig ) ; macosCustomSettings != nil {
files := resolveMacOSCustomSettingsPaths ( baseDir , macosCustomSettings )
fileContents := make ( [ ] [ ] byte , len ( files ) )
for i , f := range files {
b , err := os . ReadFile ( f )
if err != nil {
return fmt . Errorf ( "applying fleet config: %w" , err )
}
fileContents [ i ] = b
}
if err := c . ApplyNoTeamProfiles ( fileContents , opts ) ; err != nil {
return fmt . Errorf ( "applying custom settings: %w" , err )
}
}
2023-04-07 20:31:02 +00:00
if macosSetup := extractAppCfgMacOSSetup ( specs . AppConfig ) ; macosSetup != nil {
if macosSetup . BootstrapPackage != "" {
pkg , err := c . ValidateBootstrapPackageFromURL ( macosSetup . BootstrapPackage )
if err != nil {
return err
}
if ! opts . DryRun {
if err := c . EnsureBootstrapPackage ( pkg , uint ( 0 ) ) ; err != nil {
return err
}
}
}
}
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 {
2023-02-15 18:01:44 +00:00
// extract the teams' custom settings and resolve the files immediately, so
// that any non-existing file error is found before applying the specs.
tmMacSettings := extractTmSpecsMacOSCustomSettings ( specs . Teams )
tmFileContents := make ( map [ string ] [ ] [ ] byte , len ( tmMacSettings ) )
for k , paths := range tmMacSettings {
files := resolveMacOSCustomSettingsPaths ( baseDir , paths )
fileContents := make ( [ ] [ ] byte , len ( files ) )
for i , f := range files {
b , err := os . ReadFile ( f )
if err != nil {
return fmt . Errorf ( "applying teams: %w" , err )
}
fileContents [ i ] = b
}
tmFileContents [ k ] = fileContents
}
2023-04-07 20:31:02 +00:00
tmMacSetup := extractTmSpecsMacOSSetup ( specs . Teams )
tmBootstrapPackages := make ( map [ string ] * fleet . MDMAppleBootstrapPackage , len ( tmMacSetup ) )
for k , setup := range tmMacSetup {
if setup . BootstrapPackage != "" {
bp , err := c . ValidateBootstrapPackageFromURL ( setup . BootstrapPackage )
if err != nil {
return err
}
tmBootstrapPackages [ k ] = bp
}
}
2023-02-15 18:01:44 +00:00
// Next, apply the teams specs before saving the profiles, so that any
// non-existing team gets created.
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 )
}
2023-02-15 18:01:44 +00:00
if len ( tmFileContents ) > 0 {
for tmName , profs := range tmFileContents {
if err := c . ApplyTeamProfiles ( tmName , profs , opts ) ; err != nil {
return fmt . Errorf ( "applying custom settings for team %q: %w" , tmName , err )
}
}
}
2023-04-07 20:31:02 +00:00
if len ( tmBootstrapPackages ) > 0 && ! opts . DryRun {
// TODO: we need to chat an define on a better way to do
// this, maybe make the endpoints support both id/name? have
// separate endpoints?
tms , err := c . ListTeams ( "" )
if err != nil {
return err
}
for _ , tm := range tms {
if bp , ok := tmBootstrapPackages [ tm . Name ] ; ok {
if err := c . EnsureBootstrapPackage ( bp , tm . ID ) ; err != nil {
return fmt . Errorf ( "uploading bootstrap package for team %q: %w" , tm . Name , 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
}
2023-02-15 18:01:44 +00:00
2023-04-07 20:31:02 +00:00
func extractAppCfgMacOSSetup ( appCfg any ) * fleet . MacOSSetup {
asMap , ok := appCfg . ( map [ string ] interface { } )
if ! ok {
return nil
}
mmdm , ok := asMap [ "mdm" ] . ( map [ string ] interface { } )
if ! ok {
return nil
}
mos , ok := mmdm [ "macos_setup" ] . ( map [ string ] interface { } )
if ! ok || mos == nil {
return nil
}
bp := ""
rbp , ok := mos [ "bootstrap_package" ]
if ok {
bp = rbp . ( string )
}
return & fleet . MacOSSetup {
BootstrapPackage : bp ,
}
}
2023-02-15 18:01:44 +00:00
func resolveMacOSCustomSettingsPaths ( baseDir string , paths [ ] string ) [ ] string {
if baseDir == "" {
return paths
}
resolved := make ( [ ] string , 0 , len ( paths ) )
for _ , p := range paths {
if ! filepath . IsAbs ( p ) {
p = filepath . Join ( baseDir , p )
}
resolved = append ( resolved , p )
}
return resolved
}
func extractAppCfgMacOSCustomSettings ( appCfg interface { } ) [ ] string {
asMap , ok := appCfg . ( map [ string ] interface { } )
if ! ok {
return nil
}
2023-02-28 20:34:46 +00:00
mmdm , ok := asMap [ "mdm" ] . ( map [ string ] interface { } )
if ! ok {
return nil
}
mos , ok := mmdm [ "macos_settings" ] . ( map [ string ] interface { } )
2023-02-15 18:01:44 +00:00
if ! ok || mos == nil {
return nil
}
cs , ok := mos [ "custom_settings" ]
if ! ok {
// custom settings is not present
return nil
}
csAny , ok := cs . ( [ ] interface { } )
if ! ok || csAny == nil {
// return a non-nil, empty slice instead, so the caller knows that the
// custom_settings key was actually provided.
return [ ] string { }
}
csStrings := make ( [ ] string , 0 , len ( csAny ) )
for _ , v := range csAny {
s , _ := v . ( string )
if s != "" {
csStrings = append ( csStrings , s )
}
}
return csStrings
}
// returns the custom settings keyed by team name.
func extractTmSpecsMacOSCustomSettings ( tmSpecs [ ] json . RawMessage ) map [ string ] [ ] string {
var m map [ string ] [ ] string
for _ , tm := range tmSpecs {
var spec struct {
2023-02-28 20:34:46 +00:00
Name string ` json:"name" `
MDM struct {
MacOSSettings struct {
CustomSettings json . RawMessage ` json:"custom_settings" `
} ` json:"macos_settings" `
} ` json:"mdm" `
2023-02-15 18:01:44 +00:00
}
if err := json . Unmarshal ( tm , & spec ) ; err != nil {
// ignore, this will fail in the call to apply team specs
continue
}
2023-02-28 20:34:46 +00:00
if spec . Name != "" && len ( spec . MDM . MacOSSettings . CustomSettings ) > 0 {
2023-02-15 18:01:44 +00:00
if m == nil {
m = make ( map [ string ] [ ] string )
}
var cs [ ] string
2023-02-28 20:34:46 +00:00
if err := json . Unmarshal ( spec . MDM . MacOSSettings . CustomSettings , & cs ) ; err != nil {
2023-02-15 18:01:44 +00:00
// ignore, will fail in apply team specs call
continue
}
if cs == nil {
// to be consistent with the AppConfig custom settings, set it to an
// empty slice if the provided custom settings are present but empty.
cs = [ ] string { }
}
m [ spec . Name ] = cs
}
}
return m
}
2023-04-07 20:31:02 +00:00
// returns the macos_setup keyed by team name.
func extractTmSpecsMacOSSetup ( tmSpecs [ ] json . RawMessage ) map [ string ] * fleet . MacOSSetup {
var m map [ string ] * fleet . MacOSSetup
for _ , tm := range tmSpecs {
var spec struct {
Name string ` json:"name" `
MDM struct {
MacOSSetup fleet . MacOSSetup ` json:"macos_setup" `
} ` json:"mdm" `
}
if err := json . Unmarshal ( tm , & spec ) ; err != nil {
// ignore, this will fail in the call to apply team specs
continue
}
if spec . Name != "" {
if m == nil {
m = make ( map [ string ] * fleet . MacOSSetup )
}
m [ spec . Name ] = & spec . MDM . MacOSSetup
}
}
return m
}