Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,18 @@ jobs:
./gradlew :app:prefetchOfflineBuildClasspaths

- name: Run Rust unit tests
run: cargo test --manifest-path wgbridge-rs/Cargo.toml --locked --offline
run: |
cargo test --manifest-path wgbridge-rs/Cargo.toml --locked --offline
# The capi feature gates the C ABI the NetGuard engine calls; its
# FFI-boundary tests only compile with the feature enabled.
cargo test --manifest-path wgbridge-rs/Cargo.toml -p tc-dns --features capi --locked --offline

- name: Run DNS-over-TCP framing host tests
run: |
cc -Wall -Wextra -Werror -Iapp/src/main/jni/netguard \
-o /tmp/dns_frame_test \
app/src/test/native/dns_frame_test.c app/src/main/jni/netguard/dns_frame.c
/tmp/dns_frame_test

- name: Run unit tests
run: ./gradlew testFdroidDebugUnitTest --offline
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ covers — don't rely on what you remember of it.
| `agents/docs/build-and-test.md` | anything beyond the commands below — prerequisites, flavours, the native C/Rust builds, the reproducibility flags |
| `agents/docs/device-testing.md` | **before any `adb` command** — flavour choice, seeding away the permission prompts, editing preferences safely |
| `agents/docs/triage.md` | reviewing, triaging or closing an issue — the verdict vocabulary and the two reusable close messages |
| `wgbridge-rs/README.md` | touching `wgbridge-rs/` or `net.kollnig.missioncontrol.wg*` — architecture, the JNI API surface, building/testing the crate |
| `wgbridge-rs/README.md` | touching `wgbridge-rs/`, `tc-dns`, DNS response rewriting, or `net.kollnig.missioncontrol.wg*` — architecture, the C/JNI API surfaces, building/testing the workspace |
| `docs/RELEASING.md` | cutting a release — version bump, Fastlane changelog, tag-triggered unsigned build, local signing, smoke-test checklist |

---
Expand Down
21 changes: 13 additions & 8 deletions agents/docs/build-and-test.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ flavour matrix, the native builds, and the reproducibility flags.
## Prerequisites

JDK 17, Android SDK (compile/target SDK 37, min SDK 23), NDK `27.2.12479018`,
CMake. For the WireGuard bridge you also need Rust ≥ 1.95 with the four Android
targets and `cargo-ndk` — but the Gradle build wires that in for you (it even
installs `cargo-ndk` if missing). See `wgbridge-rs/README.md` for the exact
`rustup target add …` list and F-Droid build metadata.
CMake. Native builds also need Rust ≥ 1.95 with the four Android targets;
the WireGuard bridge additionally needs `cargo-ndk`. Gradle wires both Rust
builds in but deliberately does not install tools or fetch crates. See
`wgbridge-rs/README.md` for setup and F-Droid build metadata.

## Flavours

Expand Down Expand Up @@ -42,15 +42,20 @@ device, build **fdroid debug** instead — see `agents/docs/device-testing.md`.
**Rust host tests** (config/DNS/key parsing — no device needed):

```bash
cd wgbridge-rs && cargo test
cd wgbridge-rs && cargo test --workspace
cd wgbridge-rs && cargo test -p tc-dns --features capi
```

## Native code builds automatically with the app

- The **C engine** builds via CMake through AGP's `externalNativeBuild`.
- The **C engine** builds via CMake through AGP's `externalNativeBuild` and
calls `tc-dns` through the C ABI exported by `libwgbridge.so`. CMake
configure/build tasks depend on `wgbridgeBuild`; JVM compilation and unit
tests remain Rust-free.
- The **Rust WireGuard bridge** builds via the `wgbridgeBuild` Gradle task, which
runs `cargo ndk` for all four ABIs and is a `dependsOn` of `preBuild`. It only
re-runs when `wgbridge-rs/src/**`, `Cargo.toml`, or `Cargo.lock` change.
runs `cargo ndk` for all four ABIs and is needed only by JNI-library packaging
tasks. It also tracks the shared `tc-dns` source because the bridge links the
same core.
If Gradle can't find `cargo` (Android Studio sanitizes `PATH`), pass
`-PcargoBin=/path/to/cargo` or put `~/.cargo/bin` on `PATH`.

