Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@ import org.gradle.testing.jacoco.plugins.JacocoTaskExtension
import java.io.FileInputStream
import java.util.Properties



fun fleetProp(name: String): String =
(project.findProperty(name) as String?)
?: (System.getenv(name.replace('.', '_').uppercase()) ?: "")



// ==================== PLUGINS ====================

plugins {
Expand Down Expand Up @@ -101,6 +109,8 @@ android {
debug {
enableUnitTestCoverage = true
enableAndroidTestCoverage = true
buildConfigField("String", "DEBUG_FLEET_SERVER_URL", "\"${fleetProp("fleet.server_url")}\"")
buildConfigField("String", "DEBUG_FLEET_ENROLL_SECRET", "\"${fleetProp("fleet.enroll_secret")}\"")
}
release {
if (keystorePropertiesFile.exists()) {
Expand Down Expand Up @@ -255,6 +265,7 @@ dependencies {
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.amapi.sdk)
implementation(libs.kotlinx.serialization.json)
implementation("com.squareup.okhttp3:okhttp:4.12.0")

// SCEP (Simple Certificate Enrollment Protocol)
implementation(libs.jscep)
Expand Down
9 changes: 9 additions & 0 deletions android/app/src/debug/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<application
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config_debug" />

</manifest>

10 changes: 10 additions & 0 deletions android/app/src/debug/res/xml/network_security_config_debug.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- Debug ONLY: allow cleartext to local dev Fleet -->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">localhost</domain>
<domain includeSubdomains="false">127.0.0.1</domain>
<domain includeSubdomains="false">10.0.2.2</domain>
</domain-config>
</network-security-config>

110 changes: 79 additions & 31 deletions android/app/src/main/java/com/fleetdm/agent/AgentApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@ import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkRequest
import com.fleetdm.agent.device.DeviceIdManager
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import com.fleetdm.agent.device.Storage
import com.fleetdm.agent.osquery.core.TableRegistry

/**
* Custom Application class for Fleet Agent.
Expand All @@ -30,11 +33,6 @@ class AgentApplication : Application() {
companion object {
private const val TAG = "fleet-app"

/**
* Gets the CertificateOrchestrator instance from the Application.
* @param context Any context (will use applicationContext)
* @return The shared CertificateOrchestrator instance
*/
fun getCertificateOrchestrator(context: Context): CertificateOrchestrator =
(context.applicationContext as AgentApplication).certificateOrchestrator
}
Expand All @@ -46,6 +44,11 @@ class AgentApplication : Application() {
Log.i(TAG, "Fleet agent process started")

FleetLog.initialize(this)
Storage.init(this)

// Log device id (safe; not a secret)
val deviceId = DeviceIdManager.getOrCreateDeviceId()
Log.i(TAG, "DeviceId=$deviceId")

val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
Expand All @@ -56,53 +59,98 @@ class AgentApplication : Application() {
android.os.Process.killProcess(android.os.Process.myPid())
}
}

// Initialize dependencies
ApiClient.initialize(this)

// Register osquery table plugins
com.fleetdm.agent.osquery.OsqueryTables.registerAll(this)

// Register core osquery tables (including android_logcat)
TableRegistry.ensureRegistered()

if (BuildConfig.DEBUG) {
DistributedCheckinWorker.scheduleNextDebug(this)
}

certificateOrchestrator = CertificateOrchestrator()

refreshEnrollmentCredentials()
schedulePeriodicCertificateEnrollment()
}

/**
* Production path: MDM managed configuration (RestrictionsManager).
* Debug-only fallback: BuildConfig.DEBUG_* values, ONLY if MDM values are missing.
*/
private fun refreshEnrollmentCredentials() {
applicationScope.launch {
try {
val restrictionsManager = getSystemService(Context.RESTRICTIONS_SERVICE)
as? RestrictionsManager
val appRestrictions = restrictionsManager?.applicationRestrictions ?: return@launch

val enrollSecret = appRestrictions.getString("enroll_secret")
val hostUUID = appRestrictions.getString("host_uuid")
val serverURL = appRestrictions.getString("server_url")

if (enrollSecret != null && hostUUID != null && serverURL != null) {
Log.d(TAG, "Refreshing enrollment credentials from MDM config")
ApiClient.setEnrollmentCredentials(
enrollSecret = enrollSecret,
hardwareUUID = hostUUID,
serverUrl = serverURL,
computerName = "${Build.BRAND} ${Build.MODEL}",
)

// Only enroll if not already enrolled
if (ApiClient.getApiKey() == null) {
val configResult = ApiClient.getOrbitConfig()
configResult.onSuccess {
Log.d(TAG, "Successfully enrolled host with Fleet server")
}.onFailure { error ->
Log.w(TAG, "Host enrollment failed: ${error.message}")
}
val restrictionsManager =
getSystemService(Context.RESTRICTIONS_SERVICE) as? RestrictionsManager
val appRestrictions = restrictionsManager?.applicationRestrictions

val mdmEnrollSecret = appRestrictions?.getString("enroll_secret")
val mdmHostUUID = appRestrictions?.getString("host_uuid")
val mdmServerURL = appRestrictions?.getString("server_url")

val (enrollSecret, hostUUID, serverURL) = if (
!mdmEnrollSecret.isNullOrBlank() &&
!mdmHostUUID.isNullOrBlank() &&
!mdmServerURL.isNullOrBlank()
) {
Log.d(TAG, "Using MDM enrollment credentials (managed config)")
Triple(mdmEnrollSecret, mdmHostUUID, mdmServerURL)
} else if (BuildConfig.DEBUG) {
val debugUrl = getOptionalBuildConfigString("DEBUG_FLEET_SERVER_URL")
val debugSecret = getOptionalBuildConfigString("DEBUG_FLEET_ENROLL_SECRET")

if (!debugUrl.isNullOrBlank() && !debugSecret.isNullOrBlank()) {
// Debug fallback host UUID: stable per app install (acceptable for dev)
val debugHostUUID = DeviceIdManager.getOrCreateDeviceId()

Log.w(TAG, "MDM config missing; using DEBUG enrollment credentials")
Triple(debugSecret, debugHostUUID, debugUrl)
} else { Log.d(TAG, "MDM config missing and DEBUG values not set")
return@launch
}
} else {
Log.d(TAG, "MDM enrollment credentials not available")
return@launch
}

ApiClient.setEnrollmentCredentials(
enrollSecret = enrollSecret,
hardwareUUID = hostUUID,
serverUrl = serverURL,
computerName = "${Build.BRAND} ${Build.MODEL}",
)

// Only enroll if not already enrolled
if (ApiClient.getApiKey() == null) {
val configResult = ApiClient.getOrbitConfig()
configResult.onSuccess {
Log.d(TAG, "Successfully enrolled host with Fleet server")
}.onFailure { error ->
Log.w(TAG, "Host enrollment failed: ${error.message}")
}
}
} catch (e: Exception) {
FleetLog.e(TAG, "Error refreshing enrollment credentials", e)
}
}
}


private fun getOptionalBuildConfigString(fieldName: String): String? {
return try {
val clazz = Class.forName("${packageName}.BuildConfig")
val field = clazz.getField(fieldName)
(field.get(null) as? String)?.takeIf { it.isNotBlank() }
} catch (_: Throwable) {
null
}
}

private fun schedulePeriodicCertificateEnrollment() {
val workRequest = PeriodicWorkRequestBuilder<CertificateEnrollmentWorker>(
15, // 15 minutes is the minimum
Expand Down
78 changes: 77 additions & 1 deletion android/app/src/main/java/com/fleetdm/agent/ApiClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.builtins.serializer
import com.fleetdm.agent.device.DeviceIdManager

/**
* Converts a java.util.Date to ISO8601 format string.
Expand Down Expand Up @@ -79,6 +83,46 @@ object ApiClient : CertificateApiClient {
}
}

suspend fun distributedRead(): Result<DistributedReadResponse> = withReenrollOnUnauthorized {
val nodeKey = getNodeKeyOrEnroll().getOrElse { error ->
return@withReenrollOnUnauthorized Result.failure(error)
}

makeRequest(
endpoint = "/api/v1/osquery/distributed/read",
method = "POST",
body = DistributedReadRequest(nodeKey = nodeKey),
bodySerializer = DistributedReadRequest.serializer(),
responseSerializer = DistributedReadResponse.serializer(),
authorized = false,
)
}

suspend fun distributedWrite(
queryResults: Map<String, List<Map<String, String>>>,
): Result<Unit> = withReenrollOnUnauthorized {
val nodeKey = getNodeKeyOrEnroll().getOrElse { error ->
return@withReenrollOnUnauthorized Result.failure(error)
}

val req = DistributedWriteRequest(nodeKey = nodeKey, queries = queryResults)

// Fleet usually returns an empty JSON object; we don't care about the body.
val res = makeRequest(
endpoint = "/api/v1/osquery/distributed/write",
method = "POST",
body = req,
bodySerializer = DistributedWriteRequest.serializer(),
responseSerializer = JsonElement.serializer(),
authorized = false,
)

res.fold(
onSuccess = { Result.success(Unit) },
onFailure = { Result.failure(it) },
)
}

private suspend fun setApiKey(key: String) {
dataStore.edit { preferences ->
preferences[API_KEY] = KeystoreManager.encrypt(key)
Expand Down Expand Up @@ -144,6 +188,7 @@ object ApiClient : CertificateApiClient {
useCaches = false
doInput = true
setRequestProperty("Content-Type", "application/json")
setRequestProperty("X-Fleet-Device-Id", DeviceIdManager.getOrCreateDeviceId())
if (authorized) {
getNodeKeyOrEnroll().fold(
onFailure = { throwable -> return@withContext Result.failure(throwable) },
Expand All @@ -164,13 +209,18 @@ object ApiClient : CertificateApiClient {
}

val responseCode = connection.responseCode
val response = if (responseCode in 200..299) {
var response = if (responseCode in 200..299) {
connection.inputStream.bufferedReader().use { it.readText() }
} else {
connection.errorStream?.bufferedReader()?.use { it.readText() }
?: "HTTP $responseCode"
}

// Some Fleet endpoints may respond with an empty body on success.
if (responseCode in 200..299 && response.isBlank()) {
response = "{}"
}

Log.d(TAG, "server response from $method $endpoint ($responseCode)")

if (responseCode in 200..299) {
Expand Down Expand Up @@ -386,6 +436,32 @@ object ApiClient : CertificateApiClient {
)
}

@Serializable
data class DistributedReadRequest(
@SerialName("node_key")
val nodeKey: String,
@SerialName("queries")
val queries: Map<String, String> = emptyMap(),
)

@Serializable
data class DistributedReadResponse(
@SerialName("queries")
val queries: Map<String, String> = emptyMap(),
)

@Serializable
data class DistributedWriteRequest(
@SerialName("node_key")
val nodeKey: String,

// Map: queryName -> rows[] where each row is {col: value}
@SerialName("queries")
val queries: Map<String, List<Map<String, String>>> = emptyMap(),
)



@Serializable
data class EnrollRequest(
@SerialName("enroll_secret")
Expand Down
Loading
Loading