Skip to content

M1 to M3: Rust core, iOS integration, user controls and alpha 2 blocking fixes - #6

Merged
mmaher88 merged 151 commits into
mainfrom
m3-ios
Sep 30, 2026
Merged

mmaher88 merged 151 commits into
mainfrom
m3-ios

Conversation

@mmaher88

Copy link
Copy Markdown
Owner

Summary

Brings everything since M0 into main: 151 commits built on stacked branches (m1a through m3-ios) that were never merged. Alpha 1 was built from this code, and PR #5 was reviewed on top of it. 188 files, about 31,000 lines of code and tests plus 25,700 lines of docs (mostly the milestone plans).

Rust core (core/)

  • common: shared pieces: stats counters, the block event log, clocks, TLS client setup, the resolver interface.
  • policy: which hosts are never intercepted: the bundled pass-through list (Apple, sensitive services, banks, X), the owner's Never filtered and Allowed sites, and certificate pin learning.
  • filter: list compilation: the adblock-rust request engine (engine.dat) and the hashed, memory-mapped DNS blocklist (domains.bin).
  • dns: the tunnel's DNS responder: blocklist answers, DNS over HTTPS to 1.1.1.1 and 9.9.9.9 with a cache, and local network names sent to the Wi-Fi's own resolver.
  • mitm: the local HTTPS filtering proxy: CONNECT handling, TLS interception with leaves from the user-trusted root, per-request filtering, pass-through tunnels, blocked-host connections, the upstream pool, WebSockets, idle and memory limits.
  • tollgate-ffi: the uniffi bridge the app and tunnel call: engine start and stop, list compiling, stats, events, pins and the certificate authority.
  • tools/devproxy: runs the DNS responder and proxy on Linux for testing.

iOS (ios/)

  • Tunnel: the packet tunnel provider that runs the engine, with DNS-only routing, proxy settings and local name resolution.
  • App: Home (protection, HTTPS filtering, activity, diagnostics), the Activity log, Settings (filter lists, custom lists, My rules, Allowed sites, Never filtered, learned pins), certificate installation, the daily list refresh and the Connectivity Assist notice.

Tooling and docs

  • CI tests the core and builds and signs the app; scripts fetch builds and install them over USB.
  • The design spec with its revisions, the milestone plans, and on-device experiment checklists in docs/experiments.

Testing

  • 480 Rust tests pass; cargo fmt and cargo clippy -D warnings are clean.
  • Before alpha 1, 13 rounds of adversarial review; 69 confirmed findings fixed.
  • Alpha 1 (build 31) and build 32 ran on an iPhone 15 Pro Max with iOS 27.0.1: DNS and HTTPS filtering, the Connectivity Assist fix, and the X fix.
  • main is fully contained in m3-ios, so the merge has no conflicts.

🤖 Generated with Claude Code

write_atomically used .<name>.<pid>.tmp for every call, so two compiles
into one directory in the same process truncated each other's temporary
file and all but one failed to rename it. One could also write into a
file the other had already renamed into place, which breaks the promise
that a mapped engine.dat or domains.bin never changes. Temporary names
now carry a process-wide counter and are opened with create_new.
… is gone

A reset only dropped idle pooled connections, so a streaming response
(server-sent events, a long poll, a download) on a connection whose
source address had gone kept waiting on the dead path: forever over
HTTP/1.1, until the ping timeout over HTTP/2. Upstream sockets are now
wrapped so a watcher can cut them on a reset when their address is no
longer assigned, which fails the requests on them at once.
…ailures

An intercepted request whose upstream cannot be dialed (no such name,
refused, reset or timed out) now gets no response: hyper closes the
HTTP/1.1 connection or resets the HTTP/2 stream, so Safari shows its own
error page and can fall back to http://. No free upstream connection gets
503; TLS failures keep 502. Unreachable hosts are logged at info level,
once a minute per host.
Upstream certificates for another name, expired, not yet valid, revoked
or for another purpose no longer teach a pin: the client would reject them
too. When a third different host would be learned from upstream failures
within 60 s, none is kept and upstream learning pauses for 10 minutes.
A wake or network path change drops upstream pins from the last 5 minutes.
Pins from client rejections are never taken back.
The stats reply now carries the tunnel process start time, and the
heartbeat is re-read on every stats refresh and when the app becomes
active, so a jetsam kill and on-demand restart while the app was suspended
changes "Tunnel started". M2 E12 now also checks the tunnel log for a
second startTunnel line.
setup now shares the device options with the device command, as its error
messages already advised, and falls back to the pinned pymobiledevice3
when libimobiledevice is not installed.
A suspended app never answers the ping of a graceful HTTP/2 shutdown, so its
idle connection kept its interception slot until the keep-alive gave up and
new connections were passed through. A reclaimed connection with nothing in
flight is now dropped after 50 ms, any other closing one after 5 s.
…her name

