21 KiB
Zapret2 2.2.x — Architecture Redesign
Status: the foundation shipped in 2.2.0; the lifecycle hot-path and the two remaining storage contracts were completed in 2.2.1; topology-changing preset replacement is completed in 2.2.2. This document records the analysis findings, the adopted invariants, and the concrete changes.
1. Why
Eleven independent code-analysis passes over 2.1.5 (module shell layer, Android app, state files, tests) converged on the same diagnosis: the architecture had accreted layers that re-prove facts already proven, and kept machinery for data formats nothing produces anymore.
Key numbers from the analysis (2.1.5 baseline):
| Finding | Measured |
|---|---|
common.sh size |
3802 lines, 164 functions |
Legacy direct-rule migration (legacy_*) |
~665 lines, executed unconditionally on every start and stop |
| Track-journal WAL validator/reaper | ~324 lines with zero producers in production code |
firewall-teardown.wal reader |
reader/deleter only — no writer anywhere |
| Dead function cluster | 5 functions / 82 lines |
| Boot: lifecycle lock acquire/release cycles | 2 (recovery pass + start pass) |
Boot: getprop sys.boot_completed busy-wait |
1 fork/second, typically 10–40 forks |
| Warm start: compiled-artifact full parses | 5–6 per start |
Warm start: sha256sum invocations |
~7 (preset ×3, runtime.ini ×3, artifact ×1) |
Warm start: install-generation.meta reads |
up to 4 (4 stat forks + wc -c each) |
Stop: restore_status_facts calls |
3 reads of the same status file |
| Stop: owner-state reads | ~6 × ≈7 forks ≈ 42 forks for one 35-line file |
Stop/start: full /proc scans |
2–3 per operation |
Full rollback: global sync calls |
8 — violating the contract documented in common.sh itself |
Purge: recovery-artifact audits in uninstall.sh |
5 |
| Preset change from the app | 11 root round-trips, 6 full runtime.ini parses by 4 parser implementations |
Two correctness defects were also found:
- Crash residue blocks uninstall. Ephemeral files (
firewall-batch.*,z2-ports.*, …) written directly into$STATE_DIRare not covered byenumerate_recovery_artifactsnor the uninstall cleanup loop; after a crash they trip the "unknown child" guard and block uninstall instead of being swept. - Purge ordering.
zapret-purge.shpublished theremovemarker after runninguninstall.sh, forcing the long uninstall branch and leaving the destructiverm -rffenced by nothing.
2. Principles
P1. A fact is proven once per transaction. A transaction is one lock cycle. Within it, each state file is read once and cached in shell globals; verification of an invariant happens at exactly one layer. Postconditions proven by a callee are not re-proven by the caller.
P2. No consumers for formats nothing produces. Code that only validates, classifies, or retires a file format with no live writer is deleted, not hardened.
P3. Reboot is the migration barrier. Magisk/KernelSU/APatch activate updated module files only after a reboot, and iptables rules do not survive a reboot. Therefore the running module never coexists with firewall state from an older module version, and no runtime migration machinery is needed.
P4. Ephemeral state is disposable by construction. Scratch files live in
$STATE_DIR/tmp/; staging files (<target>.tmp.<pid>) live beside their
target. Neither is ever authoritative, so recovery never has to reason
about their contents: boot recovery and uninstall drop the scratch
directory wholesale, and every lifecycle audit retires entries whose
creating process is gone — by liveness rather than wholesale, because a
preset preview from the app runs without the lifecycle lock.
P5. Root-manager canon. customize.sh stages and validates; service.sh
waits for boot and delegates; action.sh performs the user action;
uninstall.sh cleans owned state. Destructive removal is fenced by the
remove marker published before teardown begins.
P6. Unprovable is not clean, all the way to the user. A firewall family
whose frontend exists but cannot answer is never reported as absent. Some
devices ship without ip6table_mangle, so this condition repeats every
boot: refusing outright would fence stop, uninstall and purge forever, and
a reboot would refuse the same way. The family is skipped instead, and the
uncertainty travels with the result rather than being resolved locally —
cleanup_owned_firewall raises it, the committed receipt withholds its
verification claim instead of contradicting it, the status projection
preserves that withheld claim rather than restoring it on the next read,
the purge receipt downgrades itself to partial, and the app names the
reservation beside the result. Every place that turned "we could not look"
back into "it is clean" was a defect, and each one sat on a different
boundary — which is why the decision is made once, by
resolve_ipv6_ownership_expectation and one probe budget spent where the
baseline is captured, and never re-derived downstream.
P7. Diagnostics never undo a completed operation. Once an effect is durable, a failure to record it may be reported but must not roll it back or fence the next operation. A rollback that cannot write its receipt still retires its journal; a purge that cannot remove a staging workspace still reports the module directory and private state it did remove, measured rather than assumed, because the same receipt is what the app reads to decide what the device still holds.
3. Changes in 2.2.0 (module)
- Removed the legacy direct-rule migration (
legacy_*family, its snapshot transaction, markers, and the calls in start/stop/full-rollback). Justified by P3.uninstall.shstill deletes the old marker file if present. - Removed the track-journal apparatus (grammar validator, liveness
classifiers, reapers) and the
firewall-teardown.walreader — P2. The firewall layer is WAL-free by design (firewall-reconciler.shheader); interrupted publications are recovered by re-running cleanup under the lock. - Removed the dead cluster:
republish_owner_ipv6_inactive,owner_load_generation_fields,owner_loaded_generation_for_write,trim_config_value,retire_installer_ephemeral_track_journals. - Ephemeral workspace (
$STATE_DIR/tmp/, 0700 root): the PID-suffixed scratch files (firewall-batch.*,firewall-cleanup.*,firewall-restore.*.error,preset-preflight.*,preset-preview.*, the dry-run capture) moved there; the port union no longer uses a file at all. Uninstall removes the directory and any staging residue outright, so a crashed operation can no longer fence it as an unknown child. Fixes defect 1. - Single boot pass: when autostart is enabled,
service.shno longer runs its own lock+audit recovery cycle —zapret-start.shperforms the same audit under its own lock moments later. The standalone pass runs when the module is disabled, when autostart is off, and as the retry path when a start is refused by recovery state (the only case where discarding an unsafe state generation wholesale is still allowed, and it happens before the start has mutated anything). Boot wait blocks onresetprop -wwhen available instead of a 1 Hzgetpropfork loop. - Read once per transaction:
read_install_generation_metaparses the installer record once per process while still re-checking the path identity on every call, and the duplicated status/owner reads on the stop path were collapsed (P1). - Boot-bound status snapshot: the committed snapshot records the boot it describes, and a reader rejects any other — leaving no facts behind. A snapshot describes processes and netfilter objects that a reboot destroys, so one from an earlier boot is not stale data to reconcile.
- Unprovable is not clean: a firewall family whose frontend exists but cannot be queried is never treated as absent. Teardown skips it only when an authenticated owner record — or a snapshot committed with a verified ruleset — proves this generation published nothing there.
- Warm-start dedup: one compiled-artifact binding check at entry plus one
TOCTOU re-check immediately before daemon launch (was 3); the artifact is
parsed twice instead of five or six times, because the compiler publishes
the metadata it just wrote and the launcher trusts the proof from the
binding check;
prepare_private_runtime_fileruns once per file; pidfile wait polls at 100 ms. (nfqws2.cmdlineis still produced: the shell layer never reads it back, but the Android app renders it on the logs screen.) - Stop dedup: status facts restored once instead of three times, the owner
record read once for both the queue number and the ownership decision,
/procscanned once, the per-family firewall-absence postcondition ofz2_fw_apply_cleanuptrusted instead of re-proven, and the unusedruntime.iniparse plus the deadaudit_owned_firewall_for_cleanupargument gone. - Purge/uninstall:
zapret-purge.shpublishes theremovemarker before invokinguninstall.sh— the fence commits the twice-confirmed purge, blocks new starts throughout the destruction (a start already holding the lifecycle lock is serialized against by the lock the removal branch now takes), and routes uninstall onto the short manager-remove branch (so the tombstone long path with its repeated audits no longer runs during a purge); the duplicate status-snapshot deletion is removed andpm clearfailure after a successful purge is a warning, not a failure exit. Fixes defect 2. - Full rollback: the duplicated firewall audit collapsed and the retired
legacy phase became a pure journal advance kept for resume compatibility.
The
syncbarriers were kept: they implement the write-ahead ordering of the hosts-preservation transaction, which thecommon.shdurability contract explicitly assigns to the dedicated mutation scripts (the analysis finding that they violated the contract was a misreading). customize.sh: permission normalization runs as flat find passes with a verification scan that fails the install loudly if the modes did not land. The previous single-traversal form nested-exec chmod {} +inside grouped-oalternations, and Magisk's busybox find silently skips such actions — it exits 0 having changed nothing, which left every module file at the umask-077 modes the installer's unzip produced and broke every app read that requires the published 0644 contract (strategy catalogs first).- Single-endpoint preset mutation (formerly deferred, now shipped): the
app performs both preset flavors through one module transaction.
zapret-apply-preset.sh <name>applies a selection;zapret-apply-preset.sh --save-content <candidate> <expected-digest> <name> <apply|auto>publishes edited content under a canonical content compare-and-swap, commits the selection only when it actually changes, replaces the daemon only when the saved preset governs a running service, and rolls back file, selection and daemon together. The category/profile path that used to take 14–16 privileged round trips is now candidate write plus one transaction. - Validation is paid once per transaction: the transaction compiles the
candidate into the canonical argv slot bound to the runtime.ini generation
it is about to commit, runs the one
nfqws2 --dry-run, and records the generation-bound validation receipt. The replacement child then finds the binding current and the receipt fresh instead of recompiling and re-running the dry-run after the commit. - Android fork-cost findings (measured on a Pixel 9 Pro XL): mksh ships
printfas an external binary, so every printf was a ~45 ms fork+exec; the machine protocol now renders its narrow format dialect through the rawprintbuiltin (common.shadapter), and the preset listing fast path buffers its rows into a single write — the catalog listing dropped from 5.4 s to 0.15 s on a 98-preset catalog. The exact owned-process scan uses onepgrep -fenumeration with the same per-candidate identity proof instead of a shell walk over every/procentry, and the post-kill death poll re-checks only pid+starttime liveness (the entry proof already pinned the identity; argv/exe cannot change while both hold) instead of paying three to four forks per 100 ms turn. A subshell forked from a shell that has the full lifecycle library sourced costs ~68 ms on this class of device, which was the dominant remaining cost of a replacement transaction.
3a. Completion in 2.2.1
- One-process daemon replacement. A running preset save no longer launches
a second
zapret-start.shthat repeats lifecycle, generation, process and validation proofs under the same lock.daemon-replace-transaction.shconsumes the already-authenticated argv and validation receipt, proves that the capture topology is unchanged, replaces only the exact owned daemon and publishes the new owner/status records. A topology change is rejected before teardown and falls back to the full firewall transaction. A failure after teardown converges to a verified stopped state without starting a second transaction. - Lazy firewall layer. The preset endpoint does not parse
firewall-reconciler.shon the unchanged-topology success path. It loads the layer only for failure convergence or delegates a topology-changing save to the complete replacement transaction. - Transaction metadata prefetch. The lifecycle lock primes one exact,
existing-path
statbatch. Missing optional files no longer make BusyBoxstatdiscard metadata for the paths that do exist, and root-only immutable facts are cached only while the lifecycle lock proves single-writer ownership. Android mksh treats an unescaped|in parameter-expansion patterns as alternation; the cache parser therefore escapes its record delimiter. Without that detail the batch was populated correctly but every lookup returned an empty first field and silently paid the originalstat. - No redundant permission forks. Files created under
umask 077are already mode 0600; their immediatechmod 0600calls were removed. Process shutdown similarly consumes the verified pid/owner proof once and checks liveness before entering its bounded poll. - Install-bound argv format.
Z2_ARGV 3records both the install generation and its archive SHA-256. Live reuse requires an exact match in addition to preset/runtime digests; an offlineunboundartifact remains valid for preview but can never be launched as a current installed artifact. - Uninstall fence has a commit point. Direct uninstall keeps its tombstone across every failure and interruption, then reacquires the lifecycle lock, authenticates the same owner and retires the fence only after all cleanup succeeds. Root-manager removal still atomically removes the entire private state tree. A successful uninstall therefore leaves neither a dead tombstone nor an empty private state directory.
Device result
The same save-content transaction was measured twice in each build on a
Pixel 9 Pro XL: replace one valid strategy in the active preset, then restore
the exact original bytes. The pre-2.2.1 hot path took 11,266 ms forward and
10,418 ms restoring. After the changes above it took 4,243 ms and 4,202 ms.
Both operations returned the complete typed APPLIED receipt; the original
and final canonical preset SHA-256 were identical, the owner PID publication
was verified, and the lifecycle log recorded firewall retained. This is a
61% reduction for the paired operation and brings both profile enable/disable
and strategy changes, which share this transaction, below the five-second
module target.
3b. Completion in 2.2.2
- Topology replacement remains one transaction. A preset whose TCP/UDP
capture union or packet limits change no longer launches a second
zapret-start.sh. The lock owner authenticates the current daemon and owner receipt once, then lazily loadstopology-replace-transaction.shand the firewall layer in the same process. - Stable private-chain identity. The authenticated
ZAPRET2_OUTandZAPRET2_INchains and their built-in anchors remain installed. Each active address family is repopulated through oneiptables-restore/ip6tables-restorecommit that flushes only those private chains and writes the complete candidate rule multiset. There is no cleanup/recreate window, duplicate anchor, or speculative test commit. A rejected restore commit leaves that family byte-for-byte unchanged; exact canonical post-publication verification still gates success. - Capabilities are inherited, never guessed. Connbytes, mark and IPv6 availability come from the authenticated owner generation. Multiport can only be retained or conservatively downgraded when the candidate port union exceeds its deterministic 15-value grammar limit; the hot transaction never probes a new kernel capability.
- Old daemon argv is part of rollback state. Before the compiler replaces the canonical argv cache, the transaction proves its binding to the active preset, runtime generation and installed package, then preserves a private 0600 copy. If either family publication, candidate launch, or owner receipt publication fails after the old daemon stops, rollback removes any partial candidate, restores the old rules, relaunches that exact preserved argv, and republishes a healthy old owner/status generation. If restoration cannot be proved complete, the transaction converges to the existing verified-stopped failure state rather than claiming either generation active.
- One mutation boundary remains visible to Android. The preset endpoint
has exactly one privileged child: the compare-and-swap commit of
runtime.ini. Unchanged-topology daemon replacement, changed-topology firewall replacement, failure convergence, and rollback all consume the current lock owner's proofs in process.
Device result
The release gate measures both directions between Default v1 (game filter)
and Default v2 (game filter) on the Pixel 9 Pro XL because the first preset
adds TCP ports and therefore forces the topology-changing path. The pre-2.2.2
baseline was 15–16 seconds per switch and re-entered the complete lifecycle
transaction. The 2.2.2 result is recorded here from the exact qualified
release candidate before publication.
4. Changes in 2.2.0 (app)
- Main-thread root I/O fixed: environment reconciliation now runs on
Dispatchers.IO; previously a cold start could block the main thread on the root transport for up to 30 s. - Status protocol negotiation is cached: the controller remembers the highest protocol version the module answered with instead of re-walking v6→v1 on every refresh.
4a. Known limitation: one ambiguous full-rollback receipt
A rollback that finishes while unable to re-read the IPv6 ruleset reports
Z2_RB_STATUS=partial with Z2_RB_FIREWALL_CLEAN=0 and every other field
affirmative. A rollback interrupted by a signal inside the final commit window
on the same device reports the identical tuple, so the app cannot tell them
apart: the first retired its journal, the second did not.
Distinguishing them needs an eleventh field, and the ten-field receipt is
exactly what a 2.1.x app parses — an extra line makes it reject the whole
payload. The ambiguity is bounded: in both cases the rollback's effect has
already happened (module disabled, autostart off, hosts preserved), and a
surviving journal surfaces on the very next lifecycle observation as
RECOVERY_BLOCKED, which names the remedy. The field is worth adding the
next time the receipt version is raised for other reasons.
5. Deferred (designed, not shipped in 2.2.0)
- App decomposition: splitting
ServiceLifecycleController/UpdateManager/ControlViewModel, DI for the privileged layer, moving long-running installs to WorkManager, replacing source-grep policy tests with lint rules and behavioral tests.
6. Compatibility
- Update path from 2.1.x:
owner.meta(v8),runtime.ini, presets, hostlists andinstall-generation.metaare unchanged. The status snapshot gained aboot_idkey and a 2.1.x snapshot is therefore rejected — which is the correct outcome, since activating a new module package requires a reboot and every snapshot predating it describes processes and rules that no longer exist. Obsolete files from older versions (legacy-direct-rules.migrated,firewall-teardown.wal, track journals, legacy rollback snapshots) are deleted on sight by boot recovery and uninstall. - Status wire protocols (v1–v6), the error envelope, and all
Z2_*machine outputs are unchanged; the Android app from 2.1.x keeps working against a 2.2.0 module and vice versa.