Serve the blocklist over DoT and hand the policy to the app as an asset

Two gaps stopped the unrooted path working end to end. The phone had no way to
read the focus policy, and the blocklist was only reachable as a hosts file or
over plain UDP/53 on the LAN -- while the rooted enforcer actively rejected
every DoT connection, so a self-hosted resolver could not have worked even if
one existed.

focus_owner now loads a generated policy.json instead of hardcoding anything,
so config.sh stays the single source of truth across Python, shell and Kotlin.
The committed asset is rendered with --redact-home and a test fails if home
coordinates ever appear in it. An unknown schema_version is rejected outright:
silently accepting a newer schema means enforcing a policy the app has misread,
and a misread allowlist is one that blocks the dialer.

dns_enforcer.sh rejected ALL port 853 traffic and pinned Private DNS off, every
20 seconds. That is right when DoT is a bypass channel and exactly wrong when
DoT is the enforcement channel, which is what an unrooted phone needs -- it has
no hosts file to fall back to. With DNS_TRUSTED_DOT_HOST set it now ACCEPTs the
trusted resolver before the blanket REJECT, and pins Private DNS to that
hostname so the user cannot pick an unfiltered one. Rule order is load-bearing:
iptables takes the first match, so an ACCEPT appended after the REJECT would
never be reached. expected_rule_count() counts the new rules, because a
mismatch there makes chain_intact() rebuild the chain every 20s -- a silent
fork storm that also drops the connection each time. With the variable empty
the behaviour is byte-identical, so the rooted phone is untouched until this is
deployed.

setup_dot_resolver.sh terminates DoT with stunnel rather than Caddy: DoT is a
raw TLS stream and stock caddy:2.8 has no layer4 module, so using Caddy would
have meant an xcaddy rebuild of a container serving five live sites. It reuses
the certificate Caddy already renews, so nothing competes for port 80. It binds
to the WireGuard address, not 0.0.0.0 -- a public DoT endpoint is an open
resolver, and over the existing tunnel it needs no port-forward, no firewall
rule and no rate limiting. Verified: example.com resolves, pornhub.com returns
0.0.0.0, TLS 1.3 against the LE cert, and both the LAN and public addresses
refuse.

The Device Owner checklist is planning only. Checking the device rather than
the config settled the one open risk: Google Wallet is not installed and no NFC
payment default is set, so the unconfirmable tap-to-pay question does not apply
here. The same check found that Aegis, MS Authenticator, Oracle IDM and PKO IKO
are absent -- they are in config.sh's whitelist, which describes the rooted
phone, not this one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwepyL5Lxs5QKbKkMpxxxL
This commit is contained in:
Krzysztof Rudnicki 2026-08-05 09:36:11 +02:00
parent f636d2b21b
commit e3d5e1dd18
14 changed files with 1547 additions and 16 deletions

View File

@ -0,0 +1,133 @@
# Device Owner provisioning: pre-wipe checklist (Pixel 6a, 23181JEGR08034)
Status: **planning only. No wipe has been performed and none is scheduled.**
Device Owner is the only unrooted tier that meets the "cannot be shut down from
the phone" requirement. `dpm set-device-owner` refuses to run while any account
exists on the device, and this phone has six, so provisioning costs a factory
reset. This is what that would take.
## The Google Wallet blocker does not apply to this device
The one genuinely unresolved risk was whether tap-to-pay survives
self-provisioned Device Owner. Research found no mechanism by which DO alone
disables Wallet — the documented failure causes are root, custom ROM, unlocked
bootloader and uncertified devices, none of which DO touches — but also no
first-hand report from anyone who self-provisioned bare DO on a recent Pixel
and then confirmed a successful contactless payment.
**Checked on the device instead of arguing about it:**
```
pm list packages -f com.google.android.apps.walletnfcrel -> not installed
settings get secure nfc_payment_default_component -> null
```
Google Wallet is **not installed** and **no NFC payment default is set**. There
is no tap-to-pay to lose. The blocker is moot here; it would only return if you
later start using Wallet, and by then the DO app's release path gives you a
no-wipe exit to test with.
## What is actually on this phone
The earlier checklist was drafted from `phone_focus_mode/config.sh`, which
describes the **rooted** phone. Verified against the Pixel by `pm list packages`,
these are absent: **Aegis, Microsoft Authenticator, Oracle IDM Authenticator,
PKO IKO, Google Wallet.**
Present and relevant:
| App | Package | Survives Google backup? |
|---|---|---|
| KeePassDX | `com.kunzisoft.keepass.libre` | File-based — you own the `.kdbx` |
| Signal | `org.thoughtcrime.securesms` | **No** — needs its own backup |
| mBank | `pl.mbank` | No — device re-pairing |
| Revolut | `com.revolut.revolut` | No — re-login + possible re-verification |
| mObywatel | `pl.nask.mobywatel` | No — full re-activation |
**No dedicated TOTP app is installed**, so the usual "authenticator seeds are
destroyed by the wipe" trap does not apply — unless your TOTP secrets live
inside the KeePassDX database, which is likely. If so, the `.kdbx` file *is*
your seed backup, and step 2 covers it.
## Phase A — break the circular dependency first
Re-verifying a bank typically needs an SMS **and** a second factor; if the
second factor died with the wipe, the loop closes on you. Do these before
touching anything.
1. **Print Google backup codes for both accounts** (`321krzychu@gmail.com`,
`krzysztofrudnicki0@gmail.com`) — myaccount.google.com → Security → 2-Step
Verification → Backup codes. If your only second factor is an on-device
prompt or passkey, the wipe locks you out of the accounts that gate
everything else.
2. **Copy the KeePassDX database off the device, and its keyfile if you use
one.** This is the highest-value artifact on the phone.
```bash
adb -s 23181JEGR08034 shell 'find /sdcard -iname "*.kdbx" 2>/dev/null'
adb -s 23181JEGR08034 pull <path> ~/phone-backup/
```
3. **Write down on paper**: mBank customer ID + password, Revolut passcode,
Signal PIN, KeePassDX master password, both Google passwords.
4. **Confirm the SIM and number will keep working** — nearly every
re-activation below sends an SMS.
## Phase B — export
5. **Signal**: Settings → Backups → On-device backups. **Record the 30-digit
passphrase on paper** — it is not your Signal PIN.
```bash
adb -s 23181JEGR08034 pull /sdcard/Signal/Backups/ ~/phone-backup/signal/
```
Registration Lock: forgetting the PIN locks you out for up to 7 days.
6. **Photos and downloads**:
```bash
adb -s 23181JEGR08034 pull /sdcard/DCIM/ ~/phone-backup/DCIM/
adb -s 23181JEGR08034 pull /sdcard/Download/ ~/phone-backup/Download/
```
7. **mBank**: in the transactional service, note how to remove a paired device.
The old phone stays listed as active after a reset and must be detached.
8. **VERIFY EVERY EXPORT BEFORE WIPING.** Open the `.kdbx` on the PC. An
untested export is not a backup — this is the step people skip and regret.
## Phase C — wipe and provision, in this order
9. **Factory reset.**
10. **Provision DO immediately, before signing into anything** — the account
check is what blocks it:
```bash
adb install -r focus_owner/build/app/outputs/flutter-apk/app-debug.apk
adb shell dpm set-device-owner com.kuhy.focus_owner/.FocusDeviceAdminReceiver
```
11. **Test the escape hatch before adding a single account.** Open Focus Owner,
confirm it reports `Device owner: yes`, press **Release device owner**, and
confirm it flips back to `no`. Then re-provision. If release does not work,
stop — a DO app that cannot let go is a soft-brick waiting to happen, and at
this moment you have nothing to lose by wiping again.
12. **Add one Google account.** Verify banking apps install and log in.
13. **Restore KeePassDX**, then Signal (the restore prompt appears at install
time only — do not skip past it).
14. **Banks.** mBank: pair via app or transactional service, SMS code, then app
PIN and mobile authorisation. Revolut: re-login, SMS/email, possible selfie
re-verification.
15. **mObywatel last.** Re-activation needs Profil Zaufany or a **bank login**
plus SMS — which is why the banks must work first. Activating on the new
device automatically deactivates the old one, so there is no rollback.
Verify current steps at <https://www.gov.pl/web/mobywatel> before relying on
this.
16. **Only then** apply enforcement, one restriction at a time, re-testing the
release path after each.
## Do not set these until the rest is proven
`DISALLOW_FACTORY_RESET` and `DISALLOW_DEBUGGING_FEATURES` close your own repair
routes as well as your bypass routes. Combined with an always-on VPN lockdown
they are the specific combination most likely to leave an unrecoverable device.
Add them last, if at all.
## Rollback
`DevicePolicyBridge.releaseDeviceOwner()` (`clearDeviceOwnerApp`) removes DO with
no wipe, but only while the app is installed and working. `dpm
remove-active-admin` does **not** work on a device owner. If the app is
uninstalled without releasing first, the only exit is another factory reset.

