diff --git a/README.md b/README.md index 2d93841..9d68cd2 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,28 @@ Schema migrations run automatically on first boot — no manual migration comman | `HEARTBEAT_TIMEOUT` | How long without an app-level heartbeat (ms) before marking a device offline. Raise for slow/jittery networks. | `45000` | | `MAX_FILE_SIZE` | Largest upload the server will accept. Bytes, or a suffix (`2GB`, `1500MB`). **A reverse proxy caps this independently** — see below. | `500MB` | | `COMMAND_QUEUE_TTL_MS` | How long the server holds commands and playlist-updates for a device that's offline at emit time (ms). Flushed in order on reconnect within this window; dropped past TTL. | `30000` | +| `OTA_ALLOW_MANAGED_DEVICES` | Let Android players self-update even when an MDM/DPC owns the device. Off by default — see below before enabling. | `0` | + +#### Android players under an MDM + +By default a player **stands down from self-updating** when it detects that another device owner +(an MDM/DPC such as an EMM agent) manages the panel. The reasoning is that on a managed device the +install confirmation dialog cannot be reliably auto-dismissed, so it ends up sitting over customer +content — and the MDM is normally the thing distributing packages anyway. Such a panel reports +`manual_update_required` rather than going quiet, so it still shows up as needing attention. + +Set `OTA_ALLOW_MANAGED_DEVICES=1` if you run an MDM that does **not** distribute the player and you +want ScreenTinker's OTA to own updates instead. The server then advertises `allow_managed: true` in +`/api/update/check` and players stop standing down. + +Two things to know before enabling it: + +- **It does not grant the ability to install silently.** Unless the player is the device owner, or + the MDM has delegated `DELEGATION_PACKAGE_INSTALLATION` to it, Android still raises a confirm + dialog that somebody (or an accessibility service) has to accept. If installs are failing *with* + an MDM present, delegating that scope is usually the real fix — not this flag. +- **It is read by the player, not the server**, so only players new enough to understand + `allow_managed` honour it. Older players keep standing down regardless. #### Raising the upload limit diff --git a/android/app/src/main/java/com/remotedisplay/player/admin/STPolicy.kt b/android/app/src/main/java/com/remotedisplay/player/admin/STPolicy.kt index d0b3b6a..11aa8e8 100644 --- a/android/app/src/main/java/com/remotedisplay/player/admin/STPolicy.kt +++ b/android/app/src/main/java/com/remotedisplay/player/admin/STPolicy.kt @@ -221,6 +221,16 @@ object ManagedLogic { // isProfileOwner is carried on Admin but intentionally NOT consulted — see above. return admins.any { it.packageName != ourPackage && it.isDeviceOwner } } + + /** + * Final say on whether self-OTA stands down: a foreign DPC owns installs AND the operator has + * not overridden it (server `allow_managed`, from OTA_ALLOW_MANAGED_DEVICES). + * + * [serverAllowsManaged] must arrive as FALSE when the server said nothing — an older server + * that has never heard of the field would otherwise read as permission. Absence is not consent. + */ + fun standDownFromSelfOta(foreignDpcOwnsInstalls: Boolean, serverAllowsManaged: Boolean): Boolean = + foreignDpcOwnsInstalls && !serverAllowsManaged } /** Pure tier decision, extracted so it's unit-testable without a device / DevicePolicyManager. */ diff --git a/android/app/src/main/java/com/remotedisplay/player/service/UpdateChecker.kt b/android/app/src/main/java/com/remotedisplay/player/service/UpdateChecker.kt index f6c281f..e61b3ce 100644 --- a/android/app/src/main/java/com/remotedisplay/player/service/UpdateChecker.kt +++ b/android/app/src/main/java/com/remotedisplay/player/service/UpdateChecker.kt @@ -138,6 +138,10 @@ class UpdateChecker(private val context: Context) { val updateAvailable = json.optBoolean("update_available", false) val latestVersion = json.optString("latest_version", currentVersion) val downloadUrl = json.optString("download_url", "") + // #166 escape hatch: the operator set OTA_ALLOW_MANAGED_DEVICES, so self-update is + // permitted even under a foreign DPC. Defaults FALSE, which is also what an older + // server (that never sends the field) yields — absence must never read as consent. + val allowManaged = json.optBoolean("allow_managed", false) Log.i(TAG, "Current: $currentVersion, Latest: $latestVersion, Update: $updateAvailable") @@ -159,7 +163,8 @@ class UpdateChecker(private val context: Context) { // Checked HERE rather than before the request: standing down early meant a // stood-down panel never learned an update existed, so it reported ota_status // 'none' — indistinguishable from up to date — and no dashboard ever flagged it. - if (isManagedByForeignDeviceOwner()) { + if (com.remotedisplay.player.admin.ManagedLogic.standDownFromSelfOta( + isManagedByForeignDeviceOwner(), allowManaged)) { val (managed, first) = OtaThrottle.onManagedStandDown( otaState(), latestVersion, System.currentTimeMillis()) persistOta(managed) @@ -170,6 +175,11 @@ class UpdateChecker(private val context: Context) { } return@Thread } + if (allowManaged && isManagedByForeignDeviceOwner()) { + // Loud on purpose: the operator overrode a safety default, and the confirm + // dialog this may raise over customer content is the cost of that choice. + Log.i(TAG, "Managed by a foreign DPC, but the server allows managed self-update — proceeding") + } maybeUpdate(latestVersion, "${config.serverUrl}$downloadUrl") } } catch (e: Exception) { diff --git a/android/app/src/test/java/com/remotedisplay/player/admin/ManagedLogicTest.kt b/android/app/src/test/java/com/remotedisplay/player/admin/ManagedLogicTest.kt index 66dce19..a19c516 100644 --- a/android/app/src/test/java/com/remotedisplay/player/admin/ManagedLogicTest.kt +++ b/android/app/src/test/java/com/remotedisplay/player/admin/ManagedLogicTest.kt @@ -75,4 +75,27 @@ class ManagedLogicTest { val admins = listOf(ManagedLogic.Admin("com.mdm.dpc", isDeviceOwner = true)) assertFalse(ManagedLogic.foreignDpcOwnsInstalls(true, OURS, admins)) } + + // ---- #166 escape hatch: OTA_ALLOW_MANAGED_DEVICES -> server `allow_managed` ---------------- + + @Test fun a_managed_panel_stands_down_when_the_operator_has_not_overridden() { + assertTrue(ManagedLogic.standDownFromSelfOta(foreignDpcOwnsInstalls = true, serverAllowsManaged = false)) + } + + @Test fun the_override_lets_a_managed_panel_self_update() { + assertFalse(ManagedLogic.standDownFromSelfOta(foreignDpcOwnsInstalls = true, serverAllowsManaged = true)) + } + + @Test fun an_unmanaged_panel_never_stands_down_either_way() { + assertFalse(ManagedLogic.standDownFromSelfOta(foreignDpcOwnsInstalls = false, serverAllowsManaged = false)) + assertFalse(ManagedLogic.standDownFromSelfOta(foreignDpcOwnsInstalls = false, serverAllowsManaged = true)) + } + + @Test fun ABSENCE_IS_NOT_CONSENT_an_old_server_that_omits_the_field_reads_as_false() { + // The caller parses it with optBoolean("allow_managed", false). A server predating the + // flag says nothing, and that silence must mean "stand down", not "go ahead" — otherwise + // upgrading a PLAYER against an older SERVER would silently switch the safe default off. + val serverSaidNothing = false + assertTrue(ManagedLogic.standDownFromSelfOta(true, serverSaidNothing)) + } } diff --git a/server/config.js b/server/config.js index 3189cf4..64b5321 100644 --- a/server/config.js +++ b/server/config.js @@ -127,6 +127,14 @@ module.exports = { // Disable public registration (OAuth auto-signup is also blocked when set). // First-user setup is still allowed so a fresh install can be initialized. disableRegistration: ['true', '1'].includes(String(process.env.DISABLE_REGISTRATION || '').toLowerCase()), + // #166 escape hatch: let players self-update EVEN WHEN an MDM/DPC owns the device. + // Off by default, because the default is the safe one — on a managed panel the install + // confirm dialog can't be reliably auto-dismissed and ends up sitting over customer content, + // and the MDM is normally the thing that pushes packages. Set this only when you run an MDM + // that does NOT distribute the player and you want ScreenTinker's OTA to own updates instead. + // Advertised to players in /api/update/check as `allow_managed`; a player that doesn't + // understand the field simply keeps its own behaviour. + otaAllowManagedDevices: ['true', '1'].includes(String(process.env.OTA_ALLOW_MANAGED_DEVICES || '').toLowerCase()), // Redirect / -> /app instead of serving the marketing landing page. // For self-hosted internal deployments that don't want the public homepage. disableHomepage: ['true', '1'].includes(String(process.env.DISABLE_HOMEPAGE || '').toLowerCase()), diff --git a/server/server.js b/server/server.js index be9d87a..bfa2590 100644 --- a/server/server.js +++ b/server/server.js @@ -759,6 +759,11 @@ app.get('/api/update/check', (req, res) => { download_url: '/download/apk', apk_size: updateAvailable ? apk.size : 0, apk_modified: updateAvailable ? apk.mtime : 0, + // #166 escape hatch (OTA_ALLOW_MANAGED_DEVICES). Tells a player it may self-update even when + // a foreign DPC owns the device. Always present, so a player can distinguish "the operator + // said no" from "this server is too old to have an opinion" — both mean stand down, but only + // the first is a decision. + allow_managed: !!config.otaAllowManagedDevices, }); }); diff --git a/server/test/ota-allow-managed-config.test.js b/server/test/ota-allow-managed-config.test.js new file mode 100644 index 0000000..1cdcbe8 --- /dev/null +++ b/server/test/ota-allow-managed-config.test.js @@ -0,0 +1,52 @@ +'use strict'; + +// #166 escape hatch: OTA_ALLOW_MANAGED_DEVICES lets players self-update even when an MDM/DPC owns +// the device. The default has to be OFF, and "off" has to be the answer for every shape of a +// not-set / mistyped value — an operator who fat-fingers the variable must not silently get the +// unsafe behaviour, because the failure mode is an install confirm dialog parked over a customer's +// content on a fleet nobody is standing in front of. +// +// The value is also advertised to players as `allow_managed` in /api/update/check. A player that +// gets no field at all (older server) must read that as NO. Absence is not consent. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); + +function loadConfig(value) { + if (value === undefined) delete process.env.OTA_ALLOW_MANAGED_DEVICES; + else process.env.OTA_ALLOW_MANAGED_DEVICES = value; + delete require.cache[require.resolve('../config')]; + return require('../config'); +} + +test('THE DEFAULT: unset means managed devices do NOT self-update', () => { + const config = loadConfig(undefined); + assert.equal(config.otaAllowManagedDevices, false); + assert.equal(typeof config.otaAllowManagedDevices, 'boolean'); +}); + +test('the documented ways to turn it on', () => { + for (const v of ['1', 'true', 'TRUE', 'True']) { + assert.equal(loadConfig(v).otaAllowManagedDevices, true, `${v} should enable`); + } +}); + +test('everything else is OFF — a typo must not enable an unsafe default', () => { + // '0'/'false' are the explicit no. The rest are the fat-finger cases: they must land on the + // safe side rather than being treated as "any non-empty string is truthy". + for (const v of ['0', 'false', 'FALSE', 'no', 'off', 'yes', 'ture', 'enabled', '2', '', ' ']) { + assert.equal(loadConfig(v).otaAllowManagedDevices, false, `${JSON.stringify(v)} should stay off`); + } +}); + +test('it is always a real boolean, never a string, so the JSON field is unambiguous', () => { + // Players read this over the wire; the string "false" is truthy in every client language. + for (const v of [undefined, '1', 'nonsense']) { + assert.equal(typeof loadConfig(v).otaAllowManagedDevices, 'boolean'); + } +}); + +test.after(() => { + delete process.env.OTA_ALLOW_MANAGED_DEVICES; + delete require.cache[require.resolve('../config')]; +});