Expand Down
15 changes: 14 additions & 1 deletion app/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ add_library( netguard
src/main/jni/netguard/ip.c
src/main/jni/netguard/tls.c
src/main/jni/netguard/tcp.c
src/main/jni/netguard/dns_frame.c
src/main/jni/netguard/udp.c
src/main/jni/netguard/icmp.c
src/main/jni/netguard/dns.c
Expand All @@ -26,8 +27,20 @@ include_directories( src/main/jni/netguard/ )
find_library( log-lib
log )

if(NOT DEFINED WGBRIDGE_LIB_DIR)
message(FATAL_ERROR "WGBRIDGE_LIB_DIR not set -- build through Gradle, which runs wgbridgeBuild first.")
endif()

add_library(wgbridge SHARED IMPORTED)
set_target_properties(wgbridge PROPERTIES
IMPORTED_LOCATION "${WGBRIDGE_LIB_DIR}/${ANDROID_ABI}/libwgbridge.so"
# cargo-ndk's cdylib has no DT_SONAME; use -lwgbridge so DT_NEEDED is
# the packaged basename instead of a build-directory-relative path.
IMPORTED_NO_SONAME TRUE)

target_link_libraries( netguard
${log-lib} )
${log-lib}
wgbridge )

target_link_options(netguard PRIVATE
"-Wl,-z,max-page-size=16384"
Expand Down
3 changes: 2 additions & 1 deletion app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ android {
cppFlags ""
arguments "-DANDROID_PLATFORM=android-22",
"-DCMAKE_C_COMPILER_LAUNCHER=${ccacheLauncher}",
"-DCMAKE_CXX_COMPILER_LAUNCHER=${ccacheLauncher}"
"-DCMAKE_CXX_COMPILER_LAUNCHER=${ccacheLauncher}",
"-DWGBRIDGE_LIB_DIR=${buildDir}/rustJniLibs"
// https://developer.android.com/ndk/guides/cmake.html
}
}
Expand Down
150 changes: 107 additions & 43 deletions app/gradle/wgbridge.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -78,61 +78,83 @@ def resolveCargoTargetDir = { ->
return file("${System.getProperty('user.home')}/.cargo/tc-wgbridge-target")
}

def assertCargoAvailable = { cargoBin ->
def onPath = System.getenv('PATH')?.split(File.pathSeparator)?.any {
file("$it/cargo").canExecute()
}
if ((cargoBin == 'cargo' && !onPath) || (cargoBin != 'cargo' && !file(cargoBin).canExecute())) {
throw new GradleException("""
Could not find a `cargo` executable. Install Rust via rustup
(https://rustup.rs), then run:
./scripts/setup_rust_android.sh
and either add ~/.cargo/bin to PATH (restart Android Studio),
or pass -PcargoBin=/path/to/cargo to Gradle.
""".stripIndent())
}
return cargoBin
}

