Fixed certificate template fetch failing with DNS errors (and other issues) (#42625)

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

- Fixed certificate template fetch failing with DNS errors (known
Android issue)
- stop polling certs that failed permanently
- CertificateOrchestrator: When server returns template status "failed",
mark the certificate as locally failed (markCertificateForceFailed) and
stop polling
- CertificateOrchestrator: Non-retryable SCEP failures (e.g.
ScepEnrollmentException) now immediately mark as failed and report to
server, skipping the 3-attempt retry logic
- CertificateOrchestrator: recordEnrollmentAttemptFailure now stores the
uuid, fixing a bug where the FAILED guard was bypassed because stored
uuid was empty
- CertificateOrchestrator: Renamed markCertificateFailure to
recordEnrollmentAttemptFailure and added markCertificateForceFailed for
clarity

# Checklist for submitter

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.

## Testing

- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Fixed certificate template retrieval failures that displayed
misleading DNS errors. Optimized HTTP request header handling for GET
requests to prevent these errors during certificate enrollment
operations.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Victor Lyuboslavsky
2026-04-02 10:44:50 -05:00
committed by GitHub
parent 950402f93e
commit edfa694ac2
8 changed files with 214 additions and 34 deletions
+1 -2
View File
@@ -13,8 +13,7 @@ style:
MaxLineLength:
active: false # Handled by ktlint
ReturnCount:
active: true
max: 5
active: false
complexity:
CognitiveComplexMethod:
@@ -1,6 +1,7 @@
package com.fleetdm.agent
import android.content.Context
import android.net.ConnectivityManager
import android.util.Log
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
@@ -10,10 +11,7 @@ import androidx.datastore.preferences.preferencesDataStore
import java.math.BigInteger
import java.net.HttpURLConnection
import java.net.URL
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
@@ -64,6 +62,7 @@ object ApiClient : CertificateApiClient {
private val json = Json { ignoreUnknownKeys = true }
private lateinit var dataStore: DataStore<Preferences>
private lateinit var appContext: Context
private val API_KEY = stringPreferencesKey("api_key")
private val SERVER_URL_KEY = stringPreferencesKey("server_url")
private val ENROLL_SECRET = stringPreferencesKey("enroll_secret")
@@ -74,8 +73,9 @@ object ApiClient : CertificateApiClient {
fun initialize(context: Context) {
Log.d(TAG, "initializing api client")
appContext = context.applicationContext
if (!::dataStore.isInitialized) {
dataStore = context.applicationContext.prefDataStore
dataStore = appContext.prefDataStore
}
}
@@ -116,6 +116,8 @@ object ApiClient : CertificateApiClient {
responseSerializer: KSerializer<T>,
authorized: Boolean = true,
): Result<T> = withContext(Dispatchers.IO) {
require(method != "GET" || body == null) { "GET requests must not include a body" }
var connection: HttpURLConnection? = null
try {
val baseUrl = getBaseUrl() ?: return@withContext Result.failure(
@@ -137,13 +139,12 @@ object ApiClient : CertificateApiClient {
}
val url = URL("$baseUrl$endpoint")
connection = url.openConnection() as HttpURLConnection
connection = openConnectionOnActiveNetwork(url)
connection.apply {
requestMethod = method
useCaches = false
doInput = true
setRequestProperty("Content-Type", "application/json")
if (authorized) {
getNodeKeyOrEnroll().fold(
onFailure = { throwable -> return@withContext Result.failure(throwable) },
@@ -155,8 +156,9 @@ object ApiClient : CertificateApiClient {
connectTimeout = 15000
readTimeout = 15000
if (body != null && method != "GET") {
if (body != null) {
requireNotNull(bodySerializer) { "bodySerializer required when body is provided" }
setRequestProperty("Content-Type", "application/json")
doOutput = true
val bodyJson = json.encodeToString(value = body, serializer = bodySerializer)
outputStream.use { it.write(bodyJson.toByteArray()) }
@@ -178,6 +180,8 @@ object ApiClient : CertificateApiClient {
Result.success(parsed)
} else if (responseCode == 401) {
Result.failure(UnauthorizedException(response))
} else if (responseCode == 404) {
Result.failure(NotFoundException(response))
} else {
Result.failure(Exception("HTTP $responseCode: $response"))
}
@@ -193,6 +197,26 @@ object ApiClient : CertificateApiClient {
* This typically indicates the node key has been invalidated (e.g., host was deleted).
*/
class UnauthorizedException(message: String) : Exception("HTTP 401: $message")
class NotFoundException(message: String) : Exception("HTTP 404: $message")
/**
* Opens an HTTP connection bound to the active network when available. This ensures DNS resolution uses
* the active network's DNS servers, avoiding failures when Android reports connectivity before DNS is ready.
* Falls back to a default connection if no active network is available.
*/
internal fun openConnectionOnActiveNetwork(url: URL): HttpURLConnection {
if (useActiveNetworkBinding) {
val connectivityManager = appContext.getSystemService(ConnectivityManager::class.java)
val activeNetwork = connectivityManager?.activeNetwork
if (activeNetwork != null) {
return activeNetwork.openConnection(url) as HttpURLConnection
}
}
return url.openConnection() as HttpURLConnection
}
// Disabled in tests where Network.openConnection is not available (Robolectric)
internal var useActiveNetworkBinding = true
/**
* Executes a request block with automatic re-enrollment on 401 Unauthorized.
@@ -262,19 +286,12 @@ object ApiClient : CertificateApiClient {
}
override suspend fun getCertificateTemplate(certificateId: Int): Result<CertificateTemplateResult> = withReenrollOnUnauthorized {
val nodeKeyResult = getNodeKeyOrEnroll()
val orbitNodeKey = nodeKeyResult.getOrElse { error ->
return@withReenrollOnUnauthorized Result.failure(error)
}
val credentials = getEnrollmentCredentials()
?: return@withReenrollOnUnauthorized Result.failure(Exception("enroll credentials not set"))
makeRequest(
makeRequest<Unit, GetCertificateTemplateResponseWrapper>(
endpoint = "/api/fleetd/certificates/$certificateId",
method = "GET",
body = GetCertificateTemplateRequest(orbitNodeKey = orbitNodeKey),
bodySerializer = GetCertificateTemplateRequest.serializer(),
responseSerializer = GetCertificateTemplateResponseWrapper.serializer(),
).fold(
onSuccess = { wrapper ->
@@ -326,8 +343,14 @@ object ApiClient : CertificateApiClient {
}
},
onFailure = { throwable ->
FleetLog.e(TAG, "failed to update certificate status $certificateId: ${throwable.message}")
Result.failure(throwable)
if (throwable is NotFoundException) {
// Certificate template was deleted from the server -- nothing to report to
Log.i(TAG, "certificate template $certificateId no longer exists on server, nothing to report")
Result.success(Unit)
} else {
FleetLog.e(TAG, "failed to update certificate status $certificateId: ${throwable.message}")
Result.failure(throwable)
}
},
)
}
@@ -481,12 +504,6 @@ data class OrbitUpdateChannels(
val desktop: String = "",
)
@Serializable
private data class GetCertificateTemplateRequest(
@SerialName("orbit_node_key")
val orbitNodeKey: String,
)
@Serializable
data class UpdateCertificateStatusRequest(
@SerialName("status")
@@ -150,16 +150,29 @@ class CertificateOrchestrator(
storeCertificateState(context = context, certificateId = certificateId, certInstallInfo = newInfo)
}
internal suspend fun markCertificateFailure(context: Context, certificateId: Int, alias: String): CertificateState {
internal suspend fun markCertificateForceFailed(context: Context, certificateId: Int, alias: String, uuid: String) {
val existingInfo = getCertificateState(context = context, certificateId = certificateId)
?: CertificateState(alias = alias, status = CertificateStatus.RETRY, retries = 0)
?: CertificateState(alias = alias, status = CertificateStatus.FAILED, retries = 0, uuid = uuid)
val newInfo = existingInfo.copy(alias = alias, status = CertificateStatus.FAILED, uuid = uuid)
storeCertificateState(context = context, certificateId = certificateId, certInstallInfo = newInfo)
}
internal suspend fun recordEnrollmentAttemptFailure(
context: Context,
certificateId: Int,
alias: String,
uuid: String,
): CertificateState {
val existingInfo = getCertificateState(context = context, certificateId = certificateId)
?: CertificateState(alias = alias, status = CertificateStatus.RETRY, retries = 0, uuid = uuid)
if (existingInfo.status != CertificateStatus.RETRY) {
Log.d(TAG, "markCertificateFailure: skipping cert $certificateId, status is ${existingInfo.status}")
Log.d(TAG, "recordEnrollmentAttemptFailure: skipping cert $certificateId, status is ${existingInfo.status}")
return existingInfo
}
var newInfo = existingInfo.copy(retries = existingInfo.retries + 1)
var newInfo = existingInfo.copy(retries = existingInfo.retries + 1, uuid = uuid)
if (newInfo.retries >= MAX_CERT_INSTALL_RETRIES) {
newInfo = newInfo.copy(status = CertificateStatus.FAILED)
@@ -701,9 +714,15 @@ class CertificateOrchestrator(
Log.d(TAG, "Successfully fetched certificate template: ${template.name}")
if (template.status == "failed") {
// Server says this certificate is permanently failed. Mark it locally as failed so we stop polling
Log.i(TAG, "Certificate template ${template.name} has terminal status \"failed\", marking locally as failed")
markCertificateForceFailed(context, certificateId, template.name, uuid)
return CertificateEnrollmentHandler.EnrollmentResult.PermanentlyFailed(template.name)
}
if (template.status != "delivered") {
// The certificate template hasn't failed on the device, but isn't ready to be processed yet.
// Retry next time we fetch but don't mark as failed locally
// Not ready to be processed yet (e.g. pending, delivering). Retry next time.
Log.i(TAG, "Certificate template ${template.name} does not have status \"delivered\": status \"${template.status}\"")
return CertificateEnrollmentHandler.EnrollmentResult.Success(
alias = template.name,
@@ -769,8 +788,20 @@ class CertificateOrchestrator(
}
}
is CertificateEnrollmentHandler.EnrollmentResult.Failure -> {
val updatedInfo = markCertificateFailure(context = context, certificateId = certificateId, alias = template.name)
if (!updatedInfo.shouldRetry()) {
val shouldReportFailure = if (result.isRetryable) {
val updatedInfo = recordEnrollmentAttemptFailure(
context = context,
certificateId = certificateId,
alias = template.name,
uuid = uuid,
)
!updatedInfo.shouldRetry()
} else {
markCertificateForceFailed(context, certificateId, template.name, uuid)
true
}
if (shouldReportFailure) {
FleetLog.e(TAG, "Certificate enrollment failed for ID $certificateId: ${result.reason}", result.exception)
apiClient.updateCertificateStatus(
certificateId = certificateId,
@@ -116,9 +116,14 @@ class ScepClientImpl : ScepClient {
"Enrollment is pending - requires CA administrator approval",
)
}
response.isFailure -> {
throw ScepEnrollmentException(
"Enrollment failed with SCEP failInfo: ${response.failInfo}",
)
}
else -> {
throw ScepEnrollmentException(
"Enrollment failed - certificate not issued by SCEP server",
"Enrollment failed - unexpected SCEP response",
)
}
}
@@ -7,6 +7,7 @@ import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@@ -42,6 +43,7 @@ class ApiClientReenrollTest {
mockWebServer.start()
ApiClient.initialize(context)
ApiClient.useActiveNetworkBinding = false
clearDataStore()
// Set up enrollment credentials pointing to mock server
@@ -187,6 +189,14 @@ class ApiClientReenrollTest {
"Expected second-node-key in Authorization header",
retryRequest.getHeader("Authorization")?.contains("second-node-key") == true,
)
// GET requests should not send Content-Type since they have no body.
// A Content-Type header on a bodyless GET can cause intermediaries (proxies, CDNs)
// to reject the request, which surfaces as misleading DNS errors on Android.
assertNull(
"GET request should not have Content-Type header",
retryRequest.getHeader("Content-Type"),
)
}
@Test
@@ -1291,4 +1291,116 @@ class CertificateOrchestratorTest {
assertEquals(CertificateStatus.INSTALLED, getStoredCertificates()[1]?.status)
assertEquals(CertificateStatus.REMOVED_UNREPORTED, getStoredCertificates()[2]?.status)
}
@Test
fun `enrollCertificate marks locally failed when server template status is failed`() = runTest {
val certificateId = 42
val uuid = "test-uuid"
// Arrange: configure API to return a template with status "failed"
fakeApiClient.getCertificateTemplateHandler = { certId ->
Result.success(
CertificateTemplateResult(
template = TestCertificateTemplateFactory.create(
id = certId,
name = "cert-failed",
status = "failed",
),
scepUrl = TestCertificateTemplateFactory.DEFAULT_SCEP_URL,
),
)
}
// Act
val result = orchestrator.enrollCertificate(context, certificateId, uuid, mockInstaller)
// Assert: returns PermanentlyFailed
assertTrue(
"Expected PermanentlyFailed but got: $result",
result is CertificateEnrollmentHandler.EnrollmentResult.PermanentlyFailed,
)
// Assert: local state is FAILED so future runs skip this certificate
val stored = getStoredCertificates()
assertEquals(CertificateStatus.FAILED, stored[certificateId]?.status)
assertEquals(uuid, stored[certificateId]?.uuid)
// Assert: no SCEP enrollment was attempted
assertNull("SCEP client should not have been called", mockScepClient.capturedConfig)
// Assert: no status update sent to server (server already knows it failed)
assertTrue("No status update should be sent", fakeApiClient.updateStatusCalls.isEmpty())
}
@Test
fun `non-retryable SCEP failure immediately marks FAILED and reports to server`() = runTest {
val certificateId = 50
val uuid = "test-uuid"
// Arrange: SCEP enrollment will throw ScepEnrollmentException (non-retryable)
mockScepClient.shouldThrowEnrollmentException = true
fakeApiClient.getCertificateTemplateHandler = { certId ->
Result.success(
CertificateTemplateResult(
template = TestCertificateTemplateFactory.create(id = certId, name = "cert-nonretry", status = "delivered"),
scepUrl = TestCertificateTemplateFactory.DEFAULT_SCEP_URL,
),
)
}
// Act
val result = orchestrator.enrollCertificate(context, certificateId, uuid, mockInstaller)
// Assert: returns Failure (non-retryable)
assertTrue("Expected Failure but got: $result", result is CertificateEnrollmentHandler.EnrollmentResult.Failure)
assertFalse((result as CertificateEnrollmentHandler.EnrollmentResult.Failure).isRetryable)
// Assert: immediately marked FAILED locally (no retry attempts)
val stored = getStoredCertificates()
assertEquals(CertificateStatus.FAILED, stored[certificateId]?.status)
assertEquals(uuid, stored[certificateId]?.uuid)
assertEquals(0, stored[certificateId]?.retries)
// Assert: reported FAILED to server exactly once
assertEquals(1, fakeApiClient.updateStatusCalls.size)
assertEquals(UpdateCertificateStatusStatus.FAILED, fakeApiClient.updateStatusCalls[0].status)
}
@Test
fun `retryable SCEP failure increments retries and does not report until max retries`() = runTest {
val certificateId = 51
val uuid = "test-uuid"
// Arrange: SCEP enrollment will throw ScepNetworkException (retryable)
mockScepClient.shouldThrowNetworkException = true
fakeApiClient.getCertificateTemplateHandler = { certId ->
Result.success(
CertificateTemplateResult(
template = TestCertificateTemplateFactory.create(id = certId, name = "cert-retry", status = "delivered"),
scepUrl = TestCertificateTemplateFactory.DEFAULT_SCEP_URL,
),
)
}
// Act: first attempt
orchestrator.enrollCertificate(context, certificateId, uuid, mockInstaller)
// Assert: status is RETRY, not FAILED
var stored = getStoredCertificates()
assertEquals(CertificateStatus.RETRY, stored[certificateId]?.status)
assertEquals(1, stored[certificateId]?.retries)
assertEquals(uuid, stored[certificateId]?.uuid)
assertTrue("Should not report FAILED yet", fakeApiClient.updateStatusCalls.isEmpty())
// Act: exhaust remaining retries
repeat(MAX_CERT_INSTALL_RETRIES - 1) {
orchestrator.enrollCertificate(context, certificateId, uuid, mockInstaller)
}
// Assert: now FAILED and reported to server exactly once
stored = getStoredCertificates()
assertEquals(CertificateStatus.FAILED, stored[certificateId]?.status)
assertEquals(1, fakeApiClient.updateStatusCalls.size)
assertEquals(UpdateCertificateStatusStatus.FAILED, fakeApiClient.updateStatusCalls[0].status)
}
}
View File
@@ -0,0 +1,6 @@
- Fixed background DNS resolution failures.
- Stop polling certificates when the server reports them as permanently failed.
- Non-retryable SCEP failures (e.g. server rejection) now immediately mark the certificate as failed instead of retrying 3 times.
- Fixed duplicate FAILED status reports.
- Treat HTTP 404 on certificate status updates as success when the template has been deleted server-side.
- Include SCEP `failInfo` details in enrollment failure messages instead of a generic error.