Server — the brain
The server is a Ktor (Netty) application that holds the desired policy for every device, stores the latest report, and is the authoritative judge of compliance. It binds 0.0.0.0:8081.
Source: server/src/main/kotlin/com/arganaemre/mdmcore/ (Application.kt, Doorbell.kt).
What it stores: DeviceStore
DeviceStore is an in-memory singleton — three ConcurrentHashMaps plus an AtomicInteger:
| Map | Key | Value |
|---|---|---|
policies | device id | the desired DevicePolicy |
reports | device id | the latest ComplianceReport |
tokens | device id | the FCM registration token |
- Identity is a running counter: each enroll yields
device-1,device-2, … (AtomicInteger). - The default policy handed to every new device is
version = 1, cameraDisabled = true— non-trivial on purpose, so a fresh agent has something real to enforce and report. updatePolicyis atomic per device viacomputeIfPresent: it read-bump-writes the version and only acts if the device exists (an unknown id stays absent →404).- Nothing is persisted. Restart the server and every device, policy, and token is gone — which is exactly why the agent treats a
404on policy-pull as “re-enroll me” (see agent.md). A real deployment would back this with a database.
How it decides compliance: evaluate()
The server never trusts the agent’s self-reported compliant flag. On every status read it recomputes the verdict from the reported actual state, using the shared rule (DevicePolicy.isSatisfiedBy, see protocol.md):
when {
report == null -> UNKNOWN
report.appliedPolicyVersion < desired.version -> STALE
desired.isSatisfiedBy(report) -> COMPLIANT
else -> NON_COMPLIANT
}
STALE is checked before content, and is only knowable because the policy is versioned. Full semantics: the loop.
Routes
Eight routes, defined in Application.module(). Full reference with bodies, status codes, and curl: http-api.md. In brief:
POST /enroll,GET|PUT /devices/{id}/policy,POST /devices/{id}/token,POST /devices/{id}/report,GET /devices,GET /devices/{id}/status, andGET /(health).PUT /devices/{id}/policyis the console’s edit path: it bumps the version and rings the device’s FCM doorbell (best-effort) so the change propagates promptly.
CORS (dev-only)
The server installs CORS with anyHost() and allows GET/POST/PUT plus the Content-Type header. This exists so the browser-based web console (a different origin) can call the API, and because a JSON body triggers a CORS preflight that must permit Content-Type and the PUT method (only GET/POST/HEAD are allowed by default). anyHost() is unacceptable in production — restrict it before any real deployment.
Content negotiation
install(ContentNegotiation) { json(MdmJson) } teaches the server to read/write the contract as JSON using the contract’s own MdmJson config, so the server and agent serialize identically. See protocol.md.
Push: the Doorbell
Doorbell sends the data-only FCM wake message when policy changes. It degrades gracefully: with no credential configured, it’s a no-op and the server (and its tests) run fine — you just don’t get push wake-ups; agents still catch up on their next sync. At boot the server logs which mode it’s in:
FCM doorbell: ready — policy changes will wake devices via push.
FCM doorbell: NOT configured — push wake disabled; agents still sync over HTTP.
The full push design is in fcm-doorbell.md; credential setup is in firebase-setup.md.
Running it
./gradlew :server:run # embedded Netty on http://localhost:8081
:server:run is a long-lived task that holds the Gradle project lock — any other ./gradlew command will block until it stops. For end-to-end work, build other artifacts first, then start the server, then drive it with curl/adb. See development.md.
Tests
Server tests use Ktor’s testApplication. Because DeviceStore is a shared in-memory singleton, each test enrolls and uses its own returned DeviceId rather than assuming device-1. The suite and how to run it: development.md.