// Build the cargo environment in one place. Keeping the target directory and
// encoded flags stable is load-bearing: otherwise cargo alternates between
// fingerprints and repeats the full cross-compile on every invocation.
def buildCargoEnv = { ->
def cargoBin = assertCargoAvailable(findCargoBinary())
def cargoDir = file(cargoBin).parent
def env = new HashMap<String, String>(System.getenv())
def pathEntries = []
if (cargoDir) pathEntries << cargoDir
pathEntries << "${System.getProperty('user.home')}/.cargo/bin"
if (env['PATH']) pathEntries << env['PATH']
env['PATH'] = pathEntries.join(File.pathSeparator)
env['ANDROID_NDK_HOME'] = findNdkDirectory().absolutePath

// Shared, checkout-independent target dir so worktrees reuse artifacts.
def cargoTargetDir = resolveCargoTargetDir()
cargoTargetDir.mkdirs()
env['CARGO_TARGET_DIR'] = cargoTargetDir.absolutePath

// Android 15+ requires 16KB-aligned ELF LOAD segments. These flags are
// shared with every Rust build so cargo fingerprints stay stable.
def rustFlags = []
def separator = '\u001f'
if (env['CARGO_ENCODED_RUSTFLAGS']) {
rustFlags.addAll(env['CARGO_ENCODED_RUSTFLAGS'].split(separator).findAll { !it.isEmpty() })
} else if (env['RUSTFLAGS']) {
rustFlags.addAll(env['RUSTFLAGS'].trim().split(/\s+/))
}
def cargoHome = env['CARGO_HOME'] ?: "${System.getProperty('user.home')}/.cargo"
rustFlags.addAll([
'-Clink-arg=-Wl,-z,max-page-size=16384',
'-Clink-arg=-Wl,-z,common-page-size=16384',
'-Clink-arg=-Wl,--build-id=none',
"--remap-path-prefix=${file(cargoHome).absolutePath}=/cargo",
"--remap-path-prefix=${wgbridgeSrcDir.absolutePath}=/wgbridge-rs",
])
// Unlike RUSTFLAGS, the encoded form preserves spaces inside paths.
env['CARGO_ENCODED_RUSTFLAGS'] = rustFlags.join(separator)
env.remove('RUSTFLAGS')

return [cargoBin: cargoBin, env: env, cargoTargetDir: cargoTargetDir]
}

