From 7375b88e65a9335aae6ffe6088c9af15cd97ce93 Mon Sep 17 00:00:00 2001
From: Dante Catalfamo <43040593+dantecatalfamo@users.noreply.github.com>
Date: Wed, 10 Dec 2025 18:50:38 -0500
Subject: [PATCH] Sync app with server vars, fix retry logic (#36923)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
**Related issue:** Resolves #36591
# Checklist for submitter
If some of the following don't apply, delete the relevant line.
- [ ] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.
- [ ] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements)
- [ ] If paths of existing endpoints are modified without backwards
compatibility, checked the frontend/CLI for any necessary changes
## Testing
- [ ] Added/updated automated tests
- [ ] Where appropriate, [automated tests simulate multiple hosts and
test for host
isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing)
(updates to one hosts's records do not affect another)
- [ ] QA'd all new/changed functionality manually
For unreleased bug fixes in a release candidate, one of:
- [ ] Confirmed that the fix is not expected to adversely impact load
test results
- [ ] Alerted the release DRI if additional load testing is needed
## Database migrations
- [ ] Checked schema for all modified table for columns that will
auto-update timestamps during migration.
- [ ] Confirmed that updating the timestamps is acceptable, and will not
cause unwanted side effects.
- [ ] Ensured the correct collation is explicitly set for character
columns (`COLLATE utf8mb4_unicode_ci`).
## New Fleet configuration settings
- [ ] Setting(s) is/are explicitly excluded from GitOps
If you didn't check the box above, follow this checklist for
GitOps-enabled settings:
- [ ] Verified that the setting is exported via `fleetctl
generate-gitops`
- [ ] Verified the setting is documented in a separate PR to [the GitOps
documentation](https://github.com/fleetdm/fleet/blob/main/docs/Configuration/yaml-files.md#L485)
- [ ] Verified that the setting is cleared on the server if it is not
supplied in a YAML file (or that it is documented as being optional)
- [ ] Verified that any relevant UI is disabled when GitOps mode is
enabled
## fleetd/orbit/Fleet Desktop
- [ ] Verified compatibility with the latest released version of Fleet
(see [Must
rule](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/workflows/fleetd-development-and-release-strategy.md))
- [ ] If the change applies to only one platform, confirmed that
`runtime.GOOS` is used as needed to isolate changes
- [ ] Verified that fleetd runs on macOS, Linux and Windows
- [ ] Verified auto-update works from the released version of component
to the new version (see [tools/tuf/test](../tools/tuf/test/README.md))
## Summary by CodeRabbit
## Release Notes
* **New Features**
* Added automatic retry mechanism for failed certificate installations
with up to 3 retry attempts.
* Enhanced certificate installation status tracking and visibility.
* **Bug Fixes**
* Improved error handling and detailed error reporting for certificate
enrollment failures.
* **Tests**
* Added comprehensive test coverage for certificate enrollment and
status tracking workflows.
✏️ Tip: You can customize this high-level summary in your review
settings.
---------
Co-authored-by: Victor Lyuboslavsky <2685025+getvictor@users.noreply.github.com>
---
.../main/java/com/fleetdm/agent/ApiClient.kt | 36 +-
.../agent/CertificateEnrollmentHandler.kt | 74 ++-
.../fleetdm/agent/CertificateOrchestrator.kt | 133 +++++-
.../java/com/fleetdm/agent/MainActivity.kt | 11 +-
.../com/fleetdm/agent/scep/ScepClientImpl.kt | 5 +-
android/app/src/main/res/values/strings.xml | 12 +-
.../app/src/main/res/xml/app_restrictions.xml | 18 +-
.../agent/CertificateEnrollmentHandlerTest.kt | 2 +-
.../agent/CertificateOrchestratorTest.kt | 438 ++++++++++++++++++
.../fleetdm/agent/scep/ScepClientImplTest.kt | 2 +-
.../fleetdm/agent/scep/ScepIntegrationTest.kt | 4 +-
server/mdm/android/android.go | 2 +-
12 files changed, 637 insertions(+), 100 deletions(-)
create mode 100644 android/app/src/test/java/com/fleetdm/agent/CertificateOrchestratorTest.kt
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 85867015f8..34fe245f46 100644
--- a/android/app/src/main/java/com/fleetdm/agent/ApiClient.kt
+++ b/android/app/src/main/java/com/fleetdm/agent/ApiClient.kt
@@ -19,6 +19,7 @@ import kotlinx.coroutines.withContext
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
+import kotlinx.serialization.Transient
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
@@ -146,6 +147,8 @@ object ApiClient {
?: "HTTP $responseCode"
}
+ Log.d("ApiClient", "server response from $method $endpoint ($responseCode): $response")
+
if (responseCode in 200..299) {
val parsed = json.decodeFromString(string = response, deserializer = responseSerializer)
Result.success(parsed)
@@ -220,14 +223,14 @@ object ApiClient {
val credentials = getEnrollmentCredentials() ?: return Result.failure(Exception("enroll credentials not set"))
return makeRequest(
- endpoint = "/api/fleetd/orbit/certificates/$certificateId",
- method = "POST",
+ endpoint = "/api/fleetd/certificates/$certificateId",
+ method = "GET",
body = GetCertificateTemplateRequest(orbitNodeKey = orbitNodeKey),
bodySerializer = GetCertificateTemplateRequest.serializer(),
- responseSerializer = GetCertificateTemplateResponse.serializer(),
- authorized = false,
+ responseSerializer = GetCertificateTemplateResponseWrapper.serializer(),
).fold(
- onSuccess = { res ->
+ onSuccess = { wrapper ->
+ val res = wrapper.certificate
Log.i("ApiClient", "successfully retrieved certificate template ${res.id}: ${res.name}")
Result.success(
res.apply {
@@ -436,8 +439,15 @@ private data class UpdateCertificateStatusResponse(
val error: String? = null,
)
+@Serializable
+data class GetCertificateTemplateResponseWrapper(
+ @SerialName("certificate")
+ val certificate: GetCertificateTemplateResponse,
+)
+
@Serializable
data class GetCertificateTemplateResponse(
+ // CertificateTemplateResponseSummary
@SerialName("id")
val id: Int,
@@ -445,7 +455,7 @@ data class GetCertificateTemplateResponse(
val name: String,
@SerialName("certificate_authority_id")
- val certificateAuthorityId: String,
+ val certificateAuthorityId: Int,
@SerialName("certificate_authority_name")
val certificateAuthorityName: String,
@@ -453,6 +463,7 @@ data class GetCertificateTemplateResponse(
@SerialName("created_at")
val createdAt: String,
+ // CertificateTemplateResponseFull
@SerialName("subject_name")
val subjectName: String,
@@ -463,20 +474,21 @@ data class GetCertificateTemplateResponse(
val status: String,
@SerialName("scep_challenge")
- val scepChallenge: String,
+ val scepChallenge: String? = "",
@SerialName("fleet_challenge")
- val fleetChallenge: String?,
+ val fleetChallenge: String? = "",
- @SerialName("key_length")
+ @Transient
val keyLength: Int = 2048,
- @SerialName("signature_algorithm")
+ @Transient
val signatureAlgorithm: String = "SHA256withRSA",
- var url: String?,
+ @Transient
+ var url: String? = null,
) {
fun setUrl(serverUrl: String, hostUUID: String) {
- url = "$serverUrl/mdm/scep/proxy/$hostUUID,g$id,$certificateAuthorityType,$fleetChallenge"
+ url = "$serverUrl/mdm/scep/proxy/$hostUUID,g$id,$certificateAuthorityType,${fleetChallenge ?: ""}"
}
}
diff --git a/android/app/src/main/java/com/fleetdm/agent/CertificateEnrollmentHandler.kt b/android/app/src/main/java/com/fleetdm/agent/CertificateEnrollmentHandler.kt
index ef1c713fe8..393a020ddd 100644
--- a/android/app/src/main/java/com/fleetdm/agent/CertificateEnrollmentHandler.kt
+++ b/android/app/src/main/java/com/fleetdm/agent/CertificateEnrollmentHandler.kt
@@ -1,9 +1,12 @@
package com.fleetdm.agent
+import com.fleetdm.agent.scep.ScepCertificateException
import com.fleetdm.agent.scep.ScepClient
import com.fleetdm.agent.scep.ScepConfig
+import com.fleetdm.agent.scep.ScepCsrException
import com.fleetdm.agent.scep.ScepEnrollmentException
-import com.fleetdm.agent.scep.ScepException
+import com.fleetdm.agent.scep.ScepKeyGenerationException
+import com.fleetdm.agent.scep.ScepNetworkException
import com.fleetdm.agent.scep.ScepResult
import org.json.JSONObject
import java.security.PrivateKey
@@ -34,47 +37,42 @@ class CertificateEnrollmentHandler(private val scepClient: ScepClient, private v
/**
* Main enrollment flow: parse config, enroll via SCEP, install certificate.
*/
- suspend fun handleEnrollment(config: GetCertificateTemplateResponse): EnrollmentResult {
- return try {
- // Step 2: Perform SCEP enrollment
- val result = performEnrollment(config) ?: return EnrollmentResult.Failure(
- reason = "SCEP enrollment failed or returned null",
- exception = null,
- )
+ suspend fun handleEnrollment(config: GetCertificateTemplateResponse): EnrollmentResult = try {
+ // Perform SCEP enrollment
+ val result = scepClient.enroll(config)
- // Step 3: Install certificate
- val installed = certificateInstaller.installCertificate(
- config.name,
- result.privateKey,
- result.certificateChain.toTypedArray(),
- )
+ // Install certificate
+ val installed = certificateInstaller.installCertificate(
+ config.name,
+ result.privateKey,
+ result.certificateChain.toTypedArray(),
+ )
- if (installed) {
- EnrollmentResult.Success(config.name)
- } else {
- EnrollmentResult.Failure("Certificate installation failed")
- }
- } catch (e: IllegalArgumentException) {
- EnrollmentResult.Failure("Invalid configuration: ${e.message}", e)
- } catch (e: Exception) {
- EnrollmentResult.Failure("Unexpected error: ${e.message}", e)
+ if (installed) {
+ EnrollmentResult.Success(config.name)
+ } else {
+ EnrollmentResult.Failure("Certificate installation failed")
}
- }
-
- /**
- * Performs SCEP enrollment, returning result or null on failure.
- */
- @Suppress("SwallowedException")
- suspend fun performEnrollment(config: GetCertificateTemplateResponse): ScepResult? = try {
- scepClient.enroll(config)
} catch (e: ScepEnrollmentException) {
- // Enrollment failure is expected in some scenarios (pending approval, invalid challenge)
- null
- } catch (e: ScepException) {
- // SCEP protocol errors are expected in some scenarios
- null
+ // SCEP server rejected enrollment (e.g., PENDING status, invalid challenge)
+ EnrollmentResult.Failure("SCEP enrollment failed: ${e.message}", e)
+ } catch (e: ScepNetworkException) {
+ // Network communication failure - likely transient, can retry
+ EnrollmentResult.Failure("Network error during SCEP enrollment: ${e.message}", e)
+ } catch (e: ScepCertificateException) {
+ // Certificate validation or processing failed
+ EnrollmentResult.Failure("Certificate validation failed: ${e.message}", e)
+ } catch (e: ScepKeyGenerationException) {
+ // Key generation failed - device cryptography issue
+ EnrollmentResult.Failure("Failed to generate key pair: ${e.message}", e)
+ } catch (e: ScepCsrException) {
+ // CSR creation failed - likely configuration issue
+ EnrollmentResult.Failure("Failed to create CSR: ${e.message}", e)
+ } catch (e: IllegalArgumentException) {
+ // Configuration validation failed
+ EnrollmentResult.Failure("Invalid configuration: ${e.message}", e)
} catch (e: Exception) {
- // Unexpected errors are logged by the SCEP client
- null
+ // Unexpected errors
+ EnrollmentResult.Failure("Unexpected error during enrollment: ${e.message}", e)
}
}
diff --git a/android/app/src/main/java/com/fleetdm/agent/CertificateOrchestrator.kt b/android/app/src/main/java/com/fleetdm/agent/CertificateOrchestrator.kt
index 4961c68be4..4d90b68bba 100644
--- a/android/app/src/main/java/com/fleetdm/agent/CertificateOrchestrator.kt
+++ b/android/app/src/main/java/com/fleetdm/agent/CertificateOrchestrator.kt
@@ -12,12 +12,17 @@ import java.security.PrivateKey
import java.security.cert.Certificate
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
+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.serialization.encodeToString
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
+const val MAX_CERT_INSTALL_RETRIES = 3
+
/**
* Orchestrates certificate enrollment operations by coordinating API calls,
* SCEP enrollment, and certificate installation.
@@ -60,6 +65,17 @@ object CertificateOrchestrator {
// Mutex to protect concurrent access to certificate storage
private val certificateStorageMutex = Mutex()
+ fun installedCertsFlow(context: Context): Flow = context.prefDataStore.data.map { preferences ->
+ try {
+ val jsonStr = preferences[INSTALLED_CERTIFICATES_KEY]
+ Log.d("installedCertsFlow", "json: $jsonStr")
+ json.decodeFromString(jsonStr!!)
+ } catch (e: Exception) {
+ Log.d("installedCertsFlow", e.toString())
+ emptyMap()
+ }
+ }
+
/**
* Reads certificate IDs from Android Managed Configuration.
*
@@ -70,8 +86,8 @@ object CertificateOrchestrator {
val restrictionsManager = context.getSystemService(Context.RESTRICTIONS_SERVICE) as android.content.RestrictionsManager
val appRestrictions = restrictionsManager.applicationRestrictions
- val certRequestList = appRestrictions.getParcelableArray("certificates", Bundle::class.java)?.toList()
- return certRequestList?.map { bundle -> bundle.getInt("certificate_id") }
+ val certRequestList = appRestrictions.getParcelableArray("certificate_templates", Bundle::class.java)?.toList()
+ return certRequestList?.map { bundle -> bundle.getInt("id") }
}
/**
@@ -80,7 +96,7 @@ object CertificateOrchestrator {
* @param context Android context
* @return Map of certificate ID to alias, or empty map if none stored
*/
- internal suspend fun getInstalledCertificates(context: Context): Map {
+ internal suspend fun getCertificateInstallInfos(context: Context): CertStatusMap {
certificateStorageMutex.withLock {
return try {
val prefs = context.prefDataStore.data.first()
@@ -91,7 +107,7 @@ object CertificateOrchestrator {
return emptyMap()
}
- val map = json.decodeFromString