View File

@ -0,0 +1,25 @@
{
"title": "Deliver the blocklist over DoT and hand the policy to the Kotlin backend",
"objective": "Close the two gaps that stopped the unrooted path from working end to end. First, the phone had no way to consume the focus policy: the app now loads a generated JSON asset instead of the app and the shell config drifting apart. Second, the blocklist was only reachable as a hosts file or over plain UDP/53 on the LAN, and the rooted enforcer actively rejected every DoT connection, so a self-hosted DoT resolver could not have worked even if one existed. Success is a resolver that answers filtered queries over TLS, reachable only over WireGuard, with the rooted enforcer permitting exactly that one endpoint and nothing else.",
"acceptance_criteria": [
"python3 -m python_pkg.focus_policy renders the policy as JSON and refuses to run against the placeholder coordinates",
"The committed focus_owner/assets/policy.json carries no home coordinates, and a test fails if it ever does",
"focus_owner parses the asset and displays the real counts on-device, matching config.sh",
"An unknown schema_version is rejected rather than partially interpreted",
"dns_enforcer.sh inserts an ACCEPT for the trusted resolver BEFORE the blanket 853 REJECT - order is load-bearing, since iptables takes the first match",
"expected_rule_count() counts those ACCEPTs, so chain_intact() does not treat the chain as tampered and rebuild it every 20s",
"With DNS_TRUSTED_DOT_HOST empty the behaviour is byte-identical to before, so the live rooted phone is unaffected until deployed",
"With it set, Private DNS is pinned to that hostname instead of forced off, and user changes are reverted within DNS_CHECK_INTERVAL",
"stunnel serves DoT on 853 backed by the Caddy-managed Let's Encrypt certificate, renewed without a second ACME client",
"The resolver is bound to the WireGuard address only, and is verifiably unreachable from both LAN and WAN",
"A blocked domain returns 0.0.0.0 over DoT while a normal domain resolves"
],
"out_of_scope": [
"Device Owner provisioning and the factory reset it requires - the checklist is planning only",
"Any enforcement in focus_owner: it reads the policy but applies nothing",
"Public exposure of the resolver - deliberately WireGuard-only, no port-forward, no nftables rule",
"Deploying the dns_enforcer change to the rooted phone - the code is in, the deploy is not",
"Fixing generate_hosts_file.sh, which still comments out unblocks rather than deleting them"
],
"verifier": "python3 -m python_pkg.focus_policy (both success and placeholder-rejection paths); cd focus_owner && flutter analyze && flutter test (27 tests, incl. the committed-asset redaction guard); adb install + am start + screenshot showing the parsed policy; shellcheck + shfmt on dns_enforcer.sh, config.sh and setup_dot_resolver.sh; a stubbed-iptables harness asserting rule order, rule counts and the private-DNS pin in both modes; setup_dot_resolver.sh --check; kdig +tls @10.8.0.1 -p 853 for a normal and a blocked domain; ss -lnt plus kdig against the LAN and WAN addresses to prove non-exposure"
}

View File

@ -0,0 +1,80 @@
{
"intent": "Make the unrooted path work end to end: the phone can now consume the focus policy as a generated asset, and the blocklist is served over DNS-over-TLS from a resolver the rooted enforcer permits instead of blocking.",
"scope": [
"python_pkg/focus_policy/__main__.py - new CLI that renders the policy as JSON",
"focus_owner/lib/policy.dart, assets/policy.json, test/policy_test.dart - new: parse and display the policy",
"phone_focus_mode/dns_enforcer.sh - allow one trusted DoT endpoint instead of rejecting all 853; pin Private DNS to it",
"phone_focus_mode/config.sh - new DNS_TRUSTED_DOT_HOST / DNS_TRUSTED_DOT_IPS",
"linux_configuration/scripts/single_use/features/setup_dot_resolver.sh - new: stunnel DoT front end",
"docs/device-owner-wipe-checklist.md - planning only, no wipe performed",
"Non-goal: no Device Owner provisioning, no factory reset, no enforcement in focus_owner",
"Non-goal: the dns_enforcer change is NOT deployed to the rooted phone"
],
"changes": [
"focus_owner loads a generated policy.json rather than hardcoding anything, so config.sh remains the single source of truth across Python, shell and Kotlin. The committed asset is rendered with --redact-home, and a test fails if home coordinates ever appear in it. An unknown schema_version is rejected outright, because silently accepting a newer schema means enforcing a policy the app has misread - and a misread allowlist is one that blocks the dialer.",
"dns_enforcer.sh previously rejected ALL port 853 traffic and pinned Private DNS off, re-asserted every 20s. That is correct when DoT is a bypass channel, but it is exactly wrong when DoT is the enforcement channel, which is what an unrooted phone needs. With DNS_TRUSTED_DOT_HOST set it now ACCEPTs the trusted resolver's addresses BEFORE the blanket REJECT and pins Private DNS to that hostname. With the variable empty the old behaviour is byte-identical.",
"expected_rule_count() gained a family argument and counts the new ACCEPTs. Without that, chain_intact() would see a size mismatch and rebuild the chain every DNS_CHECK_INTERVAL - a silent fork storm that also drops the DoT connection on each rebuild.",
"setup_dot_resolver.sh terminates DoT with stunnel rather than Caddy: DoT is a raw TLS stream and stock caddy:2.8 lacks the layer4 module, so using Caddy would have meant an xcaddy rebuild of a container serving five live sites. It reuses the certificate Caddy already renews, so there is no second ACME client competing for port 80.",
"The resolver binds to the WireGuard address, not 0.0.0.0. A public DoT endpoint is an open resolver; over the existing tunnel it needs no port-forward, no firewall rule and no rate limiting, because only configured peers can route to it."
],
"verification": [
{
"command": "python3 -m python_pkg.focus_policy --config phone_focus_mode/config.sh --redact-home --output focus_owner/assets/policy.json",
"result": "pass",
"evidence": "Renders 84 allowed / 39 night / 44 never-disable / 15 workout domains, curfew 23:00-05:00, latitude and longitude null. Running it against the real config_secrets.sh exits 1 with the placeholder message rather than emitting a policy."
},
{
"command": "cd focus_owner && flutter analyze && flutter test",
"result": "pass",
"evidence": "'No issues found!' and 27 tests passing, including a guard that loads the actually-committed assets/policy.json and asserts hasCoordinates is false."
},
{
"command": "adb install -r && am start && screencap",
"result": "pass",
"evidence": "On-device screenshot shows 'Allowed apps 84 (39 at night)', 'Curfew 23:00-05:00', 'Home radius 150 m (coords redacted)', 'Workout domains 15' - matching config.sh, read through the generated asset. Device owner still 'no', restrictions still 'none'."
},
{
"command": "bash scratchpad/test_dot.sh (stubbed iptables/settings harness)",
"result": "pass",
"evidence": "14/14 checks. Confirms: with no trusted host there are zero ACCEPT rules and Private DNS is forced off (old behaviour preserved); with one set, the v4 and v6 ACCEPTs exist, the rule counts match expected_rule_count exactly, the ACCEPT precedes the REJECT, Private DNS is pinned to hostname, and a user changing it in Settings is reverted."
},
{
"command": "shellcheck + shfmt on dns_enforcer.sh, config.sh, setup_dot_resolver.sh",
"result": "pass",
"evidence": "Clean. CI fails on any shellcheck finding, so an SC2155 on the new script was fixed by splitting declaration from assignment rather than suppressed."
},
{
"command": "setup_dot_resolver.sh && --check && kdig +tls @10.8.0.1 -p 853",
"result": "pass",
"evidence": "'OK - DoT resolver healthy'. example.com resolves (104.20.23.154); pornhub.com returns 0.0.0.0, i.e. the blocklist is applied; TLS 1.3 negotiates against the Let's Encrypt cert. Survives systemctl restart; service and cert-sync timer both enabled at boot."
},
{
"command": "ss -lnt | grep 853; kdig +tls @192.168.1.43 and @79.184.36.76",
"result": "pass",
"evidence": "Listening on 10.8.0.1:853 only. Both the LAN address and the public IP fail to answer, confirming the resolver is not exposed."
},
{
"command": "caddy validate + reload, then curl each existing site",
"result": "pass",
"evidence": "Config validated BEFORE reload because a bad site block previously broke ACME for every domain. After reload the cert for dns.kuhy.duckdns.org was issued and kuhy.duckdns.org, gitea.kuhy.duckdns.org and kuhy-sync.duckdns.org all still return 200."
},
{
"command": "adb shell pm list packages (Wallet and 2FA apps)",
"result": "pass",
"evidence": "Google Wallet is NOT installed and nfc_payment_default_component is null, so the one unresolved Device Owner risk does not apply to this device. Also found that Aegis, MS Authenticator, Oracle IDM and PKO IKO are absent - they were in config.sh's whitelist, which describes the ROOTED phone, not this one. The checklist was corrected against the device rather than the config."
}
],
"risks": [
"config.sh now ships DNS_TRUSTED_DOT_HOST populated. Deploying it to the rooted phone flips DNS enforcement from 'no DoT at all' to 'only this DoT', and the phone must be on the WireGuard tunnel or DNS stops resolving. This has NOT been deployed; do it attended.",
"The DoT endpoint is reachable only over WireGuard, and phone.conf uses AllowedIPs = 10.8.0.0/24 (split tunnel). Filtering therefore applies only while the tunnel is up - it is not an always-on guarantee.",
"stunnel reads the certificate from disk; the daily timer copies it out of the Caddy container. If Caddy renews and the timer fails silently, stunnel serves an expired certificate and Android rejects the connection, which presents as total DNS failure on the phone.",
"A dns.caddy site block now exists purely to make Caddy issue the certificate. It responds 200 on a public hostname. Harmless, but it is a live endpoint that did not exist before.",
"The wipe checklist is drafted, not rehearsed. mObywatel re-activation in particular is documented from secondary Polish sources and should be re-checked at gov.pl before anyone relies on it."
],
"rollback": [
"DoT resolver: sudo systemctl disable --now stunnel-dot.service dot-cert-sync.timer; rm /etc/stunnel/dot-resolver.conf /etc/systemd/system/stunnel-dot.service; rm /home/kuhy/gitea/sites/dns.caddy and reload Caddy.",
"dns_enforcer: set DNS_TRUSTED_DOT_HOST=\"\" in config.sh. That alone restores the previous behaviour exactly - no code revert needed, which is why the flag defaults to empty.",
"focus_owner: git revert. The app reads the policy but enforces nothing, so nothing on the phone changes.",
"After rollback validate: setup_dot_resolver.sh --check fails as expected, the five existing Caddy sites still return 200, and the stubbed enforcer harness still passes section A."
]
}

