fleet/server/mdm/android/service/androidmgmt/proxy_client.go

343 lines
13 KiB
Go

package androidmgmt
import (
"bytes"
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"slices"
"strings"
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/dev_mode"
"github.com/fleetdm/fleet/v4/server/mdm/android"
"github.com/go-json-experiment/json"
kitlog "github.com/go-kit/log"
"github.com/go-kit/log/level"
"google.golang.org/api/androidmanagement/v1"
"google.golang.org/api/googleapi"
"google.golang.org/api/option"
)
const defaultProxyEndpoint = "https://fleetdm.com/api/android/"
// ProxyClient connects to Google's Android Management API via a proxy, which is hosted at fleetdm.com by default.
type ProxyClient struct {
logger kitlog.Logger
mgmt *androidmanagement.Service
licenseKey string
proxyEndpoint string
fleetServerSecret string
}
// Compile-time check to ensure that ProxyClient implements Client.
var _ Client = &ProxyClient{}
func NewProxyClient(ctx context.Context, logger kitlog.Logger, licenseKey string, getenv dev_mode.GetEnv) Client {
proxyEndpoint := getenv("FLEET_DEV_ANDROID_PROXY_ENDPOINT")
if proxyEndpoint == "" {
proxyEndpoint = defaultProxyEndpoint
}
slogLogger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
if slices.Contains(groups, "request") && slices.Contains(groups, "headers") && a.Key == "Authorization" {
// Redact request Authorization headers
a.Value = slog.StringValue("REDACTED")
}
return a
},
}))
// We use the same client that we use to directly connect to Google to minimize issues/maintenance.
// But we point it to our proxy endpoint instead of Google.
mgmt, err := androidmanagement.NewService(ctx,
option.WithEndpoint(proxyEndpoint),
option.WithLogger(slogLogger),
// The API key is required to exist but not used by this client. Instead, we use the FleetServerSecret as a bearer token.
option.WithAPIKey("not_used"),
option.WithHTTPClient(fleethttp.NewClient()),
)
if err != nil {
level.Error(logger).Log("msg", "creating android management service", "err", err)
return nil
}
return &ProxyClient{
logger: logger,
mgmt: mgmt,
licenseKey: licenseKey,
proxyEndpoint: proxyEndpoint,
}
}
func (p *ProxyClient) SetAuthenticationSecret(secret string) error {
p.fleetServerSecret = secret
return nil
}
// SignupURLsCreate hits the unauthenticated endpoint of the proxy. If a record already exists for this serverURL,
// then the proxy will return a conflict error.
func (p *ProxyClient) SignupURLsCreate(ctx context.Context, serverURL, callbackURL string) (*android.SignupDetails, error) {
call := p.mgmt.SignupUrls.Create().CallbackUrl(callbackURL).Context(ctx)
call.Header().Set("Origin", serverURL)
signupURL, err := call.Do()
switch {
case isErrorCode(err, http.StatusConflict):
// The frontend looks for the text in this error. Please update the frontend code if modifying this error.
return nil, android.NewConflictError(fmt.Errorf("android enterprise already exists. For help, please contact Fleet support https://fleetdm.com/support: %w", err))
case err != nil:
return nil, fmt.Errorf("creating signup url: %w", err)
}
return &android.SignupDetails{
Url: signupURL.Url,
Name: signupURL.Name,
}, nil
}
// EnterprisesCreate hits a custom endpoint of the proxy that does not exactly match the Google API's counterpart.
// The reason is that we are passing additional information such as license key, pubSubURL, etc. Because of that,
// we use a separate HTTP client in this method.
func (p *ProxyClient) EnterprisesCreate(ctx context.Context, req EnterprisesCreateRequest) (EnterprisesCreateResponse, error) {
type proxyEnterprise struct {
FleetLicenseKey string `json:"fleetLicenseKey"`
PubSubPushURL string `json:"pubsubPushUrl"`
EnterpriseToken string `json:"enterpriseToken"`
SignupURLName string `json:"signupUrlName"`
Enterprise androidmanagement.Enterprise `json:"enterprise"`
}
client := fleethttp.NewClient()
pe := proxyEnterprise{
FleetLicenseKey: p.licenseKey,
PubSubPushURL: req.PubSubPushURL,
EnterpriseToken: req.EnterpriseToken,
SignupURLName: req.SignupURLName,
Enterprise: androidmanagement.Enterprise{
EnabledNotificationTypes: req.EnabledNotificationTypes,
},
}
reqBody, err := json.Marshal(pe)
if err != nil {
return EnterprisesCreateResponse{}, fmt.Errorf("marshaling enterprise request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, "POST", p.proxyEndpoint+"v1/enterprises", bytes.NewBuffer(reqBody))
if err != nil {
return EnterprisesCreateResponse{}, fmt.Errorf("creating enterprise request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Origin", req.ServerURL)
resp, err := client.Do(httpReq)
if err != nil {
return EnterprisesCreateResponse{}, fmt.Errorf("sending enterprise request: %w", err)
}
defer resp.Body.Close()
switch {
case resp.StatusCode == http.StatusNotModified:
return EnterprisesCreateResponse{}, fmt.Errorf("android enterprise %s was already created", req.SignupURLName)
case resp.StatusCode != http.StatusOK:
return EnterprisesCreateResponse{}, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
type proxyEnterpriseResponse struct {
FleetServerSecret string `json:"fleetServerSecret"`
Name string `json:"name"`
}
var per proxyEnterpriseResponse
if err := json.UnmarshalRead(resp.Body, &per); err != nil {
return EnterprisesCreateResponse{}, fmt.Errorf("decoding enterprise response: %w", err)
}
return EnterprisesCreateResponse{
EnterpriseName: per.Name,
FleetServerSecret: per.FleetServerSecret,
}, nil
}
type PoliciesPatchOpts struct {
// OnlyUpdateApps tells the client to only update the policy's application list.
OnlyUpdateApps bool
// ExcludeApps tells the client to not update the policy's application list.
ExcludeApps bool
}
func (p *ProxyClient) EnterprisesPoliciesPatch(ctx context.Context, policyName string, policy *androidmanagement.Policy, opts PoliciesPatchOpts) (*androidmanagement.Policy, error) {
call := p.mgmt.Enterprises.Policies.Patch(policyName, policy).Context(ctx)
switch {
case opts.ExcludeApps:
call = call.UpdateMask(policyFieldMask)
case opts.OnlyUpdateApps:
call = call.UpdateMask("applications")
}
call.Header().Set("Authorization", "Bearer "+p.fleetServerSecret)
ret, err := call.Do()
switch {
case googleapi.IsNotModified(err):
p.logger.Log("msg", "Android policy not modified", "policy_name", policyName)
return nil, err
case err != nil:
return nil, fmt.Errorf("patching policy %s: %w", policyName, err)
}
return ret, nil
}
func (p *ProxyClient) EnterprisesDevicesPatch(ctx context.Context, deviceName string, device *androidmanagement.Device) (*androidmanagement.Device, error) {
call := p.mgmt.Enterprises.Devices.Patch(deviceName, device).Context(ctx)
call.Header().Set("Authorization", "Bearer "+p.fleetServerSecret)
ret, err := call.Do()
switch {
case googleapi.IsNotModified(err):
p.logger.Log("msg", "Android device not modified", "device_name", deviceName)
return nil, err
case err != nil:
return nil, fmt.Errorf("patching device %s: %w", deviceName, err)
}
return ret, nil
}
func (p *ProxyClient) EnterprisesDevicesGet(ctx context.Context, deviceName string) (*androidmanagement.Device, error) {
call := p.mgmt.Enterprises.Devices.Get(deviceName).Context(ctx)
call.Header().Set("Authorization", "Bearer "+p.fleetServerSecret)
ret, err := call.Do()
if err != nil {
return nil, fmt.Errorf("getting device %s: %w", deviceName, err)
}
return ret, nil
}
func (p *ProxyClient) EnterprisesDevicesDelete(ctx context.Context, deviceName string) error {
call := p.mgmt.Enterprises.Devices.Delete(deviceName).Context(ctx)
call.Header().Set("Authorization", "Bearer "+p.fleetServerSecret)
_, err := call.Do()
switch {
case googleapi.IsNotModified(err) || isErrorCode(err, http.StatusNotFound):
p.logger.Log("msg", "Android device already deleted", "device_name", deviceName)
return nil
case err != nil:
return fmt.Errorf("deleting device %s: %w", deviceName, err)
}
return nil
}
func (p *ProxyClient) EnterprisesDevicesListPartial(ctx context.Context, enterpriseName string, pageToken string) (*androidmanagement.ListDevicesResponse, error) {
call := p.mgmt.Enterprises.Devices.List(enterpriseName).Context(ctx).PageToken(pageToken).PageSize(100).Fields("nextPageToken", "devices/name")
call.Header().Set("Authorization", "Bearer "+p.fleetServerSecret)
resp, err := call.Do()
if err != nil {
return nil, fmt.Errorf("listing devices: %w", err)
}
return resp, nil
}
func (p *ProxyClient) EnterprisesEnrollmentTokensCreate(ctx context.Context, enterpriseName string,
token *androidmanagement.EnrollmentToken,
) (*androidmanagement.EnrollmentToken, error) {
call := p.mgmt.Enterprises.EnrollmentTokens.Create(enterpriseName, token).Context(ctx)
call.Header().Set("Authorization", "Bearer "+p.fleetServerSecret)
token, err := call.Do()
if err != nil {
return nil, fmt.Errorf("creating enrollment token: %w", err)
}
return token, nil
}
func (p *ProxyClient) EnterpriseDelete(ctx context.Context, enterpriseName string) error {
call := p.mgmt.Enterprises.Delete(enterpriseName).Context(ctx)
call.Header().Set("Authorization", "Bearer "+p.fleetServerSecret)
_, err := call.Do()
switch {
case googleapi.IsNotModified(err) || isErrorCode(err, http.StatusNotFound):
level.Info(p.logger).Log("msg", "enterprise was already deleted", "enterprise_name", enterpriseName)
return nil
case err != nil:
return fmt.Errorf("deleting enterprise %s: %w", enterpriseName, err)
}
return nil
}
func (p *ProxyClient) EnterprisesList(ctx context.Context, serverURL string) ([]*androidmanagement.Enterprise, error) {
call := p.mgmt.Enterprises.List().Context(ctx)
call.Header().Set("Authorization", "Bearer "+p.fleetServerSecret)
call.Header().Set("Origin", serverURL)
// NOTE: we don't call .Pages(...) here because the Fleet proxy takes care of
// listing enterprises on all pages and filtering those that belong to this Fleet instance:
// https://github.com/fleetdm/fleet/blob/ac960d64fce49175b4f3ee396ed30c27824450ea/website/api/controllers/android-proxy/get-android-enterprises.js#L74-L91
resp, err := call.Do()
if err != nil {
// Convert proxy errors to proper googleapi.Error for service layer
var ae *googleapi.Error
switch {
case errors.As(err, &ae):
// Already a googleapi.Error, pass through
return nil, err
case isErrorCode(err, http.StatusForbidden):
// Convert 403 from proxy to proper googleapi.Error
return nil, &googleapi.Error{
Code: http.StatusForbidden,
Message: "Enterprises list access forbidden",
}
default:
return nil, fmt.Errorf("listing enterprises: %w", err)
}
}
return resp.Enterprises, nil
}
func isErrorCode(err error, code int) bool {
if err == nil {
return false
}
var ae *googleapi.Error
ok := errors.As(err, &ae)
return ok && ae.Code == code
}
func (p *ProxyClient) EnterprisesApplications(ctx context.Context, enterpriseName, packageName string) (*androidmanagement.Application, error) {
path := fmt.Sprintf("%s/applications/%s", enterpriseName, packageName)
call := p.mgmt.Enterprises.Applications.Get(path).Context(ctx)
call.Header().Set("Authorization", "Bearer "+p.fleetServerSecret)
app, err := call.Do()
if err != nil {
if isErrorCode(err, http.StatusNotFound) || (isErrorCode(err, http.StatusInternalServerError) && strings.Contains(err.Error(), "Requested entity was not found")) {
// For some reason, the AMAPI can return a 500 when an app is not found.
return nil, ctxerr.Wrap(ctx, appNotFoundError{})
}
return nil, fmt.Errorf("getting application %s: %w", packageName, err)
}
return app, nil
}
func (p *ProxyClient) EnterprisesPoliciesModifyPolicyApplications(ctx context.Context, policyName string, appPolicies []*androidmanagement.ApplicationPolicy) (*androidmanagement.Policy, error) {
var changes []*androidmanagement.ApplicationPolicyChange
for _, p := range appPolicies {
changes = append(changes, &androidmanagement.ApplicationPolicyChange{
Application: p,
})
}
req := androidmanagement.ModifyPolicyApplicationsRequest{
Changes: changes,
}
call := p.mgmt.Enterprises.Policies.ModifyPolicyApplications(policyName, &req).Context(ctx)
call.Header().Set("Authorization", "Bearer "+p.fleetServerSecret)
ret, err := call.Do()
switch {
case googleapi.IsNotModified(err):
p.logger.Log("msg", "Android application policy not modified", "policy_name", policyName)
return nil, err
case err != nil:
return nil, ctxerr.Wrapf(ctx, err, "modifying application policy %s", policyName)
}
return ret.Policy, nil
}