Releases: vannt-dev/aegis-net
Release list
AegisNet 1.2.0
Fixes a bug that broke the YouTube app for everyone running 1.1.0, and stops
the rule parser storing entries that could never match.
Verified on an Android 14 emulator with the tunnel running — the same Android
version the bug was reported on. Every endpoint the YouTube app needs now
resolves, and filtering is untouched:
| domain | 1.2.0 |
|---|---|
youtubei.googleapis.com |
resolves (172.217.115.4) — was NXDOMAIN in 1.1.0 |
www.youtube.com, i.ytimg.com, yt3.ggpht.com |
resolve |
rr1---sn-*.googlevideo.com, jnn-pa.googleapis.com |
resolve |
graph.facebook.com |
resolves — was NXDOMAIN in 1.1.0 |
s.youtube.com |
blocked, as intended |
pagead2.googlesyndication.com |
blocked, as intended |
iOS is still not verified — no Swift in the PacketTunnel target has been
compiled on a Mac. Treat this release as Android-only, as with 1.1.0.
🐛 YouTube would not load with the tunnel on
-
youtubei.googleapis.comwas blocked by default, which breaks the YouTube
app outright. It is not a tracker: it is YouTube's InnerTube API, the one
the app fetches its home feed, its search results and the player config
carrying the stream URLs from. Blocked, the app renders its skeleton and
nothing ever fills it in — no thumbnails, no playback. Reported on Realme /
Android 14, but nothing about it was device-specific.The rule came from
seed_default_rules(), a built-in list that applies
before any filter list is downloaded, in the Trackers category, which is on
by default. So it hit every user from the first launch, and no setting on
screen explained why. -
graph.facebook.comremoved for the same reason — the Facebook Graph
API, which every app offering Facebook login depends on. -
Genuine telemetry stays blocked:
s.youtube.comand
video-stats.l.google.comare playback statistics, and YouTube works fine
without them. -
test_seed_rules_never_block_an_app_s_own_apinow guards the seed list, and
the comment above it says what the list is allowed to contain.
🎛️ Three of the four rule categories had nothing in them
Every downloaded list fed category 0 (Ads), so Trackers, Malware and Adult held
only the hardcoded seed rules — and Adult held nothing at all.
Two things had to be wrong for that. FilterSource.categoryId defaulted to 0
and no default source overrode it — but fixing that alone changes nothing,
because syncAllFilters() called loadRulesText(content) without the category
and the engine then applied its own default of 0. The field was only ever
reaching the per-category files written for the iOS extension.
| Category | Was | Now |
|---|---|---|
| Ads | ~251,000 domains | AdGuard DNS + StevenBlack + OISD Small |
| Trackers | 5 seed rules | Peter Lowe's list (3,526) |
| Malware | 3 invented domains | URLhaus (370 real hosts, +0.0 MB) |
| Adult | nothing at all | StevenBlack adult hosts (76,751), opt-in |
crypto-miner.org,bad-malware-site.netandphishing-login.comwere not
real. The Malware category protected against three names that do not exist,
and counted them as rules loaded.- The adult list is not downloaded until it is switched on: 76,751 hostnames
cost 4.3 MB in the trie, and the Adult category is off by default, so paying
that up front would be 4.3 MB of the iOS extension's budget spent on nothing. - Honest caveat: DNS blocklists do not split cleanly into "ads" and "trackers".
Nearly everything in the Trackers list is also in the ad lists, so disabling
Trackers would still not unblock much.
Known gap, not fixed here. The categories have no switches on screen — the
only Switch in the app is Scheduled Parental Controls, and the only thing it
does is turn the Adult category on. Since that category was empty, the
schedule filtered nothing whatsoever before this release, and it still does
nothing until the adult list is switched on in Rules. Wiring the two together
is a change worth making carefully: the obvious version routes through
syncAllFilters(), which starts by clearing every downloaded rule and
re-fetching ~7 MB, so a flick of that switch on a bad connection would leave
the user with no filtering at all.
🧽 The user's own lists shipped pre-filled with invented entries
_whitelist came seeded with mybank.com and workplace.com, _blacklist
with bad-tracker.net and crypto-miner.org. A fresh install presented four
rules as though the user had written them. This is the same class of problem as
the fabricated statistics removed in 1.1.0, and worse in one way: whitelist
entries are pushed into the engine, so the app really was allowing two domains
nobody chose.
- The split-tunnel bypass list was seeded too, and that one had teeth. It
shipped holdingcom.zing.zaloandcom.vietcombank.mobile, and the list
goes toaddDisallowedApplication()when the tunnel is built. A messaging app
and a banking app were carved out of the VPN on every fresh install, chosen by
nobody, in an app whose entire promise is that traffic goes through it. - A source declared
isEnabled: falsecould not stay off. The preference
stores only the ids that are off, and a missing key was read as an empty
list, which switched everything on. Guarded by
a source declared off stays off before any toggle is saved.
All three lists now start empty, and the declared defaults survive first launch.
🌏 The four languages now cover the whole app
The settings screen offers English, Tiếng Việt, 한국어 and 日本語, and all four
tables were complete — but only the dashboard and part of settings ever read
them. Rules, Analytics, Logs and the navigation bar were hardcoded English, so
switching language changed roughly a quarter of what is on screen.
- Every user-visible string in all six screens now goes through
AppStrings:
35 keys became 118, in each of the four languages. That includes the filter
list names and descriptions under "Subscribe to Filter Lists", which are
looked up assrc_<id>_name— a list the user added themselves has no
translation and keeps whatever they typed. - The screens never rebuilt on a language change.
MainNavigationScreen
held its five screens in aconstlist in a field, soIndexedStackgot the
identical widget objects every build and Flutter skipped the whole subtree.
Only the tab labels changed language; everything behind them stayed as it
was.conston the individual constructors does the same thing, since a
const constructor is canonicalised. every language defines every stringfails if a translation is missing.
AppStrings.getfalls back to English, so a gap does not throw — the screen
just quietly renders in the wrong language. The test compares raw lookups.- Fixed
theme_titlein Japanese, which readサイバーパンクネ온カラー— a Korean
syllable had found its way into the middle of a Japanese string.
⌨️ Text fields in Settings cleared themselves every two seconds
The DoH URL field and the split-tunnel package field created their
TextEditingController inside build(). The settings screen watches
VpnProvider, which notifies every two seconds while the tunnel is up, so both
fields were handed a brand new empty controller on every tick: whatever you
typed disappeared mid-sentence, and neither controller was ever disposed.
Setting a custom DoH resolver or excluding an app while protected was simply
not possible. Confirmed on device before and after the fix — the controllers
now belong to a State that disposes them.
⏱️ A category change no longer hides behind a cached answer
The engine caches replies for five minutes and nothing invalidated them when a
category was switched on, so a rule change silently did not apply to anything
already looked up. aegis_set_category now clears the cache, guarded by
test_toggling_a_category_drops_cached_answers.
This does not make the change instant. Android keeps its own resolver cache
outside the app, and that one cannot be flushed from here — measured on device,
a domain resolved two minutes before the Adult category was switched on kept
resolving afterwards, and only started returning NXDOMAIN once the tunnel was
stopped and started again. Domains never looked up before are blocked
immediately. Anyone turning on parental controls in a hurry should restart the
tunnel.
🧪 Guard against the next one
test_seed_rules_never_block_an_app_s_own_api now covers eleven endpoints an
app cannot start without — YouTube, Instagram, Telegram, Twitter, OpenAI, and
Firebase Cloud Messaging, where a block would silently kill push notifications.
🧹 Filter rule parsing
-
Rules that could never match are no longer stored. The parser ended in a
catch-all — any line with a dot and no space became a "domain" — so filter
syntax the DNS matcher cannot express was kept verbatim. Measured against the
lists actually shipped: 669 such entries in the AdGuard DNS filter, and
17,779 in EasyList. Each one occupied memory, inflated the "rules loaded"
count shown to the user, and matched nothing.AdGuard DNS StevenBlack Peter Lowe unusable entries before 669 0 0 after 0 0 0 -
||domainwithout a trailing^now loads. 172 rules in the AdGuard DNS
filter are written that way. They fell through to the catch-all and were
stored with the||still attached, so those domains were never actually
blocked while the UI counted them as active rules. The same fix applies to
@@||domainexceptions. -
Wildcards (
||ads.livetv*.me^), regex, path-scoped and resource-type rules
are now dropped deliberately rather than stored as garbage. A DNS filter sees
a hostname and nothing else; none of these can be honoured. -
Added Peter Lowe's Ad and Tracking Server List to the default sources.
Hostname-only, so every line survives the DNS parser — 3,525 rules, zero
unusable. It is one of...
AegisNet 1.1.0
Android is verified on an Android 14 emulator: the tunnel establishes, a blocked
domain answers NXDOMAIN (ping doubleclick.net → unknown host), a normal domain
resolves through the DoH upstream (ping example.com → 172.66.147.243), and the
dashboard shows the engine's own counters. iOS is not verified — the
PacketTunnel target was only just added and none of its Swift has been compiled
on a Mac yet. Treat this release as Android-only.
🚨 Fixed — release blockers found by review
- The Android tunnel could not start at all. The IPv6 ULA was written
fd00:aegis::2, which is not a valid IPv6 literal (g,iandsare not
hex digits), soVpnService.Builder.addAddressthrew beforeestablish()was
ever reached. - DNS failed outright with the default upstream. Routing public resolver IPs
(1.1.1.1, 8.8.8.8, 9.9.9.9, …) into the TUN to stop apps bypassing the filter
also captured the engine's own DoH traffic tohttps://1.1.1.1/dns-query,
where the DNS-only filter dropped it. Every lookup ended in SERVFAIL after a
5s timeout. The routes are removed; doing this properly needs a protected
upstream socket and is tracked in ROADMAP.md. - Quick Settings tile crashed on Android 14+.
startActivityAndCollapse(Intent)
throwsUnsupportedOperationExceptionfor apps targeting API 34, and the app
targets 36. - Quick Settings tile crashed on Android 12+. Tapping it with the app closed
calledstartForegroundServicefrom the background, which is not an exempt
context for a tile click; the resultingForegroundServiceStartNotAllowedException
went uncaught. It now falls back to opening the app. - The service came back as a zombie after being killed.
START_STICKY
redelivers a null intent, which matched no branch, leaving the process alive
with no notification and no tunnel — the routine outcome on MIUI. It now
rebuilds the tunnel, using a bypass list persisted to storage so a process
kill does not silently route the user's excluded apps through the VPN. - The tile reported stale state. It kept showing "ON" after the tunnel went
down, including after MIUI revoked VPN consent.
🌲 Rust Core Engine — DomainTrie Optimization & Custom Hosts
-
DomainTrie prefix tree. Replaced
HashSet<String>domain matching with a
DomainTriethat stores one node per label, so a blocked zone costs one
terminal node instead of one entry per host. Note the behaviour change: the
user denylist now covers subdomains, where it used to match exact hosts only. -
DomainTrie memory fix. The first version gave each node a
HashMap<String, TrieNode>, which measured at 2.5x–7.7x the memory of the
HashSetit replaced — the opposite of the intended effect, because a
domain trie is mostly single-child chains and every one of them paid for a
hash table. Children are now a sortedVec<(Box<str>, TrieNode)>searched by
binary search. Measured over 300k rules with a counting allocator:Rule shape Before After HashSetbaseline2-label, hosts-style 44.4 MB 17.6 MB 17.5 MB 3-label, unique second level 142.2 MB 31.9 MB 18.4 MB Many subdomains under 500 zones 41.5 MB 15.7 MB 17.8 MB This is what made it a correctness issue rather than a tuning one: the iOS
PacketTunnel extension has a hard memory limit in the tens of MB, and the
default blocklists are large enough that the old layout got it killed.
Lookups are ~170 ns, so no speed claim is made either way — the trie's win is
that one rule covers a whole zone. -
Custom DNS Host Overrides (Local DNS Mapping). Added local DNS mapping
support (domain->IP, e.g.myrouter.local->192.168.1.1) directly in
the Rust engine, with C-FFI exportsaegis_add_custom_hostand
aegis_remove_custom_host. Overrides answer A and AAAA with a record of the
matching family and NOERROR/empty otherwise, and travel in the settings
snapshot so the iOS extension honours them too. -
Bounded top-domain statistics. The per-domain hit counters are capped at
2,000 names per direction, evicting the coldest half when full, and the top-5
is selected linearly instead of sorting and cloning the whole table on every
UI poll.
🧹 Removed — numbers the UI invented
Several screens filled empty state with realistic-looking sample data, which is
indistinguishable from a measurement once it is rendered. All of it is gone; the
screens now say they have no data yet.
- Dashboard opened at 1,420 queries / 385 blocked / 27.1% / 55.1 MB on a fresh
install, and the query log came pre-seeded with three fabricated entries. - Analytics fell back to a hand-written top-blocked list (
doubleclick.net
×142,api.github.com×320, …) whenever the engine had counted nothing. - "Hourly Query Distribution" drew seven hardcoded bars that never changed.
There is no hourly bucketing to plot, so the chart now shows the query-rate
history that does exist, retitled to match. - The dashboard's "Traffic & Latency" curve was seeded with
[15, 28, 42, 35, 50, 48, 62], drawing convincing traffic on a device that
had never resolved anything, next to a hardcoded "14 ms (Ultra Fast)" that
was never measured — the engine does not time its lookups. The chart starts
empty and the badge reports the sample count instead. - Settings had an "Export / Import Configuration" button that built a JSON
string, discarded it, and reported "Config exported successfully: N bytes".
Removed — the Backup & Restore section does the real thing.
🔢 Versioning
- The settings footer hardcoded
v1.0.0with nothing keeping it honest. It now
readskAppVersion, and a test asserts that constant matchespubspec.yaml
— the release workflow runs the test before it builds.
💻 Desktop Scaffolding & Desktop DNS Proxy
- Multi-Platform Desktop Shell. Added native desktop scaffolding
(windows/,macos/,linux/) so the app compiles and runs as a native
desktop application. - Desktop DNS Resolver Integration. Connected
DesktopDnsProxythrough
AegisBridgeandVpnProviderfor desktop platforms.
⏰ Quiet Hours Schedule Blocking & Custom Subscriptions
- Scheduled Parental Control. Added
setScheduleand quiet hours
evaluation (default 22:00 - 06:00) to automatically enforce Adult category
filters during quiet hours. - Custom Hosts UI Tab. Added a dedicated Local DNS Hosts tab in
RulesScreenfor managing local DNS host overrides with real-time UI mapping. - Status Filter Chips in Logs. Added
ALL LOGS,BLOCKED, andALLOWED
filter chips inLogsScreenfor fast real-time query log inspection. - Full Configuration Backup. Extended
ConfigSyncServiceJSON
export/import to backup custom hosts, schedule settings, and custom filter
sources. - Expanded Test Coverage. Rust tests: 16 → 33, Flutter unit & widget
tests: 7 → 28.
🔴 Android — Real DNS filtering (verified on device)
-
Native TUN → Rust → device pipeline wired end-to-end.
AegisVpnService
now reads each IPv4 packet off the TUN, hands it to the Rust engine
(aegis_process_ip_packetvia a JNI bridge), and writes synthesized DNS
replies back. Previously the read loop discarded every packet. -
DNS-only tunnel routing. Only the virtual DNS server (
10.0.0.3/32) is
routed into the TUN. Non-DNS traffic and the engine's own upstream lookups
stay on the real network, so nothing loops and noVpnService.protect()is
required. -
Graceful native-absent fallback / crash fix.
System.loadLibraryran in
the service's static initializer and crashed the whole app on VPN start when
libaegis_core.sowas not bundled. It is now loaded defensively behind a
nativeAvailableflag; the app falls back to simulation instead of crashing. -
Reproducible native build. A best-effort Gradle
preBuildtask compiles
the Rust engine withcargo-ndkintojniLibsfor all ABIs. It runs only
whencargo-ndkis onPATH, so toolchain-less machines still build.Verified on an Android 34 emulator:
doubleclick.netand
graph.facebook.comresolve to a null address (blocked) whilegithub.com
resolves to its real IP via DoH.
🟠 Rust core — Correctness fixes
- DNS cache correctness. The cache is now keyed by
(domain, qtype)and
stamps the current request's transaction id onto cached replies. Previously it
returned a stale transaction id and ignored the record type, so clients
rejected cached answers. - SafeSearch precision. Rewrites now match an exact allow-list of search
hostnames instead of a substring.mail.google.com/drive.google.comand
look-alikes such asgoogle.com.attacker.netare no longer hijacked. - Whitelist covers subdomains + removal wired. Whitelisting
facebook.com
now also allowsgraph.facebook.com. Newaegis_remove_whitelist/
aegis_remove_blacklistFFI exports are wired through Dart and the provider,
so removing an entry in the UI actually reaches the engine. - DNS-over-HTTPS upstream (RFC 8484). Cleartext UDP:53 forwarding was
replaced with a DoHPOST(application/dns-message). The endpoint is an
IP literal (https://1.1.1.1/dns-query) on purpose — resolving a hostname
here would recurse into our own captured resolver and deadlock. - New
packetmodule. Minimal IPv4/UDP parsing, reply reassembly and RFC
1071 checksum, fully unit-tested. - JNI bridge (
nativeProcessPacket) for the Android service. - Dependency cleanup. Removed unused
tokio,aho-corasick,regex,
parking_lot. - Rust tests: 6 → 16, no compiler warnings.
🟡 Flutter / Dart
- Fallback matching fixed. The pure-Dart fallback matched domains by
substring (adnxs.comblockedmyadnxs.com). It now matches a domain or its
subdomains only. - Removal wiring + single source of truth.
removeWhitelist/
`remov...
AegisNet 1.0.0
What's Changed
- fix(ios): register the VPN channel under the UIScene lifecycle by @vannt-dev in #9
- iOS Encrypted DNS profile, and an Android tunnel that reports its real state by @vannt-dev in #11
Full Changelog: v0.0.1-test...v1.0.0
AegisNet 0.0.1-test
What's Changed
- Feat/core upgrades by @vannt-dev in #1
- @ by @vannt-dev in #2
- Feat/native dns filtering by @vannt-dev in #3
- Feat/ios tunnel and docs by @vannt-dev in #4
- @ by @vannt-dev in #5
- Fix/engine category pause nxdomain by @vannt-dev in #6
- Fix/engine category pause nxdomain by @vannt-dev in #7
- Fix/engine category pause nxdomain by @vannt-dev in #8
New Contributors
- @vannt-dev made their first contribution in #1
Full Changelog: https://github.com/vannt-dev/aegis-net/commits/v0.0.1-test