fleet/server/mdm/android/service/service.go
Jahziel Villasana-Espinoza 0a3c6c35d3
Android software ingestion (#33826)
> Closes #33581 


<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #

# 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] QA'd all new/changed functionality manually

## Database migrations

- [x] Checked table schema to confirm autoupdate
- [x] Checked schema for all modified table for columns that will
auto-update timestamps during migration.
- [x] Ensured the correct collation is explicitly set for character
columns (`COLLATE utf8mb4_unicode_ci`).

---------

Co-authored-by: RachelElysia <rachel@fleetdm.com>
2025-10-08 10:24:38 -04:00

857 lines
30 KiB
Go

package service
import (
"context"
_ "embed"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/fleetdm/fleet/v4/pkg/mdm"
shared_mdm "github.com/fleetdm/fleet/v4/pkg/mdm"
"github.com/fleetdm/fleet/v4/server"
"github.com/fleetdm/fleet/v4/server/authz"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/android"
"github.com/fleetdm/fleet/v4/server/mdm/android/service/androidmgmt"
kitlog "github.com/go-kit/log"
"github.com/go-kit/log/level"
"google.golang.org/api/androidmanagement/v1"
"google.golang.org/api/googleapi"
)
// Used for overriding the private key validation in testing
var testSetEmptyPrivateKey bool
// We use numbers for policy names for easier mapping/indexing with Fleet DB.
const (
defaultAndroidPolicyID = 1
DefaultSignupSSEInterval = 3 * time.Second
SignupSSESuccess = "Android Enterprise successfully connected"
)
type Service struct {
logger kitlog.Logger
authz *authz.Authorizer
ds fleet.AndroidDatastore
fleetDS fleet.Datastore
androidAPIClient androidmgmt.Client
fleetSvc fleet.Service
serverPrivateKey string
// SignupSSEInterval can be overwritten in tests.
SignupSSEInterval time.Duration
// AllowLocalhostServerURL is set during tests.
AllowLocalhostServerURL bool
}
func NewService(
ctx context.Context,
logger kitlog.Logger,
ds fleet.AndroidDatastore,
fleetSvc fleet.Service,
licenseKey string,
serverPrivateKey string,
fleetDS fleet.Datastore,
) (android.Service, error) {
client := newAMAPIClient(ctx, logger, licenseKey)
return NewServiceWithClient(logger, ds, client, fleetSvc, serverPrivateKey, fleetDS)
}
func NewServiceWithClient(
logger kitlog.Logger,
ds fleet.AndroidDatastore,
client androidmgmt.Client,
fleetSvc fleet.Service,
serverPrivateKey string,
fleetDS fleet.Datastore,
) (android.Service, error) {
authorizer, err := authz.NewAuthorizer()
if err != nil {
return nil, fmt.Errorf("new authorizer: %w", err)
}
return &Service{
logger: logger,
authz: authorizer,
ds: ds,
androidAPIClient: client,
fleetSvc: fleetSvc,
serverPrivateKey: serverPrivateKey,
SignupSSEInterval: DefaultSignupSSEInterval,
fleetDS: fleetDS,
}, nil
}
func newAMAPIClient(ctx context.Context, logger kitlog.Logger, licenseKey string) androidmgmt.Client {
var client androidmgmt.Client
if os.Getenv("FLEET_DEV_ANDROID_GOOGLE_CLIENT") == "1" || strings.ToUpper(os.Getenv("FLEET_DEV_ANDROID_GOOGLE_CLIENT")) == "ON" {
client = androidmgmt.NewGoogleClient(ctx, logger, os.Getenv)
} else {
client = androidmgmt.NewProxyClient(ctx, logger, licenseKey, os.Getenv)
}
return client
}
func newErrResponse(err error) android.DefaultResponse {
return android.DefaultResponse{Err: err}
}
func enterpriseSignupEndpoint(ctx context.Context, _ interface{}, svc android.Service) fleet.Errorer {
result, err := svc.EnterpriseSignup(ctx)
if err != nil {
return newErrResponse(err)
}
return android.EnterpriseSignupResponse{Url: result.Url}
}
func (svc *Service) EnterpriseSignup(ctx context.Context) (*android.SignupDetails, error) {
if err := svc.authz.Authorize(ctx, &android.Enterprise{}, fleet.ActionWrite); err != nil {
return nil, err
}
// Check if server private key is configured (required for Android MDM)
if err := svc.checkServerPrivateKey(ctx); err != nil {
return nil, err
}
// Before checking if Android is already configured, verify if existing enterprise still exists
// This ensures we detect enterprise deletion even if user goes directly to signup page
if err := svc.verifyExistingEnterpriseIfAny(ctx); err != nil {
// If verification returns NotFound (enterprise was deleted), continue with signup
// Other errors should be returned as-is
if !fleet.IsNotFound(err) {
return nil, err
}
}
appConfig, err := svc.checkIfAndroidAlreadyConfigured(ctx)
if err != nil {
return nil, err
}
serverURL := appConfig.ServerSettings.ServerURL
u, err := url.Parse(serverURL)
if err != nil {
return nil, &fleet.BadRequestError{Message: "parsing Fleet server URL: " + err.Error(), InternalErr: err}
}
if !svc.AllowLocalhostServerURL {
host := u.Hostname()
if host == "localhost" || host == "127.0.0.1" || host == "::1" {
return nil, &fleet.BadRequestError{Message: fmt.Sprintf("Android Enterprise cannot be enabled with localhost server URL: %s", serverURL)}
}
}
vc, ok := viewer.FromContext(ctx)
if !ok {
return nil, fleet.ErrNoContext
}
id, err := svc.ds.CreateEnterprise(ctx, vc.User.ID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "creating enterprise")
}
// signupToken is used to authenticate the signup callback URL -- to ensure that the callback came from our Android enterprise signup flow
signupToken, err := server.GenerateRandomURLSafeText(32)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "generating Android enterprise signup token")
}
callbackURL := fmt.Sprintf("%s/api/v1/fleet/android_enterprise/connect/%s", appConfig.ServerSettings.ServerURL, signupToken)
signupDetails, err := svc.androidAPIClient.SignupURLsCreate(ctx, appConfig.ServerSettings.ServerURL, callbackURL)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "creating signup url")
}
err = svc.ds.UpdateEnterprise(ctx, &android.EnterpriseDetails{
Enterprise: android.Enterprise{
ID: id,
},
SignupName: signupDetails.Name,
SignupToken: signupToken,
})
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "updating enterprise")
}
return signupDetails, nil
}
func (svc *Service) checkServerPrivateKey(ctx context.Context) error {
if testSetEmptyPrivateKey {
return &fleet.BadRequestError{
Message: "missing required private key. Learn how to configure the private key here: https://fleetdm.com/learn-more-about/fleet-server-private-key",
}
}
if svc.serverPrivateKey == "" {
return &fleet.BadRequestError{
Message: "missing required private key. Learn how to configure the private key here: https://fleetdm.com/learn-more-about/fleet-server-private-key",
}
}
return nil
}
func (svc *Service) checkIfAndroidAlreadyConfigured(ctx context.Context) (*fleet.AppConfig, error) {
appConfig, err := svc.ds.AppConfig(ctx)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting app config")
}
if appConfig.MDM.AndroidEnabledAndConfigured {
return nil, fleet.NewInvalidArgumentError("android",
"Android is already enabled and configured").WithStatus(http.StatusConflict)
}
return appConfig, nil
}
type enterpriseSignupCallbackRequest struct {
SignupToken string `url:"token"`
EnterpriseToken string `query:"enterpriseToken"`
}
type enterpriseSignupCallbackResponse struct {
Err error `json:"error,omitempty"`
}
func (res enterpriseSignupCallbackResponse) Error() error { return res.Err }
//go:embed enterpriseCallback.html
var enterpriseCallbackHTML []byte
func (res enterpriseSignupCallbackResponse) HijackRender(_ context.Context, w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/html; charset=UTF-8")
_, _ = w.Write(enterpriseCallbackHTML)
}
func enterpriseSignupCallbackEndpoint(ctx context.Context, request interface{}, svc android.Service) fleet.Errorer {
req := request.(*enterpriseSignupCallbackRequest)
err := svc.EnterpriseSignupCallback(ctx, req.SignupToken, req.EnterpriseToken)
return enterpriseSignupCallbackResponse{Err: err}
}
// EnterpriseSignupCallback handles the callback from Google UI during signup flow.
// signupToken is for authentication with Fleet server
// enterpriseToken is for authentication with Google
func (svc *Service) EnterpriseSignupCallback(ctx context.Context, signupToken string, enterpriseToken string) error {
// Authorization is done by GetEnterpriseBySignupToken below.
// We call SkipAuthorization here to avoid explicitly calling it when errors occur.
// Also, this method call will fail if ProxyClient (Google Project) is not configured.
svc.authz.SkipAuthorization(ctx)
appConfig, err := svc.checkIfAndroidAlreadyConfigured(ctx)
if err != nil {
return err
}
enterprise, err := svc.ds.GetEnterpriseBySignupToken(ctx, signupToken)
switch {
case fleet.IsNotFound(err):
return authz.ForbiddenWithInternal("invalid signup token", nil, nil, nil)
case err != nil:
return ctxerr.Wrap(ctx, err, "getting enterprise")
}
// pubSubToken is used to authenticate the pubsub push endpoint -- to ensure that the push came from our Android enterprise
pubSubToken, err := server.GenerateRandomURLSafeText(64)
if err != nil {
return ctxerr.Wrap(ctx, err, "generating pubsub token")
}
err = svc.ds.InsertOrReplaceMDMConfigAsset(ctx, fleet.MDMConfigAsset{
Name: fleet.MDMAssetAndroidPubSubToken,
Value: []byte(pubSubToken),
})
if err != nil {
return ctxerr.Wrap(ctx, err, "inserting pubsub authentication token")
}
createRsp, err := svc.androidAPIClient.EnterprisesCreate(
ctx,
androidmgmt.EnterprisesCreateRequest{
Enterprise: androidmanagement.Enterprise{
EnabledNotificationTypes: []string{
string(android.PubSubEnrollment),
string(android.PubSubStatusReport),
string(android.PubSubCommand),
string(android.PubSubUsageLogs),
},
},
EnterpriseToken: enterpriseToken,
SignupURLName: enterprise.SignupName,
PubSubPushURL: appConfig.ServerSettings.ServerURL + pubSubPushPath + "?token=" + pubSubToken,
ServerURL: appConfig.ServerSettings.ServerURL,
},
)
if err != nil {
return ctxerr.Wrap(ctx, err, "creating enterprise")
}
if createRsp.FleetServerSecret != "" {
err = svc.ds.InsertOrReplaceMDMConfigAsset(ctx, fleet.MDMConfigAsset{
Name: fleet.MDMAssetAndroidFleetServerSecret,
Value: []byte(createRsp.FleetServerSecret),
})
if err != nil {
return ctxerr.Wrap(ctx, err, "inserting pubsub authentication token")
}
_ = svc.androidAPIClient.SetAuthenticationSecret(createRsp.FleetServerSecret)
}
enterpriseID := strings.TrimPrefix(createRsp.EnterpriseName, "enterprises/")
enterprise.EnterpriseID = enterpriseID
if createRsp.TopicName != "" {
topicID, err := topicIDFromName(createRsp.TopicName)
if err != nil {
return ctxerr.Wrap(ctx, err, "parsing topic name")
}
enterprise.TopicID = topicID
}
err = svc.ds.UpdateEnterprise(ctx, enterprise)
if err != nil {
return ctxerr.Wrap(ctx, err, "updating enterprise")
}
policyName := fmt.Sprintf("%s/policies/%s", enterprise.Name(), fmt.Sprintf("%d", defaultAndroidPolicyID))
_, err = svc.androidAPIClient.EnterprisesPoliciesPatch(ctx, policyName, &androidmanagement.Policy{
StatusReportingSettings: &androidmanagement.StatusReportingSettings{
DeviceSettingsEnabled: true,
MemoryInfoEnabled: true,
NetworkInfoEnabled: true,
DisplayInfoEnabled: true,
PowerManagementEventsEnabled: true,
HardwareStatusEnabled: true,
SystemPropertiesEnabled: true,
SoftwareInfoEnabled: true, // Android OS version, etc.
CommonCriteriaModeEnabled: true,
// Application inventory will likely be a Premium feature.
// applicationReports take a lot of space in device status reports. They are not free -- our current cost is $40 per TiB (2025-02-20).
// We should disable them for free accounts. To enable them for a server transitioning from Free to Premium, we will need to patch the existing policies.
// For server transitioning from Premium to Free, we will need to patch the existing policies to disable software inventory, which could also be done
// by the fleetdm.com androidAPIClient or manually. The androidAPIClient could also enforce this report setting.
ApplicationReportsEnabled: false,
ApplicationReportingSettings: nil,
},
})
if err != nil && !androidmgmt.IsNotModifiedError(err) {
return ctxerr.Wrapf(ctx, err, "patching %d policy", defaultAndroidPolicyID)
}
err = svc.ds.DeleteOtherEnterprises(ctx, enterprise.ID)
if err != nil {
return ctxerr.Wrap(ctx, err, "deleting temp enterprises")
}
err = svc.ds.SetAndroidEnabledAndConfigured(ctx, true)
if err != nil {
return ctxerr.Wrap(ctx, err, "setting android enabled and configured")
}
user, err := svc.ds.UserOrDeletedUserByID(ctx, enterprise.UserID)
switch {
case fleet.IsNotFound(err):
// This should never happen.
level.Error(svc.logger).Log("msg", "User that created the Android enterprise was not found", "user_id", enterprise.UserID)
case err != nil:
return ctxerr.Wrap(ctx, err, "getting user")
}
if err = svc.fleetSvc.NewActivity(ctx, user, fleet.ActivityTypeEnabledAndroidMDM{}); err != nil {
return ctxerr.Wrap(ctx, err, "create activity for enabled Android MDM")
}
return nil
}
func topicIDFromName(name string) (string, error) {
lastSlash := strings.LastIndex(name, "/")
if lastSlash == -1 || lastSlash == len(name)-1 {
return "", fmt.Errorf("topic name %s is not a fully-qualified name", name)
}
return name[lastSlash+1:], nil
}
func getEnterpriseEndpoint(ctx context.Context, _ interface{}, svc android.Service) fleet.Errorer {
enterprise, err := svc.GetEnterprise(ctx)
if err != nil {
return android.DefaultResponse{Err: err}
}
return android.GetEnterpriseResponse{EnterpriseID: enterprise.EnterpriseID}
}
func (svc *Service) GetEnterprise(ctx context.Context) (*android.Enterprise, error) {
if err := svc.authz.Authorize(ctx, &android.Enterprise{}, fleet.ActionRead); err != nil {
return nil, err
}
enterprise, err := svc.ds.GetEnterprise(ctx)
switch {
case fleet.IsNotFound(err):
return nil, fleet.NewInvalidArgumentError("enterprise", "No enterprise found").WithStatus(http.StatusNotFound)
case err != nil:
return nil, ctxerr.Wrap(ctx, err, "getting enterprise")
}
// Verify the enterprise still exists via Google API using shared method
if err := svc.verifyEnterpriseExistsWithGoogle(ctx, enterprise); err != nil {
return nil, err
}
return enterprise, nil
}
func deleteEnterpriseEndpoint(ctx context.Context, _ interface{}, svc android.Service) fleet.Errorer {
err := svc.DeleteEnterprise(ctx)
return android.DefaultResponse{Err: err}
}
func (svc *Service) DeleteEnterprise(ctx context.Context) error {
if err := svc.authz.Authorize(ctx, &android.Enterprise{}, fleet.ActionWrite); err != nil {
return err
}
// Get enterprise
enterprise, err := svc.ds.GetEnterprise(ctx)
switch {
case fleet.IsNotFound(err):
// No enterprise to delete
case err != nil:
return ctxerr.Wrap(ctx, err, "getting enterprise")
default:
secret, err := svc.getClientAuthenticationSecret(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting client authentication secret")
}
_ = svc.androidAPIClient.SetAuthenticationSecret(secret)
err = svc.androidAPIClient.EnterpriseDelete(ctx, enterprise.Name())
if err != nil {
return ctxerr.Wrap(ctx, err, "deleting enterprise via Google API")
}
}
err = svc.ds.DeleteAllEnterprises(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "deleting enterprises")
}
err = svc.ds.SetAndroidEnabledAndConfigured(ctx, false)
if err != nil {
return ctxerr.Wrap(ctx, err, "clearing android enabled and configured")
}
err = svc.ds.BulkSetAndroidHostsUnenrolled(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "bulk set android hosts as unenrolled")
}
if err = svc.fleetSvc.NewActivity(ctx, authz.UserFromContext(ctx), fleet.ActivityTypeDisabledAndroidMDM{}); err != nil {
return ctxerr.Wrap(ctx, err, "create activity for disabled Android MDM")
}
err = svc.ds.DeleteMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetAndroidPubSubToken, fleet.MDMAssetAndroidFleetServerSecret})
if err != nil {
return ctxerr.Wrap(ctx, err, "deleting MDM Android encrypted config assets")
}
return nil
}
type enrollmentTokenRequest struct {
EnrollSecret string `query:"enroll_secret"`
IdpUUID string // The UUID of the mdm_idp_account that was used if any, can be empty, will be taken from cookies
}
func (enrollmentTokenRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) {
enrollSecret := r.URL.Query().Get("enroll_secret")
if enrollSecret == "" {
return nil, &fleet.BadRequestError{
Message: "enroll_secret is required",
}
}
byodIdpCookie, err := r.Cookie(mdm.BYODIdpCookieName)
if err == http.ErrNoCookie {
// We do not fail here if no cookie is found, we validate later down the line if it's required
return &enrollmentTokenRequest{
EnrollSecret: enrollSecret,
IdpUUID: "",
}, nil
}
if err != nil {
return nil, &fleet.BadRequestError{
Message: "something went wrong parsing the boyd idp cookie",
InternalErr: err,
}
}
if err = byodIdpCookie.Valid(); err != nil {
return nil, &fleet.BadRequestError{
Message: "boyd idp cookie is not valid",
InternalErr: err,
}
}
return &enrollmentTokenRequest{
EnrollSecret: enrollSecret,
IdpUUID: byodIdpCookie.Value,
}, nil
}
func enrollmentTokenEndpoint(ctx context.Context, request interface{}, svc android.Service) fleet.Errorer {
req := request.(*enrollmentTokenRequest)
token, err := svc.CreateEnrollmentToken(ctx, req.EnrollSecret, req.IdpUUID)
if err != nil {
return android.DefaultResponse{Err: err}
}
return android.EnrollmentTokenResponse{EnrollmentToken: token}
}
func (svc *Service) CreateEnrollmentToken(ctx context.Context, enrollSecret, idpUUID string) (*android.EnrollmentToken, error) {
// Authorization is done by VerifyEnrollSecret below.
// We call SkipAuthorization here to avoid explicitly calling it when errors occur.
svc.authz.SkipAuthorization(ctx)
_, err := svc.checkIfAndroidNotConfigured(ctx)
if err != nil {
return nil, err
}
_, err = svc.ds.VerifyEnrollSecret(ctx, enrollSecret)
switch {
case fleet.IsNotFound(err):
return nil, fleet.NewAuthFailedError("invalid secret")
case err != nil:
return nil, ctxerr.Wrap(ctx, err, "verifying enroll secret")
}
appCfg, err := svc.ds.AppConfig(ctx)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting app config")
}
requiresIdPUUID, err := shared_mdm.RequiresEnrollOTAAuthentication(ctx, svc.ds, enrollSecret, appCfg.MDM.MacOSSetup.EnableEndUserAuthentication)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "checking requirement of ota enrollment authentication")
}
if requiresIdPUUID && idpUUID == "" {
return nil, fleet.NewAuthFailedError("required idp uuid to be set, but none found")
}
if idpUUID != "" {
_, err := svc.ds.GetMDMIdPAccountByUUID(ctx, idpUUID)
if err != nil {
iae := &fleet.InvalidArgumentError{}
iae.Append("IDP UUID", "Failed validating IDP account existence")
return nil, ctxerr.Wrap(ctx, iae)
}
}
enterprise, err := svc.ds.GetEnterprise(ctx)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting enterprise")
}
secret, err := svc.getClientAuthenticationSecret(ctx)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting client authentication secret")
}
_ = svc.androidAPIClient.SetAuthenticationSecret(secret)
enrollmentTokenRequest, err := json.Marshal(enrollmentTokenRequest{
EnrollSecret: enrollSecret,
IdpUUID: idpUUID,
})
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "marshalling enrollment token request")
}
token := &androidmanagement.EnrollmentToken{
// Default duration is 1 hour
AdditionalData: string(enrollmentTokenRequest),
AllowPersonalUsage: "PERSONAL_USAGE_ALLOWED",
PolicyName: fmt.Sprintf("%s/policies/%d", enterprise.Name(), +defaultAndroidPolicyID),
OneTimeOnly: true,
}
token, err = svc.androidAPIClient.EnterprisesEnrollmentTokensCreate(ctx, enterprise.Name(), token)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "creating Android enrollment token")
}
return &android.EnrollmentToken{
EnrollmentToken: token.Value,
EnrollmentURL: "https://enterprise.google.com/android/enroll?et=" + token.Value,
}, nil
}
func (svc *Service) checkIfAndroidNotConfigured(ctx context.Context) (*fleet.AppConfig, error) {
// This call uses cached_mysql implementation, so it's safe to call it multiple times
appConfig, err := svc.ds.AppConfig(ctx)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting app config")
}
if !appConfig.MDM.AndroidEnabledAndConfigured {
return nil, fleet.NewInvalidArgumentError("android",
"Android MDM is NOT configured").WithStatus(http.StatusConflict)
}
return appConfig, nil
}
type enterpriseSSEResponse struct {
android.DefaultResponse
done chan string
}
func (r enterpriseSSEResponse) HijackRender(_ context.Context, w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
w.Header().Set("Transfer-Encoding", "chunked")
if r.done == nil {
w.WriteHeader(http.StatusInternalServerError)
_, _ = fmt.Fprint(w, "Error: No SSE data available")
return
}
w.WriteHeader(http.StatusOK)
w.(http.Flusher).Flush()
for {
select {
case data, ok := <-r.done:
if ok {
_, _ = fmt.Fprint(w, data)
w.(http.Flusher).Flush()
}
return
case <-time.After(5 * time.Second):
// We send a heartbeat to prevent the load balancer from closing the (otherwise idle) connection.
// The leading colon indicates this is a comment, and is ignored.
// https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events
_, _ = fmt.Fprint(w, ":heartbeat\n")
w.(http.Flusher).Flush()
}
}
}
func enterpriseSSE(ctx context.Context, _ interface{}, svc android.Service) fleet.Errorer {
done, err := svc.EnterpriseSignupSSE(ctx)
if err != nil {
return android.DefaultResponse{Err: err}
}
return enterpriseSSEResponse{done: done}
}
func (svc *Service) EnterpriseSignupSSE(ctx context.Context) (chan string, error) {
if err := svc.authz.Authorize(ctx, &android.Enterprise{}, fleet.ActionRead); err != nil {
return nil, err
}
done := make(chan string)
go func() {
if svc.signupSSECheck(ctx, done) {
return
}
for {
select {
case <-ctx.Done():
level.Debug(svc.logger).Log("msg", "Context cancelled during Android signup SSE")
return
case <-time.After(svc.SignupSSEInterval):
if svc.signupSSECheck(ctx, done) {
return
}
}
}
}()
return done, nil
}
func (svc *Service) signupSSECheck(ctx context.Context, done chan string) bool {
appConfig, err := svc.ds.AppConfig(ctx)
if err != nil {
done <- fmt.Sprintf("Error getting app config: %v", err)
return true
}
if appConfig.MDM.AndroidEnabledAndConfigured {
done <- SignupSSESuccess
return true
}
return false
}
// verifyEnterpriseExistsWithGoogle verifies if the given enterprise still exists in Google API.
// Uses LIST-first approach for efficiency and better error handling.
// Returns fleet.IsNotFound error if enterprise was deleted, nil if verification passed.
func (svc *Service) verifyEnterpriseExistsWithGoogle(ctx context.Context, enterprise *android.Enterprise) error {
secret, err := svc.getClientAuthenticationSecret(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting client authentication secret")
}
_ = svc.androidAPIClient.SetAuthenticationSecret(secret)
// Get server URL from app config
appConfig, err := svc.ds.AppConfig(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting app config")
}
// Use LIST API as primary verification method
enterprises, err := svc.androidAPIClient.EnterprisesList(ctx, appConfig.ServerSettings.ServerURL)
if err != nil {
var gerr *googleapi.Error
if errors.As(err, &gerr) {
switch gerr.Code {
case http.StatusNotFound:
// Special case: 404 from proxy with deletion confirmation
if strings.Contains(gerr.Message, "PROXY_VERIFIED_DELETED:") {
level.Info(svc.logger).Log("msg", "enterprise confirmed deleted by proxy", "enterpriseID", enterprise.EnterpriseID)
svc.cleanupDeletedEnterprise(ctx, enterprise, enterprise.EnterpriseID)
return fleet.NewInvalidArgumentError("enterprise", "Android Enterprise has been deleted").WithStatus(http.StatusNotFound)
}
case http.StatusBadRequest:
// Bad request might indicate missing headers or invalid request format
// Don't delete the enterprise in this case
level.Error(svc.logger).Log("msg", "bad request when verifying enterprise", "error", err)
return fmt.Errorf("verifying enterprise with Google: %s (check request headers and format)", err.Error())
case http.StatusUnauthorized, http.StatusForbidden:
// Authentication/authorization issues - don't delete the enterprise
level.Error(svc.logger).Log("msg", "authentication/authorization error when verifying enterprise", "error", err)
return fmt.Errorf("verifying enterprise with Google: authentication error: %w", err)
}
}
// LIST failed - this is likely a technical issue, not deletion
// Log the error but don't delete the enterprise
level.Error(svc.logger).Log("msg", "failed to list enterprises", "error", err)
return fmt.Errorf("verifying enterprise with Google: %s", err.Error())
}
// Check if our enterprise is in the list
enterpriseID := strings.TrimPrefix(enterprise.EnterpriseID, "enterprises/")
for _, ent := range enterprises {
if strings.HasSuffix(ent.Name, enterpriseID) {
// Enterprise exists - verification passed
return nil
}
}
// Enterprise NOT in list - it's deleted - perform cleanup
level.Info(svc.logger).Log("msg", "enterprise confirmed deleted via LIST API", "enterpriseID", enterpriseID)
svc.cleanupDeletedEnterprise(ctx, enterprise, enterpriseID)
return fleet.NewInvalidArgumentError("enterprise", "Android Enterprise has been deleted").WithStatus(http.StatusNotFound)
}
// verifyExistingEnterpriseIfAny checks if there's an existing enterprise in the database
// and if so, verifies it still exists in Google API. If it doesn't exist, performs cleanup.
// Returns fleet.IsNotFound error if enterprise was deleted, nil if no enterprise exists or verification passed.
func (svc *Service) verifyExistingEnterpriseIfAny(ctx context.Context) error {
// Check if there's an existing enterprise
enterprise, err := svc.ds.GetEnterprise(ctx)
switch {
case fleet.IsNotFound(err):
// No enterprise exists - this is fine for signup
return nil
case err != nil:
return ctxerr.Wrap(ctx, err, "checking for existing enterprise")
}
// Enterprise exists - verify it using the shared method
return svc.verifyEnterpriseExistsWithGoogle(ctx, enterprise)
}
// cleanupDeletedEnterprise performs the complete cleanup when an enterprise deletion is detected
func (svc *Service) cleanupDeletedEnterprise(ctx context.Context, enterprise *android.Enterprise, enterpriseID string) {
// Clean up proxy database records by calling proxy DELETE endpoint
// This ensures the proxy won't return conflicts when creating new signup URLs
if deleteErr := svc.androidAPIClient.EnterpriseDelete(ctx, enterprise.Name()); deleteErr != nil {
level.Warn(svc.logger).Log("msg", "failed to delete proxy records after enterprise deletion (may not exist)", "err", deleteErr)
}
// Delete local enterprise records
if deleteErr := svc.ds.DeleteAllEnterprises(ctx); deleteErr != nil {
level.Error(svc.logger).Log("msg", "failed to delete local enterprise records after deletion", "err", deleteErr)
}
// Turn off Android MDM
if setErr := svc.ds.SetAndroidEnabledAndConfigured(ctx, false); setErr != nil {
level.Error(svc.logger).Log("msg", "failed to turn off Android MDM after enterprise deletion", "err", setErr)
}
// Unenroll Android hosts
if unenrollErr := svc.ds.BulkSetAndroidHostsUnenrolled(ctx); unenrollErr != nil {
level.Error(svc.logger).Log("msg", "failed to unenroll Android hosts after enterprise deletion", "err", unenrollErr)
}
}
// Admin-initiated Android unenroll
// Request decoder for POST /api/_version_/fleet/hosts/{id}/mdm/unenroll
type androidHostUnenrollRequest struct {
HostID uint `url:"id"`
}
func unenrollAndroidHostEndpoint(ctx context.Context, request interface{}, svc android.Service) fleet.Errorer {
req := request.(*androidHostUnenrollRequest)
err := svc.UnenrollAndroidHost(ctx, req.HostID)
return android.DefaultResponse{Err: err}
}
// UnenrollAndroidHost calls AMAPI to delete the device (work profile) and emits an activity.
// The actual MDM status flip to Off is performed when Pub/Sub sends DELETED for the device.
func (svc *Service) UnenrollAndroidHost(ctx context.Context, hostID uint) error {
// Load host and authorize based on team
h, err := svc.fleetSvc.GetHostLite(ctx, hostID)
if err != nil {
return err
}
if err := svc.authz.Authorize(ctx, h, fleet.ActionWrite); err != nil {
return err
}
if strings.ToLower(h.Platform) != "android" {
return &fleet.BadRequestError{Message: "host is not an android device"}
}
// Resolve Android device and enterprise
ah, err := svc.ds.AndroidHostLiteByHostUUID(ctx, h.UUID)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting android host by uuid")
}
enterprise, err := svc.ds.GetEnterprise(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting android enterprise")
}
if ah.Device == nil || ah.Device.DeviceID == "" || enterprise.EnterpriseID == "" {
return &fleet.BadRequestError{Message: "missing android device or enterprise id"}
}
// Authenticate client and call AMAPI delete
secret, err := svc.getClientAuthenticationSecret(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting Android authentication secret")
}
_ = svc.androidAPIClient.SetAuthenticationSecret(secret)
deviceName := fmt.Sprintf("enterprises/%s/devices/%s", enterprise.EnterpriseID, ah.Device.DeviceID)
if err := svc.androidAPIClient.EnterprisesDevicesDelete(ctx, deviceName); err != nil {
return ctxerr.Wrap(ctx, err, "amapi delete device")
}
// Emit activity: admin told Fleet to unenroll
displayName := fleet.HostDisplayName(h.ComputerName, h.Hostname, h.HardwareModel, h.HardwareSerial)
if err := svc.fleetSvc.NewActivity(ctx, authz.UserFromContext(ctx), fleet.ActivityTypeMDMUnenrolled{
HostSerial: h.HardwareSerial,
HostDisplayName: displayName,
InstalledFromDEP: false,
Platform: h.Platform,
}); err != nil {
return ctxerr.Wrap(ctx, err, "create android unenroll activity")
}
return nil
}