fleet/server/mdm/android/service/androidmgmt/proxy_client.go
Jahziel Villasana-Espinoza eb87048714
34376 android sw gitops (#36595)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #34376

# 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.

- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements)

## Testing

- [x] Added/updated automated tests
- [x] 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)
- [x] QA'd all new/changed functionality manually

## New Fleet configuration settings

If you didn't check the box above, follow this checklist for
GitOps-enabled settings:

- [x] Verified that the setting is exported via `fleetctl
generate-gitops`
- [x] Verified that the setting is cleared on the server if it is not
supplied in a YAML file (or that it is documented as being optional)
2025-12-05 20:01:57 -05:00

339 lines
12 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/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 func(string) string) 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)
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
}