From fd949a9fc38160a52e776c86f731549e124baa9c Mon Sep 17 00:00:00 2001 From: Dante Catalfamo <43040593+dantecatalfamo@users.noreply.github.com> Date: Tue, 2 Dec 2025 14:32:09 -0500 Subject: [PATCH] Fix host duplicated hosts, auto-enroll, sync with server MC (#36414) --- .../com/fleetdm/agent/AgentApplication.kt | 48 +++++ .../main/java/com/fleetdm/agent/ApiClient.kt | 196 ++++++++++++++++-- .../com/fleetdm/agent/ConfigCheckWorker.kt | 15 +- .../java/com/fleetdm/agent/MainActivity.kt | 43 +--- android/app/src/main/res/values/strings.xml | 10 +- .../app/src/main/res/xml/app_restrictions.xml | 14 +- .../fleetdm/agent/ConfigCheckWorkerTest.kt | 6 +- server/datastore/mysql/hosts.go | 6 +- server/datastore/mysql/hosts_test.go | 12 +- tools/android/android.go | 85 ++++++-- 10 files changed, 338 insertions(+), 97 deletions(-) diff --git a/android/app/src/main/java/com/fleetdm/agent/AgentApplication.kt b/android/app/src/main/java/com/fleetdm/agent/AgentApplication.kt index 3ddc9a0a0a..379c4a861e 100644 --- a/android/app/src/main/java/com/fleetdm/agent/AgentApplication.kt +++ b/android/app/src/main/java/com/fleetdm/agent/AgentApplication.kt @@ -1,11 +1,18 @@ package com.fleetdm.agent import android.app.Application +import android.content.Context +import android.content.RestrictionsManager +import android.os.Build import android.util.Log import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import java.util.concurrent.TimeUnit +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch /** * Custom Application class for Fleet Agent. @@ -17,12 +24,53 @@ class AgentApplication : Application() { private const val CONFIG_CHECK_WORK_NAME = "config_check_periodic" } + private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + override fun onCreate() { super.onCreate() Log.i(TAG, "Fleet agent process started") + ApiClient.initialize(this) + refreshEnrollmentCredentials() schedulePeriodicConfigCheck() } + private fun refreshEnrollmentCredentials() { + applicationScope.launch { + try { + val restrictionsManager = getSystemService(Context.RESTRICTIONS_SERVICE) + as? RestrictionsManager + val appRestrictions = restrictionsManager?.applicationRestrictions ?: return@launch + + val enrollSecret = appRestrictions.getString("enrollSecret") + val hostUUID = appRestrictions.getString("hostUUID") + val serverURL = appRestrictions.getString("serverURL") + + if (enrollSecret != null && hostUUID != null && serverURL != null) { + Log.d(TAG, "Refreshing enrollment credentials from MDM config") + ApiClient.setEnrollmentCredentials( + enrollSecret = enrollSecret, + hardwareUUID = hostUUID, + baseUrl = serverURL, + computerName = "${Build.BRAND} ${Build.MODEL}", + ) + + // Trigger auto-enrollment if node key is missing + // This also fetches initial orbit config + val configResult = ApiClient.getOrbitConfig() + configResult.onSuccess { + Log.d(TAG, "Successfully enrolled and fetched initial orbit config") + }.onFailure { error -> + Log.w(TAG, "Auto-enrollment on startup failed: ${error.message}") + } + } else { + Log.d(TAG, "MDM enrollment credentials not available") + } + } catch (e: Exception) { + Log.e(TAG, "Error refreshing enrollment credentials", e) + } + } + } + private fun schedulePeriodicConfigCheck() { val workRequest = PeriodicWorkRequestBuilder( diff --git a/android/app/src/main/java/com/fleetdm/agent/ApiClient.kt b/android/app/src/main/java/com/fleetdm/agent/ApiClient.kt index 2f10fd4270..b381b88965 100644 --- a/android/app/src/main/java/com/fleetdm/agent/ApiClient.kt +++ b/android/app/src/main/java/com/fleetdm/agent/ApiClient.kt @@ -13,11 +13,14 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement private val Context.credentialStore: DataStore by preferencesDataStore(name = "api_credentials") @@ -27,8 +30,14 @@ object ApiClient { private lateinit var dataStore: DataStore private val API_KEY = stringPreferencesKey("api_key") private val BASE_URL_KEY = stringPreferencesKey("base_url") + private val ENROLL_SECRET = stringPreferencesKey("enroll_secret") + private val HARDWARE_UUID = stringPreferencesKey("hardware_uuid") + private val COMPUTER_NAME = stringPreferencesKey("computer_name") + + private val enrollmentMutex = Mutex() fun initialize(context: Context) { + Log.d("fleet-apiClient", "initializing api client") if (!::dataStore.isInitialized) { dataStore = context.applicationContext.credentialStore } @@ -40,7 +49,7 @@ object ApiClient { } } - private suspend fun setBaseUrl(url: String) { + suspend fun setBaseUrl(url: String) { dataStore.edit { preferences -> preferences[BASE_URL_KEY] = url } @@ -60,23 +69,15 @@ object ApiClient { suspend fun getBaseUrl(): String? = dataStore.data.first()[BASE_URL_KEY] - suspend fun makeRequest( + private suspend fun makeRequest( endpoint: String, method: String = "GET", body: R? = null, - authenticated: Boolean = true, - bodySerializer: KSerializer, + bodySerializer: KSerializer? = null, responseSerializer: KSerializer, ): Result = withContext(Dispatchers.IO) { var connection: HttpURLConnection? = null try { - val apiKey = getApiKey() - if (authenticated && apiKey == null) { - return@withContext Result.failure( - Exception("API key not configured"), - ) - } - val baseUrl = getBaseUrl() ?: return@withContext Result.failure( Exception("Base URL not configured"), ) @@ -102,16 +103,13 @@ object ApiClient { requestMethod = method useCaches = false doInput = true - if (authenticated) { - setRequestProperty("Authorization", "Bearer $apiKey") - } setRequestProperty("Content-Type", "application/json") connectTimeout = 15000 readTimeout = 15000 if (body != null && method != "GET") { doOutput = true - val bodyJson = json.encodeToString(value = body, serializer = bodySerializer) + val bodyJson = json.encodeToString(value = body, serializer = bodySerializer!!) outputStream.use { it.write(bodyJson.toByteArray()) } } } @@ -137,17 +135,18 @@ object ApiClient { } } - suspend fun enroll(baseUrl: String, enrollSecret: String, hardwareUUID: String, computerName: String): Result { - setBaseUrl(baseUrl) + suspend fun enroll(): Result { + val credentials = getEnrollmentCredentials() + credentials ?: return Result.failure(Exception("Credentials not set")) val resp = makeRequest( endpoint = "/api/fleet/orbit/enroll", method = "POST", body = EnrollRequest( - enrollSecret = enrollSecret, - hardwareUUID = hardwareUUID, - computerName = computerName, + enrollSecret = credentials.enrollSecret, + hardwareUUID = credentials.hardwareUUID, + hardwareSerial = credentials.hardwareUUID, + computerName = credentials.computerName, ), - authenticated = false, bodySerializer = EnrollRequest.serializer(), responseSerializer = EnrollResponse.serializer(), ) @@ -160,6 +159,84 @@ object ApiClient { return resp } + + suspend fun getOrbitConfig(): Result { + val nodeKeyResult = getNodeKeyOrEnroll() + + val orbitNodeKey = nodeKeyResult.getOrElse { error -> + return Result.failure(error) + } + + return makeRequest( + endpoint = "/api/fleet/orbit/config", + method = "POST", + body = GetConfigRequest(orbitNodeKey = orbitNodeKey), + bodySerializer = GetConfigRequest.serializer(), + responseSerializer = OrbitConfig.serializer(), + ) + } + + suspend fun setEnrollmentCredentials(enrollSecret: String, hardwareUUID: String, computerName: String, baseUrl: String) { + dataStore.edit { preferences -> + preferences[ENROLL_SECRET] = enrollSecret + preferences[HARDWARE_UUID] = hardwareUUID + preferences[COMPUTER_NAME] = computerName + preferences[BASE_URL_KEY] = baseUrl + } + } + + private suspend fun getEnrollmentCredentials(): EnrollmentCredentials? { + val prefs = dataStore.data.first() + val enrollSecret = prefs[ENROLL_SECRET] + val hardwareUUID = prefs[HARDWARE_UUID] + val computerName = prefs[COMPUTER_NAME] + val baseUrl = prefs[BASE_URL_KEY] + + if (enrollSecret == null || hardwareUUID == null || computerName == null || baseUrl == null) { + return null + } + + return EnrollmentCredentials( + baseUrl = baseUrl, + enrollSecret = enrollSecret, + hardwareUUID = hardwareUUID, + computerName = computerName, + ) + } + + private suspend fun getNodeKeyOrEnroll(): Result { + enrollmentMutex.withLock { + // Check again inside lock in case another coroutine just enrolled + val existingKey = getApiKey() + if (existingKey != null) { + return Result.success(existingKey) + } + + // Node key is missing, attempt auto-enrollment + Log.d("ApiClient", "Orbit node key missing, attempting auto-enrollment") + + // Re-enroll + val enrollResult = enroll() + + return enrollResult.fold( + onSuccess = { response -> + Log.d("ApiClient", "Auto-enrollment successful") + Result.success(response.orbitNodeKey) + }, + onFailure = { error -> + Log.e("ApiClient", "Auto-enrollment failed: ${error.message}") + Result.failure(error) + }, + ) + } + } + + private data class EnrollmentCredentials( + val baseUrl: String, + val enrollSecret: String, + val hardwareUUID: String, + val computerName: String, + ) } @Serializable @@ -168,6 +245,8 @@ data class EnrollRequest( val enrollSecret: String, @SerialName("hardware_uuid") val hardwareUUID: String, + @SerialName("hardware_serial") + val hardwareSerial: String, @SerialName("platform") val platform: String = "android", @SerialName("computer_name") @@ -179,3 +258,78 @@ data class EnrollResponse( @SerialName("orbit_node_key") val orbitNodeKey: String, ) + +@Serializable +private data class GetConfigRequest( + @SerialName("orbit_node_key") + val orbitNodeKey: String, +) + +@Serializable +data class OrbitConfig( + @SerialName("script_execution_timeout") + val scriptExecutionTimeout: Int = 0, + + @SerialName("command_line_startup_flags") + val commandLineStartupFlags: JsonElement? = null, + + @SerialName("extensions") + val extensions: JsonElement? = null, + + @SerialName("nudge_config") + val nudgeConfig: JsonElement? = null, + + @SerialName("notifications") + val notifications: OrbitConfigNotifications = OrbitConfigNotifications(), + + @SerialName("update_channels") + val updateChannels: OrbitUpdateChannels? = null, +) + +@Serializable +data class OrbitConfigNotifications( + @SerialName("pending_script_execution_ids") + val pendingScriptExecutionIDs: List = emptyList(), + + @SerialName("pending_software_installer_ids") + val pendingSoftwareInstallerIDs: List = emptyList(), + + @SerialName("renew_enrollment_profile") + val renewEnrollmentProfile: Boolean = false, + + @SerialName("rotate_disk_encryption_key") + val rotateDiskEncryptionKey: Boolean = false, + + @SerialName("needs_mdm_migration") + val needsMDMMigration: Boolean = false, + + @SerialName("run_setup_experience") + val runSetupExperience: Boolean = false, + + @SerialName("run_disk_encryption_escrow") + val runDiskEncryptionEscrow: Boolean = false, + + @SerialName("needs_programmatic_windows_mdm_enrollment") + val needsProgrammaticWindowsMDMEnrollment: Boolean = false, + + @SerialName("windows_mdm_discovery_endpoint") + val windowsMDMDiscoveryEndpoint: String = "", + + @SerialName("needs_programmatic_windows_mdm_unenrollment") + val needsProgrammaticWindowsMDMUnenrollment: Boolean = false, + + @SerialName("enforce_bitlocker_encryption") + val enforceBitLockerEncryption: Boolean = false, +) + +@Serializable +data class OrbitUpdateChannels( + @SerialName("orbit") + val orbit: String = "", + + @SerialName("osqueryd") + val osqueryd: String = "", + + @SerialName("desktop") + val desktop: String = "", +) diff --git a/android/app/src/main/java/com/fleetdm/agent/ConfigCheckWorker.kt b/android/app/src/main/java/com/fleetdm/agent/ConfigCheckWorker.kt index 6dbbff5932..0596090c66 100644 --- a/android/app/src/main/java/com/fleetdm/agent/ConfigCheckWorker.kt +++ b/android/app/src/main/java/com/fleetdm/agent/ConfigCheckWorker.kt @@ -2,19 +2,28 @@ package com.fleetdm.agent import android.content.Context import android.util.Log -import androidx.work.Worker +import androidx.work.CoroutineWorker import androidx.work.WorkerParameters /** * WorkManager worker that periodically checks managed configurations. */ -class ConfigCheckWorker(context: Context, params: WorkerParameters) : Worker(context, params) { +class ConfigCheckWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { + companion object { private const val TAG = "fleet-worker" } - override fun doWork(): Result { + override suspend fun doWork(): Result { Log.i(TAG, "Periodic config check triggered") + + val configResult = ApiClient.getOrbitConfig() + configResult.onSuccess { config -> + Log.d(TAG, "Successfully fetched orbit config") + }.onFailure { error -> + Log.e(TAG, "Failed to fetch orbit config: ${error.message}", error) + } + return Result.success() } } diff --git a/android/app/src/main/java/com/fleetdm/agent/MainActivity.kt b/android/app/src/main/java/com/fleetdm/agent/MainActivity.kt index 2cab267424..b36e8db7b9 100644 --- a/android/app/src/main/java/com/fleetdm/agent/MainActivity.kt +++ b/android/app/src/main/java/com/fleetdm/agent/MainActivity.kt @@ -25,7 +25,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -55,16 +54,14 @@ class MainActivity : ComponentActivity() { val appRestrictions = restrictionsManager.applicationRestrictions val dpm = getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager - ApiClient.initialize(this) - setContent { val enrollSecret by remember { mutableStateOf(appRestrictions.getString("enrollSecret")) } - val delegatedScopes by remember { mutableStateOf(dpm.getDelegatedScopes(null, packageName)) } + val delegatedScopes by remember { mutableStateOf(dpm.getDelegatedScopes(null, packageName).toList()) } val delegatedCertScope by remember { mutableStateOf(delegatedScopes.contains(DevicePolicyManager.DELEGATION_CERT_INSTALL)) } val androidID by remember { mutableStateOf(Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID)) } - val enrollmentSpecificID by remember { mutableStateOf(appRestrictions.getString("enrollmentSpecificID")) } + val enrollmentSpecificID by remember { mutableStateOf(appRestrictions.getString("hostUUID")) } val certRequestList by remember { mutableStateOf(appRestrictions.getParcelableArray("certificates", Bundle::class.java)?.toList()) } @@ -83,16 +80,14 @@ class MainActivity : ComponentActivity() { } } } - mutableStateOf(grantedPermissions) + mutableStateOf(grantedPermissions.toList()) } val fleetBaseUrl by remember { - mutableStateOf(appRestrictions.getString("fleetBaseUrl")) + mutableStateOf(appRestrictions.getString("serverURL")) } - var enrollBody by remember { mutableStateOf("enroll not run") } var installedCertificates: List by remember { mutableStateOf(listOf()) } val apiKey by ApiClient.apiKeyFlow.collectAsState(initial = null) val baseUrl by ApiClient.baseUrlFlow.collectAsState(initial = null) - val scope = rememberCoroutineScope() LaunchedEffect(Unit) { installedCertificates = listKeystoreCertificates() @@ -107,42 +102,20 @@ class MainActivity : ComponentActivity() { ) { StatusScreen() KeyValue("packageName", packageName) + KeyValue("versionName", packageManager.getPackageInfo(packageName, 0).versionName) + KeyValue("longVersionCode", packageManager.getPackageInfo(packageName, 0).longVersionCode.toString()) KeyValue("enrollSecret", enrollSecret) KeyValue("delegatedScopes", delegatedScopes.toString()) KeyValue("delegated cert scope", delegatedCertScope.toString()) KeyValue("android id", androidID) - KeyValue("enrollmentSpecificID (MC)", enrollmentSpecificID) - KeyValue("fleetBaseUrl (MC)", fleetBaseUrl) + KeyValue("hostUUID (MC)", enrollmentSpecificID) + KeyValue("serverURL (MC)", fleetBaseUrl) KeyValue("orbit_node_key (datastore)", apiKey) KeyValue("base_url (datastore)", baseUrl) KeyValue("certificate_ids", certIds.toString()) PermissionList( permissionsList = permissionsList, ) - Button(onClick = { - scope.launch { - enrollBody = "launched!!" - if (enrollSecret == null) { - enrollBody = "no enroll secret" - } - if (fleetBaseUrl == null) { - enrollBody = "no fleet URL" - } - try { - Log.d("main_activity", "sending request!") - val resp = ApiClient.enroll( - baseUrl = fleetBaseUrl ?: "", - enrollSecret = enrollSecret ?: "", - hardwareUUID = enrollmentSpecificID ?: "", - computerName = Build.MODEL, - ) - enrollBody = resp.toString() - } catch (e: Exception) { - enrollBody = e.toString() - } - } - }) { Text("enroll") } - Text(enrollBody) CertificateList(certificateList = installedCertificates) } }, diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index afd340037d..7d22be31e1 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -3,14 +3,14 @@ Enroll Secret Secret used to enroll in a fleet instance - Fleet Base URL - The base URL of the fleet server + Fleet Base URL + The base URL of the fleet server Certificates Array of bundles containing certificate information Certificate Certificate information Certificate ID Certificate ID to be requested - Enrollment-Specific ID - The enrollment-specific ID for this device - \ No newline at end of file + Host UUID + The host UUID to present to fleet during enrollment + diff --git a/android/app/src/main/res/xml/app_restrictions.xml b/android/app/src/main/res/xml/app_restrictions.xml index 629ac937df..fb0f9a87c9 100644 --- a/android/app/src/main/res/xml/app_restrictions.xml +++ b/android/app/src/main/res/xml/app_restrictions.xml @@ -8,16 +8,16 @@ android:description="@string/enroll_secret_description" /> + android:description="@string/server_url_description" /> + android:description="@string/host_uuid_description" /> - \ No newline at end of file + diff --git a/android/app/src/test/java/com/fleetdm/agent/ConfigCheckWorkerTest.kt b/android/app/src/test/java/com/fleetdm/agent/ConfigCheckWorkerTest.kt index ce2b2cf291..0443a6bb93 100644 --- a/android/app/src/test/java/com/fleetdm/agent/ConfigCheckWorkerTest.kt +++ b/android/app/src/test/java/com/fleetdm/agent/ConfigCheckWorkerTest.kt @@ -9,6 +9,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment +import kotlinx.coroutines.runBlocking @RunWith(RobolectricTestRunner::class) class ConfigCheckWorkerTest { @@ -17,6 +18,7 @@ class ConfigCheckWorkerTest { @Before fun setUp() { context = RuntimeEnvironment.getApplication() + ApiClient.initialize(context) } @Test @@ -26,7 +28,9 @@ class ConfigCheckWorkerTest { .build() // Execute the worker - val result = worker.doWork() + val result = runBlocking { + worker.doWork() + } assertEquals(ListenableWorker.Result.success(), result) } } diff --git a/server/datastore/mysql/hosts.go b/server/datastore/mysql/hosts.go index 2625146d95..7b31e20c29 100644 --- a/server/datastore/mysql/hosts.go +++ b/server/datastore/mysql/hosts.go @@ -2130,8 +2130,8 @@ type enrolledHostInfo struct { // guaranteed to match a single host. For that reason, we only attempt the // serial number lookup if Fleet MDM is enabled on the server (as we must be // able to match by serial in this scenario, since this is the only information -// we get when enrolling hosts via Apple DEP) AND if the matched host is on the -// macOS platform (darwin). +// we get when enrolling hosts via Apple DEP or Android EMM) AND if the matched +// host is on a supported MDM platform (darwin, ios, ipados, or android). func matchHostDuringEnrollment( ctx context.Context, q sqlx.QueryerContext, @@ -2176,7 +2176,7 @@ func matchHostDuringEnrollment( if query.Len() > 0 { _, _ = query.WriteString(" UNION ") } - _, _ = query.WriteString(fmt.Sprintf(`(SELECT id, last_enrolled_at, %s IS NOT NULL AS node_key_set, 2 priority, platform FROM hosts WHERE hardware_serial = ? AND (platform = 'darwin' OR platform = 'ios' OR platform = 'ipados') ORDER BY id LIMIT 1)`, nodeKeyColumn)) + _, _ = query.WriteString(fmt.Sprintf(`(SELECT id, last_enrolled_at, %s IS NOT NULL AS node_key_set, 2 priority, platform FROM hosts WHERE hardware_serial = ? AND (platform = 'darwin' OR platform = 'ios' OR platform = 'ipados' OR platform = 'android') ORDER BY id LIMIT 1)`, nodeKeyColumn)) args = append(args, serial) } diff --git a/server/datastore/mysql/hosts_test.go b/server/datastore/mysql/hosts_test.go index e8191f1936..54f022b442 100644 --- a/server/datastore/mysql/hosts_test.go +++ b/server/datastore/mysql/hosts_test.go @@ -10205,16 +10205,16 @@ func testHostsEnrollOrbit(t *testing.T, ds *Datastore) { // Scenario C: // - Fleet with MDM enabled. - // - Two linux|darwin|windows hosts with the same hardware identifiers (e.g. two cloned VMs). + // - Two linux|darwin|windows|android hosts with the same hardware identifiers (e.g. two cloned VMs). // - fleetd running with host identifier set to instance. // - orbit and osquery of the two hosts enroll in mixed order. // // For Linux and Windows this scenario behaves as expected. The two hosts are enrolled separately. // - // For macOS: + // For macOS, iOS, iPadOS, and Android: // Somewhat unexpected output of this scenario is that two hosts are enrolled as one // because MDM makes the effort to match by hardware serial. - // Using fleetd's `--host-identifier=instance` with Fleet's MDM enabled is not compatible on macOS. + // Using fleetd's `--host-identifier=instance` with Fleet's MDM enabled is not compatible on these platforms. scenarioC := func(platform string) { dupUUID := uuid.New().String() dupHWSerial := uuid.New().String() @@ -10264,15 +10264,15 @@ func testHostsEnrollOrbit(t *testing.T, ds *Datastore) { require.NoError(t, err) require.Equal(t, h2Orbit.ID, h2Osquery.ID) - if platform == "darwin" { + if platform == "darwin" || platform == "ios" || platform == "ipados" || platform == "android" { // This is a expected output of this scenario because MDM makes - // the effort to match by hardware serial. + // the effort to match by hardware serial for these platforms. require.Equal(t, h1Orbit.ID, h2Orbit.ID) } else { require.NotEqual(t, h1Orbit.ID, h2Orbit.ID) } } - for _, platform := range []string{"ubuntu", "windows", "darwin"} { + for _, platform := range []string{"ubuntu", "windows", "darwin", "ios", "ipados", "android"} { platform := platform t.Run("scenarioC_"+platform, func(t *testing.T) { scenarioC(platform) diff --git a/tools/android/android.go b/tools/android/android.go index 926dabb1e9..ad6dad57be 100644 --- a/tools/android/android.go +++ b/tools/android/android.go @@ -3,8 +3,10 @@ package main import ( "context" "flag" + "fmt" "log" "os" + "slices" "strings" "github.com/go-json-experiment/json" @@ -19,6 +21,28 @@ var ( androidProjectID string ) +const ( + cmdEnterprisesDelete = "enterprises.delete" + cmdEnterprisesList = "enterprises.list" + cmdEnterprisesWebTokensCreate = "enterprises.webTokens.create" + cmdApplicationsGet = "applications.get" + cmdPoliciesList = "policies.list" + cmdDevicesList = "devices.list" + cmdDevicesDelete = "devices.delete" + cmdDevicesRelinquish = "devices.issueCommand.RELINQUISH_OWNERSHIP" +) + +var commands = []string{ + cmdEnterprisesDelete, + cmdEnterprisesList, + cmdEnterprisesWebTokensCreate, + cmdApplicationsGet, + cmdPoliciesList, + cmdDevicesList, + cmdDevicesDelete, + cmdDevicesRelinquish, +} + func main() { if androidServiceCredentials == "" { log.Fatal("FLEET_DEV_ANDROID_GOOGLE_SERVICE_CREDENTIALS must be set") @@ -37,17 +61,26 @@ func main() { log.Fatal("project_id not found in android service credentials") } - command := flag.String("command", "", "") + command := flag.String("command", "", strings.Join(commands, "\n")) enterpriseID := flag.String("enterprise_id", "", "") deviceID := flag.String("device_id", "", "") policyID := flag.String("policy_id", "", "") flag.Parse() + if !slices.Contains(commands, *command) { + flag.Usage() + os.Exit(1) + } + // Normalize enterprise_id by stripping "enterprises/" prefix if present if *enterpriseID != "" { *enterpriseID = strings.TrimPrefix(*enterpriseID, "enterprises/") } + if slices.Index(commands, *command) == -1 { + log.Fatalf("Command must be one of: %s", strings.Join(commands, ", ")) + } + ctx := context.Background() mgmt, err := androidmanagement.NewService(ctx, option.WithCredentialsJSON([]byte(androidServiceCredentials))) if err != nil { @@ -55,21 +88,23 @@ func main() { } switch *command { - case "enterprises.delete": + case cmdEnterprisesDelete: enterprisesDelete(mgmt, *enterpriseID) - case "enterprises.list": + case cmdEnterprisesList: enterprisesList(mgmt) - case "enterprises.webTokens.create": + case cmdEnterprisesWebTokensCreate: enterprisesWebTokensCreate(mgmt, *enterpriseID) - case "policies.list": + case cmdApplicationsGet: + applicationsGet(mgmt, *enterpriseID, flag.Arg(0)) + case cmdPoliciesList: policiesList(mgmt, *enterpriseID) case "policies.delete": policiesDelete(mgmt, *enterpriseID, *policyID) - case "devices.list": + case cmdDevicesList: devicesList(mgmt, *enterpriseID) - case "devices.delete": + case cmdDevicesDelete: devicesDelete(mgmt, *enterpriseID, *deviceID) - case "devices.issueCommand.RELINQUISH_OWNERSHIP": + case cmdDevicesRelinquish: devicesRelinquishOwnership(mgmt, *enterpriseID, *deviceID) default: log.Fatalf("Unknown command: %s", *command) @@ -113,9 +148,29 @@ func policiesList(mgmt *androidmanagement.Service, enterpriseID string) { log.Printf("No policies found") return } - for _, policy := range result.Policies { - log.Printf("Policy: %+v", *policy) + b, err := json.Marshal(result.Policies, jsontext.WithIndent(" ")) + if err != nil { + log.Fatalf("Error marshalling policies: %v", err) } + fmt.Println(string(b)) +} + +func applicationsGet(mgmt *androidmanagement.Service, enterpriseID string, applicationID string) { + if enterpriseID == "" { + log.Fatalf("enterprise_id must be set") + } + if applicationID == "" { + log.Fatal("application ID argument missing") + } + result, err := mgmt.Enterprises.Applications.Get(fmt.Sprintf("enterprises/%s/applications/%s", enterpriseID, applicationID)).Do() + if err != nil { + log.Fatalf("Error getting application: %v", err) + } + b, err := json.Marshal(result, jsontext.WithIndent(" ")) + if err != nil { + log.Fatalf("Error marshalling application: %v", err) + } + fmt.Println(string(b)) } func policiesDelete(mgmt *androidmanagement.Service, enterpriseID, policyID string) { @@ -141,13 +196,11 @@ func devicesList(mgmt *androidmanagement.Service, enterpriseID string) { log.Printf("No policies found") return } - for _, device := range result.Devices { - data, err := json.Marshal(device, jsontext.WithIndent(" ")) - if err != nil { - log.Fatalf("Error marshalling device: %v", err) - } - log.Println(string(data)) + b, err := json.Marshal(result.Devices, jsontext.WithIndent(" ")) + if err != nil { + log.Fatalf("Error marshalling devices: %v", err) } + fmt.Println(string(b)) log.Printf("Total devices: %d", len(result.Devices)) }