Pin RethinkDNS as always-on VPN, the layer that reaches every app
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run

Hiding the YouTube package stops the app. It does nothing about
youtube.com in Firefox, in a webview, or in any client nobody has
enumerated. This is that layer.

setAlwaysOnVpnPackage from the device owner is not the mechanism that
failed on 2026-08-09 -- that was `settings put secure always_on_vpn_app`,
which writes successfully, survives a reboot, and is ignored by
VpnManager. The DPM call takes effect: dumpsys now reports
mAlwaysOnVpnPackage=com.celzero.bravedns.

Lockdown stays off. It drops all traffic whenever the tunnel is down,
and the wipe checklist names it together with the DISALLOW_*
restrictions as the combination most likely to leave an unrecoverable
device. DISALLOW_CONFIG_VPN is deferred for the same reason: locking VPN
configuration before the filter is confirmed working would pin a broken
setup in place.

The exporter emits always_on_vpn_package only when the provider is also
in never_disable_prefixes, and RethinkDNS was added there. Pinning a
package the sweep can hide is the worst case -- the restriction would
point at a hidden app and cost general connectivity, not just the
filter. The pin is re-asserted every pass and, unlike the uninstall
block, does not track the decision: the network filter must not lift
when the geofence says AWAY.

releaseDeviceOwner now unpins the VPN and clears the restriction before
clearing ownership, for the same reason it already unblocked uninstall.

Not yet active, and the commit does not pretend otherwise: the tunnel
needs a one-time in-app VPN consent tap that adb cannot grant, and
RethinkDNS is at first-run state because its wildcard rules live in
app-private storage that the backup never reached. They have to be
re-entered by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014DqEd1uUhyT52KjaNeLanC
This commit is contained in:
Krzysztof Rudnicki 2026-08-10 21:40:04 +02:00
parent b1de6f8d38
commit db1d8f8247
9 changed files with 212 additions and 5 deletions

View File

@ -0,0 +1,55 @@
{
"intent": "Add the network-level layer: pin RethinkDNS as the device owner's always-on VPN so YouTube is blocked by host, in every app, not only by hiding the YouTube package.",
"scope": [
"python_pkg/focus_policy/export.py",
"python_pkg/focus_policy/tests/test_export.py",
"focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/DevicePolicyBridge.kt",
"focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementRunner.kt",
"focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/MainActivity.kt",
"phone_focus_mode/config.sh",
"focus_owner/assets/policy.json",
"Non-goal: DISALLOW_CONFIG_VPN, deliberately deferred until the filter is confirmed working",
"Non-goal: the domain rules themselves, which cannot be scripted"
],
"changes": [
"DevicePolicyBridge.setAlwaysOnVpn uses DevicePolicyManager.setAlwaysOnVpnPackage. This is not the mechanism that failed on 2026-08-09: that was `settings put secure always_on_vpn_app`, which writes, survives a reboot, and is ignored by VpnManager. The DPM call from a device owner actually takes effect.",
"lockdownEnabled=false deliberately. Lockdown drops all traffic when the tunnel is down, and the wipe checklist names lockdown plus the DISALLOW_* restrictions as the combination most likely to produce an unrecoverable device.",
"releaseDeviceOwner now also clears DISALLOW_CONFIG_VPN and unpins the VPN before clearDeviceOwnerApp, for the same reason it already unblocked uninstall: these are device-owner powers, so clearing ownership first would strand them with nothing privileged left to lift them.",
"The pin is re-asserted every pass from EnforcementRunner.apply, unconditionally rather than tracking decision.isEnforcing -- the network filter must not lift when the geofence says AWAY.",
"always_on_vpn_package is emitted by the exporter only when the provider is also in never_disable_prefixes, and com.celzero.bravedns was added there. Pinning a package the sweep can hide is the worst case: the restriction would point at a hidden app and cost general connectivity, not just the filter."
],
"verification": [
{
"command": "pytest python_pkg/focus_policy/ --cov",
"result": "pass",
"evidence": "129 tests, 100% branch coverage retained on every module. The new test asserts the field is empty when the provider is unprotected and set when it is."
},
{
"command": "dumpsys package com.celzero.bravedns | grep BIND_VPN_SERVICE",
"result": "pass",
"evidence": "com.celzero.bravedns/.service.BraveVPNService declares BIND_VPN_SERVICE, so it is a valid always-on target rather than a package that would silently no-op."
},
{
"command": "run an enforcement pass, then dumpsys device_policy",
"result": "pass",
"evidence": "logcat 'FocusOwner: alwaysOnVpn -> com.celzero.bravedns'; dumpsys reports mAlwaysOnVpnPackage=com.celzero.bravedns, mAlwaysOnVpnLockdown=false. RethinkDNS was also unhidden by the same pass, confirming the never-disable entry works."
},
{
"command": "ip addr show tun0 ; pidof com.celzero.bravedns",
"result": "partial",
"evidence": "The app is running (pid 8871) and the pin is registered, but there is NO tun0 interface -- the tunnel has not come up. Android requires a one-time in-app VPN consent tap that cannot be granted over adb. The pin is armed but the filter is not yet carrying traffic."
}
],
"risks": [
"THE NETWORK BLOCK IS NOT YET ACTIVE. The pin is set but the tunnel is down pending the VPN consent tap, and RethinkDNS is at first-run state: the 8 wildcard YouTube rules died with the wipe. They live in app-private storage, so phone_backup.sh never captured them and adb cannot restore them -- they must be re-entered by hand.",
"Pinning RethinkDNS consumes Android's single VPN slot, so com.zaneschepke.wireguardautotunnel cannot run, and dns.kuhy.duckdns.org (bound to 10.8.0.1, WireGuard-only) is unreachable. The user chose this trade explicitly.",
"DISALLOW_CONFIG_VPN is NOT applied yet, so the user can still turn the VPN off from Settings. Applying it before the filter is confirmed working would lock in a non-functional configuration.",
"Whether DISALLOW_CONFIG_VPN also locks RethinkDNS's own in-app rule editor is untested. If it does, every domain rule must be final before the restriction is applied.",
"The always-on pin has not been reboot-tested."
],
"rollback": [
"In-app 'Release device owner' unpins the VPN and clears the restriction before releasing, all in one action.",
"To unpin without releasing: remove com.celzero.bravedns from SYSTEM_NEVER_DISABLE, regenerate the asset (the exporter then emits an empty always_on_vpn_package), rebuild, install, run a pass.",
"adb shell dpm set-always-on-vpn is not a rollback path; the DPM state is owned by the app."
]
}

