diff --git a/cmd/android-amapi-mock/google_forwarder.go b/cmd/android-amapi-mock/google_forwarder.go new file mode 100644 index 00000000000..8ee165f1b64 --- /dev/null +++ b/cmd/android-amapi-mock/google_forwarder.go @@ -0,0 +1,227 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + + "golang.org/x/oauth2/google" + "google.golang.org/api/androidmanagement/v1" + "google.golang.org/api/option" +) + +// googleForwarder wraps an authenticated Google Android Management API client +// for forwarding requests targeting real devices. +type googleForwarder struct { + svc *androidmanagement.Service +} + +func newGoogleForwarder(credentialsFile string) (*googleForwarder, error) { + credJSON, err := os.ReadFile(credentialsFile) + if err != nil { + return nil, fmt.Errorf("read credentials file: %w", err) + } + + ctx := context.Background() + creds, err := google.CredentialsFromJSON(ctx, credJSON, androidmanagement.AndroidmanagementScope) //nolint:staticcheck // SA1019 -- load testing tool, credentials are from a trusted local file + if err != nil { + return nil, fmt.Errorf("parse credentials: %w", err) + } + + svc, err := androidmanagement.NewService(ctx, + option.WithCredentials(creds), + ) + if err != nil { + return nil, fmt.Errorf("create android management service: %w", err) + } + + return &googleForwarder{svc: svc}, nil +} + +// ForwardDevicesGet forwards a GET .../devices/{id} request to Google. +func (g *googleForwarder) ForwardDevicesGet(w http.ResponseWriter, r *http.Request) { + name := deviceName(r) + device, err := g.svc.Enterprises.Devices.Get(name).Context(r.Context()).Do() + if err != nil { + writeGoogleError(w, err) + return + } + writeJSON(w, device) +} + +// ForwardDevicesPatch forwards a PATCH .../devices/{id} request to Google. +func (g *googleForwarder) ForwardDevicesPatch(w http.ResponseWriter, r *http.Request) { + name := deviceName(r) + var device androidmanagement.Device + if err := readBody(r, &device); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + result, err := g.svc.Enterprises.Devices.Patch(name, &device).Context(r.Context()).Do() + if err != nil { + writeGoogleError(w, err) + return + } + writeJSON(w, result) +} + +// ForwardDevicesDelete forwards a DELETE .../devices/{id} request to Google. +func (g *googleForwarder) ForwardDevicesDelete(w http.ResponseWriter, r *http.Request) { + name := deviceName(r) + _, err := g.svc.Enterprises.Devices.Delete(name).Context(r.Context()).Do() + if err != nil { + writeGoogleError(w, err) + return + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, "{}") +} + +// ForwardIssueCommand forwards a POST .../devices/{id}:issueCommand request to Google. +func (g *googleForwarder) ForwardIssueCommand(w http.ResponseWriter, r *http.Request) { + name := deviceName(r) + var cmd androidmanagement.Command + if err := readBody(r, &cmd); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + op, err := g.svc.Enterprises.Devices.IssueCommand(name, &cmd).Context(r.Context()).Do() + if err != nil { + writeGoogleError(w, err) + return + } + writeJSON(w, op) +} + +// ForwardDevicesList forwards a GET .../devices request to Google and returns all device names. +func (g *googleForwarder) ForwardDevicesList(enterpriseName string, ctx context.Context) ([]map[string]string, error) { + var allDevices []map[string]string + pageToken := "" + + for { + call := g.svc.Enterprises.Devices.List(enterpriseName).Context(ctx).PageSize(100).Fields("nextPageToken", "devices/name") + if pageToken != "" { + call = call.PageToken(pageToken) + } + resp, err := call.Do() + if err != nil { + return nil, fmt.Errorf("list devices from Google: %w", err) + } + for _, d := range resp.Devices { + allDevices = append(allDevices, map[string]string{"name": d.Name}) + } + if resp.NextPageToken == "" { + break + } + pageToken = resp.NextPageToken + } + + return allDevices, nil +} + +// ForwardPoliciesPatch forwards a PATCH .../policies/{id} request to Google. +func (g *googleForwarder) ForwardPoliciesPatch(w http.ResponseWriter, r *http.Request) { + name := policyName(r) + var policy androidmanagement.Policy + if err := readBody(r, &policy); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + result, err := g.svc.Enterprises.Policies.Patch(name, &policy).Context(r.Context()).Do() + if err != nil { + writeGoogleError(w, err) + return + } + writeJSON(w, result) +} + +// ForwardEnrollmentTokenCreate forwards a POST .../enrollmentTokens request to Google. +func (g *googleForwarder) ForwardEnrollmentTokenCreate(w http.ResponseWriter, r *http.Request) { + enterpriseName := "enterprises/" + r.PathValue("eid") + var token androidmanagement.EnrollmentToken + if err := readBody(r, &token); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + result, err := g.svc.Enterprises.EnrollmentTokens.Create(enterpriseName, &token).Context(r.Context()).Do() + if err != nil { + writeGoogleError(w, err) + return + } + writeJSON(w, result) +} + +// ForwardApplicationsGet forwards a GET .../applications/{package} request to Google. +func (g *googleForwarder) ForwardApplicationsGet(w http.ResponseWriter, r *http.Request) { + name := "enterprises/" + r.PathValue("eid") + "/applications/" + r.PathValue("pkg") + result, err := g.svc.Enterprises.Applications.Get(name).Context(r.Context()).Do() + if err != nil { + writeGoogleError(w, err) + return + } + writeJSON(w, result) +} + +// ForwardWebAppsCreate forwards a POST .../webApps request to Google. +func (g *googleForwarder) ForwardWebAppsCreate(w http.ResponseWriter, r *http.Request) { + enterpriseName := "enterprises/" + r.PathValue("eid") + var webApp androidmanagement.WebApp + if err := readBody(r, &webApp); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + result, err := g.svc.Enterprises.WebApps.Create(enterpriseName, &webApp).Context(r.Context()).Do() + if err != nil { + writeGoogleError(w, err) + return + } + writeJSON(w, result) +} + +// ForwardEnterprisesList forwards a GET /v1/enterprises request to Google. +func (g *googleForwarder) ForwardEnterprisesList(w http.ResponseWriter, r *http.Request) { + resp, err := g.svc.Enterprises.List().Context(r.Context()).Do() + if err != nil { + writeGoogleError(w, err) + return + } + writeJSON(w, resp) +} + +// ---- helpers ---- + +func readBody(r *http.Request, v any) error { + if r.Body == nil { + return nil + } + body, err := io.ReadAll(r.Body) + if err != nil { + return fmt.Errorf("read body: %w", err) + } + if len(body) == 0 { + return nil + } + return json.Unmarshal(body, v) +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(v) //nolint:errcheck +} + +func writeGoogleError(w http.ResponseWriter, err error) { + log.Printf("googleForwarder: Google API error: %v", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadGateway) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{ + "code": 502, + "message": err.Error(), + "status": "BAD_GATEWAY", + }, + }) //nolint:errcheck +} diff --git a/cmd/android-amapi-mock/handlers.go b/cmd/android-amapi-mock/handlers.go new file mode 100644 index 00000000000..bda4b62133d --- /dev/null +++ b/cmd/android-amapi-mock/handlers.go @@ -0,0 +1,368 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strconv" + + "github.com/google/uuid" +) + +// ---- Coordination API handlers ---- + +func handleRegister(store *deviceStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var d fakeDevice + if err := json.NewDecoder(r.Body).Decode(&d); err != nil { + http.Error(w, "invalid json: "+err.Error(), http.StatusBadRequest) + return + } + if d.EnterpriseSpecificID == "" || d.DeviceName == "" { + http.Error(w, "enterprise_specific_id and device_name required", http.StatusBadRequest) + return + } + d.PolicyVersion = 0 + if d.EnterpriseID != "" { + d.PolicyName = fmt.Sprintf("enterprises/%s/policies/default", d.EnterpriseID) + } + store.register(&d) + log.Printf("Registered fake device: %s (name: %s)", d.EnterpriseSpecificID, d.DeviceName) + w.WriteHeader(http.StatusOK) + } +} + +func handleGetState(store *deviceStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + esid := r.PathValue("esid") + d := store.getByESID(esid) + if d == nil { + http.Error(w, "device not found", http.StatusNotFound) + return + } + + d.mu.Lock() + policyVersion := d.PolicyVersion + if d.PolicyName != "" { + if v := store.getPolicyVersion(d.PolicyName); v > 0 { + policyVersion = v + d.PolicyVersion = v + } + } + state := struct { + PolicyVersion int64 `json:"policy_version"` + PolicyName string `json:"policy_name"` + PendingCommands []string `json:"pending_commands"` + }{ + PolicyVersion: policyVersion, + PolicyName: d.PolicyName, + PendingCommands: d.PendingCommands, + } + d.PendingCommands = nil + d.mu.Unlock() + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(state) + } +} + +// ---- Device handlers ---- + +func handleDevicesGet(store *deviceStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + name := deviceName(r) + d := store.getByName(name) + if d == nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + fmt.Fprintf(w, `{"error":{"code":404,"message":"Device not found","status":"NOT_FOUND"}}`) + return + } + + d.mu.Lock() + resp := map[string]any{ + "name": name, + "appliedPolicyVersion": d.PolicyVersion, + "appliedPolicyName": d.PolicyName, + } + d.mu.Unlock() + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + } +} + +func handleDevicesPatch(store *deviceStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + name := deviceName(r) + d := store.getByName(name) + + var reqBody struct { + PolicyName string `json:"policyName"` + } + if r.Body != nil { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "failed to read request body: "+err.Error(), http.StatusBadRequest) + return + } + if len(body) > 0 { + if err := json.Unmarshal(body, &reqBody); err != nil { + http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest) + return + } + } + } + + var appliedVersion int64 + if d != nil { + d.mu.Lock() + if reqBody.PolicyName != "" { + d.PolicyName = reqBody.PolicyName + } + if d.PolicyName != "" { + appliedVersion = store.getPolicyVersion(d.PolicyName) + d.PolicyVersion = appliedVersion + } + d.mu.Unlock() + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": name, + "appliedPolicyVersion": appliedVersion, + }) + } +} + +func handleDevicesDelete(store *deviceStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + name := deviceName(r) + + store.mu.Lock() + if d, ok := store.byName[name]; ok { + delete(store.byName, name) + delete(store.byESID, d.EnterpriseSpecificID) + log.Printf("Deleted fake device: %q (ESID: %q)", name, d.EnterpriseSpecificID) // #nosec G706 -- load testing tool + } + store.mu.Unlock() + + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, "{}") + } +} + +func handleIssueCommand(store *deviceStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + name := deviceName(r) + operationID := uuid.New().String() + operationName := fmt.Sprintf("%s/operations/%s", name, operationID) + + d := store.getByName(name) + if d != nil { + d.mu.Lock() + d.PendingCommands = append(d.PendingCommands, operationName) + d.mu.Unlock() + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": operationName, + "done": false, + }) + } +} + +func handleDevicesList(store *deviceStore, google *googleForwarder) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + fakeNames := store.allDeviceNames() + + var realDevices []map[string]string + if google != nil { + enterpriseName := "enterprises/" + r.PathValue("eid") + var err error + realDevices, err = google.ForwardDevicesList(enterpriseName, r.Context()) + if err != nil { + log.Printf("Failed to list real devices from Google: %v", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadGateway) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{ + "code": 502, + "message": "failed to list real devices: " + err.Error(), + "status": "BAD_GATEWAY", + }, + }) + return + } + if len(realDevices) > 0 { + hasSeenRealDevice.Store(true) + } + } + + allDevices := make([]map[string]string, 0, len(realDevices)+len(fakeNames)) + allDevices = append(allDevices, realDevices...) + for _, name := range fakeNames { + allDevices = append(allDevices, map[string]string{"name": name}) + } + + pageSize := 100 + offset := 0 + if pt := r.URL.Query().Get("pageToken"); pt != "" { + if v, err := strconv.Atoi(pt); err == nil { + offset = v + } + } + if offset < 0 { + offset = 0 + } + if offset > len(allDevices) { + offset = len(allDevices) + } + + end := min(offset+pageSize, len(allDevices)) + + resp := map[string]any{ + "devices": allDevices[offset:end], + } + if end < len(allDevices) { + resp["nextPageToken"] = fmt.Sprintf("%d", end) + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + } +} + +// ---- Policy handlers ---- + +func handlePoliciesPatch(store *deviceStore, google *googleForwarder) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + name := policyName(r) + enterpriseID := r.PathValue("eid") + + if !store.hasDevicesForEnterprise(enterpriseID) && google != nil { + log.Printf("Forwarding policy patch to Google AMAPI: %q", name) // #nosec G706 -- load testing tool + google.ForwardPoliciesPatch(w, r) + return + } + + var bodyBytes []byte + if r.Body != nil { + var err error + bodyBytes, err = io.ReadAll(r.Body) + if err != nil { + http.Error(w, "failed to read request body: "+err.Error(), http.StatusBadRequest) + return + } + } + + version := policyVersionCounter.Add(1) + store.setPolicyVersion(name, version) + + if google != nil && hasSeenRealDevice.Load() { + fwdReq := r.Clone(context.Background()) + fwdReq.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + go func() { + google.ForwardPoliciesPatch(&discardResponseWriter{}, fwdReq) + }() + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": name, + "version": version, + }) + } +} + +// handlePolicyAction handles POST on policies: modifyPolicyApplications and removePolicyApplications. +func handlePolicyAction(store *deviceStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + name := policyName(r) + + version := policyVersionCounter.Add(1) + store.setPolicyVersion(name, version) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "version": version, + }) + } +} + +// ---- Other AMAPI handlers ---- + +func handleEnrollmentTokenCreate() http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + token := uuid.New().String() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "enterprises/mock/enrollmentTokens/" + token, + "value": token, + "qrCode": fmt.Sprintf(`{"android.app.extra.PROVISIONING_DEVICE_ADMIN_COMPONENT_NAME":"com.google.android.apps.work.clouddpc/.receivers.CloudDeviceAdminReceiver","android.app.extra.PROVISIONING_DEVICE_ADMIN_SIGNATURE_CHECKSUM":"I5YvS0O5hXY46mb01BlRjq4oJJGs2kuUcHvVkAPEXlg","android.app.extra.PROVISIONING_DEVICE_ADMIN_PACKAGE_DOWNLOAD_LOCATION":"https://play.google.com/managed/downloadManagingApp?identifier=setup","android.app.extra.PROVISIONING_ADMIN_EXTRAS_BUNDLE":{"com.google.android.apps.work.clouddpc.EXTRA_ENROLLMENT_TOKEN":"%s"}}`, token), + }) + } +} + +func handleApplicationsGet() http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "mock-app", + "title": "Mock Application", + }) + } +} + +func handleWebAppsCreate() http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "enterprises/mock/webApps/" + uuid.New().String(), + "title": "Mock Web App", + }) + } +} + +func handleEnterprisesList(store *deviceStore) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + store.mu.RLock() + seen := make(map[string]bool) + for _, d := range store.byESID { + if d.EnterpriseID != "" { + seen[d.EnterpriseID] = true + } + } + store.mu.RUnlock() + + enterprises := make([]map[string]string, 0, len(seen)) + for id := range seen { + enterprises = append(enterprises, map[string]string{"name": "enterprises/" + id}) + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "enterprises": enterprises, + }) + } +} + +func handleCatchAll(google *googleForwarder) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + log.Printf("ERROR: unhandled AMAPI endpoint: %q %q — add a handler or forwarding for this route", r.Method, r.URL.Path) // #nosec G706 -- load testing tool + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotImplemented) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{ + "code": 501, + "message": "mock does not handle " + r.Method + " " + r.URL.Path, + "status": "NOT_IMPLEMENTED", + }, + }) + } +} diff --git a/cmd/android-amapi-mock/main.go b/cmd/android-amapi-mock/main.go new file mode 100644 index 00000000000..99d29aa3b81 --- /dev/null +++ b/cmd/android-amapi-mock/main.go @@ -0,0 +1,193 @@ +// Command android-amapi-mock is a lightweight mock of Google's Android Management API +// for load testing Fleet with fake Android devices. +// +// It serves two roles: +// 1. AMAPI surface — Fleet calls these endpoints (policy patches, device patches, commands, etc.). +// For registered fake devices, it returns canned responses. For real devices, it forwards +// requests to the real Google AMAPI using service account credentials. +// 2. Coordination API — osquery-perf's Android agents call these to register devices and poll for +// state (policy versions, pending commands) so they can send realistic PubSub messages to Fleet. +// +// Usage: +// +// android-amapi-mock --listen :9999 +// android-amapi-mock --listen :9999 --google-credentials /path/to/service-account.json +package main + +import ( + "flag" + "log" + "net/http" + "strings" + "sync" + "sync/atomic" + "time" +) + +// fakeDevice holds the in-memory state for a single fake Android device. +type fakeDevice struct { + mu sync.Mutex + EnterpriseSpecificID string `json:"enterprise_specific_id"` + DeviceName string `json:"device_name"` + EnterpriseID string `json:"enterprise_id"` + PolicyVersion int64 `json:"policy_version"` + PolicyName string `json:"policy_name"` + PendingCommands []string `json:"pending_commands"` +} + +// deviceStore is the in-memory registry of fake devices and policy versions. +type deviceStore struct { + mu sync.RWMutex + // byESID maps EnterpriseSpecificID -> device + byESID map[string]*fakeDevice + // byName maps AMAPI device resource name -> device + byName map[string]*fakeDevice + + // policyVersions tracks the latest version for each policy name. + // Fleet uses per-device policies named enterprises/{id}/policies/{hostUUID}. + policyMu sync.RWMutex + policyVersions map[string]int64 +} + +func newDeviceStore() *deviceStore { + return &deviceStore{ + byESID: make(map[string]*fakeDevice), + byName: make(map[string]*fakeDevice), + policyVersions: make(map[string]int64), + } +} + +func (ds *deviceStore) setPolicyVersion(policyName string, version int64) { + ds.policyMu.Lock() + defer ds.policyMu.Unlock() + ds.policyVersions[policyName] = version +} + +func (ds *deviceStore) getPolicyVersion(policyName string) int64 { + ds.policyMu.RLock() + defer ds.policyMu.RUnlock() + return ds.policyVersions[policyName] +} + +func (ds *deviceStore) register(d *fakeDevice) { + ds.mu.Lock() + defer ds.mu.Unlock() + ds.byESID[d.EnterpriseSpecificID] = d + ds.byName[d.DeviceName] = d +} + +func (ds *deviceStore) getByESID(esid string) *fakeDevice { + ds.mu.RLock() + defer ds.mu.RUnlock() + return ds.byESID[esid] +} + +func (ds *deviceStore) getByName(name string) *fakeDevice { + ds.mu.RLock() + defer ds.mu.RUnlock() + return ds.byName[name] +} + +func (ds *deviceStore) allDeviceNames() []string { + ds.mu.RLock() + defer ds.mu.RUnlock() + names := make([]string, 0, len(ds.byName)) + for name := range ds.byName { + names = append(names, name) + } + return names +} + +func (ds *deviceStore) hasDevicesForEnterprise(enterpriseID string) bool { + if enterpriseID == "" { + return false + } + ds.mu.RLock() + defer ds.mu.RUnlock() + for _, d := range ds.byESID { + if d.EnterpriseID == enterpriseID { + return true + } + } + return false +} + +// policyVersionCounter is a global atomic counter for policy versions. +var policyVersionCounter atomic.Int64 + +// hasSeenRealDevice indicates that a real device has been seen. +var hasSeenRealDevice atomic.Bool + +func main() { + listen := flag.String("listen", ":9999", "Address to listen on") + googleCredentials := flag.String("google-credentials", "", "Path to Google service account JSON credentials file (enables forwarding for real devices)") + flag.Parse() + + policyVersionCounter.Store(1) + + store := newDeviceStore() + + // Set up authenticated Google API client for real device forwarding + var google *googleForwarder + if *googleCredentials != "" { + var err error + google, err = newGoogleForwarder(*googleCredentials) + if err != nil { + log.Fatalf("Failed to create Google forwarder: %v", err) + } + log.Printf("Google credentials loaded — forwarding real device requests to Google AMAPI") + } + + mux := http.NewServeMux() + + // ---- Coordination API (osquery-perf calls these) ---- + mux.HandleFunc("POST /mock/devices/register", handleRegister(store)) + mux.HandleFunc("GET /mock/devices/{esid}/state", handleGetState(store)) + + // ---- AMAPI: Devices ---- + fwd := forwardForRealDevice(store, google) + mux.HandleFunc("GET /v1/enterprises/{eid}/devices/{did}", fwd(handleDevicesGet(store))) + mux.HandleFunc("PATCH /v1/enterprises/{eid}/devices/{did}", fwd(handleDevicesPatch(store))) + mux.HandleFunc("DELETE /v1/enterprises/{eid}/devices/{did}", fwd(handleDevicesDelete(store))) + mux.HandleFunc("POST /v1/enterprises/{eid}/devices/{did}", fwd(handleIssueCommand(store))) + mux.HandleFunc("GET /v1/enterprises/{eid}/devices", handleDevicesList(store, google)) + + // ---- AMAPI: Policies ---- + mux.HandleFunc("PATCH /v1/enterprises/{eid}/policies/{pid}", handlePoliciesPatch(store, google)) + mux.HandleFunc("POST /v1/enterprises/{eid}/policies/{pid}", handlePolicyAction(store)) + + // ---- AMAPI: Other ---- + mux.HandleFunc("POST /v1/enterprises/{eid}/enrollmentTokens", forwardOrMock(google, handleEnrollmentTokenCreate())) + mux.HandleFunc("GET /v1/enterprises/{eid}/applications/{pkg}", forwardOrMock(google, handleApplicationsGet())) + mux.HandleFunc("POST /v1/enterprises/{eid}/webApps", forwardOrMock(google, handleWebAppsCreate())) + mux.HandleFunc("GET /v1/enterprises", forwardOrMock(google, handleEnterprisesList(store))) + + // Catch-all for unmatched /v1/ requests + mux.HandleFunc("/v1/", handleCatchAll(google)) + + srv := &http.Server{ + Addr: *listen, + Handler: mux, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + } + log.Printf("Mock AMAPI proxy listening on %s", *listen) + log.Fatal(srv.ListenAndServe()) +} + +// ---- Route helpers ---- + +// deviceName builds the AMAPI resource name from path values. +func deviceName(r *http.Request) string { + did := r.PathValue("did") + did = strings.TrimSuffix(did, ":issueCommand") + return "enterprises/" + r.PathValue("eid") + "/devices/" + did +} + +// policyName builds the AMAPI policy resource name from path values. +func policyName(r *http.Request) string { + pid := r.PathValue("pid") + pid = strings.TrimSuffix(pid, ":modifyPolicyApplications") + pid = strings.TrimSuffix(pid, ":removePolicyApplications") + return "enterprises/" + r.PathValue("eid") + "/policies/" + pid +} diff --git a/cmd/android-amapi-mock/middleware.go b/cmd/android-amapi-mock/middleware.go new file mode 100644 index 00000000000..31028e56334 --- /dev/null +++ b/cmd/android-amapi-mock/middleware.go @@ -0,0 +1,76 @@ +package main + +import ( + "fmt" + "log" + "net/http" + "strings" +) + +// forwardForRealDevice returns middleware that checks if a device-specific request +// targets a registered fake device. If not, it forwards to Google via the authenticated client. +func forwardForRealDevice(store *deviceStore, google *googleForwarder) func(http.HandlerFunc) http.HandlerFunc { + return func(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + name := deviceName(r) + if store.getByName(name) != nil { + next(w, r) + return + } + // Real device — forward via Google SDK if available + if google != nil { + hasSeenRealDevice.Store(true) + log.Printf("Real device request: %s %s (device: %q)", r.Method, r.URL.Path, name) // #nosec G706 -- load testing tool + switch r.Method { + case "GET": + google.ForwardDevicesGet(w, r) + case "PATCH": + google.ForwardDevicesPatch(w, r) + case "DELETE": + google.ForwardDevicesDelete(w, r) + case "POST": + google.ForwardIssueCommand(w, r) + default: + http.Error(w, fmt.Sprintf("unsupported method %s for device forwarding", r.Method), http.StatusMethodNotAllowed) + } + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + fmt.Fprintf(w, `{"error":{"code":404,"message":"Device not found","status":"NOT_FOUND"}}`) + } + } +} + +// forwardOrMock forwards to Google if credentials are configured, +// otherwise falls back to the local mock handler. +func forwardOrMock(google *googleForwarder, fallback http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if google == nil { + fallback(w, r) + return + } + // Route to the appropriate Google forwarder method based on the path + path := r.URL.Path + switch { + case r.Method == "POST" && strings.Contains(path, "/enrollmentTokens"): + google.ForwardEnrollmentTokenCreate(w, r) + case r.Method == "GET" && strings.Contains(path, "/applications/"): + google.ForwardApplicationsGet(w, r) + case r.Method == "POST" && strings.Contains(path, "/webApps"): + google.ForwardWebAppsCreate(w, r) + case r.Method == "GET" && (path == "/v1/enterprises" || strings.HasSuffix(path, "/enterprises")): + google.ForwardEnterprisesList(w, r) + default: + fallback(w, r) + } + } +} + +// discardResponseWriter is an http.ResponseWriter that discards the response. +// Used for fire-and-forget forwarding where we don't need the response. +type discardResponseWriter struct{} + +func (discardResponseWriter) Header() http.Header { return http.Header{} } +func (discardResponseWriter) Write(b []byte) (int, error) { return len(b), nil } +func (discardResponseWriter) WriteHeader(int) {} diff --git a/cmd/osquery-perf/agent.go b/cmd/osquery-perf/agent.go index 52e45c3bf2e..17d42fe65b3 100644 --- a/cmd/osquery-perf/agent.go +++ b/cmd/osquery-perf/agent.go @@ -3769,6 +3769,7 @@ func main() { tr := http.DefaultTransport.(*http.Transport).Clone() tr.TLSClientConfig = tlsConfig http.DefaultClient.Transport = tr + http.DefaultClient.Timeout = 30 * time.Second validTemplateNames := map[string]bool{ "macos_13.6.2.tmpl": true, @@ -3783,6 +3784,7 @@ func main() { "iphone_14.6.tmpl": true, "ipad_13.18.tmpl": true, "iphone_17.tmpl": true, + "android.tmpl": true, } allowedTemplateNames := make([]string, 0, len(validTemplateNames)) for k := range validTemplateNames { @@ -3889,6 +3891,14 @@ func main() { commonSoftwareNameSuffix = flag.String("common_software_name_suffix", "", "Suffix to add to generated common software names") softwareDatabasePath = flag.String("software_db_path", "software-library/software.db", "Path to software.db (SQLite database with realistic software data). Auto-generates from software.sql if missing.") + + // Android load testing flags + androidPubSubToken = flag.String("android_pubsub_token", "", "PubSub token for authenticating fake Android device messages to Fleet") + androidProxyAddress = flag.String("android_proxy_address", "", "Address of the mock AMAPI proxy (e.g., http://localhost:9999)") + androidEnterpriseID = flag.String("android_enterprise_id", "", "Android enterprise ID (e.g., LC03k6enk8)") + androidStatusInterval = flag.Duration("android_status_interval", 1*time.Minute, "Interval between Android STATUS_REPORT messages") + androidAppCount = flag.Int("android_app_count", 50, "Number of installed apps each Android device reports") + androidNonComplianceProb = flag.Float64("android_non_compliance_prob", 0.05, "Probability of an Android STATUS_REPORT including non-compliance details [0, 1]") ) flag.Parse() @@ -4047,6 +4057,27 @@ func main() { continue } + if tmpl.Name() == "android.tmpl" { + if *androidPubSubToken == "" || *androidProxyAddress == "" || *androidEnterpriseID == "" { + log.Fatalf("Android template requires --android_pubsub_token, --android_proxy_address, and --android_enterprise_id flags") + } + androidDevice := newAndroidAgent( + i+1, + *serverURL, + *enrollSecret, + *androidPubSubToken, + *androidProxyAddress, + *androidEnterpriseID, + *androidStatusInterval, + *androidAppCount, + *androidNonComplianceProb, + stats, + ) + go androidDevice.runLoop() + time.Sleep(sleepTime) + continue + } + a := newAgent(i+1, *hostCount, *totalHostCount, diff --git a/cmd/osquery-perf/android.tmpl b/cmd/osquery-perf/android.tmpl new file mode 100644 index 00000000000..90ef97302b8 --- /dev/null +++ b/cmd/osquery-perf/android.tmpl @@ -0,0 +1,3 @@ +{{/* Android devices don't use osquery templates. This file exists only so that + template.ParseFS succeeds when "android" is specified in --os_templates. + The android agent communicates with Fleet via PubSub messages, not osquery endpoints. */}} diff --git a/cmd/osquery-perf/android_agent.go b/cmd/osquery-perf/android_agent.go new file mode 100644 index 00000000000..e5bb542c1a2 --- /dev/null +++ b/cmd/osquery-perf/android_agent.go @@ -0,0 +1,449 @@ +package main + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log" + "math/rand/v2" + "net/http" + "strings" + "time" + + "github.com/fleetdm/fleet/v4/cmd/osquery-perf/osquery_perf" + "github.com/fleetdm/fleet/v4/server/mdm/android" + "github.com/google/uuid" + "google.golang.org/api/androidmanagement/v1" +) + +// androidAgent simulates a single Android device for load testing. +// It communicates with Fleet via PubSub messages (enrollment, status reports, command acks) +// and coordinates with a mock AMAPI proxy to get policy versions and pending commands. +type androidAgent struct { + agentIndex int + serverAddress string + enrollSecret string + pubSubToken string + proxyAddress string + stats *osquery_perf.Stats + + // Device identity (stable across the agent lifetime) + enterpriseSpecificID string + serialNumber string + deviceName string // AMAPI resource name: enterprises/{id}/devices/{id} + enterpriseID string + + // Hardware details + brand string + model string + hardware string + + // Software + androidVersion string + androidBuildNumber string + + // Memory + totalRAM int64 + totalInternalStorage int64 + + // Installed apps reported in STATUS_REPORT + installedApps []*androidmanagement.ApplicationReport + + // Timing + statusReportInterval time.Duration + + // Non-compliance probability (fraction of STATUS_REPORTs that include non-compliance details) + nonComplianceProb float64 +} + +// androidApp is a simplified app definition for generating realistic ApplicationReports. +var androidApps = []struct { + displayName string + packageName string + baseVersion string +}{ + {"Google Chrome", "com.android.chrome", "126.0.6478.122"}, + {"Gmail", "com.google.android.gm", "2024.06.30.649015803"}, + {"Google Maps", "com.google.android.apps.maps", "11.125.0102"}, + {"YouTube", "com.google.android.youtube", "19.25.33"}, + {"Google Drive", "com.google.android.apps.docs", "2.24.277.0"}, + {"Google Photos", "com.google.android.apps.photos", "7.1.0.611579560"}, + {"Google Calendar", "com.google.android.calendar", "2024.25.0-647498253"}, + {"Google Meet", "com.google.android.apps.tachyon", "2024.06.30.643793517"}, + {"Slack", "com.Slack", "24.06.10.0"}, + {"Microsoft Teams", "com.microsoft.teams", "1416/1.0.0.2024063002"}, + {"Microsoft Outlook", "com.microsoft.office.outlook", "4.2425.1"}, + {"Zoom", "us.zoom.videomeetings", "6.1.1.21782"}, + {"Salesforce", "com.salesforce.chatter", "246.010.0"}, + {"1Password", "com.onepassword.android", "8.10.38"}, + {"Authenticator", "com.google.android.apps.authenticator2", "7.0"}, + {"Google Docs", "com.google.android.apps.docs.editors.docs", "1.24.272.01"}, + {"Google Sheets", "com.google.android.apps.docs.editors.sheets", "1.24.272.01"}, + {"Google Slides", "com.google.android.apps.docs.editors.slides", "1.24.272.01"}, + {"Google Keep", "com.google.android.keep", "5.24.272.00"}, + {"Google Messages", "com.google.android.apps.messaging", "20240625"}, + {"Files by Google", "com.google.android.apps.nbu.files", "1.4396.621459950"}, + {"Google Phone", "com.google.android.dialer", "130.0.631022283"}, + {"Google Contacts", "com.google.android.contacts", "4.32.33.621636488"}, + {"Google Clock", "com.google.android.deskclock", "7.8"}, + {"Google Calculator", "com.google.android.calculator", "8.8"}, + {"Google Camera", "com.google.android.GoogleCamera", "9.3.160.621982096"}, + {"Google Play Store", "com.android.vending", "41.6.26"}, + {"Google Play Services", "com.google.android.gms", "24.26.14"}, + {"Android System WebView", "com.google.android.webview", "126.0.6478.122"}, + {"Google Translate", "com.google.android.apps.translate", "8.7.29.626714160"}, + {"LinkedIn", "com.linkedin.android", "4.1.972"}, + {"Spotify", "com.spotify.music", "8.9.42.575"}, + {"WhatsApp", "com.whatsapp", "2.24.14.78"}, + {"Signal", "org.thoughtcrime.securesms", "7.11.3"}, + {"Firefox", "org.mozilla.firefox", "127.0.2"}, + {"Adobe Acrobat", "com.adobe.reader", "24.6.0.33768"}, + {"Dropbox", "com.dropbox.android", "372.2.2"}, + {"Evernote", "com.evernote", "10.95"}, + {"Trello", "com.trello", "2024.10"}, + {"Notion", "notion.id", "0.6.2413"}, + {"GitHub", "com.github.android", "1.148.0"}, + {"Jira Cloud", "com.atlassian.android.jira.core", "2024.06.30"}, + {"Okta Verify", "com.okta.android.auth", "9.6.1"}, + {"Duo Mobile", "com.duosecurity.duomobile", "4.62.0"}, + {"CrowdStrike Falcon", "com.crowdstrike.android.falcon", "7.19.17004"}, + {"Intune Company Portal", "com.microsoft.windowsintune.companyportal", "5.0.6233.0"}, + {"Fleet Agent", "com.fleetdm.agent", "1.3.0"}, + {"Samsung Knox", "com.samsung.android.knox.containercore", "2.7.1"}, + {"Google Admin", "com.google.android.apps.enterprise.cpanel", "2024.06.30.627"}, + {"LastPass", "com.lastpass.lpandroid", "5.21.0.13562"}, +} + +// newAndroidAgent creates a new Android device simulator. +func newAndroidAgent( + agentIndex int, + serverAddress string, + enrollSecret string, + pubSubToken string, + proxyAddress string, + enterpriseID string, + statusReportInterval time.Duration, + appCount int, + nonComplianceProb float64, + stats *osquery_perf.Stats, +) *androidAgent { + enterpriseSpecificID := strings.ToUpper(uuid.New().String()) + deviceID := "fake" + strings.ReplaceAll(uuid.New().String()[:28], "-", "") + serialNumber := fmt.Sprintf("AND%s", randomString(10)) + + brands := []string{"Google", "Samsung", "OnePlus", "Motorola", "Nokia"} + models := []string{"Pixel 8 Pro", "Pixel 7a", "Galaxy S24", "Galaxy A54", "Nord CE 3", "Edge 40", "X30"} + hardwareTypes := []string{"qcom", "exynos", "tensor", "dimensity"} + + brand := brands[rand.IntN(len(brands))] // #nosec G404 -- load testing only + model := models[rand.IntN(len(models))] // #nosec G404 -- load testing only + hardware := hardwareTypes[rand.IntN(len(hardwareTypes))] // #nosec G404 -- load testing only + + // Android versions 13-15 + androidVersions := []string{"13", "14", "15"} + androidVersion := androidVersions[rand.IntN(len(androidVersions))] // #nosec G404 -- load testing only + buildNumber := fmt.Sprintf("TP1A.%d%02d%02d.003", 2024+rand.IntN(2), 1+rand.IntN(12), 1+rand.IntN(28)) // #nosec G404 -- load testing only + + // Generate installed apps list + if appCount > len(androidApps) { + appCount = len(androidApps) + } + // Shuffle and pick appCount apps + perm := rand.Perm(len(androidApps)) + apps := make([]*androidmanagement.ApplicationReport, 0, appCount) + for i := 0; i < appCount; i++ { + app := androidApps[perm[i]] + apps = append(apps, &androidmanagement.ApplicationReport{ + DisplayName: app.displayName, + PackageName: app.packageName, + VersionName: app.baseVersion, + State: "INSTALLED", + }) + } + + // Memory: 4-12 GB RAM, 64-256 GB storage + ramOptions := []int64{4, 6, 8, 12} + storageOptions := []int64{64, 128, 256} + totalRAM := ramOptions[rand.IntN(len(ramOptions))] * 1024 * 1024 * 1024 // #nosec G404 -- load testing only + totalStorage := storageOptions[rand.IntN(len(storageOptions))] * 1024 * 1024 * 1024 // #nosec G404 -- load testing only + + return &androidAgent{ + agentIndex: agentIndex, + serverAddress: serverAddress, + enrollSecret: enrollSecret, + pubSubToken: pubSubToken, + proxyAddress: proxyAddress, + enterpriseID: enterpriseID, + stats: stats, + enterpriseSpecificID: enterpriseSpecificID, + serialNumber: serialNumber, + deviceName: fmt.Sprintf("enterprises/%s/devices/%s", enterpriseID, deviceID), + brand: brand, + model: model, + hardware: hardware, + androidVersion: androidVersion, + androidBuildNumber: buildNumber, + totalRAM: totalRAM, + totalInternalStorage: totalStorage, + installedApps: apps, + statusReportInterval: statusReportInterval, + nonComplianceProb: nonComplianceProb, + } +} + +// runLoop is the main loop for the Android agent. +// It registers with the mock proxy, sends enrollment to Fleet, then periodically sends status reports. +func (a *androidAgent) runLoop() { + // Step 1: Register with mock AMAPI proxy + if err := a.registerWithProxy(); err != nil { + log.Printf("Android agent %d: failed to register with proxy: %v", a.agentIndex, err) + return + } + + // Step 2: Send ENROLLMENT PubSub to Fleet + if err := a.sendEnrollment(); err != nil { + log.Printf("Android agent %d: enrollment failed: %v", a.agentIndex, err) + return + } + a.stats.IncrementAndroidEnrollments() + + // Step 3: Periodic status reports + command ack loop + statusTicker := time.NewTicker(a.statusReportInterval) + defer statusTicker.Stop() + + for range statusTicker.C { + // Poll proxy for current state (policy version, pending commands) + state, err := a.pollProxyState() + if err != nil { + log.Printf("Android agent %d: failed to poll proxy: %v", a.agentIndex, err) + a.stats.IncrementAndroidErrors() + continue + } + + // Send STATUS_REPORT + if err := a.sendStatusReport(state); err != nil { + log.Printf("Android agent %d: status report failed: %v", a.agentIndex, err) + a.stats.IncrementAndroidErrors() + continue + } + a.stats.IncrementAndroidStatusReports() + + // Ack any pending commands + for _, opName := range state.PendingCommands { + if err := a.sendCommandAck(opName); err != nil { + log.Printf("Android agent %d: command ack failed for %s: %v", a.agentIndex, opName, err) + a.stats.IncrementAndroidErrors() + continue + } + a.stats.IncrementAndroidCommandAcks() + } + } +} + +// proxyDeviceState is the response from the mock proxy's coordination API. +type proxyDeviceState struct { + PolicyVersion int64 `json:"policy_version"` + PolicyName string `json:"policy_name"` + PendingCommands []string `json:"pending_commands"` +} + +// registerWithProxy registers this fake device with the mock AMAPI proxy. +func (a *androidAgent) registerWithProxy() error { + body := struct { + EnterpriseSpecificID string `json:"enterprise_specific_id"` + DeviceName string `json:"device_name"` + EnterpriseID string `json:"enterprise_id"` + }{ + EnterpriseSpecificID: a.enterpriseSpecificID, + DeviceName: a.deviceName, + EnterpriseID: a.enterpriseID, + } + data, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("marshal register body: %w", err) + } + + resp, err := http.Post(a.proxyAddress+"/mock/devices/register", "application/json", bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("register request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("register returned %d: %s", resp.StatusCode, string(respBody)) + } + return nil +} + +// pollProxyState asks the mock proxy for the current state this device should report. +func (a *androidAgent) pollProxyState() (*proxyDeviceState, error) { + resp, err := http.Get(a.proxyAddress + "/mock/devices/" + a.enterpriseSpecificID + "/state") + if err != nil { + return nil, fmt.Errorf("poll state request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("poll state returned %d: %s", resp.StatusCode, string(respBody)) + } + + var state proxyDeviceState + if err := json.NewDecoder(resp.Body).Decode(&state); err != nil { + return nil, fmt.Errorf("decode state: %w", err) + } + return &state, nil +} + +// sendEnrollment sends an ENROLLMENT PubSub message to Fleet. +func (a *androidAgent) sendEnrollment() error { + device := androidmanagement.Device{ + Name: a.deviceName, + Ownership: "COMPANY_OWNED", + EnrollmentTokenData: fmt.Sprintf(`{"EnrollSecret": "%s"}`, a.enrollSecret), + HardwareInfo: &androidmanagement.HardwareInfo{ + EnterpriseSpecificId: a.enterpriseSpecificID, + SerialNumber: a.serialNumber, + Brand: a.brand, + Model: a.model, + Hardware: a.hardware, + }, + SoftwareInfo: &androidmanagement.SoftwareInfo{ + AndroidVersion: a.androidVersion, + AndroidBuildNumber: a.androidBuildNumber, + }, + MemoryInfo: &androidmanagement.MemoryInfo{ + TotalRam: a.totalRAM, + TotalInternalStorage: a.totalInternalStorage, + }, + MemoryEvents: a.generateMemoryEvents(), + } + + return a.sendPubSubMessage(android.PubSubEnrollment, device) +} + +// sendStatusReport sends a STATUS_REPORT PubSub message to Fleet. +func (a *androidAgent) sendStatusReport(state *proxyDeviceState) error { + now := time.Now().UTC() + + device := androidmanagement.Device{ + Name: a.deviceName, + Ownership: "COMPANY_OWNED", + HardwareInfo: &androidmanagement.HardwareInfo{ + EnterpriseSpecificId: a.enterpriseSpecificID, + SerialNumber: a.serialNumber, + Brand: a.brand, + Model: a.model, + Hardware: a.hardware, + }, + SoftwareInfo: &androidmanagement.SoftwareInfo{ + AndroidVersion: a.androidVersion, + AndroidBuildNumber: a.androidBuildNumber, + }, + MemoryInfo: &androidmanagement.MemoryInfo{ + TotalRam: a.totalRAM, + TotalInternalStorage: a.totalInternalStorage, + }, + MemoryEvents: a.generateMemoryEvents(), + ApplicationReports: a.installedApps, + AppliedPolicyVersion: state.PolicyVersion, + AppliedPolicyName: state.PolicyName, + LastPolicySyncTime: now.Format(time.RFC3339), + LastStatusReportTime: now.Format(time.RFC3339), + EnrollmentTokenData: fmt.Sprintf(`{"EnrollSecret": "%s"}`, a.enrollSecret), + } + + // Optionally add non-compliance details + nonCompliant := rand.Float64() < a.nonComplianceProb // #nosec G404 -- load testing only + if nonCompliant { + device.NonComplianceDetails = []*androidmanagement.NonComplianceDetail{ + { + SettingName: "passwordPolicies", + NonComplianceReason: "USER_ACTION", + InstallationFailureReason: "", + }, + } + } + + return a.sendPubSubMessage(android.PubSubStatusReport, device) +} + +// sendCommandAck sends a COMMAND PubSub message to Fleet acknowledging a completed command. +func (a *androidAgent) sendCommandAck(operationName string) error { + op := androidmanagement.Operation{ + Name: operationName, + Done: true, + } + return a.sendPubSubMessage(android.PubSubCommand, op) +} + +// sendPubSubMessage constructs and sends a PubSub push message to Fleet's endpoint. +func (a *androidAgent) sendPubSubMessage(notificationType android.NotificationType, payload any) error { + data, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal payload: %w", err) + } + + encodedData := base64.StdEncoding.EncodeToString(data) + + msg := struct { + Message android.PubSubMessage `json:"message"` + }{ + Message: android.PubSubMessage{ + Attributes: map[string]string{ + "notificationType": string(notificationType), + }, + Data: encodedData, + }, + } + + body, err := json.Marshal(msg) + if err != nil { + return fmt.Errorf("marshal pubsub message: %w", err) + } + + // POST to Fleet's PubSub endpoint with the token as a query parameter + url := fmt.Sprintf("%s/api/v1/fleet/android_enterprise/pubsub?token=%s", a.serverAddress, a.pubSubToken) + resp, err := http.Post(url, "application/json", bytes.NewReader(body)) // #nosec G107 -- URL is constructed from trusted config + if err != nil { + return fmt.Errorf("pubsub POST: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("pubsub returned %d: %s", resp.StatusCode, string(respBody)) + } + return nil +} + +// generateMemoryEvents creates realistic memory events for the device. +func (a *androidAgent) generateMemoryEvents() []*androidmanagement.MemoryEvent { + now := time.Now().UTC() + // External storage = half of internal for simplicity + externalTotal := a.totalInternalStorage / 2 + // Available = 30-80% of total + internalAvail := int64(float64(a.totalInternalStorage) * (0.3 + rand.Float64()*0.5)) // #nosec G404 -- load testing only + externalAvail := int64(float64(externalTotal) * (0.3 + rand.Float64()*0.5)) // #nosec G404 -- load testing only + + return []*androidmanagement.MemoryEvent{ + { + EventType: "EXTERNAL_STORAGE_DETECTED", + ByteCount: externalTotal, + CreateTime: now.Add(-24 * time.Hour).Format(time.RFC3339), + }, + { + EventType: "INTERNAL_STORAGE_MEASURED", + ByteCount: internalAvail, + CreateTime: now.Format(time.RFC3339), + }, + { + EventType: "EXTERNAL_STORAGE_MEASURED", + ByteCount: externalAvail, + CreateTime: now.Format(time.RFC3339), + }, + } +} diff --git a/cmd/osquery-perf/osquery_perf/stats.go b/cmd/osquery-perf/osquery_perf/stats.go index 05c3157c87a..874f15ffd30 100644 --- a/cmd/osquery-perf/osquery_perf/stats.go +++ b/cmd/osquery-perf/osquery_perf/stats.go @@ -44,6 +44,10 @@ type Stats struct { scriptExecErrs int softwareInstalls int softwareInstallErrs int + androidEnrollments int + androidStatusReports int + androidCommandAcks int + androidErrors int l sync.Mutex } @@ -269,14 +273,43 @@ func (s *Stats) IncrementSoftwareInstallErrs() { s.softwareInstallErrs++ } +func (s *Stats) IncrementAndroidEnrollments() { + s.l.Lock() + defer s.l.Unlock() + s.androidEnrollments++ +} + +func (s *Stats) IncrementAndroidStatusReports() { + s.l.Lock() + defer s.l.Unlock() + s.androidStatusReports++ +} + +func (s *Stats) IncrementAndroidCommandAcks() { + s.l.Lock() + defer s.l.Unlock() + s.androidCommandAcks++ +} + +func (s *Stats) IncrementAndroidErrors() { + s.l.Lock() + defer s.l.Unlock() + s.androidErrors++ +} + func (s *Stats) Log() { s.l.Lock() defer s.l.Unlock() + var errorRate float64 + if s.osqueryEnrollments > 0 { + errorRate = float64(s.errors) / float64(s.osqueryEnrollments) + } + log.Printf( - "uptime: %s, error rate: %.2f, osquery enrolls: %d, orbit enrolls: %d, mdm enrolls: %d, distributed/reads: %d, distributed/writes: %d, config requests: %d, result log requests: %d, mdm sessions initiated: %d, mdm on-demand syncs: %d, mdm commands received: %d, config errors: %d, distributed/read errors: %d, distributed/write errors: %d, log result errors: %d, orbit errors: %d, desktop errors: %d, mdm errors: %d, mdm scep requests: %d, mdm scep success: %d, mdm scep errors: %d, ddm tokens success: %d, ddm tokens errors: %d, ddm declaration items success: %d, ddm declaration items errors: %d, ddm activation success: %d, ddm activation errors: %d, ddm configuration success: %d, ddm configuration errors: %d, ddm status success: %d, ddm status errors: %d, buffered logs: %d, script execs (errs): %d (%d), software installs (errs): %d (%d)", + "uptime: %s, error rate: %.2f, osquery enrolls: %d, orbit enrolls: %d, mdm enrolls: %d, distributed/reads: %d, distributed/writes: %d, config requests: %d, result log requests: %d, mdm sessions initiated: %d, mdm on-demand syncs: %d, mdm commands received: %d, config errors: %d, distributed/read errors: %d, distributed/write errors: %d, log result errors: %d, orbit errors: %d, desktop errors: %d, mdm errors: %d, mdm scep requests: %d, mdm scep success: %d, mdm scep errors: %d, ddm tokens success: %d, ddm tokens errors: %d, ddm declaration items success: %d, ddm declaration items errors: %d, ddm activation success: %d, ddm activation errors: %d, ddm configuration success: %d, ddm configuration errors: %d, ddm status success: %d, ddm status errors: %d, buffered logs: %d, script execs (errs): %d (%d), software installs (errs): %d (%d), android enrolls: %d, android status reports: %d, android command acks: %d, android errors: %d", time.Since(s.StartTime).Round(time.Second), - float64(s.errors)/float64(s.osqueryEnrollments), + errorRate, s.osqueryEnrollments, s.orbitEnrollments, s.mdmEnrollments, @@ -312,6 +345,10 @@ func (s *Stats) Log() { s.scriptExecErrs, s.softwareInstalls, s.softwareInstallErrs, + s.androidEnrollments, + s.androidStatusReports, + s.androidCommandAcks, + s.androidErrors, ) }