mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 21:47:20 +00:00
343 lines
13 KiB
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
|
|
}
|