build: align native cdylib on 16 KiB; unify kmod/zygisk build scripts

Two related changes that ship together because they touch the same
build-script + docs surface and were verified together on-device.

16 KiB alignment
  - zygisk/build.rs: pass `-Wl,-z,max-page-size=16384` to lld so the
    cdylib's LOAD segments line up on 16 KiB pages. NDK r28+ already
    does this by default, but the flag keeps r27 builds compatible.
  - lsposed/native/build.rs: new file, same flag, for libvpnhide_checks.so.
  - docs/development.md: bumped the NDK requirement to r28+ and noted
    the 16 KiB rationale.

Verified via `llvm-readelf -l`: both libvpnhide_zygisk.so and
libvpnhide_checks.so now show `Align 0x4000` on every LOAD segment.

Unified build entry points
  - kmod/build.py replaces kmod/build-zip.py. Single script that
    auto-detects whether to build natively (we're inside the DDK image
    or `--kdir` was passed) or to spawn `ghcr.io/ylarod/ddk-min` via
    podman/docker. CI uses the same script with `--inside-container`.
  - zygisk/build-zip.py renamed to zygisk/build.py for symmetry; logic
    unchanged.
  - kmod/BUILDING.md rewritten — local build is now one command:
    `./kmod/build.py --kmi android14-6.1` (or `--all`). The old
    hand-rolled podman/docker recipes are gone.
  - .github/workflows/ci.yml updated to call the new entry points.
    The DDK image tag in CI now has a comment pointing at
    `DDK_IMAGE_TAG` in kmod/build.py as the source of truth.
  - README.{md,en.md}, kmod/README.md, zygisk/README.md, docs/releasing.md,
    scripts/build_lib.py: reference updates.
  - README.en.md: also fixes a "bacame" typo and tightens the Windows
    zygisk-build note (the aux.rs / libgit2 issue is still real).

