Skip to content

Releases: vannt-dev/aegis-net

AegisNet 1.2.0

Choose a tag to compare

@github-actions github-actions released this 13 Aug 16:36
df95619

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.com was 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.com removed for the same reason — the Facebook Graph
    API, which every app offering Facebook login depends on.

  • Genuine telemetry stays blocked: s.youtube.com and
    video-stats.l.google.com are playback statistics, and YouTube works fine
    without them.

  • test_seed_rules_never_block_an_app_s_own_api now 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.net and phishing-login.com were 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 holding com.zing.zalo and com.vietcombank.mobile, and the list
    goes to addDisallowedApplication() 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: false could 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 as src_<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 a const list in a field, so IndexedStack got 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. const on the individual constructors does the same thing, since a
    const constructor is canonicalised.
  • every language defines every string fails if a translation is missing.
    AppStrings.get falls 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_title in 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
  • ||domain without 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
    @@||domain exceptions.

  • 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...

Read more

AegisNet 1.1.0

Choose a tag to compare

@github-actions github-actions released this 12 Aug 16:52
2074ad6

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, i and s are not
    hex digits), so VpnService.Builder.addAddress threw before establish() 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 to https://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)
    throws UnsupportedOperationException for apps targeting API 34, and the app
    targets 36.
  • Quick Settings tile crashed on Android 12+. Tapping it with the app closed
    called startForegroundService from the background, which is not an exempt
    context for a tile click; the resulting ForegroundServiceStartNotAllowedException
    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
    DomainTrie that 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
    HashSet it 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 sorted Vec<(Box<str>, TrieNode)> searched by
    binary search. Measured over 300k rules with a counting allocator:

    Rule shape Before After HashSet baseline
    2-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 exports aegis_add_custom_host and
    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.0 with nothing keeping it honest. It now
    reads kAppVersion, and a test asserts that constant matches pubspec.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 DesktopDnsProxy through
    AegisBridge and VpnProvider for desktop platforms.

⏰ Quiet Hours Schedule Blocking & Custom Subscriptions

  • Scheduled Parental Control. Added setSchedule and 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
    RulesScreen for managing local DNS host overrides with real-time UI mapping.
  • Status Filter Chips in Logs. Added ALL LOGS, BLOCKED, and ALLOWED
    filter chips in LogsScreen for fast real-time query log inspection.
  • Full Configuration Backup. Extended ConfigSyncService JSON
    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_packet via 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 no VpnService.protect() is
    required.

  • Graceful native-absent fallback / crash fix. System.loadLibrary ran in
    the service's static initializer and crashed the whole app on VPN start when
    libaegis_core.so was not bundled. It is now loaded defensively behind a
    nativeAvailable flag; the app falls back to simulation instead of crashing.

  • Reproducible native build. A best-effort Gradle preBuild task compiles
    the Rust engine with cargo-ndk into jniLibs for all ABIs. It runs only
    when cargo-ndk is on PATH, so toolchain-less machines still build.

    Verified on an Android 34 emulator: doubleclick.net and
    graph.facebook.com resolve to a null address (blocked) while github.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.com and
    look-alikes such as google.com.attacker.net are no longer hijacked.
  • Whitelist covers subdomains + removal wired. Whitelisting facebook.com
    now also allows graph.facebook.com. New aegis_remove_whitelist /
    aegis_remove_blacklist FFI 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 DoH POST (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 packet module. 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.com blocked myadnxs.com). It now matches a domain or its
    subdomains only.
  • Removal wiring + single source of truth. removeWhitelist /
    `remov...
Read more

AegisNet 1.0.0

Choose a tag to compare

@github-actions github-actions released this 07 Aug 00:39
ab328d1

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

Choose a tag to compare

@github-actions github-actions released this 30 Jul 17:30
f087661

What's Changed

New Contributors

Full Changelog: https://github.com/vannt-dev/aegis-net/commits/v0.0.1-test