From bdb167a7d3f0d4823a6c6c45c05e50aa824edf32 Mon Sep 17 00:00:00 2001 From: Konstantin Sykulev Date: Tue, 30 Jun 2026 19:44:11 -0500 Subject: [PATCH 01/10] Android Load testing with osquery perf --- cmd/android-amapi-mock/google_forwarder.go | 222 ++++++++++ cmd/android-amapi-mock/handlers.go | 325 +++++++++++++++ cmd/android-amapi-mock/main.go | 186 +++++++++ cmd/android-amapi-mock/middleware.go | 74 ++++ cmd/osquery-perf/agent.go | 30 ++ cmd/osquery-perf/android.tmpl | 3 + cmd/osquery-perf/android_agent.go | 448 +++++++++++++++++++++ cmd/osquery-perf/osquery_perf/stats.go | 34 +- 8 files changed, 1321 insertions(+), 1 deletion(-) create mode 100644 cmd/android-amapi-mock/google_forwarder.go create mode 100644 cmd/android-amapi-mock/handlers.go create mode 100644 cmd/android-amapi-mock/main.go create mode 100644 cmd/android-amapi-mock/middleware.go create mode 100644 cmd/osquery-perf/android.tmpl create mode 100644 cmd/osquery-perf/android_agent.go diff --git a/cmd/android-amapi-mock/google_forwarder.go b/cmd/android-amapi-mock/google_forwarder.go new file mode 100644 index 00000000000..458e36a68c7 --- /dev/null +++ b/cmd/android-amapi-mock/google_forwarder.go @@ -0,0 +1,222 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + + "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() + svc, err := androidmanagement.NewService(ctx, + option.WithCredentialsJSON(credJSON), + ) + 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 { + 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 { + log.Printf("googleForwarder: list devices failed: %v", err) + break + } + for _, d := range resp.Devices { + allDevices = append(allDevices, map[string]string{"name": d.Name}) + } + if resp.NextPageToken == "" { + break + } + pageToken = resp.NextPageToken + } + + return allDevices +} + +// 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..f781d68bef8 --- /dev/null +++ b/cmd/android-amapi-mock/handlers.go @@ -0,0 +1,325 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + + "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) //nolint:errcheck + } +} + +// ---- 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) //nolint:errcheck + } +} + +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, _ := io.ReadAll(r.Body) + json.Unmarshal(body, &reqBody) //nolint:errcheck + } + + 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, + }) //nolint:errcheck + } +} + +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: %s (ESID: %s)", name, d.EnterpriseSpecificID) + } + 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, + }) //nolint:errcheck + } +} + +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") + realDevices = google.ForwardDevicesList(enterpriseName, r.Context()) + 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 != "" { + fmt.Sscanf(pt, "%d", &offset) + } + + end := offset + pageSize + if end > len(allDevices) { + end = len(allDevices) + } + if offset > len(allDevices) { + offset = 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) //nolint:errcheck + } +} + +// ---- 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: %s", name) + google.ForwardPoliciesPatch(w, r) + return + } + + version := policyVersionCounter.Add(1) + store.setPolicyVersion(name, version) + + if google != nil && hasSeenRealDevice.Load() { + go func() { + google.ForwardPoliciesPatch(&discardResponseWriter{}, r) + }() + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "name": name, + "version": version, + }) //nolint:errcheck + } +} + +// 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, + }) //nolint:errcheck + } +} + +// ---- 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), + }) //nolint:errcheck + } +} + +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", + }) //nolint:errcheck + } +} + +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", + }) //nolint:errcheck + } +} + +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, + }) //nolint:errcheck + } +} + +func handleCatchAll(google *googleForwarder) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if google != nil { + log.Printf("Unhandled request (no Google SDK mapping): %s %s", r.Method, r.URL.Path) + } else { + log.Printf("Mock AMAPI: unhandled %s %s", r.Method, r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, "{}") + } +} diff --git a/cmd/android-amapi-mock/main.go b/cmd/android-amapi-mock/main.go new file mode 100644 index 00000000000..1943b79e3e5 --- /dev/null +++ b/cmd/android-amapi-mock/main.go @@ -0,0 +1,186 @@ +// 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" +) + +// 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)) + + log.Printf("Mock AMAPI proxy listening on %s", *listen) + log.Fatal(http.ListenAndServe(*listen, mux)) +} + +// ---- 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..0aa6a8d039e --- /dev/null +++ b/cmd/android-amapi-mock/middleware.go @@ -0,0 +1,74 @@ +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("Forwarding to Google AMAPI: %s %s", r.Method, r.URL.Path) + 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) + } + 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..cfad771c4ec 100644 --- a/cmd/osquery-perf/agent.go +++ b/cmd/osquery-perf/agent.go @@ -3783,6 +3783,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 +3890,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 +4056,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..98fcc98ac96 --- /dev/null +++ b/cmd/osquery-perf/android_agent.go @@ -0,0 +1,448 @@ +package main + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log" + "math/rand" + "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))] + model := models[rand.Intn(len(models))] + hardware := hardwareTypes[rand.Intn(len(hardwareTypes))] + + // Android versions 13-15 + androidVersions := []string{"13", "14", "15"} + androidVersion := androidVersions[rand.Intn(len(androidVersions))] + buildNumber := fmt.Sprintf("TP1A.%d%02d%02d.003", 2024+rand.Intn(2), 1+rand.Intn(12), 1+rand.Intn(28)) + + // 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 + totalStorage := storageOptions[rand.Intn(len(storageOptions))] * 1024 * 1024 * 1024 + + 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 + if rand.Float64() < a.nonComplianceProb { + 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)) + 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)) + externalAvail := int64(float64(externalTotal) * (0.3 + rand.Float64()*0.5)) + + 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..e4c785a0bf8 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,12 +273,36 @@ 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() 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), s.osqueryEnrollments, @@ -312,6 +340,10 @@ func (s *Stats) Log() { s.scriptExecErrs, s.softwareInstalls, s.softwareInstallErrs, + s.androidEnrollments, + s.androidStatusReports, + s.androidCommandAcks, + s.androidErrors, ) } From 5228e1fd1fd08878287090a8e26ba2e495148311 Mon Sep 17 00:00:00 2001 From: Konstantin Sykulev Date: Wed, 1 Jul 2026 10:22:01 -0500 Subject: [PATCH 02/10] feedback and linting --- cmd/android-amapi-mock/google_forwarder.go | 9 +- cmd/android-amapi-mock/handlers.go | 101 ++++++++++++++------- cmd/android-amapi-mock/main.go | 9 +- cmd/android-amapi-mock/middleware.go | 4 +- cmd/osquery-perf/agent.go | 13 +-- cmd/osquery-perf/android_agent.go | 23 ++--- cmd/osquery-perf/osquery_perf/stats.go | 7 +- 7 files changed, 108 insertions(+), 58 deletions(-) diff --git a/cmd/android-amapi-mock/google_forwarder.go b/cmd/android-amapi-mock/google_forwarder.go index 458e36a68c7..d069a437c5f 100644 --- a/cmd/android-amapi-mock/google_forwarder.go +++ b/cmd/android-amapi-mock/google_forwarder.go @@ -92,7 +92,7 @@ func (g *googleForwarder) ForwardIssueCommand(w http.ResponseWriter, r *http.Req } // 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 { +func (g *googleForwarder) ForwardDevicesList(enterpriseName string, ctx context.Context) ([]map[string]string, error) { var allDevices []map[string]string pageToken := "" @@ -103,8 +103,7 @@ func (g *googleForwarder) ForwardDevicesList(enterpriseName string, ctx context. } resp, err := call.Do() if err != nil { - log.Printf("googleForwarder: list devices failed: %v", err) - break + return nil, fmt.Errorf("list devices from Google: %w", err) } for _, d := range resp.Devices { allDevices = append(allDevices, map[string]string{"name": d.Name}) @@ -115,7 +114,7 @@ func (g *googleForwarder) ForwardDevicesList(enterpriseName string, ctx context. pageToken = resp.NextPageToken } - return allDevices + return allDevices, nil } // ForwardPoliciesPatch forwards a PATCH .../policies/{id} request to Google. @@ -212,7 +211,7 @@ 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{ + _ = json.NewEncoder(w).Encode(map[string]any{ "error": map[string]any{ "code": 502, "message": err.Error(), diff --git a/cmd/android-amapi-mock/handlers.go b/cmd/android-amapi-mock/handlers.go index f781d68bef8..c84f9170821 100644 --- a/cmd/android-amapi-mock/handlers.go +++ b/cmd/android-amapi-mock/handlers.go @@ -1,11 +1,14 @@ package main import ( + "bytes" + "context" "encoding/json" "fmt" "io" "log" "net/http" + "strconv" "github.com/google/uuid" ) @@ -63,7 +66,7 @@ func handleGetState(store *deviceStore) http.HandlerFunc { d.mu.Unlock() w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(state) //nolint:errcheck + _ = json.NewEncoder(w).Encode(state) } } @@ -89,7 +92,7 @@ func handleDevicesGet(store *deviceStore) http.HandlerFunc { d.mu.Unlock() w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) //nolint:errcheck + _ = json.NewEncoder(w).Encode(resp) } } @@ -102,8 +105,17 @@ func handleDevicesPatch(store *deviceStore) http.HandlerFunc { PolicyName string `json:"policyName"` } if r.Body != nil { - body, _ := io.ReadAll(r.Body) - json.Unmarshal(body, &reqBody) //nolint:errcheck + 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 @@ -120,10 +132,10 @@ func handleDevicesPatch(store *deviceStore) http.HandlerFunc { } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ + _ = json.NewEncoder(w).Encode(map[string]any{ "name": name, "appliedPolicyVersion": appliedVersion, - }) //nolint:errcheck + }) } } @@ -158,10 +170,10 @@ func handleIssueCommand(store *deviceStore) http.HandlerFunc { } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ + _ = json.NewEncoder(w).Encode(map[string]any{ "name": operationName, "done": false, - }) //nolint:errcheck + }) } } @@ -172,7 +184,21 @@ func handleDevicesList(store *deviceStore, google *googleForwarder) http.Handler var realDevices []map[string]string if google != nil { enterpriseName := "enterprises/" + r.PathValue("eid") - realDevices = google.ForwardDevicesList(enterpriseName, r.Context()) + 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) } @@ -187,16 +213,21 @@ func handleDevicesList(store *deviceStore, google *googleForwarder) http.Handler pageSize := 100 offset := 0 if pt := r.URL.Query().Get("pageToken"); pt != "" { - fmt.Sscanf(pt, "%d", &offset) + if v, err := strconv.Atoi(pt); err == nil { + offset = v + } + } + if offset < 0 { + offset = 0 + } + if offset > len(allDevices) { + offset = len(allDevices) } end := offset + pageSize if end > len(allDevices) { end = len(allDevices) } - if offset > len(allDevices) { - offset = len(allDevices) - } resp := map[string]any{ "devices": allDevices[offset:end], @@ -206,7 +237,7 @@ func handleDevicesList(store *deviceStore, google *googleForwarder) http.Handler } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) //nolint:errcheck + _ = json.NewEncoder(w).Encode(resp) } } @@ -223,20 +254,27 @@ func handlePoliciesPatch(store *deviceStore, google *googleForwarder) http.Handl return } + var bodyBytes []byte + if r.Body != nil { + bodyBytes, _ = io.ReadAll(r.Body) + } + 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{}, r) + google.ForwardPoliciesPatch(&discardResponseWriter{}, fwdReq) }() } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ + _ = json.NewEncoder(w).Encode(map[string]any{ "name": name, "version": version, - }) //nolint:errcheck + }) } } @@ -249,9 +287,9 @@ func handlePolicyAction(store *deviceStore) http.HandlerFunc { store.setPolicyVersion(name, version) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ + _ = json.NewEncoder(w).Encode(map[string]any{ "version": version, - }) //nolint:errcheck + }) } } @@ -261,31 +299,31 @@ 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{ + _ = 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), - }) //nolint:errcheck + }) } } 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{ + _ = json.NewEncoder(w).Encode(map[string]any{ "name": "mock-app", "title": "Mock Application", - }) //nolint:errcheck + }) } } 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{ + _ = json.NewEncoder(w).Encode(map[string]any{ "name": "enterprises/mock/webApps/" + uuid.New().String(), "title": "Mock Web App", - }) //nolint:errcheck + }) } } @@ -306,20 +344,17 @@ func handleEnterprisesList(store *deviceStore) http.HandlerFunc { } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ + _ = json.NewEncoder(w).Encode(map[string]any{ "enterprises": enterprises, - }) //nolint:errcheck + }) } } func handleCatchAll(google *googleForwarder) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - if google != nil { - log.Printf("Unhandled request (no Google SDK mapping): %s %s", r.Method, r.URL.Path) - } else { - log.Printf("Mock AMAPI: unhandled %s %s", r.Method, r.URL.Path) - } + log.Printf("ERROR: unhandled AMAPI endpoint: %s %s — add a handler or forwarding for this route", r.Method, r.URL.Path) w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, "{}") + w.WriteHeader(http.StatusNotImplemented) + fmt.Fprintf(w, `{"error":{"code":501,"message":"mock does not handle %s %s","status":"NOT_IMPLEMENTED"}}`, r.Method, r.URL.Path) } } diff --git a/cmd/android-amapi-mock/main.go b/cmd/android-amapi-mock/main.go index 1943b79e3e5..99d29aa3b81 100644 --- a/cmd/android-amapi-mock/main.go +++ b/cmd/android-amapi-mock/main.go @@ -21,6 +21,7 @@ import ( "strings" "sync" "sync/atomic" + "time" ) // fakeDevice holds the in-memory state for a single fake Android device. @@ -164,8 +165,14 @@ func main() { // 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(http.ListenAndServe(*listen, mux)) + log.Fatal(srv.ListenAndServe()) } // ---- Route helpers ---- diff --git a/cmd/android-amapi-mock/middleware.go b/cmd/android-amapi-mock/middleware.go index 0aa6a8d039e..7b78dc4f24a 100644 --- a/cmd/android-amapi-mock/middleware.go +++ b/cmd/android-amapi-mock/middleware.go @@ -30,6 +30,8 @@ func forwardForRealDevice(store *deviceStore, google *googleForwarder) func(http 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 } @@ -71,4 +73,4 @@ 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) {} +func (discardResponseWriter) WriteHeader(int) {} diff --git a/cmd/osquery-perf/agent.go b/cmd/osquery-perf/agent.go index cfad771c4ec..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, @@ -3892,12 +3893,12 @@ func main() { "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]") + 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() diff --git a/cmd/osquery-perf/android_agent.go b/cmd/osquery-perf/android_agent.go index 98fcc98ac96..ad4c36b17ed 100644 --- a/cmd/osquery-perf/android_agent.go +++ b/cmd/osquery-perf/android_agent.go @@ -137,14 +137,14 @@ func newAndroidAgent( 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))] - model := models[rand.Intn(len(models))] - hardware := hardwareTypes[rand.Intn(len(hardwareTypes))] + 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))] - buildNumber := fmt.Sprintf("TP1A.%d%02d%02d.003", 2024+rand.Intn(2), 1+rand.Intn(12), 1+rand.Intn(28)) + 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) { @@ -166,8 +166,8 @@ func newAndroidAgent( // 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 - totalStorage := storageOptions[rand.Intn(len(storageOptions))] * 1024 * 1024 * 1024 + 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, @@ -357,7 +357,8 @@ func (a *androidAgent) sendStatusReport(state *proxyDeviceState) error { } // Optionally add non-compliance details - if rand.Float64() < a.nonComplianceProb { + nonCompliant := rand.Float64() < a.nonComplianceProb // #nosec G404 -- load testing only + if nonCompliant { device.NonComplianceDetails = []*androidmanagement.NonComplianceDetail{ { SettingName: "passwordPolicies", @@ -406,7 +407,7 @@ func (a *androidAgent) sendPubSubMessage(notificationType android.NotificationTy // 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)) + 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) } @@ -425,8 +426,8 @@ func (a *androidAgent) generateMemoryEvents() []*androidmanagement.MemoryEvent { // 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)) - externalAvail := int64(float64(externalTotal) * (0.3 + rand.Float64()*0.5)) + 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{ { diff --git a/cmd/osquery-perf/osquery_perf/stats.go b/cmd/osquery-perf/osquery_perf/stats.go index e4c785a0bf8..874f15ffd30 100644 --- a/cmd/osquery-perf/osquery_perf/stats.go +++ b/cmd/osquery-perf/osquery_perf/stats.go @@ -301,10 +301,15 @@ 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), 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, From 597b5d395f4172761c03651233397b718ca9e552 Mon Sep 17 00:00:00 2001 From: Konstantin Sykulev Date: Wed, 1 Jul 2026 11:14:49 -0500 Subject: [PATCH 03/10] linting --- cmd/android-amapi-mock/google_forwarder.go | 8 +------- cmd/android-amapi-mock/handlers.go | 19 +++++++++++-------- cmd/android-amapi-mock/middleware.go | 2 +- cmd/osquery-perf/android_agent.go | 16 ++++++++-------- 4 files changed, 21 insertions(+), 24 deletions(-) diff --git a/cmd/android-amapi-mock/google_forwarder.go b/cmd/android-amapi-mock/google_forwarder.go index d069a437c5f..ce0060b6d92 100644 --- a/cmd/android-amapi-mock/google_forwarder.go +++ b/cmd/android-amapi-mock/google_forwarder.go @@ -7,7 +7,6 @@ import ( "io" "log" "net/http" - "os" "google.golang.org/api/androidmanagement/v1" "google.golang.org/api/option" @@ -20,14 +19,9 @@ type googleForwarder struct { } 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() svc, err := androidmanagement.NewService(ctx, - option.WithCredentialsJSON(credJSON), + option.WithCredentialsFile(credentialsFile), ) if err != nil { return nil, fmt.Errorf("create android management service: %w", err) diff --git a/cmd/android-amapi-mock/handlers.go b/cmd/android-amapi-mock/handlers.go index c84f9170821..867691d0110 100644 --- a/cmd/android-amapi-mock/handlers.go +++ b/cmd/android-amapi-mock/handlers.go @@ -147,7 +147,7 @@ func handleDevicesDelete(store *deviceStore) http.HandlerFunc { if d, ok := store.byName[name]; ok { delete(store.byName, name) delete(store.byESID, d.EnterpriseSpecificID) - log.Printf("Deleted fake device: %s (ESID: %s)", name, d.EnterpriseSpecificID) + log.Printf("Deleted fake device: %q (ESID: %q)", name, d.EnterpriseSpecificID) } store.mu.Unlock() @@ -224,10 +224,7 @@ func handleDevicesList(store *deviceStore, google *googleForwarder) http.Handler offset = len(allDevices) } - end := offset + pageSize - if end > len(allDevices) { - end = len(allDevices) - } + end := min(offset+pageSize, len(allDevices)) resp := map[string]any{ "devices": allDevices[offset:end], @@ -249,7 +246,7 @@ func handlePoliciesPatch(store *deviceStore, google *googleForwarder) http.Handl enterpriseID := r.PathValue("eid") if !store.hasDevicesForEnterprise(enterpriseID) && google != nil { - log.Printf("Forwarding policy patch to Google AMAPI: %s", name) + log.Printf("Forwarding policy patch to Google AMAPI: %q", name) google.ForwardPoliciesPatch(w, r) return } @@ -352,9 +349,15 @@ func handleEnterprisesList(store *deviceStore) http.HandlerFunc { func handleCatchAll(google *googleForwarder) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - log.Printf("ERROR: unhandled AMAPI endpoint: %s %s — add a handler or forwarding for this route", r.Method, r.URL.Path) + log.Printf("ERROR: unhandled AMAPI endpoint: %q %q — add a handler or forwarding for this route", r.Method, r.URL.Path) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusNotImplemented) - fmt.Fprintf(w, `{"error":{"code":501,"message":"mock does not handle %s %s","status":"NOT_IMPLEMENTED"}}`, r.Method, r.URL.Path) + _ = 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/middleware.go b/cmd/android-amapi-mock/middleware.go index 7b78dc4f24a..4736b455364 100644 --- a/cmd/android-amapi-mock/middleware.go +++ b/cmd/android-amapi-mock/middleware.go @@ -20,7 +20,7 @@ func forwardForRealDevice(store *deviceStore, google *googleForwarder) func(http // Real device — forward via Google SDK if available if google != nil { hasSeenRealDevice.Store(true) - log.Printf("Forwarding to Google AMAPI: %s %s", r.Method, r.URL.Path) + log.Printf("Real device request: %s %s (device: %q)", r.Method, r.URL.Path, name) switch r.Method { case "GET": google.ForwardDevicesGet(w, r) diff --git a/cmd/osquery-perf/android_agent.go b/cmd/osquery-perf/android_agent.go index ad4c36b17ed..e5bb542c1a2 100644 --- a/cmd/osquery-perf/android_agent.go +++ b/cmd/osquery-perf/android_agent.go @@ -7,7 +7,7 @@ import ( "fmt" "io" "log" - "math/rand" + "math/rand/v2" "net/http" "strings" "time" @@ -137,14 +137,14 @@ func newAndroidAgent( 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 + 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 + 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) { @@ -166,8 +166,8 @@ func newAndroidAgent( // 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 + 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, From de904efcf7ec6f2ca970055b2f110cb3c6af1cb7 Mon Sep 17 00:00:00 2001 From: Konstantin Sykulev Date: Wed, 1 Jul 2026 11:17:42 -0500 Subject: [PATCH 04/10] ai feedback --- cmd/android-amapi-mock/handlers.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/cmd/android-amapi-mock/handlers.go b/cmd/android-amapi-mock/handlers.go index 867691d0110..bb1c19859cc 100644 --- a/cmd/android-amapi-mock/handlers.go +++ b/cmd/android-amapi-mock/handlers.go @@ -253,7 +253,12 @@ func handlePoliciesPatch(store *deviceStore, google *googleForwarder) http.Handl var bodyBytes []byte if r.Body != nil { - bodyBytes, _ = io.ReadAll(r.Body) + 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) From 9474dda08bd83b4af8965a7f08a70c00dc6c3044 Mon Sep 17 00:00:00 2001 From: Konstantin Sykulev Date: Wed, 1 Jul 2026 14:02:16 -0500 Subject: [PATCH 05/10] linting --- cmd/android-amapi-mock/google_forwarder.go | 14 +++++++++++++- cmd/android-amapi-mock/handlers.go | 6 +++--- cmd/android-amapi-mock/middleware.go | 2 +- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/cmd/android-amapi-mock/google_forwarder.go b/cmd/android-amapi-mock/google_forwarder.go index ce0060b6d92..2cd39f428fa 100644 --- a/cmd/android-amapi-mock/google_forwarder.go +++ b/cmd/android-amapi-mock/google_forwarder.go @@ -7,7 +7,9 @@ import ( "io" "log" "net/http" + "os" + "golang.org/x/oauth2/google" "google.golang.org/api/androidmanagement/v1" "google.golang.org/api/option" ) @@ -19,9 +21,19 @@ type googleForwarder struct { } 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) + if err != nil { + return nil, fmt.Errorf("parse credentials: %w", err) + } + svc, err := androidmanagement.NewService(ctx, - option.WithCredentialsFile(credentialsFile), + option.WithCredentials(creds), ) if err != nil { return nil, fmt.Errorf("create android management service: %w", err) diff --git a/cmd/android-amapi-mock/handlers.go b/cmd/android-amapi-mock/handlers.go index bb1c19859cc..bda4b62133d 100644 --- a/cmd/android-amapi-mock/handlers.go +++ b/cmd/android-amapi-mock/handlers.go @@ -147,7 +147,7 @@ func handleDevicesDelete(store *deviceStore) http.HandlerFunc { 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) + log.Printf("Deleted fake device: %q (ESID: %q)", name, d.EnterpriseSpecificID) // #nosec G706 -- load testing tool } store.mu.Unlock() @@ -246,7 +246,7 @@ func handlePoliciesPatch(store *deviceStore, google *googleForwarder) http.Handl enterpriseID := r.PathValue("eid") if !store.hasDevicesForEnterprise(enterpriseID) && google != nil { - log.Printf("Forwarding policy patch to Google AMAPI: %q", name) + log.Printf("Forwarding policy patch to Google AMAPI: %q", name) // #nosec G706 -- load testing tool google.ForwardPoliciesPatch(w, r) return } @@ -354,7 +354,7 @@ func handleEnterprisesList(store *deviceStore) http.HandlerFunc { 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) + 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{ diff --git a/cmd/android-amapi-mock/middleware.go b/cmd/android-amapi-mock/middleware.go index 4736b455364..31028e56334 100644 --- a/cmd/android-amapi-mock/middleware.go +++ b/cmd/android-amapi-mock/middleware.go @@ -20,7 +20,7 @@ func forwardForRealDevice(store *deviceStore, google *googleForwarder) func(http // 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) + 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) From 5caeb516d31403a73e030b504324143dba000115 Mon Sep 17 00:00:00 2001 From: Konstantin Sykulev Date: Wed, 1 Jul 2026 14:12:40 -0500 Subject: [PATCH 06/10] liniting --- cmd/android-amapi-mock/google_forwarder.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/android-amapi-mock/google_forwarder.go b/cmd/android-amapi-mock/google_forwarder.go index 2cd39f428fa..8ee165f1b64 100644 --- a/cmd/android-amapi-mock/google_forwarder.go +++ b/cmd/android-amapi-mock/google_forwarder.go @@ -27,7 +27,7 @@ func newGoogleForwarder(credentialsFile string) (*googleForwarder, error) { } ctx := context.Background() - creds, err := google.CredentialsFromJSON(ctx, credJSON, androidmanagement.AndroidmanagementScope) + 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) } From 331326edc56d43e2c54a55bdac7344d72b10a2c9 Mon Sep 17 00:00:00 2001 From: Konstantin Sykulev Date: Thu, 2 Jul 2026 16:15:01 -0500 Subject: [PATCH 07/10] changing credentials file arg to json rather than a path --- cmd/android-amapi-mock/google_forwarder.go | 10 ++-------- cmd/android-amapi-mock/main.go | 2 +- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/cmd/android-amapi-mock/google_forwarder.go b/cmd/android-amapi-mock/google_forwarder.go index 8ee165f1b64..be87860e991 100644 --- a/cmd/android-amapi-mock/google_forwarder.go +++ b/cmd/android-amapi-mock/google_forwarder.go @@ -7,7 +7,6 @@ import ( "io" "log" "net/http" - "os" "golang.org/x/oauth2/google" "google.golang.org/api/androidmanagement/v1" @@ -20,14 +19,9 @@ 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) - } - +func newGoogleForwarder(credentialsJSON string) (*googleForwarder, error) { ctx := context.Background() - creds, err := google.CredentialsFromJSON(ctx, credJSON, androidmanagement.AndroidmanagementScope) //nolint:staticcheck // SA1019 -- load testing tool, credentials are from a trusted local file + creds, err := google.CredentialsFromJSON(ctx, []byte(credentialsJSON), androidmanagement.AndroidmanagementScope) //nolint:staticcheck // SA1019 -- load testing tool, credentials are from a trusted source if err != nil { return nil, fmt.Errorf("parse credentials: %w", err) } diff --git a/cmd/android-amapi-mock/main.go b/cmd/android-amapi-mock/main.go index 99d29aa3b81..f7da49daa3f 100644 --- a/cmd/android-amapi-mock/main.go +++ b/cmd/android-amapi-mock/main.go @@ -120,7 +120,7 @@ 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)") + googleCredentials := flag.String("google-credentials", "", "Google service account JSON credentials (enables forwarding for real devices). Pass via: --google-credentials \"$(cat credentials.json)\"") flag.Parse() policyVersionCounter.Store(1) From 96e7b7870412f57da0c9c2245233f16290e2c913 Mon Sep 17 00:00:00 2001 From: Konstantin Sykulev Date: Thu, 2 Jul 2026 17:25:46 -0500 Subject: [PATCH 08/10] health check for terraform --- cmd/android-amapi-mock/main.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cmd/android-amapi-mock/main.go b/cmd/android-amapi-mock/main.go index f7da49daa3f..c3f3081b7fb 100644 --- a/cmd/android-amapi-mock/main.go +++ b/cmd/android-amapi-mock/main.go @@ -140,6 +140,11 @@ func main() { mux := http.NewServeMux() + // ---- Health check ---- + mux.HandleFunc("GET /mock/health", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + // ---- Coordination API (osquery-perf calls these) ---- mux.HandleFunc("POST /mock/devices/register", handleRegister(store)) mux.HandleFunc("GET /mock/devices/{esid}/state", handleGetState(store)) From 53864aa82773ef80267bbf7ff3330f21a744b1ad Mon Sep 17 00:00:00 2001 From: Konstantin Sykulev Date: Thu, 2 Jul 2026 18:08:08 -0500 Subject: [PATCH 09/10] URL-encode pubsub token in android agent --- cmd/android-amapi-mock/seed_android.go | 90 ++++++++++++++++++++++++++ cmd/osquery-perf/android_agent.go | 3 +- 2 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 cmd/android-amapi-mock/seed_android.go diff --git a/cmd/android-amapi-mock/seed_android.go b/cmd/android-amapi-mock/seed_android.go new file mode 100644 index 00000000000..ce7e296a5ec --- /dev/null +++ b/cmd/android-amapi-mock/seed_android.go @@ -0,0 +1,90 @@ +//go:build ignore + +// Usage: go run seed_android.go +// +// Generates SQL to seed android_enterprises and mdm_config_assets with +// values encrypted using the given Fleet server private key. +// +// Example: +// go run seed_android.go 'TwmSR]%_$x7$rt[VveeRjjc$3c18ln:2' LC03k6enk8 my-pubsub-token +// # Pipe output to mysql to import +package main + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/md5" //nolint:gosec + "crypto/rand" + "encoding/hex" + "fmt" + "io" + "os" +) + +func main() { + if len(os.Args) != 4 { + fmt.Fprintf(os.Stderr, "usage: %s \n", os.Args[0]) + os.Exit(1) + } + key := os.Args[1][:32] + enterpriseID := os.Args[2] + pubsubToken := os.Args[3] + + // Encrypt the pubsub token + encToken, err := encrypt([]byte(pubsubToken), key) + if err != nil { + fmt.Fprintf(os.Stderr, "encrypt pubsub token: %v\n", err) + os.Exit(1) + } + + // Encrypt a fleet server secret (can be any value, mock doesn't validate it) + fleetSecret := "mock-fleet-server-secret-for-loadtest" + encSecret, err := encrypt([]byte(fleetSecret), key) + if err != nil { + fmt.Fprintf(os.Stderr, "encrypt fleet secret: %v\n", err) + os.Exit(1) + } + + hexToken := hex.EncodeToString(encToken) + hexSecret := hex.EncodeToString(encSecret) + md5Token := md5Hex(encToken) + md5Secret := md5Hex(encSecret) + + fmt.Println("-- Seed Android MDM for load testing") + fmt.Println("-- Generated by seed_android.go") + fmt.Println() + fmt.Println("-- Clean up any existing android data") + fmt.Println("DELETE FROM android_devices;") + fmt.Println("DELETE FROM hosts WHERE platform = 'android';") + fmt.Println("DELETE FROM android_enterprises;") + fmt.Println("DELETE FROM mdm_config_assets WHERE name IN ('android_pubsub_token', 'android_fleet_server_secret');") + fmt.Println() + fmt.Printf("INSERT INTO android_enterprises (signup_name, enterprise_id, signup_token, pubsub_topic_id, user_id) VALUES ('loadtest-signup', '%s', 'loadtest-token', 'loadtest-topic', 1);\n", enterpriseID) + fmt.Println() + fmt.Printf("INSERT INTO mdm_config_assets (name, value, md5_checksum) VALUES ('android_pubsub_token', 0x%s, 0x%s);\n", hexToken, md5Token) + fmt.Printf("INSERT INTO mdm_config_assets (name, value, md5_checksum) VALUES ('android_fleet_server_secret', 0x%s, 0x%s);\n", hexSecret, md5Secret) + fmt.Println() + fmt.Println("-- Enable Android MDM") + fmt.Println("UPDATE app_config_json SET json_value = JSON_SET(json_value, '$.mdm.android_enabled_and_configured', true) WHERE id = 1;") +} + +func encrypt(plainText []byte, privateKey string) ([]byte, error) { + block, err := aes.NewCipher([]byte(privateKey)) + if err != nil { + return nil, fmt.Errorf("create cipher: %w", err) + } + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("create gcm: %w", err) + } + nonce := make([]byte, aesGCM.NonceSize()) + if _, err = io.ReadFull(rand.Reader, nonce); err != nil { + return nil, fmt.Errorf("generate nonce: %w", err) + } + return aesGCM.Seal(nonce, nonce, plainText, nil), nil +} + +func md5Hex(data []byte) string { + h := md5.Sum(data) //nolint:gosec + return hex.EncodeToString(h[:]) +} diff --git a/cmd/osquery-perf/android_agent.go b/cmd/osquery-perf/android_agent.go index e5bb542c1a2..e55b2650c2c 100644 --- a/cmd/osquery-perf/android_agent.go +++ b/cmd/osquery-perf/android_agent.go @@ -9,6 +9,7 @@ import ( "log" "math/rand/v2" "net/http" + neturl "net/url" "strings" "time" @@ -406,7 +407,7 @@ func (a *androidAgent) sendPubSubMessage(notificationType android.NotificationTy } // 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) + url := fmt.Sprintf("%s/api/v1/fleet/android_enterprise/pubsub?token=%s", a.serverAddress, neturl.QueryEscape(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) From c188ce80014f61ba85a7e70d13546e3e71856d1f Mon Sep 17 00:00:00 2001 From: Konstantin Sykulev Date: Thu, 2 Jul 2026 18:38:16 -0500 Subject: [PATCH 10/10] google credentials --- cmd/android-amapi-mock/main.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/cmd/android-amapi-mock/main.go b/cmd/android-amapi-mock/main.go index c3f3081b7fb..736d25602e2 100644 --- a/cmd/android-amapi-mock/main.go +++ b/cmd/android-amapi-mock/main.go @@ -18,6 +18,7 @@ import ( "flag" "log" "net/http" + "os" "strings" "sync" "sync/atomic" @@ -120,18 +121,24 @@ var hasSeenRealDevice atomic.Bool func main() { listen := flag.String("listen", ":9999", "Address to listen on") - googleCredentials := flag.String("google-credentials", "", "Google service account JSON credentials (enables forwarding for real devices). Pass via: --google-credentials \"$(cat credentials.json)\"") + googleCredentials := flag.String("google-credentials", "", "Google service account JSON credentials (enables forwarding for real devices). Pass via: --google-credentials \"$(cat credentials.json)\" or set GOOGLE_CREDENTIALS env var") flag.Parse() + // Fall back to env var if flag not provided (for ECS Secrets Manager injection) + credJSON := *googleCredentials + if credJSON == "" { + credJSON = os.Getenv("GOOGLE_CREDENTIALS") + } + policyVersionCounter.Store(1) store := newDeviceStore() // Set up authenticated Google API client for real device forwarding var google *googleForwarder - if *googleCredentials != "" { + if credJSON != "" { var err error - google, err = newGoogleForwarder(*googleCredentials) + google, err = newGoogleForwarder(credJSON) if err != nil { log.Fatalf("Failed to create Google forwarder: %v", err) }