The client accepted the proxy's leaf, so Safari cannot show its own warning and
rendered the empty 502 as a blank page. The 502 now carries a short text that
names the problem, and M3 E15 step 2 says what the owner will see.
…list

Since the proxy checks the DNS blocklist on CONNECT, curl prints 000 for
doubleclick.net. Step 3.3 now checks the CONNECT status and adds a URL-rule
block on an allowed host; step 5.3 says how Firefox shows both.
ListUpdater.run now holds background time, like the tunnel restart, so a
download in flight is not cut off by suspension. A download that still fails
with a lost connection or a timeout is retried once before the list counts
as failed.
A server that accepts TLS and then closes or resets the connection, or
resets the HTTP/2 stream or sends GOAWAY, before any response now gets
NoResponse instead of an empty 502, so the browser shows its own error
page or falls back from https:// to http:// as it does without the
proxy. TLS alerts, malformed responses and client certificate
requirements keep their 502.
A failed first request on a new connection to a server that asked for a
client certificate was always learned as a 30-day pin. Now it is learned
only when the handshake was TLS 1.3 (over TLS 1.2 a completed handshake
proves the certificate was optional) and a fatal alert from the server
was read, which a stream wrapper records for HTTP/1.1 and HTTP/2 alike.
Resets, closes and cut network paths are handled like any other failure.
Absolute-form requests (http:// through the system proxy) whose upstream
cannot be reached got an empty 502, which Safari shows as a blank page.
The client connection now closes without a response, as for intercepted
requests, so Safari shows its own error page. No free upstream
connection gets 503, and certificate problems get the same explanatory
502 as intercepted requests.
The AdGuard DNS filter writes some host blocks as name^ (the host and
its subdomains) and some as ://name^ (that host only). Both were
skipped, so hosts such as dlsdk.appsflyer.com and jhf.ru resolved. The
unanchored form must start with a letter or digit, so -pia.example^
stays skipped instead of blocking pia.example.
The request whose upstream TLS failure made the host a learned pin got an
empty 502, shown by Safari as a blank page that also kept it from falling
back to http. It now gets no response: HTTP/1.1 closes the connection and
HTTP/2 resets the stream with REFUSED_STREAM and sends GOAWAY, so the
browser retries on a new CONNECT, now passed through, or shows its own
error page. A failure the burst guard declines to learn gets a 502 text
that names a captive portal or a network filter as a likely cause.
IIS resets a request with HTTP_1_1_REQUIRED when it needs HTTP/1.1 for
Windows authentication or client certificate renegotiation. The proxy
treated it as a hang-up, so every visit failed. Such a reset now makes the
host a learned pin, the reason is passed on to the client stream and its
connection closes, so the browser falls back to HTTP/1.1 through a
passed-through CONNECT. Other stream resets are unchanged.
In a List, a row holding a default-style button acts as one button, so a
tap anywhere on the Home Filter lists row downloaded and recompiled every
list. The Update button, and the Add button next to the host pattern
field, are now borderless, so only a tap on the button itself acts.
iOS 27 Connectivity Assist retries a failed Wi-Fi attempt over cellular, with the
carrier's DNS and no proxy, so a CONNECT refused with 403 let the blocked host load
over cellular. A DNS-blocked host now gets 200, a TLS handshake with a Tollgate leaf
and a connection that fails every request without a response (HTTP/2 stream reset
with INTERNAL_ERROR, HTTP/1.1 closed). Pages still see a network error; iOS sees a
working connection.

Blocked hosts that the policy passes through keep the 403, and so does any blocked
host when memory is low or when max_blocked (64) blocked connections are open and
none can be closed within 150 ms. Failed handshakes on blocked connections teach no
pin. After a list reload, open blocked connections refuse the next request with
REFUSED_STREAM and close, so the client reconnects and is classified again.
Blocks made only by DNS still fail the lookup, which iOS 27 can retry over cellular
data, and Tollgate cannot read the setting. Home shows a dismissible notice on iOS 27
and later; Settings keeps the advice and can show the notice again.
Adds the alpha 2 checklist, a design-spec revision for the connection race, and the
new devproxy expectations for blocked hosts.
The X iOS app pins its certificates and cancels every intercepted connection in its
own certificate check without a TLS alert, so pin learning never sees the rejection
and the app fails on every attempt. Confirmed in a device log (X 12.29, iOS 27.0.1).
X's hosts (*.x.com, *.twitter.com, *.twimg.com) are now in the bundled passthrough
list, in a new group for apps that refuse our certificate silently.
@mmaher88
mmaher88 merged commit a180587 into main Sep 30, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant