name: Build MSN-GUARD on: push: branches: [master] workflow_dispatch: jobs: build: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Set up JDK 17 uses: actions/setup-java@v4 with: java-version: '17' distribution: 'temurin' - name: Install Rust uses: dtolnay/rust-toolchain@stable with: targets: aarch64-linux-android,armv7-linux-androideabi - name: Install cargo-ndk run: cargo install cargo-ndk - name: Setup Android SDK uses: android-actions/setup-android@v3 with: packages: 'platform-tools build-tools;36.0.0 platforms;android-36 ndk;26.3.11579264 cmake;3.22.1' # Tor binaries come from the tor-binaries workflow's artifacts, never from # a third-party download at build time. The run is pinned by id so a # rebuild of this repo cannot silently pick up different binaries — # moving the pin is the explicit, auditable act. # # app/src/main/jniLibs/ is gitignored (it also receives the Rust core's # outputs), so placing them here is what makes AGP package them. - name: Fetch Tor binaries env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TOR_RUN_ID: "33008595023" run: | set -euo pipefail mkdir -p /tmp/torbin for ART in libtor lyrebird; do ID=$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${TOR_RUN_ID}/artifacts" \ --jq ".artifacts[] | select(.name==\"$ART\") | .id") [ -n "$ID" ] || { echo "artifact $ART not found in run $TOR_RUN_ID"; exit 1; } gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/$ID/zip" > "/tmp/$ART.zip" # Keep the archive's arm64-v8a/armeabi-v7a layout — -j would flatten # both ABIs onto one name and silently keep only the last one. unzip -o "/tmp/$ART.zip" -d "/tmp/torbin/$ART" done mkdir -p app/src/main/jniLibs/arm64-v8a app/src/main/jniLibs/armeabi-v7a cp /tmp/torbin/libtor/arm64-v8a/libtor.so app/src/main/jniLibs/arm64-v8a/ cp /tmp/torbin/lyrebird/arm64-v8a/libobfs4proxy.so app/src/main/jniLibs/arm64-v8a/ cp /tmp/torbin/libtor/armeabi-v7a/libtor.so app/src/main/jniLibs/armeabi-v7a/ cp /tmp/torbin/lyrebird/armeabi-v7a/libobfs4proxy.so app/src/main/jniLibs/armeabi-v7a/ # Sanity gate, same as the producing workflow asserts: PIE (Type: DYN). # A wrong binary here fails at runtime with no message at all. # # readelf output is captured into a variable before parsing — piping # LLVM tools into grep/awk trips SIGPIPE handling (exit 74) under # pipefail, which is exactly how the binaries workflow lost two runs. READELF="$ANDROID_HOME/ndk/26.3.11579264/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-readelf" for f in app/src/main/jniLibs/*/lib*.so; do OUT=$("$READELF" -h "$f") TYPE=$(printf '%s' "$OUT" | sed -n 's/.*Type: *\([A-Z]*\).*/\1/p') [ "$TYPE" = "DYN" ] || { echo "ERROR: $f is not PIE"; exit 1; } done # Second gate, because TOR_RUN_ID above is a hand-moved pin: a stale or # mistyped run id can hand us a binary whose PT_GNU_RELRO mprotects past # the end of its LOAD segment, and bionic then refuses to exec it with # "can't enable GNU RELRO protection". That is what shipped in 1.5.0 and # broke Tor on every 4K-page device. The producing workflow asserts this # too; assert it again here so the APK build cannot package a bad binary # no matter which run it pulled from. for f in app/src/main/jniLibs/*/libtor.so; do phdrs=$("$READELF" -lW "$f") RV=$(awk '$1=="GNU_RELRO" {print $3}' <<< "$phdrs") RM=$(awk '$1=="GNU_RELRO" {print $6}' <<< "$phdrs") LM=$(awk -v v="$RV" '$1=="LOAD" && $3==v {print $6}' <<< "$phdrs") [ -n "$RM" ] && [ -n "$LM" ] || { echo "ERROR: cannot read RELRO/LOAD in $f"; exit 1; } V=$((RV)); R=$((RM)); L=$((LM)) for P in 4096 16384; do REND=$(( ((V + R + P - 1) / P) * P )) LEND=$(( ((V + L + P - 1) / P) * P )) [ "$REND" -le "$LEND" ] || { echo "ERROR: $f mprotects $((REND - LEND)) bytes past its LOAD at ${P}-byte pages." echo " bionic will refuse to exec it. Is TOR_RUN_ID pointing at a" echo " run built without -z common-page-size?" exit 1 } done echo "OK: $f RELRO fits at 4K and 16K pages" done ls -la app/src/main/jniLibs/*/ - name: Make scripts executable run: chmod +x ./gradlew ./core/build-android.sh # GeoIP database for the Tor exit-country picker. Without it, ExitNodes # {cc} resolves to an empty relay set and bootstrap stalls at 45-50% # forever — the exact bug 1.4.3 shipped. Built here rather than committed: # 6.4 MB of generated data does not belong in git, and generating it from # the checksum-pinned Tor tarball means the ranges are auditable. # # Filtered to the countries TorRegions.kt offers (1.8 MB in the APK instead # of 4.5 MB) and the IPv6 database is replaced by a stub, since the country # of a relay comes from its IPv4 address. Verified on live Tor: FR DE NL RO # SE CA all honoured, 6/6. - name: Build filtered GeoIP database run: | set -euo pipefail TOR_VERSION=0.4.9.11 EXPECTED=2e6c1720118c812acf0079fd47cf91b6bfaba5d766c321c4d3d2a28d6a11a8ed curl -fL --retry 3 -o tor.tar.gz "https://dist.torproject.org/tor-$TOR_VERSION.tar.gz" echo "$EXPECTED tor.tar.gz" | sha256sum -c - tar -xzf tor.tar.gz "tor-$TOR_VERSION/src/config/geoip" python3 tools/filter-geoip.py \ "tor-$TOR_VERSION/src/config/geoip" \ app/src/main/java/com/msnguard/vpn/TorRegions.kt \ app/src/main/assets rm -rf tor.tar.gz "tor-$TOR_VERSION" ls -la app/src/main/assets/geoip app/src/main/assets/geoip6 # The release keystore lives only in repo secrets. Without it we fall back # to a debug build, so a fork or a PR from someone without secret access # still gets a working APK instead of a hard failure. # # The AETHERY_* secret names are deliberately NOT renamed alongside the # package rename. They are GitHub secret keys, never shipped in the APK, # and renaming them means re-entering the keystore in repo settings — one # typo there and releases get signed with a different key, which is the # single thing that would force every user to uninstall. Cosmetics are not # worth that risk; the same applies to the -PaetheryKeystore property. - name: Restore release keystore id: keystore env: KEYSTORE_B64: ${{ secrets.AETHERY_KEYSTORE_BASE64 }} run: | if [ -n "$KEYSTORE_B64" ]; then printf '%s' "$KEYSTORE_B64" | base64 -d > msn-guard-release.jks echo "present=true" >> "$GITHUB_OUTPUT" else echo "present=false" >> "$GITHUB_OUTPUT" fi - name: Build signed release APK if: steps.keystore.outputs.present == 'true' env: ANDROID_HOME: ${{ env.ANDROID_HOME }} ANDROID_SDK_ROOT: ${{ env.ANDROID_HOME }} AETHERY_KEYSTORE_PASSWORD: ${{ secrets.AETHERY_KEYSTORE_PASSWORD }} AETHERY_KEY_PASSWORD: ${{ secrets.AETHERY_KEY_PASSWORD }} AETHERY_KEY_ALIAS: ${{ secrets.AETHERY_KEY_ALIAS }} run: | ./gradlew assembleRelease \ -PtargetAbi=arm64-v8a,armeabi-v7a \ -PaetheryKeystore=msn-guard-release.jks \ --no-daemon - name: Build debug APK (no signing secrets available) if: steps.keystore.outputs.present != 'true' env: ANDROID_HOME: ${{ env.ANDROID_HOME }} ANDROID_SDK_ROOT: ${{ env.ANDROID_HOME }} run: ./gradlew assembleDebug -PtargetAbi=arm64-v8a,armeabi-v7a --no-daemon # versionName drives the artifact and release-asset filenames so a # downloaded APK is self-identifying. - name: Read version id: ver run: | V=$(grep -m1 'versionName' app/build.gradle.kts | sed 's/.*"\(.*\)".*/\1/') echo "name=$V" >> "$GITHUB_OUTPUT" - name: Stage APKs run: | mkdir -p out V="${{ steps.ver.outputs.name }}" for f in app/build/outputs/apk/*/*.apk; do [ -e "$f" ] || continue case "$f" in *arm64-v8a*) ABI=arm64-v8a ;; *armeabi-v7a*) ABI=armeabi-v7a ;; *) ABI=universal ;; esac cp "$f" "out/MSN-GUARD-v$V-$ABI.apk" done (cd out && sha256sum *.apk > SHA256SUMS.txt) ls -la out - name: Delete stale keystore if: always() run: rm -f msn-guard-release.jks - name: Upload APK uses: actions/upload-artifact@v4 with: name: MSN-GUARD path: out/*