Skip to content

Deduplicate DNS response rewriting - #739

Merged
kasnder merged 5 commits into
masterfrom
codex/dedupe-dns-response-rewriters
Aug 20, 2026
Merged

Deduplicate DNS response rewriting#739
kasnder merged 5 commits into
masterfrom
codex/dedupe-dns-response-rewriters

Conversation

@kasnder

@kasnder kasnder commented Aug 20, 2026

Copy link
Copy Markdown
Member

Summary

  • move the duplicate C and Rust DNS response rewriting logic into a dependency-free tc-dns Rust crate
  • expose the shared implementation to NetGuard through a small C ABI while keeping libwgbridge.so as the single Rust shared library
  • safely update DNS-over-TCP length prefixes for isolated complete frames
  • document the native integration and reproducible build path

Validation

  • cargo test --workspace --locked --offline (42 passed)
  • cargo test -p tc-dns --features capi --locked --offline (16 passed)
  • Rust formatting and stable Clippy with -D warnings
  • externalNativeBuildGithubDebug, GitHub JVM unit tests, and assembleGithubDebug
  • all four Android ABIs built; libnetguard.so has the expected DT_NEEDED entry for libwgbridge.so
  • clean builds from different checkout paths and CARGO_HOME directories produced byte-identical native libraries and APKs
  • APK size increase versus the exact base revision: 4,415 bytes

Pixel 8 smoke test

Tested the GitHub debug APK on Pixel 8 44230DLJH00128.

  • WireGuard path: HTTPS/SVCB response rewriting produced a question-only RCODE=3 response with zero answer/authority/additional counts; ordinary A responses remained intact
  • native C path with wg_enabled=false: UDP HTTPS rewriting matched the WireGuard behavior; ordinary UDP A responses remained intact
  • native DNS-over-TCP: the two-byte frame length was updated to 0x0020, matching the shortened 32-byte DNS message
  • restored wg_enabled=true; WireGuard reconnected and connectivity passed
  • inspected the app database after explicit approval and confirmed expected resource attribution for both paths

Remaining limitation

The C adapter intentionally rewrites only complete isolated DNS-over-TCP frames. Split or coalesced TCP DNS frames are passed through unchanged and do not yet have a native device harness.

Follow-up test coverage (added after review)

  • CI now also runs cargo test -p tc-dns --features capi, so the three FFI-boundary tests (C ABI struct layout, invalid callback tables, string termination and returned length) execute on every run — previously they only compiled behind the non-default capi feature.
  • Six standards-coverage golden tests in tc-dns/tests/message.rs: CNAME chains ending in A/AAAA (with compression pointers; the final answer records against the question qname), EDNS(0) OPT in the additional section, RRSIG alongside a valid A, a round-trip check that a blanked message is a self-consistent DNS message, and a TC-bit response cut mid-record that fails open while still recording fully-parsed answers. All six confirmed the intended behavior; no parser changes.
  • The DNS-over-TCP framing decision (isolated vs. coalesced vs. split reads) is extracted from tcp.c into dns_frame.c (libc-only, behavior-identical) and covered by an assert-based host test — including a regression case for the coalesced-read blocking bypass fixed in 9c49cc0 — compiled and run in CI with the system compiler.

Extract the DNS message parser and response policy into the dependency-free tc-dns workspace crate, consume it from both WireGuard and the native NetGuard path through a checked C ABI, and link that ABI from libwgbridge.so to avoid duplicating Rust runtime code.\n\nAdd parser, ABI, panic-safety, framing, build, size, and reproducibility coverage while fixing DNS-over-TCP length updates and the inherited C parser edge cases.
@kasnder
kasnder marked this pull request as ready for review August 20, 2026 13:59
kasnder and others added 4 commits August 20, 2026 16:12
Four follow-ups on the tc-dns deduplication:

* tcp.c: restore filtering of coalesced and split TCP DNS reads. The
  extraction skipped parse_dns_response entirely unless the read held
  exactly one complete frame, so a coalesced or partial recv() went
  unfiltered — a blocking bypass against master, which blanked the
  first frame in place. Every frame is parsed again; only an isolated
  complete frame may have its 2-byte length prefix rewritten, because
  trimming a coalesced or split read would discard stream bytes.

* tc-dns: stop charging compression pointers against the name budget.
  RFC 1035's 255-octet limit applies to the uncompressed name, not its
  wire encoding, so counting 2 octets per pointer rejected legitimate
  near-maximum names reached via compression and left them unblocked.
  Pointer chasing stays bounded by MAX_NAME_DEPTH, MAX_NAME_LABELS and
  the existing bounds check. Two regression tests cover both sides:
  a 254-octet name reached through a pointer must be recorded and
  blanked, an over-long uncompressed owner name must still be rejected.

* wgbridge: drop rewrite_dns_response, which became test-only once the
  C engine moved to the tc-dns C ABI, and retarget its tests at
  DnsInspector::inspect_and_rewrite.

* wgbridge.gradle: assert libwgbridge.so still exports
  tcdns_process_response and tcdns_abi_version for each ABI. These
  reach the shared object only because rustc chooses to re-export
  them from the tc-dns rlib; a future LTO or --gc-sections change
  would otherwise build cleanly and fail at runtime with an
  UnsatisfiedLinkError.

Also rewrite two let...else blocks in src/dns.rs with `?`, so
`cargo clippy --all-targets -- -D warnings` passes at workspace scope.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The capi feature is not a default feature, so plain cargo test skipped the
three tests covering the C ABI contract (struct layout, invalid callback
tables, string termination and returned length) that the NetGuard engine
now depends on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Six behavior-documenting tests for response shapes the suite did not
cover: CNAME chains ending in A and AAAA (with compression pointers,
asserting the final answer records against the question qname), an
EDNS(0) OPT pseudo-record in the additional section, an RRSIG alongside
a valid A answer, a round-trip check that a blanked message is a
self-consistent DNS message, and a TC-bit response cut mid-record that
must fail open while still recording the answers parsed before the cut.

No parser changes; all six confirmed the intended behavior.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The isolated/coalesced/split frame decision in check_tcp_socket had no
automated coverage, and it is exactly where the coalesced-read blocking
bypass fixed in 9c49cc0 lived. Move the pure logic to dns_frame.c
(libc-only, no JNI) so it compiles on the host, keep tcp.c's behavior
identical, and add an assert-based host test covering isolated frames
(shortened and not), coalesced and split reads, the zero-length and
16-bit-max frame edges, and the 9c49cc0 regression. CI compiles and
runs the test with the system compiler in the existing test job.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@kasnder
kasnder merged commit aeb6f45 into master Aug 20, 2026
2 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