View File

@ -0,0 +1,208 @@
{
"allowed_packages": [
"ac.mdiq.podcini.X",
"ch.protonmail.android",
"com.Splitwise.SplitwiseMobile",
"com.anthropic.claude",
"com.aurora.store",
"com.azure.authenticator",
"com.beemdevelopment.aegis",
"com.blackview.launcher",
"com.blackview.launcher.overlay.framework",
"com.bnyro.clock",
"com.discord",
"com.facebook.katana",
"com.facebook.orca",
"com.fitatu.tracker",
"com.fsck.k9",
"com.github.android",
"com.google.android.apps.maps",
"com.google.android.calendar",
"com.google.android.contactkeys",
"com.google.android.gm",
"com.google.android.safetycore",
"com.ichi2.anki",
"com.jksalcedo.librefind",
"com.jwang123.splitbills",
"com.kolejeslaskie.mss",
"com.kuhy.diet_guard_app",
"com.kuhy.dufs_client",
"com.kuhy.epopeja_karta",
"com.kuhy.focusstatus",
"com.kuhy.habit_stack",
"com.kuhy.home_inventory",
"com.kuhy.horatio",
"com.kuhy.pomodoro_app",
"com.kuhy.vaultkitbypass",
"com.kuhy.wake_alarm_sync",
"com.kuhy.workout_app",
"com.kunzisoft.keepass.libre",
"com.machiav3lli.fdroid",
"com.maksimowiczm.foodyou",
"com.metrolist.music",
"com.microsoft.office.outlook",
"com.microsoft.teams",
"com.qqlabs.minimalistlauncher",
"com.rama.teyin",
"com.revolut.revolut",
"com.schwegelbin.openbible",
"com.shazam.android",
"com.sosauce.cutecalc",
"com.stronglifts.app",
"com.topjohnwu.magisk",
"com.tplink.iot",
"com.vayunmathur.camera",
"com.waist.line",
"com.xiaomi.smarthome",
"com.yoshi.rain",
"com.zaneschepke.wireguardautotunnel",
"de.kicktipp.mbookmark",
"de.schildbach.oeffi",
"de.thomaskuenneth.benice",
"dev.kuhy.todo",
"eu.kanade.tachiyomi.sy",
"is.xyz.mpv",
"me.phh.superuser",
"moe.shizuku.privileged.api",
"net.bible.android.activity",
"net.cozic.joplin",
"net.osmand",
"net.thunderbird.android.beta",
"oracle.idm.mobile.authenticator",
"org.connectbot",
"org.fdroid.fdroid",
"org.fossify.contacts",
"org.fossify.messages",
"org.fossify.phone",
"org.mozilla.fenix",
"org.runnerup",
"org.thoughtcrime.securesms",
"pkp.ic.eicmobile",
"pl.mbank",
"pl.nask.mobywatel",
"pl.orange.mojeorange",
"pl.pkobp.iko",
"pl.plksa.portalpasazera",
"ws.xsoh.etar"
],
"browser_packages": [
"com.android.chrome",
"org.mozilla.fenix"
],
"curfew": {
"end": "05:00",
"start": "23:00"
},
"home": {
"hysteresis_m": 30.0,
"latitude": null,
"longitude": null,
"radius_m": 150.0
},
"launcher_package": "com.qqlabs.minimalistlauncher",
"never_disable_prefixes": [
"android",
"com.android.bluetooth",
"com.android.camera",
"com.android.camera2",
"com.android.captiveportallogin",
"com.android.certinstaller",
"com.android.contacts",
"com.android.dialer",
"com.android.documentsui",
"com.android.emergency",
"com.android.externalstorage",
"com.android.inputmethod",
"com.android.keychain",
"com.android.launcher",
"com.android.launcher3",
"com.android.location",
"com.android.messaging",
"com.android.mms",
"com.android.networkstack",
"com.android.nfc",
"com.android.permissioncontroller",
"com.android.phone",
"com.android.providers",
"com.android.se",
"com.android.settings",
"com.android.shell",
"com.android.systemui",
"com.android.vpndialogs",
"com.android.wifi",
"com.google.android.apps.messaging",
"com.google.android.contacts",
"com.google.android.deskclock",
"com.google.android.dialer",
"com.google.android.ext.services",
"com.google.android.ext.shared",
"com.google.android.gms",
"com.google.android.gsf",
"com.google.android.inputmethod.latin",
"com.google.android.permissioncontroller",
"com.google.android.setupwizard",
"com.google.android.trichromelibrary",
"com.google.android.webview",
"com.mediatek",
"com.qualcomm"
],
"night_allowed_packages": [
"com.azure.authenticator",
"com.beemdevelopment.aegis",
"com.blackview.launcher",
"com.blackview.launcher.overlay.framework",
"com.bnyro.clock",
"com.fitatu.tracker",
"com.google.android.apps.maps",
"com.google.android.calendar",
"com.google.android.contactkeys",
"com.google.android.safetycore",
"com.kuhy.diet_guard_app",
"com.kuhy.focusstatus",
"com.kuhy.habit_stack",
"com.kuhy.vaultkitbypass",
"com.kuhy.wake_alarm_sync",
"com.kuhy.workout_app",
"com.kunzisoft.keepass.libre",
"com.maksimowiczm.foodyou",
"com.metrolist.music",
"com.qqlabs.minimalistlauncher",
"com.revolut.revolut",
"com.stronglifts.app",
"com.topjohnwu.magisk",
"com.tplink.iot",
"com.waist.line",
"com.xiaomi.smarthome",
"de.thomaskuenneth.benice",
"dev.kuhy.todo",
"me.phh.superuser",
"moe.shizuku.privileged.api",
"oracle.idm.mobile.authenticator",
"org.fossify.contacts",
"org.fossify.messages",
"org.fossify.phone",
"org.runnerup",
"pl.mbank",
"pl.nask.mobywatel",
"pl.pkobp.iko",
"ws.xsoh.etar"
],
"schema_version": 1,
"workout_unblock_domains": [
"googlevideo.com",
"i.ytimg.com",
"i9.ytimg.com",
"m.youtube.com",
"s.ytimg.com",
"www.youtube-nocookie.com",
"www.youtube.com",
"youtu.be",
"youtube-nocookie.com",
"youtube.com",
"youtube.googleapis.com",
"youtubei.googleapis.com",
"yt3.ggpht.com",
"yt3.googleusercontent.com",
"ytimg.com"
]
}