tasks.register('wgbridgeBuild') {
description = 'Builds libwgbridge.so via `cargo ndk`. Requires rustup with the four Android targets, cargo-ndk and the Android NDK.'
group = 'build'

inputs.files(fileTree(wgbridgeSrcDir) {
include 'src/**'
include 'tc-dns/**'
include 'Cargo.toml'
include 'Cargo.lock'
}).withPropertyName('wgbridgeSource').withPathSensitivity(PathSensitivity.RELATIVE)
inputs.file(file("$rootDir/rust-toolchain.toml")).withPropertyName('rustToolchain')
outputs.dir(wgbridgeOutDir).withPropertyName('wgbridgeJniLibs')
outputs.cacheIf { true }

doLast {
def cargoBin = findCargoBinary()
if (cargoBin == 'cargo' && !System.getenv('PATH')?.split(File.pathSeparator)?.any { file("$it/cargo").canExecute() }) {
throw new GradleException("""
Could not find a `cargo` executable. Install Rust via rustup
(https://rustup.rs), then run:
./scripts/setup_rust_android.sh
and either add ~/.cargo/bin to PATH (restart Android Studio),
or pass -PcargoBin=/path/to/cargo to Gradle.
""".stripIndent())
}
def cargoDir = file(cargoBin).parent

def env = new HashMap<String, String>(System.getenv())
env['PATH'] = "${cargoDir}${File.pathSeparator}${System.getProperty('user.home')}/.cargo/bin${File.pathSeparator}${env['PATH'] ?: ''}"
env['ANDROID_NDK_HOME'] = findNdkDirectory().absolutePath
// Shared, checkout-independent target dir so worktrees reuse artifacts.
def cargoTargetDir = resolveCargoTargetDir()
cargoTargetDir.mkdirs()
env['CARGO_TARGET_DIR'] = cargoTargetDir.absolutePath
// Android 15+ requires 16KB-aligned ELF LOAD segments.
// --remap-path-prefix strips the checkout- and machine-specific
// absolute paths (cargo registry cache, wgbridge-rs checkout dir)
// that rustc otherwise bakes into panic/track_caller strings, so
// libwgbridge.so is reproducible across build machines.
def rustFlags = []
if (env['CARGO_ENCODED_RUSTFLAGS']) {
rustFlags.addAll(env['CARGO_ENCODED_RUSTFLAGS'].split('').findAll { !it.isEmpty() })
} else if (env['RUSTFLAGS']) {
// Cargo interprets RUSTFLAGS as whitespace-separated arguments.
rustFlags.addAll(env['RUSTFLAGS'].trim().split(/\s+/))
}
def cargoHome = env['CARGO_HOME'] ?: "${System.getProperty('user.home')}/.cargo"
rustFlags.addAll([
'-Clink-arg=-Wl,-z,max-page-size=16384',
'-Clink-arg=-Wl,-z,common-page-size=16384',
'-Clink-arg=-Wl,--build-id=none',
"--remap-path-prefix=${file(cargoHome).absolutePath}=/cargo",
"--remap-path-prefix=${wgbridgeSrcDir.absolutePath}=/wgbridge-rs",
])
// Unlike RUSTFLAGS, the encoded form preserves spaces inside paths.
env['CARGO_ENCODED_RUSTFLAGS'] = rustFlags.join('')
env.remove('RUSTFLAGS')
def cargo = buildCargoEnv()
def cargoBin = cargo.cargoBin
def env = cargo.env

// Tool installation must happen before Gradle runs. In particular,
// F-Droid disables network access for the actual build phase.
Expand All @@ -150,7 +172,7 @@ tasks.register('wgbridgeBuild') {
def args = [cargoBin, 'ndk']
wgbridgeAbis.each { args += ['-t', it] }
args += ['--platform', '23', '-o', wgbridgeOutDir.absolutePath,
'build', '--release', '--locked', '--offline']
'build', '-p', 'wgbridge', '--release', '--locked', '--offline']

wgbridgeExecOperations.exec {
workingDir wgbridgeSrcDir
Expand Down Expand Up @@ -191,13 +213,55 @@ tasks.register('wgbridgeBuild') {
}.result.get().assertNormalExitValue()
}
}

// tc-dns is an rlib dependency of the wgbridge cdylib; its
// #[no_mangle] pub extern "C" symbols (tcdns_process_response,
// tcdns_abi_version — see app/src/main/jni/netguard/tcdns.h) only
// reach libwgbridge.so because rustc currently chooses to keep them.
// libnetguard.so links against libwgbridge.so for these symbols (the
// IMPORTED_NO_SONAME wgbridge target in app/CMakeLists.txt), so a
// future LTO / --gc-sections / rustc change that drops the re-export
// would silently build but fail at runtime with an
// UnsatisfiedLinkError. Catch that here instead.
def requiredTcdnsSymbols = ['tcdns_process_response', 'tcdns_abi_version']
def llvmNm = "${findNdkDirectory().absolutePath}/toolchains/llvm/prebuilt/${hostTag}/bin/llvm-nm"
wgbridgeAbis.each { abi ->
def lib = file("$wgbridgeOutDir/$abi/libwgbridge.so")
// A missing library is itself a failure here: this check exists
// precisely to stop an unusable artifact from shipping.
if (!lib.exists())
throw new GradleException("libwgbridge.so ($abi) was not produced by `cargo ndk`.")
def symbolOutput = new ByteArrayOutputStream()
wgbridgeExecOperations.exec {
commandLine llvmNm, '--defined-only', '--dynamic', lib.absolutePath
standardOutput = symbolOutput
}.assertNormalExitValue()
def exportedSymbols = symbolOutput.toString()
requiredTcdnsSymbols.each { symbol ->
if (!exportedSymbols.contains(symbol)) {
throw new GradleException("""
libwgbridge.so ($abi) is missing the expected dynamic
symbol `$symbol` from the tc-dns crate. libnetguard.so
has a hard runtime dependency on this symbol (see
app/src/main/jni/netguard/tcdns.h); without it the app
will crash with UnsatisfiedLinkError on device.
This likely means a linker/LTO/rustc change stopped
re-exporting tc-dns's #[no_mangle] symbols through the
wgbridge cdylib — check wgbridge-rs/Cargo.toml and
wgbridge-rs/tc-dns for crate-type / visibility changes.
""".stripIndent())
}
}
}
}
}

// Only packaging/installation needs the Android Rust library. Keeping this off
// preBuild is important: JVM unit tests compile app code but never load the
// Android .so, and should not cross-compile four release ABIs first.
tasks.configureEach { task ->
if (task.name ==~ /(configure|build)CMake.*/)
task.dependsOn 'wgbridgeBuild'
if (task.name ==~ /merge.*JniLibFolders/)
task.dependsOn 'wgbridgeBuild'
}
Loading