Verified end-to-end on Pixel 8 Pro (husky, android14-6.1, Android 16):
APK installs, kmod + zygisk modules load, all 26 self-checks PASS in
Enforcing, 22/26 PASS in Permissive (the same 4 by-design FAILs as
before — kmod doesn't cover those paths in Permissive).
This commit is contained in:
okhsunrog 2026-04-26 23:26:30 +03:00
parent 14f4765048
commit 35b3dcdf50
15 changed files with 439 additions and 252 deletions

View file

@ -98,6 +98,8 @@ jobs:
- android14-6.1
- android15-6.6
- android16-6.12
# Tag here mirrors `DDK_IMAGE_TAG` in kmod/build.py — bump both
# together so local builds and CI use the exact same image.
container:
image: ghcr.io/ylarod/ddk-min:${{ matrix.kmi }}-20260313
env:
@ -112,9 +114,7 @@ jobs:
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
- name: Build and package kernel module
run: |
cd kmod
python3 ./build-zip.py --kmi $KMI
run: python3 kmod/build.py --kmi $KMI --inside-container
- name: Upload artifact
uses: actions/upload-artifact@v7
@ -156,7 +156,7 @@ jobs:
UPDATE_JSON_URL: https://raw.githubusercontent.com/okhsunrog/vpnhide/main/update-json/update-zygisk.json
run: |
cd zygisk
python3 ./build-zip.py
python3 ./build.py
- name: Upload artifact
uses: actions/upload-artifact@v7

View file

@ -194,8 +194,8 @@ Rows 1-6, 21, and 24 are the only vectors reachable by regular apps. Everything
## Building from source
- **kmod**: `cd kmod && make && ./build-zip.py` — see [kmod/BUILDING.md](kmod/BUILDING.md)
- **zygisk**: `cd zygisk && ./build-zip.py` (Rust + NDK + cargo-ndk)
- **kmod**: `./kmod/build.py --kmi android14-6.1` (or `--all`) — auto-spawns the DDK container via podman/docker. Full guide: [kmod/BUILDING.md](kmod/BUILDING.md).
- **zygisk**: `cd zygisk && ./build.py` (Rust + NDK + cargo-ndk)
- **lsposed**: `cd lsposed && ./gradlew assembleDebug` (JDK 17 + Rust + NDK + cargo-ndk)
### Notes for contributors stuck on Windows
@ -208,17 +208,13 @@ If you're on Windows, there are some inconveniences with building some subprojec
For the next two, you'll (unfortunately) need to install [Docker for Windows](https://docs.docker.com/desktop/setup/install/windows-install/).
**kmod**:
```powershell
$env:KMI="android12-5.10"; docker run --rm -it -v "${PWD}:/workspace" -e KMI=$env:KMI -w /workspace "ghcr.io/ylarod/ddk-min:$($env:KMI)-20260313" bash -c 'cd kmod && python3 ./build-zip.py --kmi $KMI'
```
Be sure to use the same version of `ylarod/ddk-min` image (the date after KMI name) as used in the `ci.yml` workflow file.
**kmod**: `python .\kmod\build.py --kmi android14-6.1` — the script picks up Docker and pulls the same `ddk-min` image that CI uses.
**zygisk**:
```powershell
docker run --rm -it -v "${PWD}:/workspace" -v "vpnhide_cargo_cache:/usr/local/cargo/registry" -w /workspace ghcr.io/okhsunrog/vpnhide/ci:latest bash -c 'cd zygisk && python3 ./build-zip.py'
docker run --rm -it -v "${PWD}:/workspace" -v "vpnhide_cargo_cache:/usr/local/cargo/registry" -w /workspace ghcr.io/okhsunrog/vpnhide/ci:latest bash -c 'cd zygisk && python3 ./build.py'
```
The reason why `zygisk` can't be built directly is because source code of dependency `zygisk-api` contains a file named `aux.rs`. Cargo uses `libgit2` for git operations and it contains a guard, which forbids creating files _containing_ reserved Windows words. You'll get an error: `cannot checkout to invalid path 'src/aux.rs'; class=Checkout (20)`. [Someone reports](https://superuser.com/a/1929659), that it bacame possible to create files containing reserved words **with** an extension after some update, but it seems such behavior wasn't modified in `libgit2`.
The reason `zygisk` can't be built directly on Windows is that the `zygisk-api` dependency contains a file named `aux.rs`. Cargo uses `libgit2` for git operations, and `libgit2` refuses to create files whose names _contain_ reserved Windows device names (`AUX`, `CON`, `NUL`, …). You'll get: `cannot checkout to invalid path 'src/aux.rs'; class=Checkout (20)`. [Someone reports](https://superuser.com/a/1929659) that some Windows update made it possible to create files containing reserved words **with** an extension, but `libgit2` hasn't been updated to relax the guard.
## Verified against

View file

@ -194,8 +194,8 @@ vpnhide — это не один переключатель, а три разн
## Сборка из исходников
- **kmod**: `cd kmod && make && ./build-zip.py` — см. [kmod/BUILDING.md](kmod/BUILDING.md)
- **zygisk**: `cd zygisk && ./build-zip.py` (Rust + NDK + cargo-ndk)
- **kmod**: `./kmod/build.py --kmi android14-6.1` (или `--all`) — авто-запускает DDK-контейнер через podman/docker. Подробнее: [kmod/BUILDING.md](kmod/BUILDING.md).
- **zygisk**: `cd zygisk && ./build.py` (Rust + NDK + cargo-ndk)
- **lsposed**: `cd lsposed && ./gradlew assembleDebug` (JDK 17 + Rust + NDK + cargo-ndk)
### Заметки для контрибьюторов, застрявших на Windows
@ -208,15 +208,11 @@ vpnhide — это не один переключатель, а три разн
Для следующих двух вам (к сожалению) потребуется установить [Docker for Windows](https://docs.docker.com/desktop/setup/install/windows-install/).
**kmod**:
```powershell
$env:KMI="android12-5.10"; docker run --rm -it -v "${PWD}:/workspace" -e KMI=$env:KMI -w /workspace "ghcr.io/ylarod/ddk-min:$($env:KMI)-20260313" bash -c 'cd kmod && python3 ./build-zip.py --kmi $KMI'
```
Обязательно используйте ту же версию образа `ylarod/ddk-min` (дата после названия KMI), которая используется в `ci.yml`.
**kmod**: `python .\kmod\build.py --kmi android14-6.1` — скрипт сам подберёт docker и поднимет образ `ddk-min` (тот же, что в CI).
**zygisk**:
```powershell
docker run --rm -it -v "${PWD}:/workspace" -v "vpnhide_cargo_cache:/usr/local/cargo/registry" -w /workspace ghcr.io/okhsunrog/vpnhide/ci:latest bash -c 'cd zygisk && python3 ./build-zip.py'
docker run --rm -it -v "${PWD}:/workspace" -v "vpnhide_cargo_cache:/usr/local/cargo/registry" -w /workspace ghcr.io/okhsunrog/vpnhide/ci:latest bash -c 'cd zygisk && python3 ./build.py'
```
Причина, по которой `zygisk` нельзя собрать напрямую, заключается в том, что исходный код зависимости `zygisk-api` содержит файл с именем `aux.rs`. Cargo использует `libgit2` для работы с git, в котором есть защита, запрещающая создавать файлы, _содержащие_ зарезервированные слова Windows. Вы получите ошибку: `cannot checkout to invalid path 'src/aux.rs'; class=Checkout (20)`. [Сообщают](https://superuser.com/a/1929659), что после какого-то обновления стало возможным создавать файлы, содержащие зарезервированные слова, **с** расширением, но, похоже, в `libgit2` это поведение не было изменено.

View file

@ -0,0 +1,9 @@
_2026-04-26_
## English
Align native cdylib LOAD segments on 16 KiB so libvpnhide_zygisk.so and libvpnhide_checks.so load cleanly on Android 16 Pixel 8 Pro (and future 16 KiB-page hardware) without the "ELF LOAD not aligned" warning at app start
## Русский
Native-библиотеки (libvpnhide_zygisk.so, libvpnhide_checks.so) теперь выровнены на 16 КиБ. На Pixel 8 Pro с Android 16 (и будущем железе с 16 КиБ-страницами) больше нет предупреждения "сегмент LOAD не выровнен" при старте приложения

View file

@ -6,7 +6,7 @@ How to build vpnhide from source.
- **JDK 17 or later** — what the CI image installs (`openjdk-17-jdk-headless`); local builds with JDK 21 also work. The `lsposed/app` Gradle build sets `sourceCompatibility = 17` and `jvmTarget = "17"`.
- **Android SDK** — install `platforms;android-35`, `build-tools;35.0.0`, `platform-tools` (via Android Studio or `cmdline-tools`). Export `ANDROID_HOME`.
- **Android NDK r27c or later** — export `ANDROID_NDK_HOME` (or drop it in `$ANDROID_HOME/ndk/<version>/`, the scripts auto-detect). The Gobley Gradle plugin used by `lsposed/app` reads `ANDROID_NDK_ROOT`, not `ANDROID_NDK_HOME`, so export both (or alias one to the other) when invoking Gradle directly.
- **Android NDK r28 or later** — export `ANDROID_NDK_HOME` (or drop it in `$ANDROID_HOME/ndk/<version>/`, the scripts auto-detect). The Gobley Gradle plugin used by `lsposed/app` reads `ANDROID_NDK_ROOT`, not `ANDROID_NDK_HOME`, so export both (or alias one to the other) when invoking Gradle directly. r27c builds compile, but the resulting cdylibs trigger an Android 16 KiB-page-size compatibility warning at app start on Pixel 8 Pro / future hardware (`сегмент LOAD не выровнен`); r28+ aligns LOAD segments on 16 KiB by default. (`zygisk/build.rs` and `lsposed/native/build.rs` also pass `-Wl,-z,max-page-size=16384` explicitly so older NDKs stay compatible — defence in depth.)
- **Rust** (latest stable) with the Android target:
```sh
rustup target add aarch64-linux-android
@ -58,7 +58,7 @@ keytool -genkey -v -keystore ~/vpnhide.jks \
### zygisk module
```sh
cd zygisk && ./build-zip.py
cd zygisk && ./build.py
# → zygisk/target/vpnhide-zygisk.zip
```
@ -73,7 +73,13 @@ cd lsposed && ./gradlew :app:assembleRelease
### kernel module
Per-GKI-generation builds via DDK Docker/Podman images. See [kmod/BUILDING.md](../kmod/BUILDING.md) for the full guide (GKI identification, DDK commands, local-source builds with `direnv`).
```sh
./kmod/build.py --kmi android14-6.1 # one variant
./kmod/build.py --all # every supported GKI
# → vpnhide-kmod-<kmi>.zip at the repo root
```
The script auto-spawns the `ghcr.io/ylarod/ddk-min:<kmi>-<TAG>` container via podman/docker (same image CI uses). For local kernel-source builds via `direnv` and the GKI matrix details, see [kmod/BUILDING.md](../kmod/BUILDING.md).
## Install on device

View file

@ -61,6 +61,6 @@ This string goes into:
- APK `versionName` (visible in Android Settings → Apps, diagnostic debug zip, `BuildConfig.VERSION_NAME`)
- Inside the zip filenames (only for release tags; dev artifacts in CI keep a stable name)
The committed `module.prop` files are **not** modified — `build-zip.py` stages a copy, patches the version there, and zips. `lsposed/app/build.gradle.kts` evaluates `build-version.py` at configure time and sets `versionName` dynamically.
The committed `module.prop` files are **not** modified — `kmod/build.py` and `zygisk/build.py` stage a copy, patch the version there, and zip. `lsposed/app/build.gradle.kts` evaluates `build-version.py` at configure time and sets `versionName` dynamically.
`versionCode` stays at the value baked in by the last `release.py` run (monotonically increasing integer required by Android/Magisk).

View file

@ -2,67 +2,20 @@
Most users should download pre-built modules from [Releases](https://github.com/okhsunrog/vpnhide/releases) — builds are provided for all supported GKI generations. This guide is for contributors or users who need to build from source.
## Quick build with DDK (recommended)
## Quick build
The easiest way to build is using the same DDK container images that CI uses. No kernel source clone, no toolchain setup.
### Docker
One command — same script CI runs, no container invocation to memorize:
```bash
# Pick your GKI generation (see "Identifying your GKI generation" below)
KMI=android14-6.1
# Build the kernel module (run from repo root)
docker run --rm -v $(pwd)/kmod:/work \
ghcr.io/ylarod/ddk-min:${KMI}-20260313 sh -c "
CLANG_DIR=\$(echo /opt/ddk/clang/clang-r*/bin) \
KERNEL_SRC=/opt/ddk/kdir/${KMI} \
make"
# Package as KSU module
cp kmod/vpnhide_kmod.ko kmod/module/
(cd kmod/module && zip -qr ../../vpnhide-kmod.zip .)
./kmod/build.py --kmi android14-6.1 # one variant
./kmod/build.py --all # every supported GKI
```
### Podman (rootless + SELinux)
The script auto-detects whether to build natively (you're already inside the DDK image, or you've pointed `--kdir` at a kernel source tree) or to spawn a `ghcr.io/ylarod/ddk-min:<kmi>-<TAG>` container via podman/docker. On rootless podman (Fedora etc) it adds `--userns=keep-id` and `:Z` automatically. The output is `vpnhide-kmod-<kmi>.zip` at the repo root.
On Fedora / RHEL-family systems with rootless Podman, the `docker run` above needs two extra flags:
Requires `podman` or `docker`. The container image weighs ~1 GB per GKI variant on first pull.
- `--userns=keep-id` — maps the host UID into the container so the bind-mounted `/work` stays writable. Without it the build fails with `mkdir: cannot create directory '/work/.tmp_*': Permission denied`.
- `:Z` on the `-v` mount — SELinux relabel for the bind mount.
```bash
KMI=android14-6.1
podman run --rm --userns=keep-id -v "$(pwd)/kmod:/work:Z" \
ghcr.io/ylarod/ddk-min:${KMI}-20260313 sh -c '
CLANG_DIR=\$(echo /opt/ddk/clang/clang-r*/bin) \
KERNEL_SRC=/opt/ddk/kdir/${KMI} \
make'
# Package as KSU module
cp kmod/vpnhide_kmod.ko kmod/module/
(cd kmod/module && zip -qr ../../vpnhide-kmod.zip .)
```
On non-SELinux distros with rootful Podman, the plain `docker run` command above works with just `s/docker/podman/`.
## Local build with kernel source
If you prefer building against a local kernel source tree (e.g. for development or debugging), use the Makefile with `direnv`:
```bash
cd kmod/
cp .env.example .env
# Edit .env with paths to your kernel source and clang toolchain
direnv allow
make
./build-zip.py
```
See `.env.example` for the required variables. You need a prepared kernel source tree with headers and `Module.symvers`.
## Identifying your GKI generation
### Identifying your GKI generation
```bash
adb shell uname -r
@ -82,10 +35,20 @@ The output looks like `6.1.75-android14-11-g...` — the generation is `android1
| `6.6.xxx-android15-...` | android15-6.6 |
| `6.12.xxx-android16-...` | android16-6.12 |
## Local build with kernel source
If you prefer building against a local kernel source tree (e.g. for development or debugging), point `--kdir` at it. The script then runs natively without spinning up a container:
```bash
./kmod/build.py --kdir ~/kernels/android14-6.1 --kmi android14-6.1
```
You can also drop a `kmod/.env` file with `KDIR=` / `KERNEL_SRC=` / `CLANG_DIR=` (see `.env.example`) and use [`direnv`](https://direnv.net/) to load it automatically. The script picks those up via env, no flag needed.
## Install and test
```bash
adb push vpnhide-kmod.zip /sdcard/Download/
adb push vpnhide-kmod-<kmi>.zip /sdcard/Download/
# Install via KernelSU-Next manager -> Modules -> Install from storage
# Reboot
```
@ -100,8 +63,12 @@ adb shell "su -c 'cat /proc/vpnhide_targets'"
## Troubleshooting
**`insmod: Exec format error`** — symvers CRC mismatch. Use the DDK build (matched symvers).
**`insmod: Exec format error`** — symvers CRC mismatch. Rebuild via the DDK container (`./kmod/build.py --kmi <kmi>`); the container image carries matched symvers.
**`insmod: File exists`** — module already loaded. `rmmod vpnhide_kmod` first.
**kretprobe not firing** — check `dmesg | grep vpnhide` for registration messages and `/proc/vpnhide_targets` for correct UIDs. Target app UIDs change on reinstall — re-resolve via the VPN Hide app.
**`./kmod/build.py` says "neither podman nor docker found"** — install one (`dnf install podman` / `apt install docker.io`), or build natively against a local kernel source via `--kdir`.
**Bumping the DDK image tag** — single source of truth is `DDK_IMAGE_TAG` in `kmod/build.py`. Both this script and `.github/workflows/ci.yml`'s kmod matrix pin to the same value, so update both together.

View file

@ -34,7 +34,7 @@ CI builds are provided for all 7 GKI generations: `android12-5.10` through `andr
See [BUILDING.md](BUILDING.md) for the full guide (DDK Docker build, kernel source preparation, toolchain setup, `Module.symvers` generation).
```bash
cd kmod && ./build-zip.py
./kmod/build.py --kmi android14-6.1
```
## Install

View file

@ -1,167 +0,0 @@
#!/usr/bin/env python3
"""Build and package the KernelSU/Magisk kernel module zip.
Assembles a module staging directory so the committed module.prop stays at
its release version while the zip carries the actual build version (git describe).
Usage:
python3 build-zip.py --kdir /path/to/kdir --kmi android14-5.15 # explicit args
python3 build-zip.py --kdir /path/to/kdir --kmi android14-5.15 --out custom.zip # custom output
# Or use environment variables: KDIR and KMI (CLI args override env vars)
"""
from __future__ import annotations
import argparse
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
from build_lib import get_build_version, make_zip, version_sort_key # type: ignore[import-not-found]
# Module file names
KMOD_C = "vpnhide_kmod.c"
KMOD_KO = "vpnhide_kmod.ko"
def main() -> int:
parser = argparse.ArgumentParser(description="Build and package the kernel module zip.")
parser.add_argument(
"--kdir",
type=str,
help="Kernel source directory (overrides KDIR or KERNEL_SRC env var)",
)
parser.add_argument(
"--kmi",
type=str,
help="Kernel module interface variant (e.g., android14-5.15) for module.prop (overrides KMI env var)",
)
parser.add_argument(
"--out",
type=str,
help="Output zip filename (default: vpnhide-kmod-<kmi>.zip)",
)
parser.add_argument(
"--clang-dir",
type=str,
help="Clang binaries directory (overrides CLANG_DIR env var or auto-detects)",
)
args = parser.parse_args()
kmod_dir = Path(__file__).resolve().parent
os.chdir(kmod_dir)
# Resolve kdir: CLI arg > KDIR env var > KERNEL_SRC env var
if args.kdir:
kdir = args.kdir
kdir_src = "--kdir CLI argument"
else:
kdir = os.environ.get("KDIR")
kdir_src = "KDIR env var"
if not kdir:
kdir = os.environ.get("KERNEL_SRC")
kdir_src = "KERNEL_SRC env var"
if not kdir:
print("Error: --kdir argument, KDIR, or KERNEL_SRC env var is required", file=sys.stderr)
return 1
print(f"Using kdir from {kdir_src}: {kdir}")
# Resolve kmi: CLI arg > KMI env var
if args.kmi:
kmi = args.kmi
kmi_src = "--kmi CLI argument"
else:
kmi = os.environ.get("KMI")
kmi_src = "KMI env var"
if not kmi:
print("Error: --kmi argument or KMI env var is required", file=sys.stderr)
return 1
print(f"Using kmi from {kmi_src}: {kmi}")
# Set up environment for make
os.environ["KERNEL_SRC"] = kdir
# Resolve clang_dir: CLI arg > CLANG_DIR env var > auto-detect
clang_dir = None
clang_dir_src = None
if args.clang_dir:
clang_dir = args.clang_dir
clang_dir_src = "--clang-dir CLI argument"
else:
clang_dir = os.environ.get("CLANG_DIR")
if clang_dir:
clang_dir_src = "CLANG_DIR env var"
else:
# Auto-detect: In CI, clang is at /opt/ddk/clang/clang-r*/bin
clang_base = Path("/opt/ddk/clang")
if clang_base.exists():
clang_dirs = sorted(
(d for d in clang_base.iterdir() if d.is_dir() and d.name.startswith("clang-")),
key=lambda p: version_sort_key(p.name),
)
if clang_dirs:
clang_dir = str(clang_dirs[-1] / "bin")
clang_dir_src = "auto-detected from /opt/ddk/clang"
if clang_dir:
os.environ["CLANG_DIR"] = clang_dir
print(f"Using clang-dir from {clang_dir_src}: {clang_dir}")
else:
print("Warning: clang-dir not set, using system PATH", file=sys.stderr)
# Build the kernel module — let make decide whether anything needs
# rebuilding; its dependency tracking covers all sources, headers,
# and the kernel .config, not just vpnhide_kmod.c.
print("Building kernel module...")
subprocess.run(["make", "strip"], check=True)
# Assemble the module staging directory so the committed module.prop
# stays at its release version while the zip carries the actual build
# version (git describe).
staging = kmod_dir / "module-staging"
if staging.exists():
shutil.rmtree(staging)
shutil.copytree(kmod_dir / "module", staging)
shutil.copy(kmod_dir / KMOD_KO, staging / KMOD_KO)
# Get build version
build_version = get_build_version(kmod_dir.parent)
# Stamp version into module.prop
module_prop = staging / "module.prop"
content = module_prop.read_text(encoding="utf-8")
content = re.sub(r"^version=.*", f"version=v{build_version}", content, flags=re.MULTILINE)
# Add gkiVariant and updateJson
content = re.sub(r"^gkiVariant=.*", f"gkiVariant={kmi}", content, flags=re.MULTILINE)
if not re.search(r"^gkiVariant=", content, flags=re.MULTILINE):
content = content.rstrip() + f"\ngkiVariant={kmi}\n"
update_json_url = f"https://raw.githubusercontent.com/okhsunrog/vpnhide/main/update-json/update-kmod-{kmi}.json"
content = re.sub(r"^updateJson=.*", f"updateJson={update_json_url}", content, flags=re.MULTILINE)
if not re.search(r"^updateJson=", content, flags=re.MULTILINE):
content = content.rstrip() + f"\nupdateJson={update_json_url}\n"
module_prop.write_text(content, encoding="utf-8")
print(f"Stamped module.prop version=v{build_version} gkiVariant={kmi}")
# Create zip in parent directory (workspace root for CI)
out_zip = kmod_dir.parent / (args.out if args.out else f"vpnhide-kmod-{kmi}.zip")
if out_zip.exists():
out_zip.unlink()
make_zip(staging, out_zip)
shutil.rmtree(staging)
print()
print(f"Built: {out_zip.name}")
size_kb = out_zip.stat().st_size / 1024
print(f" {out_zip} ({size_kb:.1f} KB)")
return 0
if __name__ == "__main__":
sys.exit(main())

358
kmod/build.py Executable file
View file

@ -0,0 +1,358 @@
#!/usr/bin/env python3
"""Build the vpnhide kernel module zip — single entry point for both CI
and local builds.
Two modes, picked automatically:
1. **Native build** this Python process compiles `vpnhide_kmod.ko` and
packages the zip directly. Used when:
- `--inside-container` is passed (CI does this for clarity), OR
- `--kdir` is passed / `KDIR`|`KERNEL_SRC` is in env (local kernel
source build via direnv), OR
- `/opt/ddk/clang` is present (we're already inside the
`ghcr.io/ylarod/ddk-min` image auto-detects kdir + clang under
`/opt/ddk`).
2. **Container build** this Python process spawns podman/docker,
bind-mounts the repo, and re-invokes itself with
`--inside-container`. Used when none of the native conditions apply,
i.e. the typical local `./kmod/build.py --kmi android14-6.1`
workflow on a developer machine.
Either way the output is `vpnhide-kmod-<kmi>.zip` at the repo root,
identical between CI and local.
Examples:
./kmod/build.py --kmi android14-6.1 # local, default
./kmod/build.py --all # every GKI variant
./kmod/build.py --kdir ~/k/android14-6.1 --kmi android14-6.1
# local kernel source
The DDK container tag (`DDK_IMAGE_TAG`) is the single source of truth for
both this script and `.github/workflows/ci.yml`'s kmod matrix — keep them
in sync when bumping.
"""
from __future__ import annotations
import argparse
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
from build_lib import get_build_version, make_zip, version_sort_key # type: ignore[import-not-found]
# Module file name on disk after `make`.
KMOD_KO = "vpnhide_kmod.ko"
# Tag of `ghcr.io/ylarod/ddk-min:<kmi>-<TAG>`. Keep this in lockstep with
# the same constant in `.github/workflows/ci.yml` so a bump rebuilds
# locally and in CI from the exact same image.
DDK_IMAGE_TAG = "20260313"
# Every GKI variant we publish a kmod for. Order matches the CI matrix.
GKI_VARIANTS = (
"android12-5.10",
"android13-5.10",
"android13-5.15",
"android14-5.15",
"android14-6.1",
"android15-6.6",
"android16-6.12",
)
DEFAULT_KMI = "android14-6.1"
# ----- Native build (in-container or local kernel-source) ------------------
def detect_clang_dir() -> str | None:
"""Pick the highest-versioned `clang-r*/bin` under `/opt/ddk/clang`,
matching what the DDK image lays out. Used only when the user
didn't pass --clang-dir or set CLANG_DIR."""
clang_base = Path("/opt/ddk/clang")
if not clang_base.is_dir():
return None
candidates = sorted(
(d for d in clang_base.iterdir() if d.is_dir() and d.name.startswith("clang-")),
key=lambda p: version_sort_key(p.name),
)
return str(candidates[-1] / "bin") if candidates else None
def detect_kdir(kmi: str) -> str | None:
"""`/opt/ddk/kdir/<kmi>` is laid out by the DDK image."""
kdir = Path("/opt/ddk/kdir") / kmi
return str(kdir) if kdir.is_dir() else None
def native_build_one(
kmod_dir: Path,
kmi: str,
kdir: str,
clang_dir: str | None,
out: Path | None,
) -> int:
"""Compile + package one .ko into one zip, in the current process."""
print(f"[{kmi}] kdir={kdir}")
print(f"[{kmi}] clang-dir={clang_dir or '(system PATH)'}")
env = os.environ.copy()
env["KERNEL_SRC"] = kdir
if clang_dir:
env["CLANG_DIR"] = clang_dir
# `make strip` does the actual kernel-module build. Let make decide
# whether anything needs rebuilding — its dependency tracking covers
# all sources, headers, and the kernel .config, not just our .c file.
subprocess.run(["make", "-C", str(kmod_dir), "strip"], env=env, check=True)
# Stage the module skeleton from kmod/module/, drop the freshly built
# .ko in, patch module.prop with the real build version + gkiVariant
# + updateJson. The committed module.prop stays at the last release
# version so PR diffs don't churn it.
staging = kmod_dir / "module-staging"
if staging.exists():
shutil.rmtree(staging)
shutil.copytree(kmod_dir / "module", staging)
shutil.copy(kmod_dir / KMOD_KO, staging / KMOD_KO)
build_version = get_build_version(kmod_dir.parent)
module_prop = staging / "module.prop"
content = module_prop.read_text(encoding="utf-8")
content = re.sub(
r"^version=.*", f"version=v{build_version}", content, flags=re.MULTILINE
)
if re.search(r"^gkiVariant=", content, flags=re.MULTILINE):
content = re.sub(
r"^gkiVariant=.*", f"gkiVariant={kmi}", content, flags=re.MULTILINE
)
else:
content = content.rstrip() + f"\ngkiVariant={kmi}\n"
update_json_url = (
f"https://raw.githubusercontent.com/okhsunrog/vpnhide/main/update-json/"
f"update-kmod-{kmi}.json"
)
if re.search(r"^updateJson=", content, flags=re.MULTILINE):
content = re.sub(
r"^updateJson=.*", f"updateJson={update_json_url}", content, flags=re.MULTILINE
)
else:
content = content.rstrip() + f"\nupdateJson={update_json_url}\n"
module_prop.write_text(content, encoding="utf-8")
print(f"[{kmi}] stamped module.prop version=v{build_version} gkiVariant={kmi}")
out_zip = out if out else kmod_dir.parent / f"vpnhide-kmod-{kmi}.zip"
if out_zip.exists():
out_zip.unlink()
make_zip(staging, out_zip)
shutil.rmtree(staging)
size_kb = out_zip.stat().st_size / 1024
print(f"[{kmi}] built {out_zip} ({size_kb:.1f} KB)")
return 0
def run_native_mode(args: argparse.Namespace, kmod_dir: Path) -> int:
"""Resolve kdir + clang-dir from args/env/auto-detect and build each
requested kmi natively. Multi-kmi only makes sense when kdir is the
DDK layout `/opt/ddk/kdir/<kmi>` (auto-detected per kmi)."""
kmis = _select_kmis(args)
explicit_kdir = args.kdir or os.environ.get("KDIR") or os.environ.get("KERNEL_SRC")
explicit_clang = args.clang_dir or os.environ.get("CLANG_DIR")
if explicit_kdir and len(kmis) > 1:
print(
"error: --kdir / KDIR / KERNEL_SRC selects exactly one kernel tree, "
"so building multiple --kmi values doesn't make sense. "
"Drop --kdir to use auto-detection from /opt/ddk/kdir/<kmi>.",
file=sys.stderr,
)
return 2
if args.out and len(kmis) > 1:
print("error: --out is only valid with a single --kmi", file=sys.stderr)
return 2
for kmi in kmis:
kdir = explicit_kdir or detect_kdir(kmi)
if not kdir:
print(
f"error[{kmi}]: no kernel source. Pass --kdir, set KDIR/KERNEL_SRC, "
f"or run inside ghcr.io/ylarod/ddk-min where /opt/ddk/kdir/{kmi} exists.",
file=sys.stderr,
)
return 1
clang_dir = explicit_clang or detect_clang_dir()
rc = native_build_one(kmod_dir, kmi, kdir, clang_dir, args.out)
if rc:
return rc
return 0
# ----- Container orchestration --------------------------------------------
def find_runtime() -> tuple[str, bool]:
"""(binary, is_podman). Prefer podman when both are present —
rootless podman has the awkward SELinux/userns flags, so being
explicit about it avoids surprises on Fedora-family hosts."""
podman = shutil.which("podman")
docker = shutil.which("docker")
if podman:
return podman, True
if docker:
return docker, False
print(
"error: neither podman nor docker found in PATH. Install one, or "
"build natively by passing --kdir <kernel source> + --inside-container.",
file=sys.stderr,
)
sys.exit(1)
def container_build_one(
runtime: str, is_podman: bool, repo_root: Path, kmi: str
) -> None:
image = f"ghcr.io/ylarod/ddk-min:{kmi}-{DDK_IMAGE_TAG}"
mount_spec = f"{repo_root}:/work"
cmd = [runtime, "run", "--rm"]
if is_podman:
# Rootless podman + Fedora SELinux: keep host UID inside so the
# mount stays writable; ":Z" relabels the bind source so the
# container can read/write it.
cmd += ["--userns=keep-id"]
mount_spec += ":Z"
cmd += [
"-v",
mount_spec,
"-w",
"/work",
image,
"python3",
"kmod/build.py",
"--inside-container",
"--kmi",
kmi,
]
print(f"[{kmi}] {' '.join(cmd)}", flush=True)
subprocess.run(cmd, check=True)
def run_container_mode(args: argparse.Namespace, repo_root: Path) -> int:
if args.kdir or args.clang_dir or args.out:
# These flags only make sense in native mode — refusing here is
# better than silently dropping them after a 2-minute container
# spin-up.
print(
"error: --kdir / --clang-dir / --out are only valid with native "
"builds (pass --inside-container or run inside the DDK image).",
file=sys.stderr,
)
return 2
kmis = _select_kmis(args)
runtime, is_podman = find_runtime()
print(f"Using {'podman' if is_podman else 'docker'} at {runtime}")
for kmi in kmis:
container_build_one(runtime, is_podman, repo_root, kmi)
print()
print("Built artifacts (at repo root):")
for kmi in kmis:
out = repo_root / f"vpnhide-kmod-{kmi}.zip"
marker = "ok" if out.is_file() else "MISSING"
size = f"{out.stat().st_size / 1024:.1f} KB" if out.is_file() else ""
print(f" [{marker}] {out.name} {size}")
return 0
# ----- Argument parsing ---------------------------------------------------
def _select_kmis(args: argparse.Namespace) -> tuple[str, ...]:
if args.all:
return GKI_VARIANTS
if args.kmi:
return tuple(args.kmi)
return (DEFAULT_KMI,)
def main() -> int:
parser = argparse.ArgumentParser(
description="Build the vpnhide kernel module zip (CI + local).",
)
parser.add_argument(
"--kmi",
action="append",
choices=GKI_VARIANTS,
help=f"GKI variant to build (repeatable). Default: {DEFAULT_KMI}.",
)
parser.add_argument(
"--all",
action="store_true",
help="Build every supported GKI variant (same matrix as CI).",
)
parser.add_argument(
"--inside-container",
action="store_true",
help=(
"Force native build in the current process. CI passes this "
"explicitly; locally you only need it if you're inside a "
"container (or have a kernel source set up) and the auto-"
"detect didn't pick that up."
),
)
parser.add_argument(
"--kdir",
type=str,
help=(
"Kernel source directory (overrides KDIR/KERNEL_SRC). Implies "
"native mode."
),
)
parser.add_argument(
"--clang-dir",
type=str,
help=(
"Clang binaries directory (overrides CLANG_DIR; auto-detected "
"from /opt/ddk/clang/clang-r* in DDK images)."
),
)
parser.add_argument(
"--out",
type=Path,
help="Output zip path (single --kmi only). Default: vpnhide-kmod-<kmi>.zip in repo root.",
)
args = parser.parse_args()
if args.all and args.kmi:
parser.error("--all and --kmi are mutually exclusive")
kmod_dir = Path(__file__).resolve().parent
repo_root = kmod_dir.parent
# Native conditions: explicit flag, explicit kernel source, or we're
# already in a DDK image.
native = (
args.inside_container
or bool(args.kdir)
or "KDIR" in os.environ
or "KERNEL_SRC" in os.environ
or detect_clang_dir() is not None
)
if native:
return run_native_mode(args, kmod_dir)
return run_container_mode(args, repo_root)
if __name__ == "__main__":
sys.exit(main())

14
lsposed/native/build.rs Normal file
View file

@ -0,0 +1,14 @@
//! Build-time glue for the lsposed native cdylib (`libvpnhide_checks.so`).
//!
//! Only purpose right now: align ELF LOAD segments on 16 KiB so the
//! library loads cleanly on 16 KiB-page Android devices (Pixel 8 Pro on
//! Android 16, and any future hardware that ships with 16 KiB pages by
//! default). NDK r28+ already does this by default, but passing the flag
//! explicitly keeps r27 builds compatible too — defence in depth.
//!
//! See: <https://developer.android.com/guide/practices/page-sizes>
fn main() {
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rustc-link-arg=-Wl,-z,max-page-size=16384");
}

View file

@ -1,6 +1,6 @@
"""Shared helpers for build scripts.
Used by kmod/build-zip.py, portshide/build-zip.py, zygisk/build-zip.py,
Used by kmod/build.py, portshide/build-zip.py, zygisk/build.py,
and scripts/build-version.py.
Stdlib-only on purpose: scripts/build-version.py is invoked from

View file

@ -74,7 +74,7 @@ Requirements:
Build and package:
```bash
./build-zip.py
./build.py
# Output: target/vpnhide-zygisk.zip (~180 KB)
```
@ -131,7 +131,7 @@ VPN interface prefixes: `tun`, `ppp`, `tap`, `wg`, `ipsec`, `xfrm`, `utun`, `l2t
- `build.rs` -- drives CMake on the shadowhook submodule
- `third_party/android-inline-hook/` -- submodule (our shadowhook fork)
- `module/` -- KernelSU/Magisk module metadata
- `build-zip.py` -- cross-compile + package script
- `build.py` -- cross-compile + package script
## License

View file

@ -67,6 +67,14 @@ fn main() {
println!("cargo:rustc-link-lib=static=shadowhook");
println!("cargo:rustc-link-lib=log");
// Align ELF LOAD segments on 16 KiB so the cdylib loads cleanly on
// 16 KiB-page Android devices (Pixel 8 Pro on Android 16 and any
// future hardware that ships with 16 KiB pages by default). lld's
// 4 KiB default would otherwise produce a startup warning, and on
// future Androids may become a hard load failure.
// See: <https://developer.android.com/guide/practices/page-sizes>
println!("cargo:rustc-link-arg=-Wl,-z,max-page-size=16384");
// shadowhook's inline-patching code emits a libcall to `__clear_cache`
// (I-cache flush after rewriting instructions). Rust's own
// `compiler_builtins` doesn't export that symbol on aarch64-android,