View File

@ -98,6 +98,66 @@ class DevicePolicyBridge(private val context: Context) {
.getOrDefault(false)
}
/**
* Pins [vpnPackage] as the always-on VPN, the network-level block.
*
* This is the DPM call, not `settings put secure always_on_vpn_app` --
* that one writes successfully, survives a reboot, and does nothing,
* because VpnManager ignores adb-written values. Measured on this device.
*
* `lockdownEnabled` is false deliberately. Lockdown drops all traffic
* whenever the tunnel is down, and the wipe checklist names it, together
* with the DISALLOW_* restrictions, as the combination most likely to
* leave an unrecoverable device. The filter being bypassable during a
* VPN outage is the lesser problem.
*
* @return null on success, or a message describing why it failed.
*/
fun setAlwaysOnVpn(vpnPackage: String): String? {
if (!isDeviceOwner()) return "not device owner"
return runCatching {
dpm.setAlwaysOnVpnPackage(admin, vpnPackage, false)
val active = dpm.getAlwaysOnVpnPackage(admin)
Log.i(FocusDeviceAdminReceiver.TAG, "alwaysOnVpn -> $active")
if (active == vpnPackage) null else "pinned $active, expected $vpnPackage"
}.getOrElse { error ->
// UnsupportedOperationException is the documented signal that the
// package does not declare a VPN service, or does not opt in to
// always-on. Report it rather than leaving a silent no-op.
Log.e(FocusDeviceAdminReceiver.TAG, "setAlwaysOnVpnPackage failed", error)
error.message ?: error::class.java.simpleName
}
}
/** The package currently pinned as always-on VPN, or null. */
fun alwaysOnVpnPackage(): String? {
if (!isDeviceOwner()) return null
return runCatching { dpm.getAlwaysOnVpnPackage(admin) }.getOrNull()
}
/**
* Prevents the user from changing VPN configuration.
*
* Applied separately from [setAlwaysOnVpn] and only after it is confirmed
* working: pinning the restriction first would make a failed VPN setup
* harder to repair from the device.
*/
fun setVpnConfigBlocked(blocked: Boolean): Boolean {
if (!isDeviceOwner()) return false
return runCatching {
if (blocked) {
dpm.addUserRestriction(admin, android.os.UserManager.DISALLOW_CONFIG_VPN)
} else {
dpm.clearUserRestriction(admin, android.os.UserManager.DISALLOW_CONFIG_VPN)
}
Log.i(FocusDeviceAdminReceiver.TAG, "DISALLOW_CONFIG_VPN=$blocked")
true
}.getOrElse { error ->
Log.e(FocusDeviceAdminReceiver.TAG, "DISALLOW_CONFIG_VPN failed", error)
false
}
}
/**
* Describes provisioning state for the UI, so the escape hatch can be found
* without a PC attached.
@ -109,6 +169,7 @@ class DevicePolicyBridge(private val context: Context) {
"sdkInt" to Build.VERSION.SDK_INT,
"restrictionsApplied" to false,
"uninstallBlocked" to isSelfUninstallBlocked(),
"alwaysOnVpn" to (alwaysOnVpnPackage() ?: ""),
)
/**
@ -131,11 +192,17 @@ class DevicePolicyBridge(private val context: Context) {
return true
}
return runCatching {
// Order matters and is not interchangeable: uninstall blocking is a
// device-owner power, so clearing ownership first would leave the
// block in place with nothing privileged left to lift it — an app
// that owns nothing and cannot be removed. Unblock, then release.
// Order matters and is not interchangeable: every one of these is a
// device-owner power, so clearing ownership first would leave them
// in place with nothing privileged left to lift them — an app that
// owns nothing and cannot be removed, and a pinned VPN nobody can
// reconfigure. Undo the restrictions, then release.
setSelfUninstallBlocked(false)
setVpnConfigBlocked(false)
runCatching { dpm.setAlwaysOnVpnPackage(admin, null, false) }
.onFailure {
Log.w(FocusDeviceAdminReceiver.TAG, "could not unpin VPN", it)
}
dpm.clearDeviceOwnerApp(context.packageName)
val released = !isDeviceOwner()
Log.i(FocusDeviceAdminReceiver.TAG, "clearDeviceOwnerApp -> released=$released")

View File

@ -67,6 +67,23 @@ class EnforcementRunner(private val context: Context) {
* that restores access rather than the half that removes it.
*/
fun apply(decision: EnforcementDecision, bridge: DevicePolicyBridge) {
// Re-read rather than threaded through from decide(): an unreadable
// policy must not stop a pass that is otherwise ready to apply, and
// the VPN pin is the one step that should survive a policy problem.
val policy = runCatching { FocusPolicy.load(context) }.getOrNull()
// Re-pinned every pass, unconditionally rather than tracking the
// decision: the network filter is the layer that reaches youtube.com
// in Firefox, in a webview, and in clients nobody has enumerated, so
// it must not lift when the geofence says AWAY. DISALLOW_CONFIG_VPN is
// deliberately NOT applied here -- it is a separate, later step, so a
// misconfigured VPN stays repairable from the device.
policy?.alwaysOnVpnPackage?.let { vpn ->
bridge.setAlwaysOnVpn(vpn)?.let { failure ->
Log.w(FocusDeviceAdminReceiver.TAG, "always-on VPN not pinned: $failure")
}
}
// Re-asserted every pass rather than set once at provisioning time, so
// the protection self-heals: `adb uninstall` while enforcing is the one
// route that strands device ownership with no holder, and a factory

View File

@ -81,6 +81,13 @@ data class FocusPolicy(
* `packagesToShow`.
*/
val alwaysBlockedPackages: Set<String> = emptySet(),
/**
* Package pinned as the always-on VPN, or null when none is configured.
*
* The exporter emits this only when the package is also protected from
* the sweep, so the enforcer can never hide the app it has pinned.
*/
val alwaysOnVpnPackage: String? = null,
) {
/**
* Whether a package must never be hidden.
@ -165,6 +172,10 @@ data class FocusPolicy(
workoutUnblockDomains = json.stringSet("workout_unblock_domains"),
blockableSystemPackages = json.optionalStringSet("blockable_system_packages"),
alwaysBlockedPackages = json.optionalStringSet("always_blocked_packages"),
// Empty string means "not configured", which is what the
// exporter writes when the provider is not sweep-protected.
alwaysOnVpnPackage = json.optString("always_on_vpn_package")
.takeIf { it.isNotEmpty() },
curfew = curfewJson?.let {
CurfewWindow(
startMinutes = parseHhMm(it.getString("start"), "curfew.start"),

View File

@ -14,6 +14,24 @@ class MainActivity : FlutterActivity() {
when (call.method) {
"status" -> result.success(bridge.status())
"releaseDeviceOwner" -> result.success(bridge.releaseDeviceOwner())
"setAlwaysOnVpn" -> {
val pkg = call.argument<String>("package")
if (pkg == null) {
result.error("bad_args", "package required", null)
} else {
// Returns the failure message, or null on success,
// so the UI can say why rather than just "failed".
result.success(bridge.setAlwaysOnVpn(pkg))
}
}
"setVpnConfigBlocked" -> {
val blocked = call.argument<Boolean>("blocked")
if (blocked == null) {
result.error("bad_args", "blocked required", null)
} else {
result.success(bridge.setVpnConfigBlocked(blocked))
}
}
"setSelfUninstallBlocked" -> {
val blocked = call.argument<Boolean>("blocked")
if (blocked == null) {

View File

@ -93,6 +93,7 @@
"com.google.android.apps.youtube.music",
"com.google.android.youtube"
],
"always_on_vpn_package": "com.celzero.bravedns",
"blockable_system_packages": [
"com.android.chrome",
"com.android.vending",
@ -145,6 +146,7 @@
"com.android.systemui",
"com.android.vpndialogs",
"com.android.wifi",
"com.celzero.bravedns",
"com.google.android.apps.messaging",
"com.google.android.contacts",
"com.google.android.deskclock",

View File

@ -614,6 +614,11 @@ export BLOCKED_SYSTEM_APPS="
# curfew split, so it holds under every condition.
export SYSTEM_NEVER_DISABLE="
pl.infakt.infakt
# The always-on VPN provider. Hiding it is self-defeating in the worst way:
# it is the network-level YouTube block, and with DISALLOW_CONFIG_VPN pinned
# to a hidden package the device can lose general connectivity rather than
# just the filter. Must outrank every enforcement branch.
com.celzero.bravedns
com.android.launcher
com.android.launcher3
com.android.settings

View File

@ -76,6 +76,14 @@ ALWAYS_BLOCKED_PACKAGES = frozenset(
# the sweep by also being opted in above. Listing one without the other would
# not error -- it would silently never be hidden, because the runner filters it
# out of installedPackages before the decision layer sees it.
# The always-on VPN provider, pinned by the device owner on every pass.
#
# This is the network-level block: package hiding stops the YouTube app, but
# only a VPN reaches youtube.com in Firefox, in a webview, or in any client
# that has not been thought of. Named here rather than hardcoded in Kotlin so
# the asset stays the single description of the policy.
ALWAYS_ON_VPN_PACKAGE = "com.celzero.bravedns"
_UNSWEEPABLE = ALWAYS_BLOCKED_PACKAGES - BLOCKABLE_SYSTEM_PACKAGES
if _UNSWEEPABLE: # pragma: no cover - guards a constant, not a code path
msg = (
@ -124,6 +132,13 @@ def policy_to_dict(policy: FocusPolicy) -> dict[str, Any]:
"always_blocked_packages": sorted(
ALWAYS_BLOCKED_PACKAGES - {*policy.allowed_packages, ENFORCER_PACKAGE},
),
# Emitted only when the provider is protected from the sweep. Pinning a
# package the enforcer can hide is the worst case: DISALLOW_CONFIG_VPN
# points at a hidden app and the device loses connectivity rather than
# just the filter.
"always_on_vpn_package": (
ALWAYS_ON_VPN_PACKAGE if policy.is_protected(ALWAYS_ON_VPN_PACKAGE) else ""
),
}

View File

@ -25,6 +25,7 @@ def _policy(
allowed_packages: frozenset[str] = frozenset({"pl.mbank", "com.launcher"}),
night_allowed_packages: frozenset[str] = frozenset({"pl.mbank", "com.launcher"}),
curfew: CurfewWindow | None = _DEFAULT_CURFEW,
never_disable_prefixes: tuple[str, ...] = ("com.android.",),
) -> FocusPolicy:
"""Build a policy with the enforcer deliberately absent from both lists."""
return FocusPolicy(
@ -38,7 +39,7 @@ def _policy(
launcher_package="com.launcher",
allowed_packages=allowed_packages,
night_allowed_packages=night_allowed_packages,
never_disable_prefixes=("com.android.",),
never_disable_prefixes=never_disable_prefixes,
workout_unblock_domains=frozenset({"youtube.com"}),
browser_packages=frozenset({"org.mozilla.firefox"}),
)
@ -119,6 +120,22 @@ def test_always_blocked_exempts_youtube_and_chrome_from_the_geofence() -> None:
assert "com.android.vending" not in always
def test_always_on_vpn_requires_a_sweep_protected_provider() -> None:
"""Pinning a package the enforcer can hide is the worst failure mode.
DISALLOW_CONFIG_VPN pointing at a hidden app costs general connectivity,
not just the filter, so the field is emitted only when the provider is
protected.
"""
unprotected = policy_to_dict(_policy())["always_on_vpn_package"]
protected = policy_to_dict(
_policy(never_disable_prefixes=("com.celzero.bravedns",)),
)["always_on_vpn_package"]
assert unprotected == ""
assert protected == "com.celzero.bravedns"
def test_always_blocked_is_a_subset_of_blockable() -> None:
"""An always-blocked system app the sweep cannot see would silently no-op.