View File

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:focus_owner/device_policy.dart';
import 'package:focus_owner/policy.dart';
void main() => runApp(const FocusOwnerApp());
@ -43,16 +44,25 @@ class FocusOwnerApp extends StatelessWidget {
/// Shows provisioning state and offers the one action this build supports:
/// giving up device ownership.
class StatusPage extends StatefulWidget {
const StatusPage({super.key, this.policy = const DevicePolicy()});
const StatusPage({
super.key,
this.policy = const DevicePolicy(),
this.loadFocusPolicy = FocusPolicy.load,
});
final DevicePolicy policy;
/// Injectable so tests can supply a policy without a rootBundle asset.
final Future<FocusPolicy> Function() loadFocusPolicy;
@override
State<StatusPage> createState() => _StatusPageState();
}
class _StatusPageState extends State<StatusPage> {
DevicePolicyStatus? _status;
FocusPolicy? _focusPolicy;
String? _policyError;
String? _error;
bool _busy = false;
@ -64,6 +74,11 @@ class _StatusPageState extends State<StatusPage> {
Future<void> _refresh() async {
setState(() => _busy = true);
await Future.wait([_loadDeviceStatus(), _loadPolicy()]);
if (mounted) setState(() => _busy = false);
}
Future<void> _loadDeviceStatus() async {
try {
final status = await widget.policy.status();
if (!mounted) return;
@ -74,8 +89,25 @@ class _StatusPageState extends State<StatusPage> {
} on PlatformException catch (e) {
if (!mounted) return;
setState(() => _error = e.message ?? 'Could not read device policy state');
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _loadPolicy() async {
try {
final policy = await widget.loadFocusPolicy();
if (!mounted) return;
setState(() {
_focusPolicy = policy;
_policyError = null;
});
} on PolicyFormatException catch (e) {
// A policy that cannot be parsed must be reported, never silently
// treated as an empty allowlist - that would block everything.
if (!mounted) return;
setState(() => _policyError = e.message);
} on Exception catch (e) {
if (!mounted) return;
setState(() => _policyError = '$e');
}
}
@ -126,7 +158,13 @@ class _StatusPageState extends State<StatusPage> {
if (error != null) {
body = const _Message(text: 'Could not read state', color: _kDanger);
} else if (status != null) {
body = _StatusBody(status: status, busy: _busy, onRelease: _release);
body = _StatusBody(
status: status,
focusPolicy: _focusPolicy,
policyError: _policyError,
busy: _busy,
onRelease: _release,
);
} else {
body = const Center(child: CircularProgressIndicator());
}
@ -152,16 +190,21 @@ class _StatusPageState extends State<StatusPage> {
class _StatusBody extends StatelessWidget {
const _StatusBody({
required this.status,
required this.focusPolicy,
required this.policyError,
required this.busy,
required this.onRelease,
});
final DevicePolicyStatus status;
final FocusPolicy? focusPolicy;
final String? policyError;
final bool busy;
final Future<void> Function() onRelease;
@override
Widget build(BuildContext context) {
final policy = focusPolicy;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -173,6 +216,37 @@ class _StatusBody extends StatelessWidget {
label: 'Restrictions',
value: status.restrictionsApplied ? 'applied' : 'none (inert build)',
),
const SizedBox(height: _kGap),
const Divider(color: _kMuted),
const SizedBox(height: _kGap / 2),
if (policyError != null)
_Message(text: 'Policy: $policyError', color: _kDanger)
else if (policy == null)
const _Message(text: 'Policy: loading...', color: _kMuted)
else ...[
_Row(
label: 'Allowed apps',
value: '${policy.allowedPackages.length}'
' (${policy.nightAllowedPackages.length} at night)',
),
_Row(
label: 'Curfew',
value: policy.curfew == null
? 'disabled'
: '${_hhmm(policy.curfew!.startMinutes)}'
'-${_hhmm(policy.curfew!.endMinutes)}',
),
_Row(
label: 'Home radius',
value: policy.home.hasCoordinates
? '${policy.home.radiusM.round()} m'
: '${policy.home.radiusM.round()} m (coords redacted)',
),
_Row(
label: 'Workout domains',
value: '${policy.workoutUnblockDomains.length}',
),
],
const SizedBox(height: _kGap * 2),
if (status.isDeviceOwner)
FilledButton(
@ -190,6 +264,13 @@ class _StatusBody extends StatelessWidget {
}
}
/// Formats minutes-since-midnight as HH:MM.
String _hhmm(int minutes) {
final h = (minutes ~/ 60).toString().padLeft(2, '0');
final m = (minutes % 60).toString().padLeft(2, '0');
return '$h:$m';
}
class _Row extends StatelessWidget {
const _Row({required this.label, required this.value});

201
focus_owner/lib/policy.dart Normal file
View File

@ -0,0 +1,201 @@
import 'dart:convert';
import 'package:flutter/services.dart' show AssetBundle, rootBundle;
/// Schema version this build understands.
///
/// The asset is generated by `python3 -m python_pkg.focus_policy`, which is the
/// single source of truth. Refusing an unknown version is deliberate: silently
/// accepting a newer schema would mean enforcing a policy this code has
/// misread, and a misread allowlist is an allowlist that blocks the dialer.
const int kSupportedSchemaVersion = 1;
/// Raised when the bundled policy cannot be trusted.
class PolicyFormatException implements Exception {
const PolicyFormatException(this.message);
final String message;
@override
String toString() => 'PolicyFormatException: $message';
}
/// A nightly window that may wrap midnight (23:00 -> 05:00).
class CurfewWindow {
const CurfewWindow({required this.startMinutes, required this.endMinutes});
/// Parses an `{"start": "23:00", "end": "05:00"}` object.
factory CurfewWindow.fromJson(Map<String, Object?> json) {
return CurfewWindow(
startMinutes: _parseHhMm(json['start'], 'curfew.start'),
endMinutes: _parseHhMm(json['end'], 'curfew.end'),
);
}
/// Minutes since midnight.
final int startMinutes;
final int endMinutes;
/// Whether [minutesSinceMidnight] falls inside the window.
///
/// Wrapping windows are the normal case here, so "inside" means at or after
/// the start *or* before the end, not both.
bool contains(int minutesSinceMidnight) {
if (startMinutes <= endMinutes) {
return minutesSinceMidnight >= startMinutes &&
minutesSinceMidnight < endMinutes;
}
return minutesSinceMidnight >= startMinutes ||
minutesSinceMidnight < endMinutes;
}
}
/// The home anchor. Coordinates are null in a redacted policy.
class HomeLocation {
const HomeLocation({
required this.latitude,
required this.longitude,
required this.radiusM,
required this.hysteresisM,
});
/// Parses the `home` object.
factory HomeLocation.fromJson(Map<String, Object?> json) {
return HomeLocation(
latitude: (json['latitude'] as num?)?.toDouble(),
longitude: (json['longitude'] as num?)?.toDouble(),
radiusM: _requireNum(json['radius_m'], 'home.radius_m'),
hysteresisM: _requireNum(json['hysteresis_m'], 'home.hysteresis_m'),
);
}
final double? latitude;
final double? longitude;
final double radiusM;
final double hysteresisM;
/// Whether this policy carries real coordinates.
///
/// The committed asset is redacted, so the location gate cannot run against
/// it. Enforcement must check this rather than treating a missing coordinate
/// as (0, 0), which would put "home" in the Atlantic.
bool get hasCoordinates => latitude != null && longitude != null;
}
/// The device-independent focus policy, as generated from `config.sh`.
class FocusPolicy {
const FocusPolicy({
required this.home,
required this.allowedPackages,
required this.nightAllowedPackages,
required this.neverDisablePrefixes,
required this.workoutUnblockDomains,
required this.curfew,
required this.launcherPackage,
});
/// Parses a rendered policy document.
factory FocusPolicy.fromJson(Map<String, Object?> json) {
final version = json['schema_version'];
if (version != kSupportedSchemaVersion) {
throw PolicyFormatException(
'unsupported schema_version $version '
'(this build understands $kSupportedSchemaVersion)',
);
}
final home = json['home'];
if (home is! Map<String, Object?>) {
throw const PolicyFormatException('missing "home" object');
}
final curfew = json['curfew'];
return FocusPolicy(
home: HomeLocation.fromJson(home),
allowedPackages: _stringSet(json['allowed_packages'], 'allowed_packages'),
nightAllowedPackages:
_stringSet(json['night_allowed_packages'], 'night_allowed_packages'),
neverDisablePrefixes: _stringSet(
json['never_disable_prefixes'],
'never_disable_prefixes',
),
workoutUnblockDomains: _stringSet(
json['workout_unblock_domains'],
'workout_unblock_domains',
),
curfew: curfew == null
? null
: CurfewWindow.fromJson(curfew as Map<String, Object?>),
launcherPackage: json['launcher_package'] as String?,
);
}
/// Loads the policy bundled with the app.
static Future<FocusPolicy> load({
AssetBundle? bundle,
String assetKey = 'assets/policy.json',
}) async {
final raw = await (bundle ?? rootBundle).loadString(assetKey);
final decoded = jsonDecode(raw);
if (decoded is! Map<String, Object?>) {
throw const PolicyFormatException('policy asset is not a JSON object');
}
return FocusPolicy.fromJson(decoded);
}
final HomeLocation home;
final Set<String> allowedPackages;
final Set<String> nightAllowedPackages;
final Set<String> neverDisablePrefixes;
final Set<String> workoutUnblockDomains;
final CurfewWindow? curfew;
final String? launcherPackage;
/// Whether a package must never be disabled.
///
/// Prefix-matched on whole labels, so `com.android.providers` covers
/// `com.android.providers.telephony` but not `com.android.providersomething`.
bool isProtected(String package) => neverDisablePrefixes.any(
(prefix) => package == prefix || package.startsWith('$prefix.'),
);
/// Whether a package may run under the given conditions.
bool isAllowed(String package, {bool duringCurfew = false}) {
if (isProtected(package)) return true;
return duringCurfew
? nightAllowedPackages.contains(package)
: allowedPackages.contains(package);
}
/// Whether the curfew is in force at [minutesSinceMidnight].
bool isCurfewActive(int minutesSinceMidnight) =>
curfew?.contains(minutesSinceMidnight) ?? false;
}
Set<String> _stringSet(Object? value, String field) {
if (value is! List) {
throw PolicyFormatException('"$field" must be a list');
}
return value.map((e) => e! as String).toSet();
}
double _requireNum(Object? value, String field) {
if (value is! num) {
throw PolicyFormatException('"$field" must be a number');
}
return value.toDouble();
}
int _parseHhMm(Object? value, String field) {
if (value is! String) {
throw PolicyFormatException('"$field" must be a "HH:MM" string');
}
final parts = value.split(':');
if (parts.length != 2) {
throw PolicyFormatException('"$field" must be "HH:MM", got "$value"');
}
final hour = int.tryParse(parts[0]);
final minute = int.tryParse(parts[1]);
if (hour == null || minute == null || hour > 23 || minute > 59) {
throw PolicyFormatException('"$field" is not a valid time: "$value"');
}
return hour * 60 + minute;
}

View File

@ -57,10 +57,13 @@ flutter:
# the material Icons class.
uses-material-design: true
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
# Generated from phone_focus_mode/config.sh, which stays the source of truth:
# python3 -m python_pkg.focus_policy \
# --config phone_focus_mode/config.sh --redact-home \
# --output focus_owner/assets/policy.json
# The committed copy is redacted, so it carries no home coordinates.
assets:
- assets/policy.json
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images

View File

@ -0,0 +1,189 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:focus_owner/policy.dart';
Map<String, Object?> _doc({
Object? schemaVersion = kSupportedSchemaVersion,
Object? curfew = const {'start': '23:00', 'end': '05:00'},
double? latitude,
double? longitude,
}) =>
{
'schema_version': schemaVersion,
'home': {
'latitude': latitude,
'longitude': longitude,
'radius_m': 150.0,
'hysteresis_m': 30.0,
},
'curfew': curfew,
'launcher_package': 'com.qqlabs.minimalistlauncher',
'allowed_packages': ['com.good', 'pl.mbank'],
'night_allowed_packages': ['pl.mbank'],
'never_disable_prefixes': ['com.android.providers'],
'workout_unblock_domains': ['youtube.com'],
'browser_packages': <String>[],
};
void main() {
group('FocusPolicy.fromJson', () {
test('parses a rendered policy', () {
final policy = FocusPolicy.fromJson(_doc());
expect(policy.allowedPackages, {'com.good', 'pl.mbank'});
expect(policy.nightAllowedPackages, {'pl.mbank'});
expect(policy.launcherPackage, 'com.qqlabs.minimalistlauncher');
expect(policy.home.radiusM, 150.0);
});
test('rejects an unknown schema version', () {
// Accepting a newer schema would mean enforcing a misread policy.
expect(
() => FocusPolicy.fromJson(_doc(schemaVersion: 999)),
throwsA(isA<PolicyFormatException>()),
);
});
test('rejects a document with no home object', () {
final doc = _doc()..remove('home');
expect(
() => FocusPolicy.fromJson(doc),
throwsA(isA<PolicyFormatException>()),
);
});
test('rejects a malformed curfew time', () {
expect(
() => FocusPolicy.fromJson(_doc(curfew: {'start': '25:00', 'end': 'x'})),
throwsA(isA<PolicyFormatException>()),
);
});
test('accepts an absent curfew', () {
expect(FocusPolicy.fromJson(_doc(curfew: null)).curfew, isNull);
});
});
group('HomeLocation', () {
test('redacted coordinates are reported as absent', () {
// The committed asset is redacted; enforcement must not treat a missing
// coordinate as (0, 0), which would put "home" in the Atlantic.
final policy = FocusPolicy.fromJson(_doc());
expect(policy.home.hasCoordinates, isFalse);
expect(policy.home.radiusM, 150.0);
});
test('real coordinates are reported as present', () {
final policy =
FocusPolicy.fromJson(_doc(latitude: 52.2297, longitude: 21.0122));
expect(policy.home.hasCoordinates, isTrue);
});
});
group('CurfewWindow', () {
const window = CurfewWindow(startMinutes: 23 * 60, endMinutes: 5 * 60);
test('wraps midnight', () {
expect(window.contains(22 * 60 + 59), isFalse);
expect(window.contains(23 * 60), isTrue);
expect(window.contains(0), isTrue);
expect(window.contains(4 * 60 + 59), isTrue);
expect(window.contains(5 * 60), isFalse);
expect(window.contains(12 * 60), isFalse);
});
test('handles a same-day window', () {
const day = CurfewWindow(startMinutes: 9 * 60, endMinutes: 17 * 60);
expect(day.contains(8 * 60), isFalse);
expect(day.contains(9 * 60), isTrue);
expect(day.contains(17 * 60), isFalse);
});
});
group('allow decisions', () {
final policy = FocusPolicy.fromJson(_doc());
test('protects system prefixes on whole labels only', () {
expect(policy.isProtected('com.android.providers'), isTrue);
expect(policy.isProtected('com.android.providers.telephony'), isTrue);
expect(policy.isProtected('com.android.providersomething'), isFalse);
});
test('protected packages are allowed even during curfew', () {
expect(
policy.isAllowed('com.android.providers', duringCurfew: true),
isTrue,
);
});
test('curfew narrows the allowlist', () {
expect(policy.isAllowed('com.good'), isTrue);
expect(policy.isAllowed('com.good', duringCurfew: true), isFalse);
expect(policy.isAllowed('pl.mbank', duringCurfew: true), isTrue);
});
test('unknown packages are blocked', () {
expect(policy.isAllowed('com.evil'), isFalse);
});
test('isCurfewActive follows the window', () {
expect(policy.isCurfewActive(23 * 60 + 30), isTrue);
expect(policy.isCurfewActive(12 * 60), isFalse);
});
});
group('FocusPolicy.load', () {
test('reads and parses the bundled asset', () async {
final bundle = _StubBundle(jsonEncode(_doc()));
final policy = await FocusPolicy.load(bundle: bundle);
expect(policy.allowedPackages, contains('pl.mbank'));
});
test('rejects an asset that is not a JSON object', () async {
final bundle = _StubBundle('[]');
await expectLater(
FocusPolicy.load(bundle: bundle),
throwsA(isA<PolicyFormatException>()),
);
});
});
test('the real committed asset parses and is redacted', () async {
// Guards the generator: a policy.json that this app cannot read, or that
// leaks the home coordinates into git, is a build error.
final policy = await FocusPolicy.load(bundle: _RealAssetBundle());
expect(policy.allowedPackages.length, greaterThan(50));
expect(policy.home.hasCoordinates, isFalse,
reason: 'committed policy.json must not carry home coordinates');
expect(policy.curfew, isNotNull);
});
}
class _StubBundle extends CachingAssetBundle {
_StubBundle(this.contents);
final String contents;
@override
Future<ByteData> load(String key) async =>
ByteData.sublistView(utf8.encode(contents));
@override
Future<String> loadString(String key, {bool cache = true}) async => contents;
}
/// Reads the asset straight off disk, bypassing the (empty) test rootBundle.
class _RealAssetBundle extends CachingAssetBundle {
@override
Future<ByteData> load(String key) async {
throw UnimplementedError(key);
}
@override
Future<String> loadString(String key, {bool cache = true}) async {
final file = File('assets/policy.json');
return file.readAsString();
}
}

View File

@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:focus_owner/device_policy.dart';
import 'package:focus_owner/main.dart';
import 'package:focus_owner/policy.dart';
/// Builds a channel whose handler answers from [responses], recording calls.
({MethodChannel channel, List<MethodCall> calls}) _fakeChannel(
@ -29,6 +30,24 @@ Map<String, Object?> _statusMap({bool isDeviceOwner = false}) => {
'restrictionsApplied': false,
};
/// Minimal policy so widget tests never touch the (empty) test rootBundle.
Future<FocusPolicy> _stubPolicy() async => FocusPolicy.fromJson({
'schema_version': kSupportedSchemaVersion,
'home': {
'latitude': null,
'longitude': null,
'radius_m': 150.0,
'hysteresis_m': 30.0,
},
'curfew': {'start': '23:00', 'end': '05:00'},
'launcher_package': 'com.launcher',
'allowed_packages': ['com.good'],
'night_allowed_packages': <String>[],
'never_disable_prefixes': <String>[],
'workout_unblock_domains': ['youtube.com'],
'browser_packages': <String>[],
});
void main() {
group('DevicePolicyStatus', () {
test('parses the native status map', () {
@ -80,7 +99,12 @@ void main() {
(tester) async {
final fake = _fakeChannel({'status': _statusMap()});
await tester.pumpWidget(
MaterialApp(home: StatusPage(policy: DevicePolicy(fake.channel))),
MaterialApp(
home: StatusPage(
policy: DevicePolicy(fake.channel),
loadFocusPolicy: _stubPolicy,
),
),
);
await tester.pumpAndSettle();
@ -91,7 +115,12 @@ void main() {
testWidgets('shows the release button when device owner', (tester) async {
final fake = _fakeChannel({'status': _statusMap(isDeviceOwner: true)});
await tester.pumpWidget(
MaterialApp(home: StatusPage(policy: DevicePolicy(fake.channel))),
MaterialApp(
home: StatusPage(
policy: DevicePolicy(fake.channel),
loadFocusPolicy: _stubPolicy,
),
),
);
await tester.pumpAndSettle();
@ -105,7 +134,12 @@ void main() {
'releaseDeviceOwner': true,
});
await tester.pumpWidget(
MaterialApp(home: StatusPage(policy: DevicePolicy(fake.channel))),
MaterialApp(
home: StatusPage(
policy: DevicePolicy(fake.channel),
loadFocusPolicy: _stubPolicy,
),
),
);
await tester.pumpAndSettle();
@ -128,7 +162,12 @@ void main() {
'releaseDeviceOwner': true,
});
await tester.pumpWidget(
MaterialApp(home: StatusPage(policy: DevicePolicy(fake.channel))),
MaterialApp(
home: StatusPage(
policy: DevicePolicy(fake.channel),
loadFocusPolicy: _stubPolicy,
),
),
);
await tester.pumpAndSettle();

View File

@ -0,0 +1,290 @@
#!/bin/bash
# ============================================================================
# Expose the existing dnsmasq blocklist over DNS-over-TLS (port 853).
#
# Why this exists:
# setup_dns_blocker.sh serves the blocklist over plain UDP/53, which only
# works on the LAN -- and Android's default "Automatic" Private DNS uses DoH
# and bypasses it entirely. A phone off the LAN gets no filtering at all.
# Terminating DoT here lets the phone pin Private DNS to a resolver that
# applies the blocklist, anywhere, with no root and no VPN slot consumed.
#
# Why stunnel rather than Caddy:
# DoT is a raw TLS stream, not HTTPS. Terminating it in Caddy needs the
# `layer4` module, which the stock `caddy:2.8` image does not ship -- that
# would mean an xcaddy rebuild of a container currently serving five live
# sites. stunnel does exactly this one job, and it can reuse the Let's
# Encrypt certificate Caddy already renews, so there is no second ACME
# client competing for port 80.
#
# Usage:
# ./setup_dot_resolver.sh [--domain <fqdn>] [--check]
# ============================================================================
set -euo pipefail
SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_NAME
readonly DEFAULT_DOMAIN="dns.kuhy.duckdns.org"
readonly CADDY_CONTAINER="gitea-caddy"
readonly CERT_DIR="/etc/stunnel/dot"
readonly STUNNEL_CONF="/etc/stunnel/dot-resolver.conf"
readonly SYNC_SCRIPT="/usr/local/bin/sync-dot-cert.sh"
readonly DOT_PORT=853
readonly DEFAULT_BIND="10.8.0.1"
DOMAIN="$DEFAULT_DOMAIN"
# Bind to the WireGuard interface, not 0.0.0.0. An internet-facing DoT
# endpoint is an open resolver: TCP-only so not usable for classic UDP
# amplification, but still someone else's free DNS and a standing attack
# surface. Reaching it over the existing tunnel needs no port-forward and no
# rate limiting, because only configured peers can route to it at all.
BIND_ADDR="$DEFAULT_BIND"
CHECK_ONLY=0
log() { printf '[dot-resolver] %s\n' "$1"; }
die() {
printf '[dot-resolver] ERROR: %s\n' "$1" >&2
exit 1
}
usage() {
cat <<EOF
Usage: $SCRIPT_NAME [OPTIONS]
--domain <fqdn> DoT hostname (default: $DEFAULT_DOMAIN)
--bind <addr> Address to listen on (default: $DEFAULT_BIND, the
WireGuard interface). Use 0.0.0.0 only if you intend
to expose an open resolver to the internet.
--check Verify an existing install, change nothing
-h, --help Show this help
EOF
exit 0
}
require_tools() {
# Per repo policy a script installs what it needs rather than telling the
# user to. pacman is non-interactive here; if that fails we stop rather
# than silently skipping the dependency.
local missing=()
command -v stunnel >/dev/null 2>&1 || missing+=(stunnel)
command -v openssl >/dev/null 2>&1 || missing+=(openssl)
if [[ ${#missing[@]} -gt 0 ]]; then
log "Installing: ${missing[*]}"
sudo pacman -S --needed --noconfirm "${missing[@]}" ||
die "could not install ${missing[*]}; run: sudo pacman -S ${missing[*]}"
fi
}
cert_source_dir() {
# Caddy stores certs per-domain under its data volume.
printf '/data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/%s' \
"$DOMAIN"
}
verify_prerequisites() {
# A DoT endpoint in front of a resolver that is not running would answer
# every query with a connection error, which looks exactly like a network
# fault on the phone. Check first.
ss -lnu 2>/dev/null | grep -qE '127\.0\.0\.1:53' ||
die "dnsmasq is not listening on 127.0.0.1:53 - run setup_dns_blocker.sh first"
docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "$CADDY_CONTAINER" ||
die "container '$CADDY_CONTAINER' is not running; it owns the certificates"
local resolved
resolved="$(getent hosts "$DOMAIN" | awk '{print $1; exit}')" ||
die "$DOMAIN does not resolve - DuckDNS wildcard missing?"
log "$DOMAIN resolves to $resolved"
# Binding to an address that does not exist yet makes stunnel fail at
# start with an opaque error, so check before installing anything.
if [[ "$BIND_ADDR" != "0.0.0.0" ]]; then
ip -brief addr show 2>/dev/null | grep -q "$BIND_ADDR" ||
die "$BIND_ADDR is not assigned to any interface - is wg0 up?"
log "Binding to $BIND_ADDR (reachable over WireGuard only)"
else
log "WARNING: binding to 0.0.0.0 exposes an open resolver publicly"
fi
}
install_cert_sync() {
# Caddy renews on its own schedule; stunnel reads files from disk and must
# be told. A timer copies the current cert out of the container and
# reloads stunnel only when the content actually changed.
sudo install -d -m 700 "$CERT_DIR"
sudo tee "$SYNC_SCRIPT" >/dev/null <<EOF
#!/bin/bash
# Copy the Caddy-managed certificate for $DOMAIN out to stunnel.
# Generated by $SCRIPT_NAME - edit that instead.
set -euo pipefail
src="$(cert_source_dir)"
tmp="\$(mktemp -d)"
trap 'rm -rf "\$tmp"' EXIT
docker cp "$CADDY_CONTAINER:\$src/$DOMAIN.crt" "\$tmp/cert.pem"
docker cp "$CADDY_CONTAINER:\$src/$DOMAIN.key" "\$tmp/key.pem"
cat "\$tmp/cert.pem" "\$tmp/key.pem" >"\$tmp/combined.pem"
if ! cmp -s "\$tmp/combined.pem" "$CERT_DIR/combined.pem"; then
install -m 600 "\$tmp/combined.pem" "$CERT_DIR/combined.pem"
systemctl reload-or-restart stunnel-dot.service
echo "certificate updated for $DOMAIN"
fi
EOF
sudo chmod 755 "$SYNC_SCRIPT"
}
install_stunnel_conf() {
sudo tee "$STUNNEL_CONF" >/dev/null <<EOF
; DNS-over-TLS front end for the local dnsmasq blocklist.
; Generated by $SCRIPT_NAME - edit that instead.
foreground = yes
syslog = yes
[dns-over-tls]
accept = $BIND_ADDR:$DOT_PORT
connect = 127.0.0.1:53
cert = $CERT_DIR/combined.pem
; DoT is TLS 1.2+ only in practice; Android requires a publicly trusted cert.
sslVersionMin = TLSv1.2
EOF
}
install_units() {
sudo tee /etc/systemd/system/stunnel-dot.service >/dev/null <<EOF
[Unit]
Description=DNS-over-TLS front end for dnsmasq blocklist
After=network-online.target dnsmasq.service wg-quick@wg0.service
Wants=network-online.target
Requires=dnsmasq.service
; Binding to the WireGuard address fails if the interface is not up yet.
After=sys-devices-virtual-net-wg0.device
[Service]
ExecStart=/usr/bin/stunnel $STUNNEL_CONF
ExecReload=/bin/kill -HUP \$MAINPID
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
sudo tee /etc/systemd/system/dot-cert-sync.service >/dev/null <<EOF
[Unit]
Description=Copy the Caddy certificate for $DOMAIN to stunnel
[Service]
Type=oneshot
ExecStart=$SYNC_SCRIPT
EOF
sudo tee /etc/systemd/system/dot-cert-sync.timer >/dev/null <<EOF
[Unit]
Description=Daily certificate sync for the DoT resolver
[Timer]
OnCalendar=daily
Persistent=true
[Install]
WantedBy=timers.target
EOF
sudo systemctl daemon-reload
}
open_firewall() {
# Bound to WireGuard there is nothing to open: the tunnel is already
# permitted and the listener is not reachable from the LAN or the WAN.
if [[ "$BIND_ADDR" != "0.0.0.0" ]]; then
log "No firewall change needed - $BIND_ADDR is WireGuard-only."
log "No router port-forward needed either."
return
fi
if command -v nft >/dev/null 2>&1 && sudo nft list table inet filter >/dev/null 2>&1; then
if ! sudo nft list table inet filter | grep -q "dport $DOT_PORT"; then
sudo nft add rule inet filter input tcp dport "$DOT_PORT" accept ||
log "WARNING: could not add nftables rule for $DOT_PORT"
fi
fi
log "Ensure your router forwards TCP $DOT_PORT to this host."
}
run_check() {
local ok=0
systemctl is-active --quiet stunnel-dot.service ||
{
log "FAIL stunnel-dot.service not active"
ok=1
}
ss -lnt 2>/dev/null | grep -qE ":$DOT_PORT\b" ||
{
log "FAIL nothing listening on $DOT_PORT"
ok=1
}
# $CERT_DIR is mode 700 and owned by root because it holds a private key,
# so an unprivileged [[ -s ]] reports "missing" for a file that is
# present. Test as root, or --check reports a false failure on a healthy
# install.
sudo test -s "$CERT_DIR/combined.pem" ||
{
log "FAIL certificate missing at $CERT_DIR/combined.pem"
ok=1
}
if command -v kdig >/dev/null 2>&1; then
kdig +tls "@$BIND_ADDR" -p "$DOT_PORT" +short example.com >/dev/null 2>&1 ||
{
log "FAIL DoT query did not answer"
ok=1
}
fi
[[ $ok -eq 0 ]] && log "OK - DoT resolver healthy on $DOMAIN:$DOT_PORT"
return "$ok"
}
main() {
if [[ $CHECK_ONLY -eq 1 ]]; then
run_check
exit $?
fi
require_tools
verify_prerequisites
install_cert_sync
install_stunnel_conf
install_units
log "Syncing certificate..."
sudo "$SYNC_SCRIPT" || die "certificate sync failed - has Caddy issued $DOMAIN yet?"
sudo systemctl enable --now stunnel-dot.service
sudo systemctl enable --now dot-cert-sync.timer
open_firewall
log "Done. Point the phone at:"
log " Private DNS hostname: $DOMAIN"
log "And set in phone_focus_mode/config.sh:"
log " DNS_TRUSTED_DOT_HOST=\"$DOMAIN\""
log " DNS_TRUSTED_DOT_IPS=\"\$(getent hosts $DOMAIN | awk '{print \$1}')\""
run_check || true
}
while [[ $# -gt 0 ]]; do
case $1 in
--domain)
DOMAIN="$2"
shift 2
;;
--bind)
BIND_ADDR="$2"
shift 2
;;
--check)
CHECK_ONLY=1
shift
;;
-h | --help) usage ;;
*) die "Unknown option: $1" ;;
esac
done
main "$@"

View File

@ -231,6 +231,22 @@ i9.ytimg.com
# endpoints so lookups fall back to the system resolver -> hosts file.
export DNS_CHECK_INTERVAL=20
export DNS_LOG="$STATE_DIR/dns_enforcer.log"
# --- Trusted DoT resolver (opt-in, empty = old behaviour) ---
# Set this to the hostname of a DoT resolver you control, and the enforcer
# switches from "no DoT at all" to "only YOUR DoT". Two things change:
# 1. an ACCEPT for that resolver's IPs is inserted BEFORE the blanket
# 853 REJECT, so the connection survives;
# 2. private_dns_mode is pinned to "hostname" with this specifier, instead
# of being forced off -- so the phone cannot silently fall back to a
# resolver that does not filter.
# Leave EMPTY to keep the original behaviour (Private DNS forced off, all
# 853 rejected). Do not point this at a public resolver: the whole point is
# that the resolver applies your blocklist. See python_pkg/focus_policy.
export DNS_TRUSTED_DOT_HOST="dns.kuhy.duckdns.org"
# Where to resolve DNS_TRUSTED_DOT_HOST when Private DNS is pinned to it.
# Chicken-and-egg: the resolver's own name must resolve without using it.
# Space-separated IPv4/IPv6 literals; leave empty to resolve at runtime.
export DNS_TRUSTED_DOT_IPS="10.8.0.1"
# iptables chain used exclusively by us; we flush+refill it every check.
export DNS_IPT_CHAIN="FOCUS_DNS_BLOCK"
# DoH/DoT endpoints to DROP. Well-known public resolvers used by browsers

View File

@ -71,6 +71,21 @@ acquire_lock() {
# ---- Private DNS ----
ensure_private_dns_off() {
# Two modes, selected by DNS_TRUSTED_DOT_HOST:
#
# empty - the original behaviour. DoT is a bypass channel, so Private
# DNS is forced off and every 853 connection is rejected.
# set - DoT is the *enforcement* channel. Private DNS is pinned to
# our own filtering resolver, and pinning it is what stops the
# user picking an unfiltered one from Settings.
#
# The pinned case is what an unrooted phone needs, since it has no hosts
# file to fall back to.
if [ -n "${DNS_TRUSTED_DOT_HOST:-}" ]; then
ensure_private_dns_pinned
return
fi
local mode
mode="$(settings get global private_dns_mode 2>/dev/null)"
# Possible values: "off", "opportunistic", "hostname", null (default=opportunistic)
@ -88,6 +103,25 @@ ensure_private_dns_off() {
fi
}
ensure_private_dns_pinned() {
# Hold Private DNS on our own resolver. Re-asserted every check, so a
# change made in Settings is reverted within DNS_CHECK_INTERVAL.
local mode spec
spec="$(settings get global private_dns_specifier 2>/dev/null)"
if [ "$spec" != "$DNS_TRUSTED_DOT_HOST" ]; then
settings put global private_dns_specifier "$DNS_TRUSTED_DOT_HOST" 2>/dev/null
log "private_dns_specifier was '$spec' - pinned to '$DNS_TRUSTED_DOT_HOST'"
fi
# Set the specifier before the mode: switching to "hostname" while the
# specifier still names someone else would briefly route lookups through
# that other resolver.
mode="$(settings get global private_dns_mode 2>/dev/null)"
if [ "$mode" != "hostname" ]; then
settings put global private_dns_mode hostname 2>/dev/null
log "Private DNS was '$mode' - forced to 'hostname'"
fi
}
# ---- iptables chain management ----
ensure_chain() {
@ -117,10 +151,35 @@ ensure_chain() {
fi
}
trusted_dot_ips() {
# Echo the trusted resolver's addresses of the given family, one per line.
# $1 is "4" or "6". Literals come from DNS_TRUSTED_DOT_IPS; anything else
# is ignored, because resolving the resolver's own name here would be
# circular once Private DNS is pinned to it.
local want="$1" ip
[ -z "${DNS_TRUSTED_DOT_HOST:-}" ] && return 0
for ip in ${DNS_TRUSTED_DOT_IPS:-}; do
[ -z "$ip" ] && continue
[ "${ip#\#}" != "$ip" ] && continue
case "$ip" in
*:*) [ "$want" = "6" ] && printf '%s\n' "$ip" ;;
*) [ "$want" = "4" ] && printf '%s\n' "$ip" ;;
esac
done
}
fill_chain_v4() {
# Flush and refill so we always converge to the intended rule set.
iptables -F "$DNS_IPT_CHAIN" 2>/dev/null || return 1
# Drop DoT everywhere. This is a narrow port rule - there's no legit
# Allow the one DoT resolver we trust, BEFORE the blanket reject below.
# Order matters: iptables takes the first match, so an ACCEPT appended
# after the REJECT would never be reached.
local trusted
for trusted in $(trusted_dot_ips 4); do
iptables -A "$DNS_IPT_CHAIN" -d "$trusted" -p tcp --dport 853 \
-j ACCEPT 2>/dev/null || true
done
# Drop DoT everywhere else. This is a narrow port rule - there's no legit
# reason for arbitrary apps to talk 853/tcp on Android.
iptables -A "$DNS_IPT_CHAIN" -p tcp --dport 853 -j REJECT \
--reject-with tcp-reset 2>/dev/null || true
@ -147,6 +206,11 @@ fill_chain_v4() {
fill_chain_v6() {
ip6tables -F "$DNS_IPT_CHAIN" 2>/dev/null || return 1
local trusted
for trusted in $(trusted_dot_ips 6); do
ip6tables -A "$DNS_IPT_CHAIN" -d "$trusted" -p tcp --dport 853 \
-j ACCEPT 2>/dev/null || true
done
ip6tables -A "$DNS_IPT_CHAIN" -p tcp --dport 853 -j REJECT \
--reject-with tcp-reset 2>/dev/null || true
ip6tables -A "$DNS_IPT_CHAIN" -p udp --dport 853 -j REJECT \
@ -179,13 +243,25 @@ fill_chain_v6() {
# redundant identical rebuilds are skipped.
expected_rule_count() {
# 2 fixed DoT rules (tcp+udp/853) + 4 rules per configured DoH/DNS IP.
# 2 fixed DoT rules (tcp+udp/853) + 4 rules per configured DoH/DNS IP,
# plus 1 ACCEPT per trusted-resolver address of this family. $1 is the
# family ("4"/"6"); the remaining arguments are the DoH/DNS IPs.
#
# This has to match fill_chain_* exactly. chain_intact() compares the
# live rule count against this number and rebuilds on any mismatch, so
# an undercount here means a rebuild every DNS_CHECK_INTERVAL seconds --
# a silent fork storm that also drops the connection each time.
local family="$1"
shift
local n=2 ip
for ip in "$@"; do
[ -z "$ip" ] && continue
[ "${ip#\#}" != "$ip" ] && continue
n=$((n + 4))
done
for ip in $(trusted_dot_ips "$family"); do
n=$((n + 1))
done
echo "$n"
}
@ -202,7 +278,7 @@ enforce_iptables() {
# configured IP list. This runs under /system/bin/sh on the phone, where
# arrays do not exist, so an unquoted expansion is the POSIX way to do it.
# shellcheck disable=SC2086
if chain_intact iptables "$(expected_rule_count $DNS_DOH_IPV4)"; then
if chain_intact iptables "$(expected_rule_count 4 $DNS_DOH_IPV4)"; then
:
elif ensure_chain iptables && fill_chain_v4; then
log "iptables (v4) DNS chain rebuilt (was missing/tampered)"
@ -211,7 +287,7 @@ enforce_iptables() {
if command -v ip6tables >/dev/null 2>&1; then
# Intentional word split, as above.
# shellcheck disable=SC2086
if chain_intact ip6tables "$(expected_rule_count $DNS_DOH_IPV6)"; then
if chain_intact ip6tables "$(expected_rule_count 6 $DNS_DOH_IPV6)"; then
:
elif ensure_chain ip6tables && fill_chain_v6; then
log "ip6tables (v6) DNS chain rebuilt (was missing/tampered)"

View File

@ -0,0 +1,80 @@
r"""Render the focus policy as JSON for a non-Python enforcement backend.
The Device Owner app is Kotlin and cannot read ``config.sh`` or import this
package, so the policy is handed over as a generated asset:
python3 -m python_pkg.focus_policy \\
--config phone_focus_mode/config.sh \\
--output focus_owner/assets/policy.json
``--redact-home`` blanks the coordinates, for a policy that will be committed
or attached to a report.
"""
from __future__ import annotations
import argparse
from pathlib import Path
import sys
from python_pkg.focus_policy.export import policy_to_json
from python_pkg.focus_policy.loader import load_policy
from python_pkg.focus_policy.model import PolicyError
def build_parser() -> argparse.ArgumentParser:
"""Return the argument parser for the policy exporter."""
parser = argparse.ArgumentParser(
prog="python3 -m python_pkg.focus_policy",
description="Render phone_focus_mode's policy as backend-neutral JSON.",
)
parser.add_argument(
"--config",
type=Path,
required=True,
help="path to phone_focus_mode/config.sh",
)
parser.add_argument(
"--secrets",
type=Path,
default=None,
help="path to config_secrets.sh (defaults to alongside --config)",
)
parser.add_argument(
"--output",
type=Path,
default=None,
help="file to write (defaults to stdout)",
)
parser.add_argument(
"--redact-home",
action="store_true",
help="blank the home coordinates, keeping radius and hysteresis",
)
return parser
def main(argv: list[str] | None = None) -> int:
"""Render the policy, returning a process exit code."""
args = build_parser().parse_args(argv)
try:
policy = load_policy(args.config, args.secrets)
except (PolicyError, OSError) as error:
# Written to stderr rather than raised: the caller is a shell script or
# a build step, and a bare traceback buries the one line that says the
# coordinates were never filled in.
sys.stderr.write(f"error: {error}\n")
return 1
rendered = policy_to_json(policy, redact_home=args.redact_home) + "\n"
if args.output is None:
sys.stdout.write(rendered)
else:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(rendered, encoding="utf-8")
sys.stderr.write(f"wrote {args.output}\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,110 @@
"""Tests for the policy-export command line entry point."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
import pytest
from python_pkg.focus_policy.__main__ import build_parser, main
if TYPE_CHECKING:
from pathlib import Path
MINIMAL_CONFIG = """
export RADIUS=150
export NIGHT_CURFEW_ENABLED=1
export NIGHT_CURFEW_START="2300"
export NIGHT_CURFEW_END="0500"
export LAUNCHER_PACKAGE="com.launcher"
export WHITELIST="
com.launcher
pl.mbank
"
export NIGHT_WHITELIST="
pl.mbank
"
export SYSTEM_NEVER_DISABLE="
com.android.settings
"
"""
SECRETS = "export HOME_LAT=52.2297\nexport HOME_LON=21.0122\n"
@pytest.fixture
def config(tmp_path: Path) -> Path:
"""Write a minimal config plus secrets, returning the config path."""
(tmp_path / "config.sh").write_text(MINIMAL_CONFIG, encoding="utf-8")
(tmp_path / "config_secrets.sh").write_text(SECRETS, encoding="utf-8")
return tmp_path / "config.sh"
def test_parser_requires_config() -> None:
"""--config is mandatory; without it argparse exits."""
with pytest.raises(SystemExit):
build_parser().parse_args([])
def test_writes_policy_to_stdout(
config: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
"""With no --output the rendered policy goes to stdout."""
assert main(["--config", str(config)]) == 0
payload = json.loads(capsys.readouterr().out)
assert payload["schema_version"] == 1
assert payload["home"]["latitude"] == pytest.approx(52.2297)
def test_writes_policy_to_file(config: Path, tmp_path: Path) -> None:
"""--output writes the document, creating parent directories."""
out = tmp_path / "nested" / "policy.json"
assert main(["--config", str(config), "--output", str(out)]) == 0
assert json.loads(out.read_text(encoding="utf-8"))["schema_version"] == 1
def test_redact_home_removes_coordinates(
config: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
"""--redact-home blanks the coordinates but keeps the radius.
This is the flag the committed asset is generated with, so that the
repository never records where the user lives.
"""
assert main(["--config", str(config), "--redact-home"]) == 0
payload = json.loads(capsys.readouterr().out)
assert payload["home"]["latitude"] is None
assert payload["home"]["longitude"] is None
assert payload["home"]["radius_m"] == pytest.approx(150.0)
def test_explicit_secrets_path(config: Path, tmp_path: Path) -> None:
"""--secrets overrides the default sibling lookup."""
other = tmp_path / "other.sh"
other.write_text("export HOME_LAT=1.0\nexport HOME_LON=2.0\n", encoding="utf-8")
assert main(["--config", str(config), "--secrets", str(other)]) == 0
def test_placeholder_coordinates_exit_nonzero(
config: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
"""The shipped placeholder is a hard error, not a silent default."""
config.with_name("config_secrets.sh").write_text(
"export HOME_LAT=REDACTED_LAT\nexport HOME_LON=REDACTED_LON\n",
encoding="utf-8",
)
assert main(["--config", str(config)]) == 1
assert "placeholder" in capsys.readouterr().err
def test_missing_config_exits_nonzero(
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
"""An unreadable config reports the OS error rather than traceback."""
assert main(["--config", str(tmp_path / "absent.sh")]) == 1
assert "error:" in capsys.readouterr().err