diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..2d3bba382 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,15 @@ +# These are supported funding model platforms + +github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +polar: # Replace with a single Polar username +buy_me_a_coffee: # Replace with a single Buy Me a Coffee username +thanks_dev: # Replace with a single thanks.dev username +custom: ['https://afdian.com/a/DroidVM'] diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 48f253d69..dd7aa30a9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,6 +5,7 @@ on: branches: [ "**" ] pull_request: branches: [ "**" ] + workflow_dispatch: concurrency: group: build-${{ github.ref }} @@ -13,6 +14,8 @@ concurrency: jobs: build: runs-on: ubuntu-latest + env: + DROIDVM_PREBUILT_COMPRESSION_LEVEL: "9" steps: - name: Checkout repository @@ -76,13 +79,15 @@ jobs: echo "name=${ver}" >> "$GITHUB_OUTPUT" - name: Upload debug APK - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: DroidVM-${{ steps.version.outputs.name }}-debug path: app/build/outputs/apk/debug/*.apk + archive: false - name: Upload release APK - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: DroidVM-${{ steps.version.outputs.name }}-release path: app/build/outputs/apk/release/*.apk + archive: false diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 000000000..0318d0833 --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,41 @@ +name: Checks + +on: + push: + branches: [ "**" ] + pull_request: + branches: [ "**" ] + workflow_dispatch: + +concurrency: + group: checks-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + # No submodules: these checks only look at this repo's own sources. + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: No non-ASCII in code/config + run: python3 scripts/ci/check_ascii.py + + - name: Translations complete across all locales + run: python3 scripts/ci/check_translations.py + + - name: Use fmt() instead of string concatenation + run: python3 scripts/ci/check_string_concat.py + + - name: Icons are vector drawables + run: python3 scripts/ci/check_vector_icons.py + + - name: No signature over the parameter limit + run: python3 scripts/ci/check_arity.py diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml new file mode 100644 index 000000000..3ec3eca76 --- /dev/null +++ b/.github/workflows/dev-release.yml @@ -0,0 +1,127 @@ +name: Dev release + +# Every push to the default branch (re)publishes a rolling pre-release on a +# dedicated `dev` tag. The tag is a moving label -- it is deleted and recreated +# at the current commit each run, so it never pins a specific version. A GitHub +# Release must be attached to a tag, hence the dummy `dev` tag. +# (release.yml still cuts real, versioned releases on real tag pushes.) +on: + push: + branches: [ master ] + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: dev-release + cancel-in-progress: true + +jobs: + dev-release: + runs-on: ubuntu-latest + env: + DROIDVM_PREBUILT_COMPRESSION_LEVEL: "9" + HAS_SIGN_KEY: ${{ secrets.APP_SIGN_KEY_JKS != '' }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: recursive + fetch-depth: 0 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + with: + packages: build-tools;36.0.0 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + # With a signing key: build and sign a real release APK (current + # behaviour). Without one: ship the debug APK -- signed with the + # auto-generated debug keystore -- instead of an unsigned release. + - name: Build release APK + if: ${{ env.HAS_SIGN_KEY == 'true' }} + run: ./gradlew assembleRelease + + - name: Sign release APK + if: ${{ env.HAS_SIGN_KEY == 'true' }} + env: + APP_SIGN_KEY_JKS: ${{ secrets.APP_SIGN_KEY_JKS }} + APP_SIGN_KEY_PASS: ${{ secrets.APP_SIGN_KEY_PASS }} + run: | + BUILD_TOOLS="${ANDROID_HOME}/build-tools/36.0.0" + echo "$APP_SIGN_KEY_JKS" | base64 -d > /tmp/keystore.jks + UNSIGNED=$(find app/build/outputs/apk/release -name '*.apk' | head -1) + ALIGNED="${UNSIGNED%.apk}-aligned.apk" + "${BUILD_TOOLS}/zipalign" -f -v 4 "$UNSIGNED" "$ALIGNED" + "${BUILD_TOOLS}/apksigner" sign \ + --ks /tmp/keystore.jks \ + --ks-pass "pass:${APP_SIGN_KEY_PASS}" \ + --key-pass "pass:${APP_SIGN_KEY_PASS}" \ + --out "$UNSIGNED" \ + "$ALIGNED" + rm -f "$ALIGNED" /tmp/keystore.jks + + - name: Build debug APK (no signing key) + if: ${{ env.HAS_SIGN_KEY != 'true' }} + run: ./gradlew assembleDebug + + # Derive a version string from the latest real tag, e.g. `0.0.6.r63.52993d4` + # (matches release.yml). The moving `dev` tag is excluded so it never + # shadows the real version tag in `git describe`. + - name: Get version info + id: version + run: | + set -euo pipefail + if desc="$(git describe --long --tags --exclude dev 2>/dev/null)"; then + desc="$(echo "$desc" | sed 's/^[vV]//')" + if echo "$desc" | grep -qE '\-0\-g[0-9a-f]+$'; then + ver="$(echo "$desc" | sed 's/-0-g[0-9a-f]*$//')" + else + ver="$(echo "$desc" | sed 's/-\([0-9]*\)-g/.r\1./')" + fi + else + ver="$(git rev-parse --short HEAD)" + fi + echo "name=${ver}" >> "$GITHUB_OUTPUT" + + - name: Stage APK + run: | + set -euo pipefail + mkdir -p release + apk="release/DroidVM-${{ steps.version.outputs.name }}-dev.apk" + if [ "${HAS_SIGN_KEY}" = "true" ]; then + cp app/build/outputs/apk/release/*.apk "$apk" + else + cp app/build/outputs/apk/debug/*.apk "$apk" + fi + + - name: Publish rolling dev pre-release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VER: ${{ steps.version.outputs.name }} + run: | + set -euo pipefail + + # Drop the previous dev release + tag, then recreate at this commit so + # `dev` always points at latest master (a moving, version-less tag). + gh release delete dev --yes --cleanup-tag 2>/dev/null || true + git push origin :refs/tags/dev 2>/dev/null || true + + gh release create dev "release/DroidVM-${VER}-dev.apk" \ + --prerelease \ + --target "${GITHUB_SHA}" \ + --title "DroidVM dev (${VER})" \ + --notes "Rolling development build from ${GITHUB_SHA} (${VER}). Not a stable release." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 78ca65c98..146915b7e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,6 +3,7 @@ name: Release on: push: tags: [ "**" ] + workflow_dispatch: permissions: contents: write @@ -10,6 +11,9 @@ permissions: jobs: release: runs-on: ubuntu-latest + env: + DROIDVM_PREBUILT_COMPRESSION_LEVEL: "9" + HAS_SIGN_KEY: ${{ secrets.APP_SIGN_KEY_JKS != '' }} steps: - name: Checkout repository @@ -35,10 +39,15 @@ jobs: - name: Grant execute permission for gradlew run: chmod +x gradlew + # With a signing key: build and sign a real release APK (current + # behaviour). Without one: ship the debug APK -- signed with the + # auto-generated debug keystore -- instead of an unsigned release. - name: Build release APK + if: ${{ env.HAS_SIGN_KEY == 'true' }} run: ./gradlew assembleRelease - name: Sign release APK + if: ${{ env.HAS_SIGN_KEY == 'true' }} env: APP_SIGN_KEY_JKS: ${{ secrets.APP_SIGN_KEY_JKS }} APP_SIGN_KEY_PASS: ${{ secrets.APP_SIGN_KEY_PASS }} @@ -56,6 +65,10 @@ jobs: "$ALIGNED" rm -f "$ALIGNED" /tmp/keystore.jks + - name: Build debug APK (no signing key) + if: ${{ env.HAS_SIGN_KEY != 'true' }} + run: ./gradlew assembleDebug + - name: Get version info id: version run: | @@ -69,8 +82,13 @@ jobs: - name: Rename APK run: | + set -euo pipefail mkdir -p release - cp app/build/outputs/apk/release/*.apk "release/DroidVM-v${{ steps.version.outputs.name }}.apk" + if [ "${HAS_SIGN_KEY}" = "true" ]; then + cp app/build/outputs/apk/release/*.apk "release/DroidVM-v${{ steps.version.outputs.name }}.apk" + else + cp app/build/outputs/apk/debug/*.apk "release/DroidVM-v${{ steps.version.outputs.name }}-debug.apk" + fi - name: Create GitHub Release uses: softprops/action-gh-release@v2 @@ -78,4 +96,4 @@ jobs: name: DroidVM v${{ steps.version.outputs.name }} draft: false prerelease: ${{ contains(github.ref_name, '-') }} - files: release/DroidVM-v${{ steps.version.outputs.name }}.apk + files: release/*.apk diff --git a/.gitignore b/.gitignore index 93653b763..478500f90 100644 --- a/.gitignore +++ b/.gitignore @@ -3,9 +3,32 @@ /local.properties /.idea /.vscode +/DroidVM-Prebuilt-Root +/DroidVM-Prebuilt-Root_ .DS_Store /build /captures .externalNativeBuild .cxx local.properties + +# Legacy native-util source locations -- the daemons + lbx are now built by the +# DroidVM-Prebuilt-Root pipeline, not staged into the app tree. +/app/src/thirdparty +/app/src/main/rust + +# Stale daemon binaries from the old build.sh staging (now delivered via the +# prebuilt tar.xz); ignore leftovers so they are never packaged or committed. +/app/src/main/assets/bin/*/bridgedhcp +/app/src/main/assets/bin/*/gvswitch +/app/src/main/assets/bin/*/lbx +/app/src/main/assets/bin/*/netbox +/app/src/main/assets/bin/*/pbridge + +# Terminal font: build.sh fetches the ~2.3MB Maple Mono NL NF TTF on first +# build; only the OFL license text under app/src/main/assets/fonts is committed. +/app/src/main/assets/fonts/*.ttf + +# Python bytecode from the scripts/ci checks. +__pycache__/ +*.pyc diff --git a/ADDITIONAL-PERMISSIONS b/ADDITIONAL-PERMISSIONS new file mode 100644 index 000000000..987ba2602 --- /dev/null +++ b/ADDITIONAL-PERMISSIONS @@ -0,0 +1,61 @@ +Additional permissions under GNU GPL version 3, section 7 +========================================================= + +These additional permissions apply to the parts of this work copyrighted by +the Droid-VM organization and by contributors to this project. They do not +apply to material copyrighted by others -- in particular, material inherited +from the upstream project this repository is derived from, which remains under +its own terms and is not affected by anything below. + + +1. Upstream contribution +------------------------ + +You may modify this material and distribute the result under the license terms +that an Upstream Project requires of contributions to it, for the sole purpose +of having that material included in the Upstream Project's official repository. + +This permission takes effect only for material that the Upstream Project +accepts and publishes. Proposing material to an Upstream Project -- opening a +merge request, posting a patch, or any other act of submission -- does not by +itself place that material under any license other than the GNU GPL. Material +that is not accepted remains under the GNU GPL alone. + +Once an Upstream Project has published material under its own license, that +license governs the copy the Upstream Project published. Nothing in this +document restricts what anyone may do with that copy. + +This permission does not authorise distribution under any other license for any +other purpose. In particular, it does not authorise distributing this material +under a permissive license to the public at large, to a fork, or to a +redistributor, whether or not an Upstream Project was also asked to take it. + + +2. Upstream Projects +-------------------- + +"Upstream Project" means one of the following, and no others: + + + +An Upstream Project's "official repository" is the repository named above, or a +repository that project's own maintainers designate as its successor. A fork, +a mirror, a vendored copy, and a redistribution are not official repositories. + + +3. Notes +-------- + +Under GPL version 3 section 7, a recipient may remove these additional +permissions from a copy they convey. Doing so does not affect the terms on +which the permissions are offered here, and does not withdraw them from anyone +who received them. + +Adding permissions on top of the GNU GPL does not restrict any freedom the GPL +grants. Every recipient keeps the full GPL grant regardless of whether they use +anything in this document. + +Contributions to this project are made under this project's license, which +includes these additional permissions. A contributor therefore does not need to +sign a separate agreement for their contribution to be eligible for upstream +submission under section 1. See CONTRIBUTING.md. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..903a743a9 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,32 @@ +# Contributing + +## Sign-off + +Every commit must carry a `Signed-off-by:` line certifying the +[Developer Certificate of Origin 1.1](https://developercertificate.org/): + + git commit -s + +## What signing off means here + +The DCO asks you to certify that you have the right to submit your work under +the license this project uses. For this project that license is the GNU GPL +**plus the additional permissions in `ADDITIONAL-PERMISSIONS`**, so a +contribution made under it carries those permissions too. + +That is deliberate, and it is why this project asks for a DCO rather than a +CLA. The additional permissions let anyone relicense material from this project +in order to get it accepted into the upstream project it belongs in. If +contributions did not carry those permissions, every contribution would become +a piece of the tree that could never be sent upstream, and the permission would +quietly stop meaning anything as the project grew. + +You keep your copyright. Nothing is assigned to anyone. + +## Do not sign off on someone else's license + +If you are contributing material you did not write -- code ported from another +project, a header copied from a kernel, a function translated from elsewhere -- +say so in the file and keep that material under its own license. Do not put a +project SPDX tag on it. Several files in these repositories are in exactly that +position and are marked accordingly. diff --git a/LICENSING.md b/LICENSING.md new file mode 100644 index 000000000..bedb16593 --- /dev/null +++ b/LICENSING.md @@ -0,0 +1,31 @@ +# Licensing + +This repository holds two kinds of material and they are licensed differently. + +## Material inherited from upstream + + + +Every file that came from an upstream project stays under that project's +license. Nothing here relicenses it, and modifications to those files do not +relicense them either — a patched upstream file is still an upstream file. + +## Material written for DroidVM + +Files carrying `SPDX-License-Identifier: GPL-3.0-or-later` are DroidVM work +and are licensed under the GNU GPL, version 3 or later, **with the +additional permissions in `ADDITIONAL-PERMISSIONS`**. + +Those permissions exist so this work can go upstream. They let anyone +relicense it under the terms an upstream project requires, for the purpose of +getting it merged there — and only for that purpose. Once upstream publishes +it, upstream's license governs that copy. + +## Third-party material that is neither + +None. The app is DroidVM work throughout; `app/src/main/java/cn/classfun/droidvm` +is the only source tree and nothing is vendored into it. + +## Contributing + +See `CONTRIBUTING.md`. Sign-off is required; there is no CLA. diff --git a/README.md b/README.md index f8fe90236..957ed79f7 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,13 @@ [简体中文](README.zh.md) | [English](README.md) -A virtual machine manager for Android, powered by the **Qualcomm Gunyah Hypervisor** +An Android virtual machine manager with support for multiple hypervisors, including Qualcomm Gunyah, MediaTek GenieZone, and Linux KVM. + +Supported hypervisors: + +- Qualcomm Gunyah (`/dev/gunyah`) +- MediaTek GenieZone (`/dev/gzvm`) +- Linux KVM (`/dev/kvm`) Create and manage lightweight VMs directly on your device with near-native performance. @@ -34,8 +40,11 @@ Create and manage lightweight VMs directly on your device with near-native perfo - **Android 13** (API 33) or newer - **Root access** (Magisk, KernelSU, APatch, or similar) -- **Qualcomm Snapdragon 8 Gen 3** (SM8650) or newer SoC, **Qualcomm Snapdragon 8 Elite** (SM8750) recommended, with Gunyah Hypervisor enabled -- **Virtualization** enabled (/dev/gunyah or /dev/kvm) +- A supported ARM64 device with hardware virtualization enabled by firmware and kernel: + - Qualcomm: **Snapdragon 8 Gen 3** (SM8650) or newer SoC, **Snapdragon 8 Elite** (SM8750) recommended, with Gunyah enabled + - MediaTek: **Dimensity 9000** or newer SoC with GenieZone enabled + - Other ARM64 devices: booted with EL2 and Linux KVM enabled +- An available virtualization device node: `/dev/gunyah`, `/dev/gzvm`, or `/dev/kvm` ## Building diff --git a/README.zh.md b/README.zh.md index d98bc93c4..222ef4706 100644 --- a/README.zh.md +++ b/README.zh.md @@ -4,7 +4,13 @@ [简体中文](README.zh.md) | [English](README.md) -基于 **高通 Gunyah Hypervisor** 的 Android 虚拟机管理器 +支持多种 Hypervisor 的 Android 虚拟机管理器,包括高通 Gunyah、联发科 GenieZone 和 Linux KVM。 + +支持的虚拟化方案: + +- 高通 Gunyah(`/dev/gunyah`) +- 联发科 GenieZone(`/dev/gzvm`) +- Linux KVM(`/dev/kvm`) 在安卓设备上直接创建和管理轻量级虚拟机,享受接近原生的性能。 @@ -34,8 +40,11 @@ - **Android 13**(API 33)或更高版本 - **Root 权限**(Magisk、KernelSU、APatch 或类似工具) -- **高通骁龙 8 Gen 3**(SM8650)或更新的 SoC,推荐 **高通骁龙 8 Elite**(SM8750),需启用 Gunyah Hypervisor -- 已启用 **虚拟化** 支持(/dev/gunyah 或 /dev/kvm) +- 固件和内核已启用硬件虚拟化的 ARM64 设备: + - 高通:**骁龙 8 Gen 3**(SM8650)或更新 SoC,推荐 **骁龙 8 Elite**(SM8750),需启用 Gunyah + - 联发科:**天玑 9000** 或更新 SoC,需启用 GenieZone + - 其他 ARM64 设备:以 EL2 启动,并启用 Linux KVM +- 可用的虚拟化设备节点:`/dev/gunyah`、`/dev/gzvm` 或 `/dev/kvm` ## 构建 @@ -59,4 +68,3 @@ git submodule update --init --recursive ## 许可证 本项目基于 **GNU 通用公共许可证 v3.0** 发布。详见 [LICENSE.txt](LICENSE.txt)。 - diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 27bfebacf..8263f4ac7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,7 +1,16 @@ @file:Suppress("UnstableApiUsage") +import java.net.URI +import java.security.MessageDigest + plugins { alias(libs.plugins.android.application) + // Compose is here for one screen: the Markdown notes editor and the cards that render what + // it wrote, which come from a Compose-only library. Everything else stays Java and Views. + // Kotlin itself needs no plugin -- AGP 9 compiles it out of the box and already owns the + // "kotlin" extension -- so only the Compose compiler plugin is applied, pinned to the same + // Kotlin version AGP carries. + alias(libs.plugins.kotlin.compose) } fun runGit(vararg args: String): String { @@ -16,16 +25,26 @@ fun runGit(vararg args: String): String { return output } +// Tolerant variant: returns null instead of throwing, for git calls that may +// legitimately fail (e.g. describe on a checkout with no tags). +fun runGitOrNull(vararg args: String): String? = + runCatching { runGit(*args) }.getOrNull() + val gitCommitCount = runGit("rev-list", "--count", "HEAD").toInt() val gitShortSha = runGit("rev-parse", "--short", "HEAD") -val gitDescribe = runGit("describe", "--long", "--tags") +// describe fails on a checkout without tags (shallow clone, or tags not fetched); +// fall back to a 0.0.0 base so the build still works and the version is clearly +// marked as tag-less. Exclude the rolling `dev` tag (dev-release.yml recreates it +// at HEAD each push) so it never shadows real version tags in the version name. +val gitDescribe = (runGitOrNull("describe", "--long", "--tags", "--exclude=dev") + ?: "0.0.0-$gitCommitCount-g$gitShortSha") .removePrefix("v").removePrefix("V") val generatedVersionName: String = if (gitDescribe.matches(Regex(".*-0-g[0-9a-f]+$"))) { gitDescribe.replace(Regex("-0-g[0-9a-f]+$"), "") } else { gitDescribe - .replace(Regex("([^-]*-g)"), "r\$1") + .replace(Regex("([^-]*-g)"), $$"r$1") .replace("-", ".") } @@ -37,15 +56,14 @@ println("Version code: $generatedVersionCode") android { namespace = "cn.classfun.droidvm" compileSdk { - version = release(36) { - minorApiLevel = 1 + version = release(37) { } } defaultConfig { applicationId = "cn.classfun.droidvm" minSdk = 33 - targetSdk = 36 + targetSdk = 37 versionCode = generatedVersionCode versionName = generatedVersionName @@ -82,9 +100,48 @@ android { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 } + kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11 + } + } buildFeatures { aidl = true buildConfig = true + compose = true + } + sourceSets { + getByName("main") { + // Third-party source kept in the tree rather than pulled as an artifact, in its own + // root so that "not ours, and under its own licence" is structural. See its README. + kotlin.srcDir("src/main/vendor") + } + } + testOptions { + unitTests { + // Lets a unit test cover a class that logs. The alternative -- keeping every testable + // class free of android.util.Log -- stopped being tenable at the H.264 side channel, + // whose whole subject is a socket and a thread outliving the object that owned them, + // and which says so out loud when they do. Nothing here asserts on a stub's return + // value; the stubs are only there so the class under test can be built at all. + isReturnDefaultValues = true + } + } + packaging { + jniLibs { + // Extract native libs to a real on-disk dir so lbx (shipped as + // liblbx.so) is an executable file the app can run from its own + // nativeLibraryDir -- no root, no daemon needed for a URL fetch. + useLegacyPackaging = true + } + } + androidResources { + // prebuilt--comptime.zip is a build-time input only: Gradle unpacks + // it into jniLibs (lib//liblbx.so). Never ship it as an APK asset. + // Also exclude the legacy *.7z (replaced by *.tar.xz) so a stale archive + // lingering in the prebuilts submodule can't double the APK size. The + // runtime prebuilt-.tar.xz + .json stay packaged as assets. + ignoreAssetsPatterns += listOf("*-comptime.zip", "*.7z") } } @@ -110,24 +167,195 @@ abstract class CopyNativeBinAssetsTask : DefaultTask() { dest.parentFile.mkdirs() src.copyTo(dest, overwrite = true) } - val libraries = setOf("libsimpledump.so", "libunixhelper.so") - cmakeDir.walkTopDown() - .filter { it.name in libraries && it.isFile } - .forEach { src -> - val abi = src.parentFile.name - val dest = File(outDir, "lib/$abi/${src.name}") - dest.parentFile.mkdirs() - src.copyTo(dest, overwrite = true) + } +} + +// Unpack each prebuilt--comptime.zip from the prebuilts submodule into +// jniLibs//. These are files that must be executable from the app's +// nativeLibraryDir (currently liblbx.so) -- the data-dir copy extracted from the +// runtime tar.xz isn't executable by the app's untrusted_app SELinux domain. +// Build-time extraction (unlike a first-run extract) is fine because the package +// installer, not the app, populates nativeLibraryDir. +abstract class UnpackComptimeJniLibsTask : DefaultTask() { + @get:Inject + abstract val archives: ArchiveOperations + + @get:Inject + abstract val fs: FileSystemOperations + + @get:InputDirectory + abstract val prebuiltsDir: DirectoryProperty + + @get:OutputDirectory + abstract val outputDir: DirectoryProperty + + @TaskAction + fun unpack() { + val outDir = outputDir.get().asFile + outDir.deleteRecursively() + val prebuilts = prebuiltsDir.get().asFile + val re = Regex("^prebuilt-(.+)-comptime\\.zip$") + val zips = prebuilts.listFiles { f -> f.isFile && re.matches(f.name) } ?: return + for (zip in zips) { + val abi = re.find(zip.name)!!.groupValues[1] + fs.copy { + from(archives.zipTree(zip)) + into(File(outDir, abi)) } + } } } +// Native-dev hook: when DroidVM-Prebuilt-Root is checked out at the repo root +// with a non-empty auto-build/ (someone is editing a native util locally), +// regenerate the prebuilt-* artifacts into the prebuilts submodule before they +// are packaged. Pure-Java devs don't have this dir, so the CI-published +// submodule artifacts are used as-is (the task no-ops via onlyIf). +abstract class RegenPrebuiltsTask : DefaultTask() { + @get:Inject + abstract val exec: ExecOperations + + // The working directory only; the sources Gradle should hash are declared below. Hashing the + // whole root would drag in manual-build/, which carries hundreds of megabytes of binaries the + // script never reads. + @get:Internal + abstract val prebuiltRoot: DirectoryProperty + + /** What the script builds from: a change here is the only reason to pack again. */ + @get:InputDirectory + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val autoBuildSources: DirectoryProperty + + /** The script itself, so editing it invalidates the last run too. */ + @get:InputFile + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val autoBuildScript: RegularFileProperty + + @get:OutputDirectory + abstract val prebuiltsOut: DirectoryProperty + + @TaskAction + fun regen() { + val root = prebuiltRoot.get().asFile + val out = prebuiltsOut.get().asFile + logger.lifecycle("DroidVM-Prebuilt-Root detected; regenerating prebuilts via auto-build.py") + exec.exec { + workingDir = root + commandLine("python3", "auto-build.py", "--out", out.absolutePath) + } + } +} + +// Fetch the terminal font (Maple Mono NL NF) at build time so CI (./gradlew) and +// local builds behave identically -- it used to live only in build.sh, which CI +// never runs. Download is best-effort: a failure logs a warning and the app falls +// back to the system monospace, so it never breaks the build. +abstract class FetchTerminalFontTask : DefaultTask() { + @get:Inject + abstract val archives: ArchiveOperations + + @get:Input + abstract val url: Property + + @get:Input + abstract val sha256: Property + + @get:OutputDirectory + abstract val outputDir: DirectoryProperty + + @TaskAction + fun fetch() { + val ttf = File(outputDir.get().asFile, "fonts/MapleMonoNL-NF-Regular.ttf") + if (ttf.isFile && sha256Hex(ttf) == sha256.get()) return + ttf.parentFile.mkdirs() + val tmpZip = File(temporaryDir, "font.zip") + try { + URI(url.get()).toURL().openStream().use { input -> + tmpZip.outputStream().use { input.copyTo(it) } + } + } catch (e: Exception) { + logger.warn("Could not download terminal font (${e.message}); app uses system monospace") + return + } + val src = archives.zipTree(tmpZip).matching { include("**/*NL-NF-Regular.ttf") } + .files.firstOrNull() + if (src == null) { + logger.warn("Terminal font zip had no NL-NF-Regular.ttf; app uses system monospace") + return + } + src.copyTo(ttf, overwrite = true) + if (sha256Hex(ttf) != sha256.get()) + logger.warn("Terminal font sha256 mismatch (upstream re-released?); keeping it anyway") + } + + private fun sha256Hex(f: File): String { + val md = MessageDigest.getInstance("SHA-256") + f.inputStream().use { ins -> + val buf = ByteArray(8192) + var n = ins.read(buf) + while (n >= 0) { md.update(buf, 0, n); n = ins.read(buf) } + } + return md.digest().joinToString("") { "%02x".format(it.toInt() and 0xFF) } + } +} + +val prebuiltRootDir = rootProject.layout.projectDirectory.dir("DroidVM-Prebuilt-Root") +val prebuiltsSubmoduleDir = rootProject.layout.projectDirectory.dir("app/src/main/assets/prebuilts") +val regenPrebuilts = tasks.register("regenPrebuilts") { + description = "Regenerate prebuilts from DroidVM-Prebuilt-Root if it exists and has auto-build/ content" + prebuiltRoot.set(prebuiltRootDir) + autoBuildSources.set(prebuiltRootDir.dir("auto-build")) + autoBuildScript.set(prebuiltRootDir.file("auto-build.py")) + prebuiltsOut.set(prebuiltsSubmoduleDir) + // Only when a native dev has checked out DroidVM-Prebuilt-Root with source + // under auto-build/. Otherwise, the published submodule artifacts are used. + onlyIf { + val autoBuild = prebuiltRootDir.dir("auto-build").asFile + prebuiltRootDir.file("auto-build.py").asFile.isFile && + autoBuild.isDirectory && + (autoBuild.listFiles()?.any { it.name != ".gitignore" } == true) + } +} +// Hung on the two tasks that actually read the prebuilts dir, not on preBuild: preBuild is a +// dependency of every JVM unit test run as well, and packing an APK payload is not something a +// test of the rules engine should wait for. + androidComponents { onVariants { variant -> val variantName = variant.name.replaceFirstChar { it.uppercase() } + val unpackComptimeTask = tasks.register( + "unpackComptimeJniLibs${variantName}" + ) { + description = "Unpack prebuilt--comptime.zip into jniLibs// for ${variant.name}" + dependsOn(regenPrebuilts) + + prebuiltsDir.set(prebuiltsSubmoduleDir) + outputDir.set( + layout.buildDirectory.dir("generated/comptime_jnilibs/${variant.name}") + ) + } + variant.sources.jniLibs?.addGeneratedSourceDirectory( + unpackComptimeTask, UnpackComptimeJniLibsTask::outputDir + ) + // The other reader of the prebuilts dir. Declared here rather than on preBuild so that a + // unit test run never packs anything. + tasks.matching { it.name == "merge${variantName}Assets" } + .configureEach { dependsOn(regenPrebuilts) } + val fetchFontTask = tasks.register( + "fetchTerminalFont${variantName}" + ) { + description = "Fetch terminal font for ${variant.name}" + url.set("https://github.com/subframe7536/maple-font/releases/download/v7.9/MapleMonoNL-NF.zip") + sha256.set("aa3b096bc92df8503d77482b285a0567bafa6e83230d969700f455e610b1f655") + outputDir.set(layout.buildDirectory.dir("generated/font_assets/${variant.name}")) + } + variant.sources.assets?.addGeneratedSourceDirectory( + fetchFontTask, FetchTerminalFontTask::outputDir + ) val copyNativeTask = tasks.register( "copyNativeBinAssets${variantName}" ) { + description = "Copy native bin assets for ${variant.name}" dependsOn("externalNativeBuild${variantName}") cmakeOutputDir.set( layout.buildDirectory.dir( @@ -151,13 +379,24 @@ dependencies { implementation(libs.annotation.jvm) implementation(libs.appcompat) implementation(libs.auto.service.annotations) + implementation(platform(libs.compose.bom)) + implementation(libs.compose.animation) + implementation(libs.compose.foundation) + implementation(libs.compose.ui) + implementation(libs.compose.material3) implementation(libs.constraintlayout) implementation(libs.libsu.core) implementation(libs.libsu.nio) implementation(libs.libsu.service) + // The renderer itself lives in src/main/vendor; these are what it needs. + implementation(libs.markdown.parser) + implementation(libs.kotlinx.collections.immutable) implementation(libs.material) implementation(libs.okhttp3) implementation(libs.snakeyaml) + implementation(libs.xz) + implementation(libs.zstd) { artifact { type = "aar" } } + testImplementation(libs.zstd) implementation(libs.termux.emulator) implementation(libs.termux.view) testImplementation(libs.junit) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b1a30d734..a8470c179 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,10 +8,28 @@ + + + + + + + + + + android:windowSoftInputMode="adjustNothing"> @@ -38,104 +56,152 @@ android:configChanges="orientation|screenSize|keyboardHidden|screenLayout|smallestScreenSize" android:exported="false" android:theme="@style/Theme.DroidVM.NoActionBar" - android:windowSoftInputMode="adjustResize" /> + android:windowSoftInputMode="adjustNothing" /> + android:windowSoftInputMode="adjustNothing" /> + android:windowSoftInputMode="adjustNothing" /> + android:windowSoftInputMode="adjustNothing" /> + android:windowSoftInputMode="adjustNothing" /> + android:windowSoftInputMode="adjustNothing" /> + android:windowSoftInputMode="adjustNothing" /> + + + + + + + + + + + + + + + + + + + android:windowSoftInputMode="adjustNothing" /> + + + android:windowSoftInputMode="adjustNothing" /> + android:windowSoftInputMode="adjustNothing" /> + android:windowSoftInputMode="adjustNothing" /> + android:windowSoftInputMode="adjustNothing" /> + android:windowSoftInputMode="adjustNothing" /> + android:windowSoftInputMode="adjustNothing" /> + + + android:windowSoftInputMode="adjustNothing" /> + android:windowSoftInputMode="adjustNothing" /> + + + android:windowSoftInputMode="adjustNothing" /> + android:windowSoftInputMode="adjustNothing" /> + + + + + + + + + + + + + + + + + diff --git a/app/src/main/aidl/android/crosvm/DisplayConfig.aidl b/app/src/main/aidl/android/crosvm/DisplayConfig.aidl new file mode 100644 index 000000000..7a7382f39 --- /dev/null +++ b/app/src/main/aidl/android/crosvm/DisplayConfig.aidl @@ -0,0 +1,25 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.crosvm; + +/** Display configuration reported by crosvm. */ +parcelable DisplayConfig { + int width; + int height; + int dpi; + int refreshRate; +} diff --git a/app/src/main/aidl/android/crosvm/ICrosvmAndroidDisplayService.aidl b/app/src/main/aidl/android/crosvm/ICrosvmAndroidDisplayService.aidl new file mode 100644 index 000000000..dddfc5466 --- /dev/null +++ b/app/src/main/aidl/android/crosvm/ICrosvmAndroidDisplayService.aidl @@ -0,0 +1,44 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.crosvm; + +import android.crosvm.DisplayConfig; +import android.os.ParcelFileDescriptor; +import android.view.Surface; + +/** + * Service to provide Crosvm with an Android Surface for showing a guest's display. + * + * NOTE: This binder is implemented by crosvm itself (android_display_backend). The method order + * here MUST match the .aidl crosvm was built against, because binder transaction codes are derived + * from declaration order. Input forwarding is NOT done through this interface; input goes through + * per-device unix sockets that crosvm reads via `--input ...[path=...]`. + */ +interface ICrosvmAndroidDisplayService { + void setSurface(in Surface surface, boolean forCursor); + void setCursorStream(in ParcelFileDescriptor stream); + void removeSurface(boolean forCursor); + void saveFrameForSurface(boolean forCursor); + void drawSavedFrameForSurface(boolean forCursor); + + /** + * Returns the guest display configuration, or null if not available yet. crosvm's current + * backend may not implement this; callers must tolerate the call failing and fall back to a + * default resolution. + */ + DisplayConfig getDisplayConfig(); +} diff --git a/app/src/main/aidl/cn/classfun/droidvm/display/INativeDisplayRootService.aidl b/app/src/main/aidl/cn/classfun/droidvm/display/INativeDisplayRootService.aidl new file mode 100644 index 000000000..6d42abcd6 --- /dev/null +++ b/app/src/main/aidl/cn/classfun/droidvm/display/INativeDisplayRootService.aidl @@ -0,0 +1,58 @@ +package cn.classfun.droidvm.display; + +import cn.classfun.droidvm.display.IPhysicalKeyEcho; + +/** + * AIDL interface for the native-display broker. It is implemented and hosted by the daemon (which + * already runs as root) and handed to the UI via a broadcast (see + * NativeDisplay.BINDER_BROADCAST_ACTION), because a live IBinder cannot cross the daemon's + * TCP/JSON-RPC channel. No separate root process is involved: an untrusted_app can neither look up + * crosvm's display service nor connectto the daemon's su-domain input sockets itself, so it calls + * these methods on the daemon over binder instead. + */ +interface INativeDisplayRootService { + /** + * Calls ServiceManager.waitForService(serviceName) inside the daemon (root) and returns the + * ICrosvmAndroidDisplayService binder, or null if not found. serviceName is the per-VM key. + */ + IBinder waitForDisplayBinder(String serviceName); + + /** + * Forwards pre-encoded evdev [data] (8-byte records) for [channel] straight to the crosvm input + * socket the daemon owns, looking up the running VM by [vmId]. Runs on a daemon binder thread, so + * the bytes reach crosvm with no extra socket hop. Bytes (not an fd) cross the binder, + * sidestepping both the SELinux 'connectto' denial (app->su socket) and the 'fd use' denial (app + * receiving an su-owned fd). Returns true if written, false on any failure so the caller can fall + * back to the vm_input IPC path. + * + * [screenId] is the screen the console sending the bytes is showing. The absolute channels + * (multi-touch, tablet) have one device per screen, because their coordinates only mean + * anything under one output's geometry, so the screen is what picks the device; the keyboard + * and the relative pointer are VM-wide and ignore it. A screen whose absolute input is + * switched off has no such device, and this returns false rather than sending the events to + * some other screen. + */ + boolean writeInput(String vmId, String screenId, int channel, in byte[] data); + + /** + * Grabs the host's physical keyboards for [vmId]'s [screenId] console, or hands them back. + * A grabbed keyboard is the only way the keys Android keeps for itself -- Home, the task + * switcher, Alt+Tab, the Meta shortcuts -- can reach a guest, and while it is held that + * keyboard types nowhere else, the IME included. So a console asks for it only while it is in + * front with a typing surface that wants no IME, and gives it back on the way out. + * + * [token] is the caller's own binder: the daemon releases the grab if it dies, so a console + * that crashed cannot leave the user with a keyboard that types into nothing. Returns how + * many keyboards are held (0 when the feature is off, the VM is not running, or none is + * attached -- the console stays armed for one that appears later). + */ + int setKeyboardGrab(String vmId, String screenId, boolean grab, IBinder token); + + /** + * Registers (or clears, with null) the console's copy of the grabbed keyboard's keys and the + * guest's keyboard lamps, for [vmId]'s [screenId] console. Only the console that holds the + * grab is heard, and only while it has something to show -- the laptop-keyboard mode lights + * the pressed key on the drawn keyboard, and nothing else needs the traffic. + */ + void setKeyEcho(String vmId, String screenId, IPhysicalKeyEcho echo); +} diff --git a/app/src/main/aidl/cn/classfun/droidvm/display/IPhysicalKeyEcho.aidl b/app/src/main/aidl/cn/classfun/droidvm/display/IPhysicalKeyEcho.aidl new file mode 100644 index 000000000..6aa922724 --- /dev/null +++ b/app/src/main/aidl/cn/classfun/droidvm/display/IPhysicalKeyEcho.aidl @@ -0,0 +1,29 @@ +package cn.classfun.droidvm.display; + +/** + * The console's copy of what the grabbed physical keyboard is doing. A grab sends its keys + * straight from the daemon into the guest's keyboard socket and never through the app, which is + * what keeps the path short -- but it also means the UI has no idea a key was pressed. This is how + * it finds out, and it exists for the on-screen keyboard: in the laptop-keyboard mode the key the + * user pressed on the real keyboard lights up on the drawn one. + * + * Oneway, and always called after the keys have gone to the guest, so nothing the console does + * with them can slow typing down. A console registers it only while it has something to show. + */ +oneway interface IPhysicalKeyEcho { + /** + * One read's worth of keys, in order. [codes] are Linux KEY_* scan codes and [values] the + * matching 1 (down) or 0 (up); auto-repeat is not sent, the same as to the guest. + */ + void onKeys(in int[] codes, in int[] values); + + /** + * The guest's keyboard lamps, as the guest's own driver reports them -- authoritative, unlike + * counting key presses, because software inside the guest can toggle them on its own. + * + * Both are bitmasks over the Linux LED_* codes (1 << LED_NUML, 1 << LED_CAPSL, + * 1 << LED_SCROLLL). [known] says which lamps the guest has reported at all: until it changes + * one, its state is genuinely unknown here, because virtio-input has no way to ask. + */ + void onLeds(int known, int on); +} diff --git a/app/src/main/assets/bin/arm64-v8a/7za b/app/src/main/assets/bin/arm64-v8a/7za deleted file mode 100644 index 109880c35..000000000 Binary files a/app/src/main/assets/bin/arm64-v8a/7za and /dev/null differ diff --git a/app/src/main/assets/bin/x86_64/7za b/app/src/main/assets/bin/x86_64/7za deleted file mode 100644 index 2824e07c3..000000000 Binary files a/app/src/main/assets/bin/x86_64/7za and /dev/null differ diff --git a/app/src/main/assets/data/gunyah.yaml b/app/src/main/assets/data/gunyah.yaml index c10a4d1f3..5ccaf0077 100644 --- a/app/src/main/assets/data/gunyah.yaml +++ b/app/src/main/assets/data/gunyah.yaml @@ -1,8 +1,19 @@ gunyah: - # Qualcomm Snapdragon 8 Gen 3 + # Qualcomm Snapdragon 7s(+) Gen 3/4 + - socs: + - SM7635 + - SM7635AC + - SM7675 + - SM7675AB + - SM7675AC + capabilities: + gunyah: yes + + # Qualcomm Snapdragon 8(s) Gen 3 - socs: - SG8350P - SM8650 + - SM8635 - SM8650AA - SM8650AB - SM8650AC @@ -11,10 +22,12 @@ gunyah: - SM8650QAB capabilities: gunyah: yes + mthp_single: yes # Qualcomm Snapdragon 8 Elite - socs: - SM8735 + - SM8735P - SM8750 - SM87503AB - SM8750AB diff --git a/app/src/main/assets/data/languages.yaml b/app/src/main/assets/data/languages.yaml index 6aa0165a2..180e93aab 100644 --- a/app/src/main/assets/data/languages.yaml +++ b/app/src/main/assets/data/languages.yaml @@ -1,6 +1,13 @@ languages: - - tag: en + # en-US, not a bare en: the platform filters an app locale against the locales the app has + # resource folders for, and English lives in the unqualified values/ rather than a values-en, + # so a bare en is refused and the system's first supported locale is used instead -- which on + # a device listing Chinese after English is Chinese. See app/src/main/res/xml/locales_config. + - tag: en-US name: English - tag: zh-Hans-CN name: 简体中文 + + - tag: zh-Hant-TW + name: 繁體中文 diff --git a/app/src/main/assets/data/license.yaml b/app/src/main/assets/data/license.yaml index 8a51a06a1..693ce239f 100644 --- a/app/src/main/assets/data/license.yaml +++ b/app/src/main/assets/data/license.yaml @@ -80,11 +80,11 @@ license: urls: - https://www.ivarch.com/programs/pv.shtml - - name: 7-zip - description: Assets extractor - license: LGPL-2.1 + - name: XZ for Java + description: Prebuilt archive (tar.xz) decompressor + license: 0BSD urls: - - https://www.7-zip.org/ + - https://tukaani.org/xz/java.html - name: udisasm description: Disassembler for libsimpledump.so @@ -104,3 +104,37 @@ license: urls: - https://android.googlesource.com/platform/hardware/google/gfxstream/ - https://github.com/google/gfxstream + + - name: netbox + description: Network manage utility for DroidVM + license: GPL-3.0 + urls: + - https://github.com/Droid-VM/netbox + + - name: gvisor-vswitch + description: Virtual Switch based on gVisor + license: Apache-2.0 + urls: + - https://github.com/Droid-VM/gvisor-vswitch + - https://github.com/HuJK/gvisor-vswitch + + - name: pseudo-bridge-rs + description: Pseudo Bridge + license: Apache-2.0 + urls: + - https://github.com/Droid-VM/pseudo-bridge-rs + - https://github.com/KusakabeShi/pseudo-bridge-rs + + - name: linux-boot-extractor + description: Linux Boot Extractor + license: GPL-3.0 + urls: + - https://github.com/Droid-VM/linux-boot-extractor + - https://github.com/HuJK/linux-boot-extractor + + - name: bridgedhcp + description: DHCPv4 / DHCPv6 / SLAAC server for Linux bridge interfaces + license: GPL-3.0 + urls: + - https://github.com/Droid-VM/bridgedhcp + - https://github.com/HuJK/bridgedhcp diff --git a/app/src/main/assets/descr/gh_hugepage_reserve.html b/app/src/main/assets/descr/gh_hugepage_reserve.html new file mode 100644 index 000000000..824a7191c --- /dev/null +++ b/app/src/main/assets/descr/gh_hugepage_reserve.html @@ -0,0 +1,211 @@ + + + + + + +GH-Hugepage-Reserve + + + + + +
+
+

Why it's needed

+

A Gunyah VM's memory has to be supplied as 2MB huge pages — each one a single contiguous block. The system, though, normally hands out memory in 4KB small pages, many of which can never be moved once placed.

+

The longer the phone runs, the more those small pages scatter, and the fewer places remain where a full 2MB block can still be assembled. By the time a VM boots there are often not enough huge pages left, and it fails with Out of memory.

+
+ + + 1 + + + + + + 2 + + + + + + + + + + + + + + + 3 + + + + + + + + + + + + + + +
+ The dashed frame is the same 2MB huge page in all three rows, and all three have the same amount of memory in use — only its layout differs. +
    +
  1. Fresh boot: what is in use sits packed at the left, so that stretch is whole. Blue: the VM gets its page.
  2. +
  3. Later: only part of it is still packed, the rest has crumbled into slivers spread across everything. Orange: no 2MB fits anywhere.
  4. +
  5. With the reserve: memory is just as broken up, but the reserve keeps that stretch whole. Green: the page was set aside before the crumbling started.
  6. +
+ in usefreereserve +
+
+
+
+

What it does

+

Right after boot, while memory is still whole, it takes and holds a batch of 2MB huge pages — the amount is adjustable on the management screen. A booting VM is served straight from that reserve, and when the VM shuts down the pages are recovered into the pool for reuse.

+
+
+

Note

+

This module has to load very early in boot, before memory fragments, so it is installed separately as a Magisk / KernelSU / APatch module rather than being loaded by this app.

+
+
+ +
+
+

原因

+

Gunyah VM 的内存必须以 2MB 的大页来供应,每一个都是一整块连续的内存。但系统平常是以 4KB 的小页在分配内存,而且其中不少一旦放下就不能再搬动。

+

开机越久,这些小页越是四处散落,能凑出完整 2MB 的位置就越来越少;到 VM 要开机时,往往已经凑不齐足够的大页,只能失败并显示 Out of memory。

+
+ + + 1 + + + + + + 2 + + + + + + + + + + + + + + + 3 + + + + + + + + + + + + + + +
+ 虚线框在三行都是同一个 2MB 大页,三行的已使用总量也一样多,差别只在分布。 +
    +
  1. 刚开机:已使用的都挤在左边,这一段是完整的。蓝色:VM 要得到。
  2. +
  3. 用久了:只剩一部分还集中,其余碎成细条散落各处。橙色:哪里都塞不下 2MB。
  4. +
  5. 有保留池:内存一样零碎,但保留池把这一段整块留住。绿色:这个大页在碎掉之前就先留下来了。
  6. +
+ 已使用空闲保留池 +
+
+
+
+

功能

+

趁刚开机、内存还完整的时候,先抢下一批 2MB 大页并保留起来(数量可在管理页调整)。VM 开机时直接从保留池供应;VM 关机归还后,页面回收回池中,重复使用。

+
+
+

备注

+

这个模块必须在开机很早期就加载(否则内存已经碎了),所以以 Magisk / KernelSU / APatch 模块的形式另外安装,而不是由本 App 加载。

+
+
+ +
+
+

原因

+

Gunyah VM 的記憶體必須以 2MB 的大頁來供應,每一個都是一整塊連續的記憶體。但系統平常是以 4KB 的小頁在配置記憶體,而且其中不少一旦放下就不能再搬動。

+

開機越久,這些小頁越是四處散落,能湊出完整 2MB 的位置就越來越少;到 VM 要開機時,往往已經湊不齊足夠的大頁,只能失敗並顯示 Out of memory。

+
+ + + 1 + + + + + + 2 + + + + + + + + + + + + + + + 3 + + + + + + + + + + + + + + +
+ 虛線框在三行都是同一個 2MB 大頁,三行的已使用總量也一樣多,差別只在分布。 +
    +
  1. 剛開機:已使用的都擠在左邊,這一段是完整的。藍色:VM 要得到。
  2. +
  3. 用久了:只剩一部分還集中,其餘碎成細條散落各處。橘色:哪裡都塞不下 2MB。
  4. +
  5. 有保留池:記憶體一樣零碎,但保留池把這一段整塊留住。綠色:這個大頁在碎掉之前就先留下來了。
  6. +
+ 已使用空閒保留池 +
+
+
+
+

功能

+

趁剛開機、記憶體還完整的時候,先搶下一批 2MB 大頁並保留起來(數量可在管理頁調整)。VM 開機時直接從保留池供應;VM 關機歸還後,頁面回收回池中,重複使用。

+
+
+

備註

+

這個模組必須在開機很早期就載入(否則記憶體已經碎了),所以以 Magisk / KernelSU / APatch 模組的形式另外安裝,而不是由本 App 載入。

+
+
+ + + diff --git a/app/src/main/assets/descr/style.css b/app/src/main/assets/descr/style.css new file mode 100644 index 000000000..7e0bfe9b4 --- /dev/null +++ b/app/src/main/assets/descr/style.css @@ -0,0 +1,326 @@ +/* + * Shared stylesheet for the module description pages. + * + * THIS FILE IS THE AUTHORITY. 6_build_apk_prepare.sh copies it into the app at + * DroidVM/app/src/main/assets/descr/style.css, which is what actually renders on + * device (the app injects it inline; nothing is fetched at runtime). The copy is + * checked in so the app still builds without this repo -- edit it here, not there. + * + * The app injects, ahead of this file: + * :root { --surface, --on-surface, --on-surface-variant, --primary, --error, + * --outline, --surface-variant } <- the live Material theme, so pages + * follow dark mode and Material You + * html[data-theme="dark"|"light"] + * .i18n display rules selecting one language + * + * Opening a page straight in a browser therefore shows ALL languages stacked and + * the fallback palette below -- which is what you want when proofreading. + */ + +:root { + --surface: #fdfcff; + --on-surface: #1a1c1e; + --on-surface-variant: #43474e; + --surface-variant: #e0e2ec; + --primary: #345ca8; + --error: #ba1a1a; + --outline: #74777f; +} + +/* + * Traffic-light colours for what a request met: available / unavailable / held. + * Deliberately NOT taken from the app's palette -- under Material You --primary + * can be any hue, and "blue means you get it, orange means you don't" has to + * survive that. Only the greys and surfaces follow the theme. + */ +:root { + --fit: #1565c0; + --nofit: #b45309; + --held: #2e7d32; +} + +/* Browser-preview only: on device the app states the theme outright, below. */ +@media (prefers-color-scheme: dark) { + :root { + --surface: #1a1c1e; + --on-surface: #e2e2e6; + --on-surface-variant: #c3c6cf; + --surface-variant: #43474e; + --primary: #adc6ff; + --error: #ffb4ab; + --outline: #8d9199; + --fit: #9fc5ff; + --nofit: #ffb877; + --held: #7edb96; + } +} + +/* + * The app stamps data-theme on , and that is the authority: a WebView's own + * idea of dark need not match the app's. Both directions are spelled out because + * either has to beat the media query above. Theme colours are absent here on + * purpose -- the app injects those as :root variables, and an attribute selector + * would outrank them and undo Material You. + */ +html[data-theme="dark"] { + --fit: #9fc5ff; + --nofit: #ffb877; + --held: #7edb96; +} + +html[data-theme="light"] { + --fit: #1565c0; + --nofit: #b45309; + --held: #2e7d32; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 0 2px 8px; + background: var(--surface); + color: var(--on-surface); + font-family: system-ui, sans-serif; + font-size: 15px; + line-height: 1.65; + /* The dialog is narrow; never let a long token push a sideways scrollbar. */ + overflow-wrap: anywhere; +} + +h2 { + margin: 18px 0 6px; + font-size: 15px; + font-weight: 600; + color: var(--primary); +} + +section:first-of-type h2 { + margin-top: 2px; +} + +p { + margin: 0 0 10px; +} + +code { + font-family: ui-monospace, monospace; + font-size: 0.92em; + padding: 1px 4px; + border-radius: 4px; + background: var(--surface-variant); +} + +/* Anything the text enumerates is a list, not a run-on sentence with numbers in it. + In a caption the list is what maps the drawing's rows to words, so it inherits the + caption's smaller type rather than resetting it. */ +ol, ul { + margin: 0 0 10px; + padding-left: 1.4em; +} + +li { + margin-bottom: 5px; +} + +li:last-child { + margin-bottom: 0; +} + +/* ---- figures ---------------------------------------------------------- */ + +/* Figures close a "why" section: the words first, then the picture that settles them. */ +figure { + margin: 14px 0 4px; + padding: 12px; + border: 1px solid var(--outline); + border-radius: 12px; +} + +figure svg { + display: block; + width: 100%; + height: auto; +} + +figcaption { + margin-top: 8px; + font-size: 13px; + line-height: 1.5; + color: var(--on-surface-variant); +} + +/* SVG text and shape roles. Diagrams are drawn in theme colours only -- no + hardcoded ink -- so they stay legible when the app flips to dark. */ +svg .lbl { + font: 500 9px system-ui, sans-serif; + fill: var(--on-surface-variant); +} + +svg .lbl-strong { + font: 600 9.5px system-ui, sans-serif; + fill: var(--on-surface); +} + +svg .used { + fill: var(--on-surface-variant); +} + +svg .free { + fill: var(--surface-variant); +} + +svg .keep { + fill: var(--primary); +} + +svg .bad { + fill: var(--error); +} + +svg .edge { + fill: none; + stroke: var(--outline); + stroke-width: 1; +} + +svg .edge-strong { + fill: none; + stroke: var(--primary); + stroke-width: 1.5; +} + +/* A request that cannot be satisfied, drawn over the memory it would need. */ +svg .miss { + fill: none; + stroke: var(--nofit); + stroke-width: 1.5; + stroke-dasharray: 4 3; +} + +/* A request already covered by memory set aside for it, and that memory. */ +svg .hold { + fill: none; + stroke: var(--held); + stroke-width: 1.5; + stroke-dasharray: 4 3; +} + +svg .pool { + fill: var(--held); + fill-opacity: 0.3; +} + +/* A request that can be satisfied, drawn over the memory it will use. */ +svg .want { + fill: none; + stroke: var(--fit); + stroke-width: 1.5; + stroke-dasharray: 4 3; +} + +svg .dash { + fill: none; + stroke: var(--outline); + stroke-width: 1.2; + stroke-dasharray: 4 3; +} + +svg .x-mark { + fill: none; + stroke: var(--nofit); + stroke-width: 2; + stroke-linecap: round; +} + +svg .ok-mark { + fill: none; + stroke: var(--fit); + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +svg .arrow { + fill: var(--primary); +} + +svg .box { + fill: var(--surface-variant); +} + +/* ---- three-mode table (udmabuf) --------------------------------------- */ + +table { + width: 100%; + margin: 10px 0; + border-collapse: collapse; + font-size: 13.5px; +} + +th, td { + padding: 6px 8px; + text-align: left; + vertical-align: top; + border-bottom: 1px solid var(--outline); +} + +th { + font-weight: 600; + color: var(--on-surface-variant); +} + +tr:last-child td { + border-bottom: none; +} + +/* ---- language sections ------------------------------------------------ */ + +.i18n + .i18n { + margin-top: 28px; + padding-top: 20px; + border-top: 2px dashed var(--outline); +} + +/* What the shades in a figure mean. Swatches are the same variables the SVG uses, + so a legend cannot drift from the drawing it explains. */ +.legend { + display: flex; + flex-wrap: wrap; + gap: 4px 14px; + margin-top: 8px; + font-size: 12.5px; +} + +.legend span { + white-space: nowrap; +} + +.legend i { + display: inline-block; + width: 12px; + height: 12px; + margin-right: 5px; + border-radius: 3px; + vertical-align: -1px; +} + +.legend .sw-used { + background: var(--on-surface-variant); +} + +.legend .sw-free { + background: var(--surface-variant); +} + +.legend .sw-keep { + background: var(--primary); +} + +.legend .sw-pool { + background: var(--held); + opacity: 0.5; +} + diff --git a/app/src/main/assets/fonts/LICENSE.txt b/app/src/main/assets/fonts/LICENSE.txt new file mode 100644 index 000000000..3e68033cd --- /dev/null +++ b/app/src/main/assets/fonts/LICENSE.txt @@ -0,0 +1,94 @@ +Copyright (c) 2022, subframe7536 (https://github.com/subframe7536), +with Reserved Font Name Maple Mono. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/app/src/main/assets/hash.json b/app/src/main/assets/hash.json deleted file mode 100644 index c62f5d047..000000000 --- a/app/src/main/assets/hash.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "hash": [ - { - "file": "prebuilts/prebuilt-arm64-v8a.7z", - "sha256": "263e905b4d33fecdaba1f0744384ff6bc4b7b478e65ecafd6ac8a50b0eeec55c" - }, { - "file": "bin/arm64-v8a/7za", - "sha256": "8443af0bd27d694f0431c0e46fc33d149004e5d9f8c03d484cffb8b03e928531" - }, { - "file": "bin/x86_64/7za", - "sha256": "57ea4f617b559556a4019ac8fe8c27cc9422ff4a988ad1be3728319a622093d8" - } - ] -} diff --git a/app/src/main/assets/match.json b/app/src/main/assets/match.json new file mode 100644 index 000000000..a5be1415b --- /dev/null +++ b/app/src/main/assets/match.json @@ -0,0 +1,22 @@ +{ + "version": 1, + "modules": { + "gh_hugepage_reserve": { + "soc_vendor": ["qualcomm"], + "kernel_below": "6.18", + "comment": "the reserve exists because Gunyah could not take guest memory without migrating it out of CMA; Qualcomm fixed that in 6.18, where the module is no longer needed" + }, + "gunyah_host_share": { "soc_vendor": ["qualcomm"] }, + "gunyah_kvcalloc": { "soc_vendor": ["qualcomm"] }, + "gh_unmovable": { "soc_vendor": ["qualcomm"] }, + "udmabuf": { "comment": "generic dma-buf provider/repair, nothing Gunyah about it: any SoC" }, + "nproc_guard": { "comment": "no-reboot rescue for the per-uid RLIMIT_NPROC ucounts desync that wedges the app; loaded by the daemon with uid=. Any SoC." } + }, + "names": { + "gunyah_host_share": "Gunyah Host Share", + "gunyah_kvcalloc": "Gunyah kvcalloc Fix", + "gh_unmovable": "GH Unmovable", + "udmabuf": "Udmabuf Fix", + "nproc_guard": "NPROC Guard" + } +} diff --git a/app/src/main/assets/prebuilts b/app/src/main/assets/prebuilts index b5515e428..c4998e150 160000 --- a/app/src/main/assets/prebuilts +++ b/app/src/main/assets/prebuilts @@ -1 +1 @@ -Subproject commit b5515e428d12cabe0dfee1fb3e9d0f3d5c4aa24b +Subproject commit c4998e150a08f0e60d141591fd63ea0010eb4f75 diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt index 67d76204a..ac10ece18 100644 --- a/app/src/main/cpp/CMakeLists.txt +++ b/app/src/main/cpp/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.22.1) -project(droidvm LANGUAGES C CXX) +project(droidvm LANGUAGES C CXX ASM) set(SHARED_INCLUDE_DIRS ${CMAKE_CURRENT_SOURCE_DIR}/jsoncpp/include @@ -27,6 +27,7 @@ set(CONSOLE_SOURCES console/commands/resume.cpp console/commands/powerbtn.cpp console/commands/sleepbtn.cpp + console/commands/usb.cpp console/commands/version.cpp console/commands/help.cpp ) @@ -61,6 +62,29 @@ add_library(simpledump SHARED target_compile_options(simpledump PRIVATE -Wall -Wextra -O0 -Wno-format-security -Wno-format) +add_library(compat_a13 SHARED compat/a13.cpp compat/a13.S) +add_library(compat_a14 SHARED compat/a14.cpp compat/a14.S) +add_library(compat_a15 SHARED compat/a15.cpp compat/a15.S) +add_library(compat_a16 SHARED compat/a16.cpp compat/a16.S) +add_library(compat_a17 SHARED compat/a17.cpp compat/a17.S) +target_compile_options(compat_a13 PRIVATE -Wall -Wextra -O0 -Wno-nullability-completeness) +target_compile_options(compat_a14 PRIVATE -Wall -Wextra -O0 -Wno-nullability-completeness) +target_compile_options(compat_a15 PRIVATE -Wall -Wextra -O0 -Wno-nullability-completeness) +target_compile_options(compat_a16 PRIVATE -Wall -Wextra -O0 -Wno-nullability-completeness) +target_compile_options(compat_a17 PRIVATE -Wall -Wextra -O0 -Wno-nullability-completeness) +target_link_options(compat_a13 PRIVATE + "-Wl,--undefined=_ZTVNSt6__ndk114basic_ofstreamIcNS_11char_traitsIcEEEE" + "-Wl,--undefined=_ZTTNSt6__ndk114basic_ofstreamIcNS_11char_traitsIcEEEE" + "-Wl,--defsym,_ZTVNSt3__114basic_ofstreamIcNS_11char_traitsIcEEEE=_ZTVNSt6__ndk114basic_ofstreamIcNS_11char_traitsIcEEEE" + "-Wl,--defsym,_ZTTNSt3__114basic_ofstreamIcNS_11char_traitsIcEEEE=_ZTTNSt6__ndk114basic_ofstreamIcNS_11char_traitsIcEEEE" +) +target_link_options(compat_a14 PRIVATE + "-Wl,--undefined=_ZTVNSt6__ndk114basic_ofstreamIcNS_11char_traitsIcEEEE" + "-Wl,--undefined=_ZTTNSt6__ndk114basic_ofstreamIcNS_11char_traitsIcEEEE" + "-Wl,--defsym,_ZTVNSt3__114basic_ofstreamIcNS_11char_traitsIcEEEE=_ZTVNSt6__ndk114basic_ofstreamIcNS_11char_traitsIcEEEE" + "-Wl,--defsym,_ZTTNSt3__114basic_ofstreamIcNS_11char_traitsIcEEEE=_ZTTNSt6__ndk114basic_ofstreamIcNS_11char_traitsIcEEEE" +) + set(LIBVNCSERVER_DIR ${CMAKE_CURRENT_SOURCE_DIR}/libvncserver) set(LIBVNCSERVER_INSTALL OFF CACHE BOOL "" FORCE) set(WITH_OPENSSL ON CACHE BOOL "" FORCE) @@ -98,6 +122,13 @@ add_library(unixhelper SHARED unixhelper/unixhelper.c unixhelper/native_process. target_link_libraries(unixhelper log) target_compile_options(unixhelper PRIVATE -Wall -Wextra -O2) +# Capability probe for the native-display GPU-blit provider (GpuBlitProvider.SYSTEM). Loads the +# platform Vulkan loader at runtime (dlopen, not linked) and reports which blit extensions the +# stock driver lacks, so the UI can warn before the bridge silently degrades to a CPU copy. +add_library(vkprobe SHARED vkprobe/vkprobe.cpp) +target_link_libraries(vkprobe log) +target_compile_options(vkprobe PRIVATE -Wall -Wextra -O2) + add_executable(daemon unixhelper/daemon.c) target_compile_options(daemon PRIVATE -Wall -Wextra -O2) diff --git a/app/src/main/cpp/compat/a13.S b/app/src/main/cpp/compat/a13.S new file mode 100644 index 000000000..8e60f3d81 --- /dev/null +++ b/app/src/main/cpp/compat/a13.S @@ -0,0 +1 @@ +#include "a14.S" diff --git a/app/src/main/cpp/compat/a13.cpp b/app/src/main/cpp/compat/a13.cpp new file mode 100644 index 000000000..11fb5abe3 --- /dev/null +++ b/app/src/main/cpp/compat/a13.cpp @@ -0,0 +1,19 @@ +#include "a14.cpp" +#include +#include +#include +#include +#include +#include +#include +#include + +__attribute__((weak)) +extern "C" int32_t ANativeWindow_readFromParcel(const void* _Nonnull, void* _Nullable* _Nonnull) { + return (int32_t)0x80000002; +} + +__attribute__((weak)) +extern "C" int32_t ANativeWindow_writeToParcel(void* _Nonnull, void* _Nonnull) { + return (int32_t)0x80000002; +} diff --git a/app/src/main/cpp/compat/a14.S b/app/src/main/cpp/compat/a14.S new file mode 100644 index 000000000..c7a06eac6 --- /dev/null +++ b/app/src/main/cpp/compat/a14.S @@ -0,0 +1,38 @@ +#include "a15.S" +#if defined(__arm64__) || defined(__aarch64__) +.text + +// std::__ndk1::basic_ofstream >::open(std::__ndk1::basic_string, std::__ndk1::allocator > const&, unsigned int) +.globl _ZNSt3__114basic_ofstreamIcNS_11char_traitsIcEEE4openERKNS_12basic_stringIcS2_NS_9allocatorIcEEEEj +.type _ZNSt3__114basic_ofstreamIcNS_11char_traitsIcEEE4openERKNS_12basic_stringIcS2_NS_9allocatorIcEEEEj, %function +_ZNSt3__114basic_ofstreamIcNS_11char_traitsIcEEE4openERKNS_12basic_stringIcS2_NS_9allocatorIcEEEEj: + b _ZNSt6__ndk114basic_ofstreamIcNS_11char_traitsIcEEE4openERKNS_12basic_stringIcS2_NS_9allocatorIcEEEEj + +// std::__1::basic_filebuf >::~basic_filebuf() +.globl _ZNSt3__113basic_filebufIcNS_11char_traitsIcEEED1Ev +.type _ZNSt3__113basic_filebufIcNS_11char_traitsIcEEED1Ev, %function +_ZNSt3__113basic_filebufIcNS_11char_traitsIcEEED1Ev: + b _ZNSt6__ndk113basic_filebufIcNS_11char_traitsIcEEED1Ev + +// std::__1::basic_filebuf >::basic_filebuf() +.globl _ZNSt3__113basic_filebufIcNS_11char_traitsIcEEEC1Ev +.type _ZNSt3__113basic_filebufIcNS_11char_traitsIcEEEC1Ev, %function +_ZNSt3__113basic_filebufIcNS_11char_traitsIcEEEC1Ev: + b _ZNSt6__ndk113basic_filebufIcNS_11char_traitsIcEEEC1Ev + +// std::__1::basic_filebuf >::close() +.globl _ZNSt3__113basic_filebufIcNS_11char_traitsIcEEE5closeEv +.type _ZNSt3__113basic_filebufIcNS_11char_traitsIcEEE5closeEv, %function +_ZNSt3__113basic_filebufIcNS_11char_traitsIcEEE5closeEv: + b _ZNSt6__ndk113basic_filebufIcNS_11char_traitsIcEEE5closeEv + +// std::__1::__fs::filesystem::path::__filename() const +// a14's platform libc++ predates the out-of-line path parsers; libgfxstream_backend.so +// (built against the a16 soong libc++) is the one importer in the payload. path is +// {basic_string} and the return is a string_view in both namespaces, so the __ndk1 +// implementation baked in by c++_static is layout-compatible. +.globl _ZNKSt3__14__fs10filesystem4path10__filenameEv +.type _ZNKSt3__14__fs10filesystem4path10__filenameEv, %function +_ZNKSt3__14__fs10filesystem4path10__filenameEv: + b _ZNKSt6__ndk14__fs10filesystem4path10__filenameEv +#endif diff --git a/app/src/main/cpp/compat/a14.cpp b/app/src/main/cpp/compat/a14.cpp new file mode 100644 index 000000000..5669679ad --- /dev/null +++ b/app/src/main/cpp/compat/a14.cpp @@ -0,0 +1,133 @@ +#include "a15.cpp" +#include +#include +#include +#include +#include +#include +#include +#include + +static std::ofstream ofs; + +__attribute__((weak)) +void __libcpp_verbose_abort(char const* format, ...) __asm("_ZNSt3__122__libcpp_verbose_abortEPKcz"); + +__attribute__((weak)) +void __libcpp_verbose_abort(char const* format, ...) { + va_list list; + va_start(list, format); + vfprintf(stderr, format, list); + va_end(list); + abort(); +} + +std::to_chars_result fix_to_chars1(char* first, char* last, float value,std::chars_format fmt, int precision) __asm("_ZNSt3__18to_charsEPcS0_fNS_12chars_formatEi"); + +__attribute__((weak)) +std::to_chars_result fix_to_chars1(char* first, char* last, float value,std::chars_format fmt, int precision) { + return std::to_chars(first, last, value, fmt, precision); +} + +std::to_chars_result fix_to_chars2(char* first, char* last, float value) __asm("_ZNSt3__18to_charsEPcS0_f"); + +__attribute__((weak)) +std::to_chars_result fix_to_chars2(char* first, char* last, float value) { + return std::to_chars(first, last, value); +} + +std::to_chars_result fix_to_chars3(char* first, char* last, float value,std::chars_format fmt) __asm("_ZNSt3__18to_charsEPcS0_fNS_12chars_formatE"); + +__attribute__((weak)) +std::to_chars_result fix_to_chars3(char* first, char* last, float value,std::chars_format fmt) { + return std::to_chars(first, last, value, fmt); +} + +std::to_chars_result fix_to_chars4(char* first, char* last, double value,std::chars_format fmt, int precision) __asm("_ZNSt3__18to_charsEPcS0_dNS_12chars_formatEi"); + +__attribute__((weak)) +std::to_chars_result fix_to_chars4(char* first, char* last, double value,std::chars_format fmt, int precision) { + return std::to_chars(first, last, value, fmt, precision); +} + +std::to_chars_result fix_to_chars5(char* first, char* last, double value) __asm("_ZNSt3__18to_charsEPcS0_d"); + +__attribute__((weak)) +std::to_chars_result fix_to_chars5(char* first, char* last, double value) { + return std::to_chars(first, last, value); +} + +std::to_chars_result fix_to_chars6(char* first, char* last, double value,std::chars_format fmt) __asm("_ZNSt3__18to_charsEPcS0_dNS_12chars_formatE"); + +__attribute__((weak)) +std::to_chars_result fix_to_chars6(char* first, char* last, double value,std::chars_format fmt) { + return std::to_chars(first, last, value, fmt); +} + +std::to_chars_result fix_to_chars7(char* first, char* last, long double value,std::chars_format fmt, int precision) __asm("_ZNSt3__18to_charsEPcS0_eNS_12chars_formatEi"); + +__attribute__((weak)) +std::to_chars_result fix_to_chars7(char* first, char* last, long double value,std::chars_format fmt, int precision) { + return std::to_chars(first, last, value, fmt, precision); +} + +std::to_chars_result fix_to_chars8(char* first, char* last, long double value) __asm("_ZNSt3__18to_charsEPcS0_e"); + +__attribute__((weak)) +std::to_chars_result fix_to_chars8(char* first, char* last, long double value) { + return std::to_chars(first, last, value); +} + +std::to_chars_result fix_to_chars9(char* first, char* last, long double value,std::chars_format fmt) __asm("_ZNSt3__18to_charsEPcS0_eNS_12chars_formatE"); + +__attribute__((weak)) +std::to_chars_result fix_to_chars9(char* first, char* last, long double value,std::chars_format fmt) { + return std::to_chars(first, last, value, fmt); +} + +namespace android { + typedef int64_t PictureProfileId; + class PictureProfileHandle { + public: + static const PictureProfileHandle NONE; + PictureProfileHandle() { *this = NONE; } + explicit PictureProfileHandle(PictureProfileId id) : mId(id) {} + PictureProfileId const& getId() const { return mId; } + inline bool operator==(const PictureProfileHandle& rhs) { return mId == rhs.mId; } + inline bool operator!=(const PictureProfileHandle& rhs) { return !(*this == rhs); } + inline bool operator!() const { return mId == NONE.mId; } + operator bool() const { return !!*this; } + friend ::std::string toString(const PictureProfileHandle& handle); + private: + PictureProfileId mId; + }; + const PictureProfileHandle PictureProfileHandle::NONE(0); + ::std::string toString(const PictureProfileHandle& handle) { + return std::format("{:#010x}", handle.getId()); + } + namespace binder::os { + __attribute__((weak)) + void trace_begin(uint64_t, const char*) { + } + __attribute__((weak)) + void trace_end(uint64_t) { + } + __attribute__((weak)) + void trace_int(uint64_t, const char*, int32_t) { + } + } +} + +__attribute__((weak)) +extern "C" int epoll_pwait2( + int epfd, struct epoll_event* events, int maxevents, + const struct timespec *_Nullable ts, + const sigset_t *_Nullable sigmask +) { + int ms = -1; + if (ts) { + ms = (int)(ts->tv_sec * 1000L + ts->tv_nsec / 1000000L); + if (ms < 0) ms = 0; + } + return epoll_pwait(epfd, events, maxevents, ms, sigmask); +} diff --git a/app/src/main/cpp/compat/a15.S b/app/src/main/cpp/compat/a15.S new file mode 100644 index 000000000..a23f8b99b --- /dev/null +++ b/app/src/main/cpp/compat/a15.S @@ -0,0 +1 @@ +#include "a16.S" diff --git a/app/src/main/cpp/compat/a15.cpp b/app/src/main/cpp/compat/a15.cpp new file mode 100644 index 000000000..eaf54a51b --- /dev/null +++ b/app/src/main/cpp/compat/a15.cpp @@ -0,0 +1,7 @@ +#include "a16.cpp" +#include + +__attribute__((weak)) +extern "C" void AIBinder_Class_setTransactionCodeToFunctionNameMap(void*, const char* const*, size_t) { + +} diff --git a/app/src/main/cpp/compat/a16.S b/app/src/main/cpp/compat/a16.S new file mode 100644 index 000000000..b1093e154 --- /dev/null +++ b/app/src/main/cpp/compat/a16.S @@ -0,0 +1 @@ +#include "a17.S" diff --git a/app/src/main/cpp/compat/a16.cpp b/app/src/main/cpp/compat/a16.cpp new file mode 100644 index 000000000..08479010b --- /dev/null +++ b/app/src/main/cpp/compat/a16.cpp @@ -0,0 +1 @@ +#include "a17.cpp" diff --git a/app/src/main/cpp/compat/a17.S b/app/src/main/cpp/compat/a17.S new file mode 100644 index 000000000..e69de29bb diff --git a/app/src/main/cpp/compat/a17.cpp b/app/src/main/cpp/compat/a17.cpp new file mode 100644 index 000000000..e69de29bb diff --git a/app/src/main/cpp/console/command.cpp b/app/src/main/cpp/console/command.cpp index 60f1fbacd..ea9fd71b7 100644 --- a/app/src/main/cpp/console/command.cpp +++ b/app/src/main/cpp/console/command.cpp @@ -82,6 +82,7 @@ std::unique_ptr create_command_registry() { command_register_resume(*r); command_register_powerbtn(*r); command_register_sleepbtn(*r); + command_register_usb(*r); command_register_version(*r); command_register_help(*r); return r; diff --git a/app/src/main/cpp/console/command.h b/app/src/main/cpp/console/command.h index 901826cb0..ff78ad1ce 100644 --- a/app/src/main/cpp/console/command.h +++ b/app/src/main/cpp/console/command.h @@ -65,6 +65,8 @@ extern void command_register_powerbtn(CommandRegistry ®istry); extern void command_register_sleepbtn(CommandRegistry ®istry); +extern void command_register_usb(CommandRegistry ®istry); + extern void command_register_version(CommandRegistry ®istry); extern void command_register_help(CommandRegistry ®istry); diff --git a/app/src/main/cpp/console/commands/console.cpp b/app/src/main/cpp/console/commands/console.cpp index 99647b758..817d5d318 100644 --- a/app/src/main/cpp/console/commands/console.cpp +++ b/app/src/main/cpp/console/commands/console.cpp @@ -43,6 +43,7 @@ class ConsoleCommand : public Command { ); bool running = false; + bool rebooting = false; }; void ConsoleCommand::console_list(const std::string &vm) { @@ -94,12 +95,25 @@ void ConsoleCommand::process_event( fflush(stdout); } } - } else if (event == "exited" || event == "state") { + } else if (event == "rebooting") { + // Guest-initiated reboot: the daemon tears down crosvm and relaunches it + // reusing the same console streams. Stay attached so the new boot's output + // streams in seamlessly instead of dropping the terminal. + rebooting = true; + fprintf(stderr, "\n[droidvm] VM rebooting...\n"); + } else if (event == "exited") { + // The only authoritative terminal signal. Every non-reboot end path fires + // "exited" (with the real code); a bare "stopped" state during reboot does not. + int code = edata.get("exit_code", -1).asInt(); + fprintf(stderr, "\n[droidvm] VM exited (code %d).\n", code); + running = false; + } else if (event == "state") { auto state = edata.get("state", "").asString(); - if (state == "stopped") { - int code = edata.get("exit_code", -1).asInt(); - fprintf(stderr, "\n[droidvm] VM exited (code %d).\n", code); - running = false; + // A "stopped" state event arrives during reboot too, so it is NOT terminal + // on its own - we wait for "exited". Just clear the reboot notice once back up. + if (state == "running" && rebooting) { + rebooting = false; + fprintf(stderr, "[droidvm] VM is back up.\n"); } } } diff --git a/app/src/main/cpp/console/commands/usb.cpp b/app/src/main/cpp/console/commands/usb.cpp new file mode 100644 index 000000000..7d10c4ea6 --- /dev/null +++ b/app/src/main/cpp/console/commands/usb.cpp @@ -0,0 +1,295 @@ +#include "../command.h" +#include "../ipc_proto.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +// A detach names either the host device or the guest port it was given; an all-digit argument is +// the port, because a sysfs name always carries a '-'. +static bool is_all_digits(const char *s) { + if (!s || !*s) return false; + for (const char *p = s; *p; ++p) + if (!isdigit(static_cast(*p))) return false; + return true; +} + +class UsbListCommand : public Command { +public: + [[nodiscard]] const char *name() const override { return "usb-list"; } + + [[nodiscard]] const char *description() const override { return "List host USB devices"; } + + [[nodiscard]] const char *usage() const override { return ""; } + + [[nodiscard]] int min_args() const override { return 0; } + + int run(int argc, char *argv[]) override; +}; + +int UsbListCommand::run(int, char *[]) { + auto ipc = IPCClient::get(); + Json::Value req; + req["command"] = "usb_host_list"; + print_response(ipc->send_request(req)); + return 0; +} + +class UsbAttachCommand : public Command { +public: + [[nodiscard]] const char *name() const override { return "usb-attach"; } + + [[nodiscard]] const char *description() const override { return "Attach a host USB device to a VM"; } + + [[nodiscard]] const char *usage() const override { return " "; } + + [[nodiscard]] int min_args() const override { return 2; } + + int run(int argc, char *argv[]) override; +}; + +int UsbAttachCommand::run(int, char *argv[]) { + auto ipc = IPCClient::get(); + auto vm_id = resolve_vm_id(argv[2]); + Json::Value req; + req["command"] = "usb_attach"; + req["vm_id"] = vm_id; + req["device"] = argv[3]; + print_response(ipc->send_request(req)); + return 0; +} + +class UsbDetachCommand : public Command { +public: + [[nodiscard]] const char *name() const override { return "usb-detach"; } + + [[nodiscard]] const char *description() const override { return "Detach a USB device from a VM"; } + + [[nodiscard]] const char *usage() const override { return " "; } + + [[nodiscard]] int min_args() const override { return 2; } + + int run(int argc, char *argv[]) override; +}; + +int UsbDetachCommand::run(int, char *argv[]) { + auto ipc = IPCClient::get(); + auto vm_id = resolve_vm_id(argv[2]); + Json::Value req; + req["command"] = "usb_detach"; + req["vm_id"] = vm_id; + if (is_all_digits(argv[3])) { + // All-digit still says nothing about the range: a port wider than an int is a typo, and + // has to come back as a usage error rather than as a thrown exception. + int port = 0; + const char *end = argv[3] + strlen(argv[3]); + auto [stop, ec] = std::from_chars(argv[3], end, port); + if (ec != std::errc() || stop != end) { + fprintf(stderr, "Invalid port: %s\n", argv[3]); + return 1; + } + req["port"] = port; + } else { + req["device"] = argv[3]; + } + print_response(ipc->send_request(req)); + return 0; +} + +class UsbVmCommand : public Command { +public: + [[nodiscard]] const char *name() const override { return "usb-vm"; } + + [[nodiscard]] const char *description() const override { return "List USB devices attached to a VM"; } + + [[nodiscard]] const char *usage() const override { return ""; } + + [[nodiscard]] int min_args() const override { return 1; } + + int run(int argc, char *argv[]) override; +}; + +int UsbVmCommand::run(int, char *argv[]) { + auto ipc = IPCClient::get(); + auto vm_id = resolve_vm_id(argv[2]); + Json::Value req; + req["command"] = "usb_vm_list"; + req["vm_id"] = vm_id; + print_response(ipc->send_request(req)); + return 0; +} + +class UsbRulesCommand : public Command { +public: + [[nodiscard]] const char *name() const override { return "usb-rules"; } + + [[nodiscard]] const char *description() const override { + return "Show, dry-run or replace the USB auto-attach rules"; + } + + [[nodiscard]] const char *usage() const override { return "[test | set ]"; } + + [[nodiscard]] int min_args() const override { return 0; } + + int run(int argc, char *argv[]) override; + +private: + static int show(); + + static int test(); + + static int set(const char *path); +}; + +int UsbRulesCommand::run(int argc, char *argv[]) { + if (argc < 3) return show(); + if (strcmp(argv[2], "test") == 0) return test(); + if (strcmp(argv[2], "set") == 0) { + if (argc < 4) { + fprintf(stderr, "Usage: %s usb-rules set \n", argv[0]); + return 1; + } + return set(argv[3]); + } + fprintf(stderr, "Unknown usb-rules subcommand: %s\n", argv[2]); + return 1; +} + +int UsbRulesCommand::show() { + auto ipc = IPCClient::get(); + Json::Value req; + req["command"] = "usb_rules_get"; + auto resp = ipc->send_request(req); + // The master switch on its own line first: it makes every rule below it inert, and a reader + // scanning a long rule set should not have to find that out from one key inside the JSON. + // On stderr, because it is a header for a person: stdout stays the file `set` reads back, so + // `usb-rules > rules.json && usb-rules set rules.json` goes on working. + auto rules = resp["rules"]; + fprintf(stderr, "passthrough: %s\n", + rules.get("enabled", true).asBool() ? "enabled" : "disabled"); + // The rules alone: this is the file the UI writes, so what prints is what `set` reads back. + printf("%s\n", IPCClient::json_to_string(rules, true).c_str()); + return 0; +} + +// What a rule pass would do with a device, in one word or one address. A rule that names a +// controller inside its VM says so after a slash; one that names none means the VM's first. +static std::string describe_result(const Json::Value &result) { + if (result.isNull()) return "none"; + // A sink leaves the device to nobody: it names no VM, and it is not the host keeping it. + auto outcome = result.get("target", Json::Value::null); + std::string where; + if (!outcome.isNull() && outcome.asString() == "sink") { + where = "sink"; + } else if (auto vm = result.get("vm", Json::Value::null); vm.isNull()) { + where = "host"; + } else { + auto controller = result.get("controller", Json::Value::null); + where = controller.isNull() + ? vm.asString() + : std::format("{}/{}", vm.asString(), controller.asString()); + } + // The daemon sends an explicit null layer for a device no rule in the file matched: the + // fifth zone is the engine's default rather than a row, it is at no index, and printing + // "[-1]" would send a reader looking for a rule that is in no file and on no page. + // get() would not help -- the key is there, it is its value that is null. + if (result["layer"].isNull()) return std::format("no rule matched -> {}", where); + return std::format("{}[{}] -> {}", result["layer"].asString(), + result.get("index", 0).asInt(), where); +} + +int UsbRulesCommand::test() { + auto ipc = IPCClient::get(); + Json::Value req; + req["command"] = "usb_rules_test"; + auto resp = ipc->send_request(req); + // The switch first: off, every row below reads "none" for the same reason, and this is the + // command a person runs to ask why a rule did not fire. + printf("passthrough: %s\n", + resp.get("enabled", true).asBool() ? "enabled" : "disabled"); + // STATE and LOCK are the two things that decide whether a rule may speak for a device at + // all: what it is doing, read off the drivers bound to its interfaces, and whether the user + // has already decided about it by hand. + printf("%-10s %-30s %-8s %-8s %-5s %-36s %s\n", + "SYSFS", "ID", "PORT", "STATE", "LOCK", "ATTACHED_VM", "RESULT"); + for (const auto &dev: resp["devices"]) { + auto attached = dev.get("attached_vm", Json::Value::null); + printf("%-10s %-30s %-8s %-8s %-5s %-36s %s\n", + dev.get("sysfs", "").asCString(), + dev.get("id", "").asCString(), + dev.get("port", "").asCString(), + dev.get("state", "").asCString(), + dev.get("lock", false).asBool() ? "yes" : "no", + attached.isNull() ? "-" : attached.asCString(), + describe_result(dev.get("result", Json::Value::null)).c_str()); + } + return 0; +} + +int UsbRulesCommand::set(const char *path) { + std::ifstream in(path); + if (!in) { + fprintf(stderr, "Cannot open %s\n", path); + return 1; + } + std::stringstream buf; + buf << in.rdbuf(); + auto ipc = IPCClient::get(); + Json::Value req; + req["command"] = "usb_rules_set"; + req["rules"] = IPCClient::parse_json(buf.str()); + print_response(ipc->send_request(req)); + return 0; +} + +// The direct action, and the way to check the whole feature from a shell: no rules are read, +// written or re-run, and the device goes where the argument says. +class UsbTargetCommand : public Command { +public: + [[nodiscard]] const char *name() const override { return "usb-target"; } + + [[nodiscard]] const char *description() const override { + return "Send one USB device to the host, a VM or the sink, without touching the rules"; + } + + [[nodiscard]] const char *usage() const override { + return " host|sink|[/]"; + } + + [[nodiscard]] int min_args() const override { return 2; } + + int run(int argc, char *argv[]) override; +}; + +int UsbTargetCommand::run(int, char *argv[]) { + auto ipc = IPCClient::get(); + Json::Value req; + req["command"] = "usb_set_target"; + req["device"] = argv[2]; + std::string target = argv[3]; + if (target == "host" || target == "sink") { + req["target"] = target; + } else { + // A VM, and after a slash the controller inside it; without one the VM's first. + auto slash = target.find('/'); + req["target"] = "vm"; + req["vm_id"] = resolve_vm_id(target.substr(0, slash)); + if (slash != std::string::npos) req["controller"] = target.substr(slash + 1); + } + print_response(ipc->send_request(req)); + return 0; +} + +void command_register_usb(CommandRegistry ®istry) { + registry.register_command(std::make_unique()); + registry.register_command(std::make_unique()); + registry.register_command(std::make_unique()); + registry.register_command(std::make_unique()); + registry.register_command(std::make_unique()); + registry.register_command(std::make_unique()); +} diff --git a/app/src/main/cpp/unixhelper/unixhelper.c b/app/src/main/cpp/unixhelper/unixhelper.c index c6a9b5d32..f52959113 100644 --- a/app/src/main/cpp/unixhelper/unixhelper.c +++ b/app/src/main/cpp/unixhelper/unixhelper.c @@ -7,6 +7,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -217,6 +220,97 @@ JNI_PREFIX(nativePollIn)( return 0; } +/* + * The kernel's uevent multicast socket, which is the only place a driver bind or unbind is + * reported. Everything else this daemon watches moves a node under /dev/bus/usb and so raises an + * inotify event, but binding or unbinding a driver moves nothing: the interface directory stays, + * the device node stays, and only the driver symlink comes or goes. Measured on the phone -- + * unbinding usbhid from 1-1.6:1.0 left the node set byte-identical and still pushed the kernel's + * uevent sequence number by five. + * + * Group 1 is the kernel's own group; nl_pid 0 asks the kernel to pick an address, so several + * sockets in one process do not collide. The receive buffer is raised best-effort because a + * whole tree re-enumerating is a burst and a dropped datagram is a missed change. + */ +JNIEXPORT jint JNICALL +JNI_PREFIX(nativeUeventOpen)( + JNIEnv *env, jclass clazz +) { + (void) env; + (void) clazz; + int fd = socket(PF_NETLINK, SOCK_DGRAM | SOCK_CLOEXEC, NETLINK_KOBJECT_UEVENT); + if (fd < 0) { + LOGW("uevent socket() failed: %s", strerror(errno)); + return -1; + } + int size = 1 << 20; + if (setsockopt(fd, SOL_SOCKET, SO_RCVBUFFORCE, &size, sizeof(size)) < 0) + setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &size, sizeof(size)); + struct sockaddr_nl addr; + memset(&addr, 0, sizeof(addr)); + addr.nl_family = AF_NETLINK; + addr.nl_pid = 0; + addr.nl_groups = 1; + if (bind(fd, (struct sockaddr *) &addr, sizeof(addr)) < 0) { + LOGW("uevent bind() failed: %s", strerror(errno)); + close(fd); + return -1; + } + LOGI("uevent socket -> %d", fd); + return fd; +} + +/* + * poll() over two descriptors, for a reader that has to be stoppable without a standing timeout. + * Returns a bitmask -- 1 for the first, 2 for the second -- 0 on timeout, -1 on error and -2 when + * either side hung up. The second descriptor is normally the read end of a pipe somebody writes a + * byte to in order to end the loop, which is what keeps a watcher that may live for the whole + * daemon from waking the phone on a timer just to ask whether it should stop. + */ +JNIEXPORT jint JNICALL +JNI_PREFIX(nativePollIn2)( + JNIEnv *env, jclass clazz, jint fd1, jint fd2, jint timeoutMs +) { + (void) env; + (void) clazz; + struct pollfd pfds[2]; + pfds[0].fd = fd1; + pfds[0].events = POLLIN; + pfds[0].revents = 0; + pfds[1].fd = fd2; + pfds[1].events = POLLIN; + pfds[1].revents = 0; + int ret; + do { + ret = poll(pfds, 2, timeoutMs); + } while (ret < 0 && errno == EINTR); + if (ret < 0) return -1; + if (ret == 0) return 0; + if ((pfds[0].revents | pfds[1].revents) & (POLLERR | POLLHUP | POLLNVAL)) return -2; + jint mask = 0; + if (pfds[0].revents & POLLIN) mask |= 1; + if (pfds[1].revents & POLLIN) mask |= 2; + return mask; +} + +JNIEXPORT jint JNICALL +JNI_PREFIX(nativeWrite)( + JNIEnv *env, jclass clazz, jint fd, jbyteArray buf, jint len +) { + (void) clazz; + if (!buf) return -1; + jint arrLen = (*env)->GetArrayLength(env, buf); + if (len > arrLen) len = arrLen; + jbyte *bytes = (*env)->GetByteArrayElements(env, buf, NULL); + if (!bytes) return -1; + ssize_t n; + do { + n = write(fd, bytes, len); + } while (n < 0 && errno == EINTR); + (*env)->ReleaseByteArrayElements(env, buf, bytes, JNI_ABORT); + return (jint) n; +} + JNIEXPORT jint JNICALL JNI_PREFIX(nativeRead)( JNIEnv *env, jclass clazz, jint fd, jbyteArray buf, jint len @@ -234,3 +328,87 @@ JNI_PREFIX(nativeRead)( (*env)->ReleaseByteArrayElements(env, buf, bytes, n > 0 ? 0 : JNI_ABORT); return (jint) n; } + + +/* + * evdev, for the physical-keyboard grab. A grabbed keyboard is the only way the keys Android + * keeps for itself -- Home, the task switcher, every Meta shortcut -- can reach a guest: EVIOCGRAB + * makes this descriptor the sole recipient of the device's events, so Android's InputReader keeps + * its own fd open and is simply never woken again. Reading and writing the fd is nativeRead / + * nativeWrite's job; only the parts that need an ioctl live here. + */ + +JNIEXPORT jint JNICALL +JNI_PREFIX(nativeEvdevOpen)( + JNIEnv *env, jclass clazz, jstring path +) { + (void) clazz; + if (!path) return -1; + const char *cpath = (*env)->GetStringUTFChars(env, path, NULL); + if (!cpath) return -1; + // Read-write first because the LEDs (caps lock and friends) are written back to the same + // descriptor; a node that refuses it is still perfectly readable, and only the LEDs are lost. + int fd = open(cpath, O_RDWR | O_CLOEXEC); + if (fd < 0) fd = open(cpath, O_RDONLY | O_CLOEXEC); + if (fd < 0) LOGW("evdev open %s failed: %s", cpath, strerror(errno)); + (*env)->ReleaseStringUTFChars(env, path, cpath); + return fd; +} + +/* 0 on success, -errno otherwise. EBUSY means somebody else holds the grab. */ +JNIEXPORT jint JNICALL +JNI_PREFIX(nativeEvdevGrab)( + JNIEnv *env, jclass clazz, jint fd, jboolean grab +) { + (void) env; + (void) clazz; + int ret = ioctl(fd, EVIOCGRAB, grab ? 1 : 0); + if (ret < 0) return -errno; + return 0; +} + +JNIEXPORT jstring JNICALL +JNI_PREFIX(nativeEvdevName)( + JNIEnv *env, jclass clazz, jint fd +) { + (void) clazz; + char name[256]; + memset(name, 0, sizeof(name)); + if (ioctl(fd, EVIOCGNAME(sizeof(name) - 1), name) < 0) return NULL; + return (*env)->NewStringUTF(env, name); +} + +/* {bustype, vendor, product, version}, or null when the kernel would not say. */ +JNIEXPORT jintArray JNICALL +JNI_PREFIX(nativeEvdevIds)( + JNIEnv *env, jclass clazz, jint fd +) { + (void) clazz; + struct input_id id; + memset(&id, 0, sizeof(id)); + if (ioctl(fd, EVIOCGID, &id) < 0) return NULL; + jintArray out = (*env)->NewIntArray(env, 4); + if (!out) return NULL; + jint vals[4] = {id.bustype, id.vendor, id.product, id.version}; + (*env)->SetIntArrayRegion(env, out, 0, 4, vals); + return out; +} + +/* + * The device's EV_KEY bitmap, one bit per key code, little-endian by byte -- what says whether + * this node is a keyboard at all rather than a lid switch or a touchscreen's button. Null when the + * ioctl fails. + */ +JNIEXPORT jbyteArray JNICALL +JNI_PREFIX(nativeEvdevKeyBits)( + JNIEnv *env, jclass clazz, jint fd +) { + (void) clazz; + unsigned char bits[(KEY_MAX / 8) + 1]; + memset(bits, 0, sizeof(bits)); + if (ioctl(fd, EVIOCGBIT(EV_KEY, sizeof(bits)), bits) < 0) return NULL; + jbyteArray out = (*env)->NewByteArray(env, (jsize) sizeof(bits)); + if (!out) return NULL; + (*env)->SetByteArrayRegion(env, out, 0, (jsize) sizeof(bits), (const jbyte *) bits); + return out; +} diff --git a/app/src/main/cpp/vkprobe/vkprobe.cpp b/app/src/main/cpp/vkprobe/vkprobe.cpp new file mode 100644 index 000000000..b63b93f94 --- /dev/null +++ b/app/src/main/cpp/vkprobe/vkprobe.cpp @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. +// +// Capability probe for the native-display GPU-blit provider (GpuBlitProvider.SYSTEM). +// +// The crosvm display bridge (crosvm_android_display_client.cpp) imports the virtio-gpu +// scanout dmabuf as a VkImage and blits it into the SurfaceControl buffer. To do so it +// enables a fixed set of device extensions at vkCreateDevice; a driver that does not expose +// them cannot run the blit and the bridge silently degrades to a CPU copy. This probe lets +// the UI tell the user *before* they pick SYSTEM which of those extensions their platform's +// stock Vulkan driver lacks. +// +// It is deliberately general: it enumerates the actual driver's extension list and compares +// against the bridge's requirements, with no per-vendor ("Qualcomm fails") special-casing. +// The list below must stay in sync with the bridge's device-creation extension set. + +#include +#include +#include + +#include +#include +#include + +#define VK_NO_PROTOTYPES +#include + +#define LOG_TAG "vkprobe" +#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__) + +namespace { + +// Device extensions crosvm_android_display_client.cpp enables at vkCreateDevice. Keep in sync. +const char* const kRequired[] = { + "VK_EXT_external_memory_dma_buf", + "VK_EXT_image_drm_format_modifier", + "VK_ANDROID_external_memory_android_hardware_buffer", + "VK_KHR_external_memory_fd", + "VK_EXT_queue_family_foreign", + "VK_KHR_external_semaphore", + "VK_KHR_external_semaphore_fd", +}; + +std::vector deviceMissing(PFN_vkEnumerateDeviceExtensionProperties enumExt, + VkPhysicalDevice dev) { + uint32_t n = 0; + enumExt(dev, nullptr, &n, nullptr); + std::vector props(n); + if (n) enumExt(dev, nullptr, &n, props.data()); + std::vector missing; + for (const char* req : kRequired) { + bool found = false; + for (const auto& p : props) + if (std::strcmp(p.extensionName, req) == 0) { found = true; break; } + if (!found) missing.emplace_back(req); + } + return missing; +} + +} // namespace + +// Returns a String[] of the required extensions the most-capable physical device is missing +// (empty => some device supports all of them => SYSTEM blit is usable), or null if the probe +// could not run at all (no loader / no instance / no device) so the caller can treat it as +// "unknown" rather than "incapable". +extern "C" JNIEXPORT jobjectArray JNICALL +Java_cn_classfun_droidvm_lib_natives_VulkanBlitProbe_nativeMissingBlitExtensions(JNIEnv* env, + jclass) { + void* lib = dlopen("libvulkan.so", RTLD_NOW | RTLD_LOCAL); + if (!lib) { LOGW("dlopen libvulkan.so: %s", dlerror()); return nullptr; } + + auto gipa = reinterpret_cast(dlsym(lib, "vkGetInstanceProcAddr")); + if (!gipa) { dlclose(lib); return nullptr; } + auto createInstance = + reinterpret_cast(gipa(VK_NULL_HANDLE, "vkCreateInstance")); + if (!createInstance) { dlclose(lib); return nullptr; } + + VkApplicationInfo appInfo{}; + appInfo.sType = VK_STRUCTURE_TYPE_APPLICATION_INFO; + appInfo.pApplicationName = "droidvm-vkprobe"; + appInfo.apiVersion = VK_API_VERSION_1_0; // widest acceptance; ext query is version-agnostic + VkInstanceCreateInfo ici{}; + ici.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO; + ici.pApplicationInfo = &appInfo; + + VkInstance inst = VK_NULL_HANDLE; + if (createInstance(&ici, nullptr, &inst) != VK_SUCCESS || inst == VK_NULL_HANDLE) { + dlclose(lib); + return nullptr; + } + + auto enumPhys = + reinterpret_cast(gipa(inst, "vkEnumeratePhysicalDevices")); + auto enumExt = reinterpret_cast( + gipa(inst, "vkEnumerateDeviceExtensionProperties")); + auto destroyInstance = + reinterpret_cast(gipa(inst, "vkDestroyInstance")); + + std::vector best; + bool haveBest = false; + if (enumPhys && enumExt) { + uint32_t nDev = 0; + enumPhys(inst, &nDev, nullptr); + std::vector devs(nDev); + if (nDev) enumPhys(inst, &nDev, devs.data()); + for (VkPhysicalDevice dev : devs) { + std::vector miss = deviceMissing(enumExt, dev); + if (miss.empty()) { best.clear(); haveBest = true; break; } + if (!haveBest || miss.size() < best.size()) { best = std::move(miss); haveBest = true; } + } + } + + if (destroyInstance) destroyInstance(inst, nullptr); + dlclose(lib); + + if (!haveBest) return nullptr; // no physical device answered -> unknown + + jclass strCls = env->FindClass("java/lang/String"); + jobjectArray out = env->NewObjectArray(static_cast(best.size()), strCls, nullptr); + for (jsize i = 0; i < static_cast(best.size()); i++) { + jstring s = env->NewStringUTF(best[i].c_str()); + env->SetObjectArrayElement(out, i, s); + env->DeleteLocalRef(s); + } + return out; +} diff --git a/app/src/main/cpp/vnc/vncclient_jni.c b/app/src/main/cpp/vnc/vncclient_jni.c index f2f06fb04..61b2bd3eb 100644 --- a/app/src/main/cpp/vnc/vncclient_jni.c +++ b/app/src/main/cpp/vnc/vncclient_jni.c @@ -13,6 +13,24 @@ #define LOGW(...) __android_log_print(ANDROID_LOG_WARN, TAG, __VA_ARGS__) #define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__) +/* THE TWO ENCODINGS THAT CARRY THE HARDWARE H.264 STREAM, on the ordinary RFB port. + * + * 50 is rfbproto.rst's "Open H.264". 0x44564831 is "DVH1", DroidVM's private pseudo-encoding, + * whose fixed four-byte payload says the two things RFB negotiation cannot: whether the host has + * an encoder at all, and -- on an idle screen, where a live stream and a dead one look alike -- + * that the connection is still there. Both numbers and the payload layout are pinned by + * plans/H264_SINGLE_PORT.md section 1 and are implemented here as written, not renegotiated. */ +#define VNC_ENCODING_H264 50 +#define VNC_ENCODING_DVH1 0x44564831 +/* An encoding-50 rect body opens with u32 BE length and u32 BE flags, then the Annex-B payload. */ +#define H264_RECT_HEADER_BYTES 8 +/* The whole of a DVH1 rect: version, kind, value, reserved. */ +#define DVH1_PAYLOAD_BYTES 4 +/* Above this the length prefix describes a stream that has lost its place rather than a frame, and + * the only thing left to do with the connection is end it. Mirrors the Java parser's own guard -- + * both sides check because both sides allocate. */ +#define H264_RECT_MAX_PAYLOAD (16 * 1024 * 1024) + typedef struct { JavaVM *jvm; jobject callback; @@ -23,12 +41,163 @@ typedef struct { volatile int stop_requested; pthread_mutex_t lock; char *password; + /* Set by the rect handlers below and read-and-cleared by vnc_update. Neither of our rects + * touches the framebuffer, but libvncclient calls GotFrameBufferUpdate for every rect it + * dispatched -- and the Java side answers that by copying the whole framebuffer into a Bitmap. + * Without this the console would do a full-screen copy once per decoded frame, for pixels + * nothing is going to look at. */ + int rect_was_ours; } VncContext; static VncContext *ctx_of(rfbClient *cl) { return (VncContext *) rfbClientGetClientData(cl, (void *) 0xDEAD); } +static uint32_t be32(const uint8_t *p) { + return ((uint32_t) p[0] << 24) | ((uint32_t) p[1] << 16) + | ((uint32_t) p[2] << 8) | (uint32_t) p[3]; +} + +/** + * A JNIEnv for whichever thread is calling, and whether it had to be attached to get one. + * + * In practice every caller is the console's own message-loop thread, which is a Java thread and so + * already attached; the attach path is here because a JNIEnv borrowed from the wrong thread is + * undefined behaviour rather than an error, and that is not a thing to leave to a comment. + */ +static JNIEnv *env_for(VncContext *ctx, int *attached) { + JNIEnv *env = NULL; + *attached = 0; + if (!ctx->jvm || !ctx->callback) return NULL; + if ((*ctx->jvm)->GetEnv(ctx->jvm, (void **) &env, JNI_VERSION_1_6) != JNI_OK) { + if ((*ctx->jvm)->AttachCurrentThread(ctx->jvm, &env, NULL) != JNI_OK) return NULL; + *attached = 1; + } + return env; +} + +/** Hands one rect's bytes to a Java callback taking (byte[], int, int). */ +static void deliver_rect(VncContext *ctx, const char *method, const char *sig, + const uint8_t *data, size_t len, jint a, jint b, int with_size) { + int attached = 0; + JNIEnv *env = env_for(ctx, &attached); + if (!env) return; + jbyteArray arr = (*env)->NewByteArray(env, (jsize) len); + if (arr) { + (*env)->SetByteArrayRegion(env, arr, 0, (jsize) len, (const jbyte *) data); + jclass cls = (*env)->GetObjectClass(env, ctx->callback); + jmethodID mid = (*env)->GetMethodID(env, cls, method, sig); + if (mid) { + if (with_size) (*env)->CallVoidMethod(env, ctx->callback, mid, arr, a, b); + else (*env)->CallVoidMethod(env, ctx->callback, mid, arr); + } + (*env)->DeleteLocalRef(env, cls); + /* Deleted rather than left to the frame: a decoded second is sixty of these, and the + * native frame they would sit in is not unwound until nativeProcessMessages returns. */ + (*env)->DeleteLocalRef(env, arr); + } else { + /* The allocation failed and the exception is pending; clearing it here keeps the rect + * loop's own error reporting intact instead of tripping over ours on the next JNI call. */ + (*env)->ExceptionClear(env); + } + if (attached) (*ctx->jvm)->DetachCurrentThread(ctx->jvm); +} + +/** + * Reads an encoding-50 rect and hands the whole body -- the eight-byte header included -- to Java. + * + * The header is passed on rather than consumed here so that the length and the flags are parsed in + * exactly one place, by the Java class the unit tests can feed the seam's literal bytes to. The + * length is read twice, which is unavoidable: a length-prefixed message cannot be read off a socket + * without reading its prefix. What that costs is checked rather than assumed -- the Java parser + * refuses a body whose declared length disagrees with the bytes it was given. + */ +static rfbBool read_h264_rect(rfbClient *cl, VncContext *ctx, + rfbFramebufferUpdateRectHeader *rect) { + uint8_t head[H264_RECT_HEADER_BYTES]; + uint32_t length; + uint8_t *body; + + if (!ReadFromRFBServer(cl, (char *) head, H264_RECT_HEADER_BYTES)) return FALSE; + length = be32(head); + if (length > H264_RECT_MAX_PAYLOAD) { + LOGE("h264 rect claims %u bytes; the stream has lost its place", length); + return FALSE; + } + body = (uint8_t *) malloc(H264_RECT_HEADER_BYTES + (size_t) length); + if (!body) { + LOGE("out of memory for a %u-byte h264 rect", length); + return FALSE; + } + memcpy(body, head, H264_RECT_HEADER_BYTES); + if (length > 0 && + !ReadFromRFBServer(cl, (char *) (body + H264_RECT_HEADER_BYTES), length)) { + free(body); + return FALSE; + } + deliver_rect(ctx, "onH264Rect", "([BII)V", body, + H264_RECT_HEADER_BYTES + (size_t) length, + rect->r.w, rect->r.h, 1); + free(body); + return TRUE; +} + +/** + * The client half of the two encodings, called by libvncclient for any rect it does not know. + * + * Returning FALSE for a rect that IS ours (a short read, a length past the guard) is deliberate: + * libvncclient's rect loop treats an unhandled rect as a protocol failure and drops the + * connection, which is the only correct answer once the byte stream and the parser disagree about + * where the next rect begins. The reason is logged here first, because the loop's own message + * would blame the encoding number rather than the stream. + */ +static rfbBool handle_dvh_rect(rfbClient *cl, rfbFramebufferUpdateRectHeader *rect) { + VncContext *ctx = ctx_of(cl); + uint8_t payload[DVH1_PAYLOAD_BYTES]; + + if (!ctx) return FALSE; + if (rect->encoding == VNC_ENCODING_DVH1) { + if (!ReadFromRFBServer(cl, (char *) payload, DVH1_PAYLOAD_BYTES)) return FALSE; + ctx->rect_was_ours = 1; + deliver_rect(ctx, "onDvhRect", "([B)V", payload, DVH1_PAYLOAD_BYTES, 0, 0, 0); + return TRUE; + } + if (rect->encoding == VNC_ENCODING_H264) { + /* Set before the read, not after: a failure here ends the connection, and the framebuffer + * notification for a rect that was ours must not go out on the way down. */ + ctx->rect_was_ours = 1; + return read_h264_rect(cl, ctx, rect); + } + return FALSE; +} + +/* The encodings this client asks for, and the switch that stops it asking. + * + * libvncclient's extension list is process-global (rfbclient.c:105), so this array is read by + * every connection rather than by one. That is the right scope for the one thing that turns it + * off: a device with no video/avc decoder cannot decode this stream on any connection, and a + * console that stays enrolled on a stream it cannot decode is a console showing a frozen picture, + * because the server stops sending pixels to clients that asked for 50. Swapping the pointer + * rather than editing the array in place keeps the read side seeing one list or the other and + * never half of an edit. */ +static int dvh_encodings_on[] = {VNC_ENCODING_DVH1, VNC_ENCODING_H264, 0}; +static int dvh_encodings_off[] = {0}; + +static rfbClientProtocolExtension dvh_extension = { + .encodings = dvh_encodings_on, + .handleEncoding = handle_dvh_rect, + .handleMessage = NULL, + .next = NULL, + .securityTypes = NULL, + .handleAuthentication = NULL, +}; + +static pthread_once_t dvh_registered = PTHREAD_ONCE_INIT; + +static void register_dvh_extension(void) { + rfbClientRegisterExtension(&dvh_extension); +} + static rfbBool vnc_resize(rfbClient *cl) { VncContext *ctx = ctx_of(cl); int w = cl->width; @@ -71,6 +240,13 @@ static rfbBool vnc_resize(rfbClient *cl) { static void vnc_update(rfbClient *cl, int x, int y, int w, int h) { VncContext *ctx = ctx_of(cl); JNIEnv *env = NULL; + if (!ctx) return; + if (ctx->rect_was_ours) { + /* Cleared here rather than in the handler because this runs once per dispatched rect, + * which is the only place the flag can be retired without a second bookkeeping rule. */ + ctx->rect_was_ours = 0; + return; + } if (!ctx->jvm || !ctx->callback) return; int attached = 0; if ((*ctx->jvm)->GetEnv(ctx->jvm, (void **) &env, JNI_VERSION_1_6) != JNI_OK) { @@ -96,9 +272,21 @@ static char *vnc_get_password(rfbClient *cl) { #define JNI_PREFIX(name) \ Java_cn_classfun_droidvm_ui_vm_display_vnc_base_VncClient_##name +JNIEXPORT void JNICALL +JNI_PREFIX(nativeSetH264Advertised)(JNIEnv *env, jclass cls, jboolean advertised) { + (void) env; + (void) cls; + dvh_extension.encodings = advertised ? dvh_encodings_on : dvh_encodings_off; + LOGI("H.264 encodings %s for connections made from now on", + advertised ? "advertised" : "withdrawn"); +} + JNIEXPORT jlong JNICALL JNI_PREFIX(nativeCreate)(JNIEnv *env, jobject thiz) { (void) thiz; + /* Once per process: the list this joins is global, and registering twice would put both + * encoding numbers on the wire twice and run every rect through two identical handlers. */ + pthread_once(&dvh_registered, register_dvh_extension); rfbClient *cl = rfbGetClient(8, 3, 4); if (!cl) { LOGE("rfbGetClient failed"); diff --git a/app/src/main/java/cn/classfun/droidvm/DroidVMApp.java b/app/src/main/java/cn/classfun/droidvm/DroidVMApp.java index 43fc13809..c913ca39a 100644 --- a/app/src/main/java/cn/classfun/droidvm/DroidVMApp.java +++ b/app/src/main/java/cn/classfun/droidvm/DroidVMApp.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm; import android.app.Application; @@ -11,7 +14,9 @@ import cn.classfun.droidvm.lib.store.disk.DiskStore; import cn.classfun.droidvm.lib.store.network.NetworkStore; import cn.classfun.droidvm.lib.store.vm.VMStore; +import cn.classfun.droidvm.lib.ui.ImeInsetsApplier; import cn.classfun.droidvm.lib.utils.ThreadUtils; +import cn.classfun.droidvm.ui.main.settings.KernelModuleManager; public final class DroidVMApp extends Application { private static final String TAG = "DroidVMApp"; @@ -21,6 +26,7 @@ public final class DroidVMApp extends Application { public void onCreate() { super.onCreate(); DynamicColors.applyToActivitiesIfAvailable(this); + registerActivityLifecycleCallbacks(new ImeInsetsApplier()); vmEventHandler = new VMEventHandler(this); registerActivityLifecycleCallbacks(vmEventHandler); DaemonConnection.getInstance().addListener(vmEventHandler); @@ -28,6 +34,11 @@ public void onCreate() { initializeStore(new VMStore()); initializeStore(new DiskStore()); initializeStore(new NetworkStore()); + try { + KernelModuleManager.applyAutostart(this); + } catch (Exception e) { + Log.w(TAG, "kernel module autostart failed", e); + } }); } diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/Daemon.java b/app/src/main/java/cn/classfun/droidvm/daemon/Daemon.java index 6e2ec4ebe..7721471e9 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/Daemon.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/Daemon.java @@ -1,15 +1,21 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon; +import static android.os.Process.myPid; import static cn.classfun.droidvm.BuildConfig.APPLICATION_ID; import static cn.classfun.droidvm.lib.Constants.DATA_DIR; import static cn.classfun.droidvm.lib.daemon.DaemonHelper.getPidFile; import static cn.classfun.droidvm.lib.daemon.DaemonHelper.getPortFile; import static cn.classfun.droidvm.lib.daemon.DaemonHelper.getTokenFile; +import static cn.classfun.droidvm.lib.utils.FileUtils.findExecute; +import static cn.classfun.droidvm.lib.utils.FileUtils.writeFile; import static cn.classfun.droidvm.lib.utils.RunUtils.runList; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; import static cn.classfun.droidvm.lib.utils.StringUtils.pathJoin; -import android.os.Process; +import android.system.Os; import android.util.Log; import androidx.annotation.NonNull; @@ -17,18 +23,137 @@ import java.io.File; import java.io.FileWriter; import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.util.Arrays; import java.util.UUID; import java.util.concurrent.atomic.AtomicBoolean; +import cn.classfun.droidvm.daemon.audio.HostAudioTable; +import cn.classfun.droidvm.daemon.display.DaemonSystemContext; import cn.classfun.droidvm.daemon.server.Server; import cn.classfun.droidvm.lib.natives.UnixHelper; import cn.classfun.droidvm.lib.utils.FileUtils; +import cn.classfun.droidvm.lib.utils.ProcessUtils; public final class Daemon { public static final String TAG = "Daemon"; + public static final String LOG_PATH = pathJoin(DATA_DIR, "cache", "daemon.log"); private static final String RUN_DIR = pathJoin(DATA_DIR, "run"); + private static final String LOCK_FILE = pathJoin(RUN_DIR, "droidvmd.lock"); private static final AtomicBoolean cleaned = new AtomicBoolean(false); public static String daemonHash = null; + private static Process logcatProcess = null; + + // Held for the whole process lifetime so the advisory lock stays taken; the + // kernel drops it automatically when this process dies (including SIGKILL), + // so a stale lock can never block the next daemon. + @SuppressWarnings("FieldCanBeLocal") + private static FileChannel lockChannel; + @SuppressWarnings("FieldCanBeLocal") + private static FileLock daemonLock; + + /** + * Single-instance gate. Tries to take an exclusive lock on + * {@code droidvmd.lock}. Returns true when held by this process. When the + * lock is already taken by a live daemon: with {@code force}, that daemon + * is terminated (SIGINT, then SIGKILL) and the lock is taken over; + * without it, this returns false so the caller exits instead of running a + * second daemon. + */ + private static boolean acquireLock(boolean force) { + var runDir = new File(RUN_DIR); + if (!runDir.exists() && !runDir.mkdirs()) + Log.w(TAG, fmt("Failed to create run directory: %s", runDir)); + try { + lockChannel = FileChannel.open(Paths.get(LOCK_FILE), + StandardOpenOption.CREATE, StandardOpenOption.READ, StandardOpenOption.WRITE); + } catch (IOException e) { + Log.e(TAG, fmt("Failed to open lock file: %s", LOCK_FILE), e); + return false; + } + if (tryLock()) { + writeLockOwner(); + return true; + } + int holder = readLockOwner(); + if (!force) { + Log.e(TAG, fmt( + "Another daemon (pid=%d) is already running; exiting " + + "(start with --force to take over)", holder)); + return false; + } + Log.i(TAG, fmt("--force: terminating existing daemon (pid=%d)", holder)); + if (holder > 1) ProcessUtils.shellKillProcess(holder, ProcessUtils.SIGINT); + if (waitForLock(10_000)) { + writeLockOwner(); + return true; + } + if (holder > 1) { + Log.w(TAG, fmt("Existing daemon (pid=%d) did not exit; sending SIGKILL", holder)); + ProcessUtils.shellKillProcess(holder, ProcessUtils.SIGKILL); + } + if (waitForLock(3_000)) { + writeLockOwner(); + return true; + } + Log.e(TAG, "Failed to take over the daemon lock"); + return false; + } + + private static boolean tryLock() { + try { + daemonLock = lockChannel.tryLock(); + return daemonLock != null; + } catch (Exception e) { + // OverlappingFileLockException can't happen (single attempt); any + // other failure is treated as "not acquired". + Log.w(TAG, "tryLock failed", e); + return false; + } + } + + private static boolean waitForLock(long timeoutMs) { + long deadline = System.currentTimeMillis() + timeoutMs; + while (System.currentTimeMillis() < deadline) { + if (tryLock()) return true; + try { + //noinspection BusyWait + Thread.sleep(200); + } catch (InterruptedException e) { + return false; + } + } + return false; + } + + /** Records this daemon's pid in the lock file so a later --force can find it. */ + @SuppressWarnings("ResultOfMethodCallIgnored") + private static void writeLockOwner() { + try { + lockChannel.truncate(0); + lockChannel.position(0); + lockChannel.write(ByteBuffer.wrap(String.valueOf(myPid()).getBytes())); + lockChannel.force(false); + } catch (IOException e) { + Log.w(TAG, "Failed to write lock owner pid", e); + } + } + + /** Pid of the current lock holder: from the lock file, else the pid file. */ + private static int readLockOwner() { + for (var path : new String[]{LOCK_FILE, getPidFile()}) { + try { + var s = FileUtils.readFile(path).trim(); + if (!s.isEmpty()) return Integer.parseInt(s); + } catch (Exception ignored) { + } + } + return -1; + } private static void writePidFile() { var runDir = new File(RUN_DIR); @@ -36,9 +161,9 @@ private static void writePidFile() { Log.w(TAG, fmt("Failed to create run directory: %s", runDir)); var pidFile = new File(runDir, "droidvmd.pid"); try (var writer = new FileWriter(pidFile)) { - writer.write(String.valueOf(Process.myPid())); + writer.write(String.valueOf(myPid())); writer.flush(); - Log.i(TAG, fmt("PID file written: %s (pid=%d)", pidFile, Process.myPid())); + Log.i(TAG, fmt("PID file written: %s (pid=%d)", pidFile, myPid())); } catch (IOException e) { Log.e(TAG, fmt("Failed to write PID file: %s", pidFile), e); } @@ -63,7 +188,18 @@ private static void cleanup(Server server) { if (!cleaned.compareAndSet(false, true)) return; Log.i(TAG, "Stopping all VMs and networks..."); var ctx = server.getContext(); - ctx.getVMs().stopAll(); + try { + ctx.getVMs().stopAll(); + } finally { + // After the VMs, not before: stopping them only queues the USB releases, and the + // interfaces are handed back to the host from that queue. In a finally, because the + // first thing that shutdown does is put /sys/bus/usb/drivers_autoprobe back to the + // kernel's default: that flag is global and outlives this process, so a stop that + // throws on its way past here would leave every device plugged into the phone + // afterwards with no driver bound -- no keyboard, no storage -- and no daemon left + // to write it back. + ctx.getUsb().shutdown(); + } ctx.getNetworks().stopAll(); ctx.getNetworks().firewall.shutdown(); ctx.getRouterWatcher().stop(); @@ -96,11 +232,62 @@ private static String getMyHash() { } } + private static void setProcTitle() { + try { + FileUtils.writeFile("/proc/self/comm", "droidvmd"); + } catch (IOException e) { + Log.w(TAG, "Failed to set process title", e); + } + } + + @SuppressWarnings("OctalInteger") + private static void startLogcat() { + try { + var builder = new ProcessBuilder(); + var file = new File(LOG_PATH); + if (file.exists() && !file.delete()) + Log.w(TAG, "failed to remove old log"); + writeFile(file, ""); + var uid = Server.getDroidVMUid(); + Os.chmod(LOG_PATH, 0600); + Os.chown(LOG_PATH, uid, uid); + builder.command( + findExecute("logcat", "/system/bin/logcat"), + fmt("--pid=%d", myPid()) + ); + builder.redirectOutput(new File(LOG_PATH)); + logcatProcess = builder.start(); + } catch (Exception e) { + Log.w(TAG, "failed to start logcat", e); + logcatProcess = null; + } + } + + private static void stopLogcat() { + if (logcatProcess == null) return; + logcatProcess.destroy(); + logcatProcess = null; + } + public static void main(String... args) { + boolean force = Arrays.asList(args).contains("--force"); System.out.print("Starting DroidVM Daemon...\n"); UnixHelper.load(); - System.out.printf("Current pid: %d\n", Process.myPid()); + // Build a system Context on the main thread (it prepares the main looper) so the daemon can + // broadcast the native-display binder to the UI. Must happen before server.run() blocks here. + DaemonSystemContext.init(); + setProcTitle(); + startLogcat(); + System.out.printf("Current pid: %d\n", myPid()); Log.d(TAG, "DroidVM Daemon is starting..."); + if (!acquireLock(force)) { + System.out.print("Another DroidVM Daemon is already running.\n"); + System.exit(1); + } + // Publish the host's audio endpoints before any VM starts, and keep them current: a VM + // pinned to a headset needs to find it again by name after it is reconnected, and the + // number it had before will not be the number it has after. + HostAudioTable.start(DaemonSystemContext.get()); daemonHash = getMyHash(); Log.i(TAG, fmt("DroidVM Daemon hash: %s", daemonHash)); writePidFile(); @@ -117,8 +304,10 @@ public static void main(String... args) { Runtime.getRuntime().addShutdownHook(new Thread(() -> { server.stop(); cleanup(server); + stopLogcat(); })); server.run(); cleanup(server); + stopLogcat(); } } diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/audio/HostAudioTable.java b/app/src/main/java/cn/classfun/droidvm/daemon/audio/HostAudioTable.java new file mode 100644 index 000000000..32d9ba340 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/daemon/audio/HostAudioTable.java @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. +package cn.classfun.droidvm.daemon.audio; + +import static cn.classfun.droidvm.lib.Constants.DATA_DIR; +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; +import static cn.classfun.droidvm.lib.utils.StringUtils.pathJoin; + +import android.content.Context; +import android.media.AudioDeviceCallback; +import android.media.AudioDeviceInfo; +import android.media.AudioManager; +import android.os.Handler; +import android.os.HandlerThread; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import cn.classfun.droidvm.lib.data.HostAudioDevices; + +/** + * Publishes the host's audio endpoints where crosvm's audio backend can read them. + * + *

AAudio names a device with an integer the platform hands out per connection: unplug a headset + * and plug it back in and the number is different, though it is plainly the same headset. A VM + * pinned to an endpoint therefore cannot hold on to the number -- it has to hold the name and look + * the number up again, every time it opens a stream.

+ * + *

Only Java can enumerate the devices: AAudio has no API for it and {@link AudioManager} is not + * reachable from the backend, which is a native process running unprivileged. So the list is + * written out here and re-read there. The matching happens on the crosvm side rather than the id + * being pushed to it, so a device coming back needs no round trip -- the file changes, and the + * name the backend is holding is still the same name.

+ */ +public final class HostAudioTable { + private static final String TAG = "HostAudioTable"; + + /** Where the table lives. One per daemon, not per VM: it describes the host, not a guest. */ + public static final String PATH = pathJoin(DATA_DIR, "run", "audio_devices"); + + private static @Nullable HostAudioTable instance; + + private final Context context; + private final AudioDeviceCallback callback; + + private HostAudioTable(@NonNull Context context) { + this.context = context; + this.callback = new AudioDeviceCallback() { + @Override + public void onAudioDevicesAdded(AudioDeviceInfo[] added) { + write(); + } + + @Override + public void onAudioDevicesRemoved(AudioDeviceInfo[] removed) { + write(); + } + }; + } + + /** + * Starts publishing, and keeps publishing until the daemon exits. Safe to call more than + * once; only the first call does anything. + */ + public static synchronized void start(@Nullable Context context) { + try { + startOrThrow(context); + } catch (Throwable t) { + // Never take the daemon down over this. Without the table a VM pinned to an endpoint + // falls back to the platform's routing, which is a worse configuration than the user + // asked for; a daemon that will not start is no configuration at all. + Log.w(TAG, "failed to start publishing host audio endpoints", t); + } + } + + private static void startOrThrow(@Nullable Context context) { + if (instance != null || context == null) return; + var am = context.getSystemService(AudioManager.class); + if (am == null) { + Log.w(TAG, "AudioManager unavailable; host audio endpoints will not be published"); + return; + } + var table = new HostAudioTable(context); + // Its own thread, rather than Looper.getMainLooper(): the daemon is not an app process + // and has no main looper, so asking for one returns null and constructing a Handler on + // it throws. Writing the table is a few hundred bytes and happens only when something is + // plugged or unplugged, so a thread of its own costs nothing. + var thread = new HandlerThread("HostAudioTable"); + thread.start(); + am.registerAudioDeviceCallback(table.callback, new Handler(thread.getLooper())); + table.write(); + instance = table; + Log.i(TAG, fmt("publishing host audio endpoints to %s", PATH)); + } + + /** + * Writes the current endpoints. + * + *

Lines are {@code \t\t}. Written to a neighbouring file and + * renamed, so a reader never sees half a table -- half a table resolves an endpoint to + * nothing, and the backend would fall back to the platform's routing for no reason.

+ */ + /** + * Rewrites the table, and never lets a failure escape. + * + *

This runs on the device-callback thread, where an uncaught exception takes the whole + * daemon down with it -- which is how a failure to describe the audio devices came to stop + * VMs from starting at all.

+ */ + private void write() { + try { + writeOrThrow(); + } catch (Throwable t) { + Log.w(TAG, "failed to publish host audio endpoints", t); + } + } + + private void writeOrThrow() { + var text = new StringBuilder(); + int lines = appendAll(text, false) + appendAll(text, true); + + var target = new File(PATH); + var staging = new File(fmt("%s.new", PATH)); + try { + var parent = target.getParentFile(); + if (parent != null && !parent.isDirectory() && !parent.mkdirs()) { + Log.w(TAG, fmt("cannot create %s", parent)); + return; + } + try (var out = new FileOutputStream(staging)) { + out.write(text.toString().getBytes(StandardCharsets.UTF_8)); + out.getFD().sync(); + } + // The backend runs as this app's uid, so the default mode already lets it read. + if (!staging.renameTo(target)) { + Log.w(TAG, "failed to replace the host audio table"); + //noinspection ResultOfMethodCallIgnored + staging.delete(); + return; + } + Log.i(TAG, fmt("host audio endpoints published: %d", lines)); + } catch (IOException e) { + Log.w(TAG, "failed to write the host audio table", e); + } + } + + private int appendAll(@NonNull StringBuilder text, boolean input) { + // The platform's own routing, as an ordinary row against AAUDIO_DEVICE_UNSPECIFIED. + // Listing it means "follow the platform" resolves through the same lookup as everything + // else, instead of being an absence that every reader has to recognise separately. + // Rate and channels are left at 0 -- following the platform means whatever it routes to + // today, and naming a format for it would be describing one particular device. The kind + // is knowable regardless: the direction decides it. + text.append(fmt("%d\t%s\t%s\t0\t0\t%d\n", HostAudioDevices.DEVICE_UNSPECIFIED, + input ? "in" : "out", HostAudioDevices.SYSTEM_DEFAULT_KEY, input ? 6 : 1)); + // Deliberately not HostAudioDevices.list: that builds a label for the picker, and a label + // needs the app's string resources, which the daemon's context does not have. + var keys = new java.util.ArrayList(); + var ids = HostAudioDevices.idsAndKeys(context, input, keys); + for (int i = 0; i < ids.size() && i < keys.size(); i++) { + var row = ids.get(i); + // id, direction, name, then what the endpoint itself is: native rate, channel count, + // and kind. A reader that only wants the id stops after the third column. + text.append(fmt("%d\t%s\t%s\t%d\t%d\t%d\n", row[0], input ? "in" : "out", + keys.get(i), row[1], row[2], row[3])); + } + return keys.size(); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/console/ConsoleStream.java b/app/src/main/java/cn/classfun/droidvm/daemon/console/ConsoleStream.java index f4c7dcfef..70ad762fb 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/console/ConsoleStream.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/console/ConsoleStream.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.console; import static java.nio.charset.StandardCharsets.UTF_8; @@ -34,9 +37,15 @@ public abstract class ConsoleStream implements Closeable, JSONSerialize { protected final VMConfig config; protected final String name; protected final RingBuffer buffer = new RingBuffer(MAX_BUFFER_SIZE); + /** + * How many bytes have ever been appended, so a reader can ask for what came after a point. + * Guarded by [buffer]. + */ + private long appended = 0; private Thread readerThread; private OutputStream logWriter = null; private boolean disableSave = false; + private boolean persistentLogEnabled = true; public ConsoleStream(@NonNull VMConfig config, @NonNull String name) { this.config = config; @@ -82,8 +91,11 @@ public void appendBuffer(@NonNull byte[] data) { } public void appendBuffer(@NonNull byte[] data, int off, int len) { - buffer.adds(data, off, len); - if (disableSave) return; + synchronized (buffer) { + buffer.adds(data, off, len); + appended += len; + } + if (disableSave || !persistentLogEnabled) return; try { if (logWriter == null) { var path = getPersistentPath(); @@ -107,24 +119,44 @@ public void appendBuffer(@NonNull byte[] data, int off, int len) { } } + /** + * Controls whether future output is copied to the persistent console log. + * + *

Agent control channels can carry credentials on their input side and protocol chatter + * on their output side. They still need the small in-memory ring buffer for normal stream + * handling, but have no useful history to retain on disk.

+ */ + public synchronized void setPersistentLogEnabled(boolean enabled) { + persistentLogEnabled = enabled; + if (!enabled && logWriter != null) { + try { + logWriter.close(); + } catch (Exception e) { + Log.w(TAG, "Failed to close disabled console log", e); + } + logWriter = null; + } + } + public void clear() { buffer.clear(); + disableSave = false; if (logWriter != null) { try { logWriter.close(); } catch (Exception e) { Log.w(TAG, "Failed to close console log output stream", e); } - try { - var path = getPersistentPath(); - var file = new File(path); - if (file.exists() && !file.delete()) - Log.w(TAG, fmt("Failed to delete console log file %s", path)); - } catch (Exception e) { - Log.w(TAG, "Failed to delete console log file", e); - } logWriter = null; } + try { + var path = getPersistentPath(); + var file = new File(path); + if (file.exists() && !file.delete()) + Log.w(TAG, fmt("Failed to delete console log file %s", path)); + } catch (Exception e) { + Log.w(TAG, "Failed to delete console log file", e); + } } @NonNull @@ -133,6 +165,34 @@ public String getBuffer() { return new String(buffer.peekAll(), UTF_8); } + /** + * A point in the stream for {@link #since}: how many bytes have been appended so far. Constant + * time, so taking one before an operation whose outcome may need the log costs nothing. + */ + public long mark() { + synchronized (buffer) { + return appended; + } + } + + /** + * Everything appended after [mark] was taken, as far as the buffer still holds it: it keeps + * the last {@link #MAX_BUFFER_SIZE} bytes, so a mark older than that yields all of them. + */ + @NonNull + public String since(long mark) { + byte[] all; + long total; + synchronized (buffer) { + all = buffer.peekAll(); + total = appended; + } + var count = total - mark; + if (count <= 0) return ""; + if (count >= all.length) return new String(all, UTF_8); + return new String(all, all.length - (int) count, (int) count, UTF_8); + } + @NonNull @SuppressWarnings("unused") public byte[] getRawBuffer() { diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/console/FDPipeConsoleStream.java b/app/src/main/java/cn/classfun/droidvm/daemon/console/FDPipeConsoleStream.java index 3525a905e..d1ed98317 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/console/FDPipeConsoleStream.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/console/FDPipeConsoleStream.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.console; import android.os.ParcelFileDescriptor; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/console/FDSocketConsoleStream.java b/app/src/main/java/cn/classfun/droidvm/daemon/console/FDSocketConsoleStream.java index 7e4fe5be8..0dd9d22c5 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/console/FDSocketConsoleStream.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/console/FDSocketConsoleStream.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.console; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/console/InputConsoleStream.java b/app/src/main/java/cn/classfun/droidvm/daemon/console/InputConsoleStream.java index bb04720e6..15d3fd4ab 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/console/InputConsoleStream.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/console/InputConsoleStream.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.console; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/console/LocalSocketConsoleStream.java b/app/src/main/java/cn/classfun/droidvm/daemon/console/LocalSocketConsoleStream.java index 5aa42749b..dc51f3817 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/console/LocalSocketConsoleStream.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/console/LocalSocketConsoleStream.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.console; import android.net.LocalSocket; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/console/SimpleConsoleStream.java b/app/src/main/java/cn/classfun/droidvm/daemon/console/SimpleConsoleStream.java index 1f268d986..6b63881cc 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/console/SimpleConsoleStream.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/console/SimpleConsoleStream.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.console; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/display/DaemonSystemContext.java b/app/src/main/java/cn/classfun/droidvm/daemon/display/DaemonSystemContext.java new file mode 100644 index 000000000..c6bc56678 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/daemon/display/DaemonSystemContext.java @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. +package cn.classfun.droidvm.daemon.display; + +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; +import android.annotation.SuppressLint; +import android.content.Context; +import android.os.Looper; +import android.util.Log; + +import androidx.annotation.Nullable; + +import java.lang.reflect.Constructor; + +/** + * Builds a system {@link Context} inside the daemon's bare app_process so it can {@code sendBroadcast} + * - the channel used to hand the native-display binder to the UI (a live IBinder can't cross the + * daemon's TCP/JSON-RPC socket). + * + * We construct an {@code ActivityThread} directly and ask it for the system Context, rather than + * {@code ActivityThread.systemMain()}: systemMain()'s {@code attach(true)} runs app/Instrumentation + * init that throws in a bare daemon process. The ActivityThread ctor only needs a prepared main + * looper (for its internal Handler), so {@link #init()} must run on the daemon's main thread (see + * Daemon.main). We never loop it; a fire-and-forget broadcast is a plain binder call to AMS. + */ +public final class DaemonSystemContext { + private static final String TAG = "DaemonSysContext"; + private static Context context; + + private DaemonSystemContext() { + } + + /** Creates the system Context once, on the daemon main thread. Safe to call repeatedly. */ + @SuppressLint({"PrivateApi", "DiscouragedPrivateApi"}) + public static synchronized void init() { + if (context != null) return; + try { + if (Looper.myLooper() == null) Looper.prepare(); + } catch (Throwable t) { + Log.w(TAG, fmt("prepare looper: %s", t)); + } + try { + Class at = Class.forName("android.app.ActivityThread"); + Constructor ctor = at.getDeclaredConstructor(); + ctor.setAccessible(true); + Object thread = ctor.newInstance(); + context = (Context) at.getMethod("getSystemContext").invoke(thread); + if (context != null) { + Log.i(TAG, "system context ready"); + return; + } + Log.e(TAG, "getSystemContext returned null"); + } catch (Throwable t) { + Log.e(TAG, "failed to create system context", t); + } + } + + /** The system Context, or null if {@link #init()} hasn't run or failed. */ + @Nullable + public static Context get() { + return context; + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/display/NativeDisplayBinder.java b/app/src/main/java/cn/classfun/droidvm/daemon/display/NativeDisplayBinder.java new file mode 100644 index 000000000..6d30df059 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/daemon/display/NativeDisplayBinder.java @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. +package cn.classfun.droidvm.daemon.display; + +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; +import static cn.classfun.droidvm.BuildConfig.APPLICATION_ID; + +import android.content.Intent; +import android.os.Bundle; +import android.os.IBinder; +import android.util.Log; + +import androidx.annotation.NonNull; + +import java.lang.reflect.Method; + +import cn.classfun.droidvm.daemon.server.ServerContext; +import cn.classfun.droidvm.display.INativeDisplayRootService; +import cn.classfun.droidvm.display.IPhysicalKeyEcho; +import cn.classfun.droidvm.lib.store.vm.NativeDisplay; +import cn.classfun.droidvm.lib.store.vm.VMState; + +/** + * Daemon-hosted native-display broker. The daemon already runs as root, so it does both jobs the + * UI used to reach through a separate libsu RootService: + *
    + *
  • {@link INativeDisplayRootService#waitForDisplayBinder(String)} - look up the per-VM + * ICrosvmAndroidDisplayService binder crosvm registers via + * {@code --android-display-service } (an untrusted_app can't do this lookup).
  • + *
  • {@link INativeDisplayRootService#writeInput(String, String, int, byte[])} - write evdev + * straight to the crosvm input socket the daemon owns (no extra socket hop), by looking up + * the VM and the screen the console sending it is showing.
  • + *
+ * + * The binder can't ride the daemon's TCP/JSON-RPC channel, so it is broadcast to the UI through + * system_server (see {@link #attach}); the {@code display_attach} IPC command triggers that. + */ +public final class NativeDisplayBinder { + private static final String TAG = "NativeDisplayBinder"; + + /** How long one lookup keeps looking, and how often it looks. */ + private static final long WAIT_TOTAL_MS = 5000; + private static final long WAIT_POLL_MS = 200; + + private static INativeDisplayRootService.Stub binder; + + private NativeDisplayBinder() { + } + + /** + * Builds the broker binder once and broadcasts it to the UI Activity that requested it with + * [nonce]. Called from the {@code display_attach} IPC handler (a daemon worker thread). + */ + public static synchronized void attach(@NonNull ServerContext ctx, @NonNull String nonce) { + if (binder == null) binder = createBinder(ctx); + broadcast(binder, nonce); + } + + private static INativeDisplayRootService.Stub createBinder(@NonNull ServerContext ctx) { + return new INativeDisplayRootService.Stub() { + @Override + public IBinder waitForDisplayBinder(String serviceName) { + return doWaitForDisplayBinder(ctx, serviceName); + } + + @Override + public boolean writeInput(String vmId, String screenId, int channel, byte[] data) { + if (vmId == null || data == null || data.length == 0) return false; + var inst = ctx.getVMs().findById(vmId); + if (inst == null) return false; + return inst.writeNativeInput(screenId == null ? "" : screenId, channel, data); + } + + @Override + public int setKeyboardGrab(String vmId, String screenId, boolean grab, + IBinder token) { + var keyboard = ctx.getKeyboard(); + var screen = screenId == null ? "" : screenId; + if (!grab || vmId == null || vmId.isEmpty()) { + if (vmId != null && !vmId.isEmpty()) keyboard.releaseFor(vmId, screen); + else keyboard.release("console released it"); + return 0; + } + return keyboard.request(vmId, screen, token); + } + + @Override + public void setKeyEcho(String vmId, String screenId, IPhysicalKeyEcho echo) { + if (vmId == null || vmId.isEmpty()) return; + ctx.getKeyboard().setEcho(vmId, screenId == null ? "" : screenId, echo); + } + }; + } + + /** Broadcasts [binder] to our package with [nonce] nested in a Bundle (kept off the top-level + * extras so AMS doesn't strip the live IBinder). */ + private static void broadcast(@NonNull IBinder binder, @NonNull String nonce) { + var context = DaemonSystemContext.get(); + if (context == null) { + Log.e(TAG, "no system context; cannot broadcast display binder"); + return; + } + var bundle = new Bundle(); + bundle.putBinder(NativeDisplay.EXTRA_BINDER, binder); + bundle.putString(NativeDisplay.EXTRA_NONCE, nonce); + var intent = new Intent(NativeDisplay.BINDER_BROADCAST_ACTION); + intent.setPackage(APPLICATION_ID); + intent.putExtra(NativeDisplay.EXTRA_BUNDLE, bundle); + context.sendBroadcast(intent); + Log.i(TAG, fmt("broadcast native-display binder (nonce=%s)", nonce)); + } + + private static IBinder smCall(@NonNull String method, @NonNull String name) { + try { + Class sm = Class.forName("android.os.ServiceManager"); + Method m = sm.getMethod(method, String.class); + return (IBinder) m.invoke(null, name); + } catch (Exception e) { + Log.w(TAG, fmt("%s reflection failed: %s", method, e.getMessage())); + return null; + } + } + + /** + * Look for [name] until it turns up or [WAIT_TOTAL_MS] passes. + * + * Polls {@code checkService} rather than calling {@code waitForService}, which blocks until + * the service appears and cannot be cancelled. A caller-side timeout around it does not stop + * it: the thread carrying it stays blocked for the life of the process, and while it waits, + * servicemanager's client logs a line a second, from every thread. For a VM that has stopped + * -- the display console left open after the VM exits, retrying -- the service never appears + * at all, so every attempt leaked another permanently-waiting, permanently-logging thread. + * Measured: 315884 log lines and 77 MB of daemon.log in four minutes, ending with the daemon + * dying and taking a different, running VM's crosvm down with it. + */ + private static IBinder pollForService(@NonNull String name) { + long deadline = System.nanoTime() + WAIT_TOTAL_MS * 1_000_000L; + while (System.nanoTime() < deadline) { + try { + Thread.sleep(WAIT_POLL_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return null; + } + var binder = smCall("checkService", name); + if (binder != null) return binder; + } + return null; + } + + /** + * Whether the VM behind [serviceName] is in a state where crosvm could still register it. + * + * A stopped VM will never register, so waiting for it is time the caller spends learning + * nothing -- and the caller is a console that retries. Unknown names are not ours to judge, + * so they wait as before. + */ + private static boolean vmCouldRegister(@NonNull ServerContext ctx, @NonNull String serviceName) { + // NativeDisplay owns both halves of the name -- the VM's channel root and the screen id + // appended to it -- so the reverse mapping lives there too rather than as a prefix strip + // written out again here, which is how it would silently stop matching. + var vmId = NativeDisplay.vmIdFromServiceName(serviceName); + if (vmId.isEmpty()) return true; + var inst = ctx.getVMs().findById(vmId); + if (inst == null) return true; + var state = inst.getState(); + return state == VMState.RUNNING || state == VMState.STARTING || state == VMState.REBOOTING; + } + + private static IBinder doWaitForDisplayBinder(@NonNull ServerContext ctx, + @NonNull String serviceName) { + var direct = smCall("checkService", serviceName); + if (direct != null) return direct; + if (!vmCouldRegister(ctx, serviceName)) { + Log.i(TAG, fmt("'%s': VM not running, nothing to wait for", serviceName)); + return null; + } + Log.i(TAG, fmt("waitForDisplayBinder('%s'): not registered yet, looking for %d ms", + serviceName, WAIT_TOTAL_MS)); + var waited = pollForService(serviceName); + if (waited != null) { + Log.i(TAG, "OK: got display binder"); + return waited; + } + Log.w(TAG, fmt("'%s' not found - is crosvm running with " + + "--android-display-service %s?", serviceName, serviceName)); + return null; + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/input/HostKeyboard.java b/app/src/main/java/cn/classfun/droidvm/daemon/input/HostKeyboard.java new file mode 100644 index 000000000..4e3a49de1 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/daemon/input/HostKeyboard.java @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. +package cn.classfun.droidvm.daemon.input; + +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; + +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import cn.classfun.droidvm.lib.natives.UnixHelper; + +/** + * One {@code /dev/input/eventN} node that a person types on, as the grab needs to know it: where + * it is, what it calls itself, and what bus and ids it came in on. + * + *

A phone's input directory is mostly not keyboards -- a touchscreen, a lid switch, the power + * key, a headset's play button all live there and all carry EV_KEY -- so membership is decided by + * asking for the keys nothing but a typing keyboard has: {@code q}, {@code a}, {@code z}, space + * and enter. That test is why the phone's own volume rocker is never grabbed out from under + * Android, and why a Bluetooth keyboard is picked up whatever the vendor called it.

+ * + *

The ids matter as much as the path, because the path is not stable: a Bluetooth keyboard + * that drops and reconnects comes back as whichever {@code eventN} is free, so what is re-grabbed + * is matched by name and vendor/product, not by node.

+ */ +final class HostKeyboard { + private static final String TAG = "HostKeyboard"; + + static final String DEV_DIR = "/dev/input"; + private static final String NODE_PREFIX = "event"; + + // linux/input-event-codes.h -- the keys the test asks for. + private static final int KEY_Q = 16; + private static final int KEY_A = 30; + private static final int KEY_Z = 44; + private static final int KEY_ENTER = 28; + private static final int KEY_SPACE = 57; + private static final int[] TYPING_KEYS = {KEY_Q, KEY_A, KEY_Z, KEY_ENTER, KEY_SPACE}; + + @NonNull + final String path; + @NonNull + final String name; + final int bus; + final int vendor; + final int product; + + private HostKeyboard(@NonNull String path, @NonNull String name, + int bus, int vendor, int product) { + this.path = path; + this.name = name; + this.bus = bus; + this.vendor = vendor; + this.product = product; + } + + /** + * Every typing keyboard the host has right now, node order. Each node is opened to be asked + * what it is and closed again: nothing here holds a descriptor, so a scan can run while + * another one of these is grabbed without either disturbing the other. + */ + @NonNull + static List scan() { + var out = new ArrayList(); + var names = new File(DEV_DIR).list(); + if (names == null) { + Log.w(TAG, fmt("cannot list %s; no physical keyboard will be found", DEV_DIR)); + return out; + } + Arrays.sort(names); + for (var node : names) { + if (!node.startsWith(NODE_PREFIX)) continue; + var path = fmt("%s/%s", DEV_DIR, node); + int fd = UnixHelper.nativeEvdevOpen(path); + if (fd < 0) continue; + try { + var kb = describe(path, fd); + if (kb != null) out.add(kb); + } finally { + UnixHelper.nativeCloseFd(fd); + } + } + return out; + } + + /** What [fd] is, or null when it is not a typing keyboard. */ + @Nullable + static HostKeyboard describe(@NonNull String path, int fd) { + var bits = UnixHelper.nativeEvdevKeyBits(fd); + if (bits == null || !isTypingKeyboard(bits)) return null; + var name = UnixHelper.nativeEvdevName(fd); + var ids = UnixHelper.nativeEvdevIds(fd); + return new HostKeyboard( + path, + name == null ? "" : name.trim(), + ids == null ? 0 : ids[0], + ids == null ? 0 : ids[1], + ids == null ? 0 : ids[2]); + } + + /** True when the EV_KEY bitmap holds every key a person types with. */ + private static boolean isTypingKeyboard(@NonNull byte[] bits) { + for (var key : TYPING_KEYS) + if (!hasKey(bits, key)) return false; + return true; + } + + private static boolean hasKey(@NonNull byte[] bits, int code) { + int index = code / 8; + return index < bits.length && (bits[index] & (1 << (code % 8))) != 0; + } + + /** + * Whether [other] is this keyboard come back on a different node: same name, same vendor and + * product. The identity a re-grab after a Bluetooth reconnect is decided on. + */ + boolean sameDevice(@NonNull HostKeyboard other) { + return name.equals(other.name) && vendor == other.vendor && product == other.product; + } + + @NonNull + JSONObject toJson() throws JSONException { + var obj = new JSONObject(); + obj.put("path", path); + obj.put("name", name); + obj.put("bus", fmt("%04x", bus)); + obj.put("vendor", fmt("%04x", vendor)); + obj.put("product", fmt("%04x", product)); + return obj; + } + + @NonNull + @Override + public String toString() { + return fmt("%s \"%s\" %04x:%04x", path, name, vendor, product); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/input/KeyboardGrabConfig.java b/app/src/main/java/cn/classfun/droidvm/daemon/input/KeyboardGrabConfig.java new file mode 100644 index 000000000..1032e48b9 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/daemon/input/KeyboardGrabConfig.java @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. +package cn.classfun.droidvm.daemon.input; + +import static cn.classfun.droidvm.lib.utils.FileUtils.readFile; +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; + +import android.system.Os; +import android.util.Log; + +import androidx.annotation.NonNull; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * {@code keyboard_grab.json}: whether the physical keyboard may be taken from Android for a guest, + * and the chord that hands it back. + * + *

The chord ships unbound on purpose. While a keyboard is grabbed Android sees none of it, so + * an escape has to be a combination the guest will never want either -- and which one that is + * depends on what the guest is, which is the user's to say once there is a page to say it on. The + * mechanism is here and armed; an empty chord simply never matches, and the way out until then is + * the same one that already exists: leave the display (touch), or switch the console to the system + * keyboard, both of which release the grab.

+ */ +public final class KeyboardGrabConfig { + private static final String TAG = "KeyboardGrabConfig"; + public static final String NAME = "keyboard_grab.json"; + + private static final String KEY_ENABLED = "enabled"; + private static final String KEY_ESCAPE_CHORD = "escape_chord"; + + /** + * Whether a display console may ask for the grab at all. The switch a user who wants Android's + * shortcuts back for good reaches for; the console asks on every state change either way. + */ + public final boolean enabled; + + /** + * Linux {@code KEY_*} codes that must be held together to hand the keyboard back, in no + * particular order. Empty means nothing is bound (see the class note). + */ + @NonNull + public final List escapeChord; + + public KeyboardGrabConfig(boolean enabled, @NonNull List escapeChord) { + this.enabled = enabled; + this.escapeChord = Collections.unmodifiableList(new ArrayList<>(escapeChord)); + } + + @NonNull + public static KeyboardGrabConfig defaults() { + return new KeyboardGrabConfig(true, Collections.emptyList()); + } + + @NonNull + public static KeyboardGrabConfig fromJson(@NonNull JSONObject obj) { + var chord = new ArrayList(); + var arr = obj.optJSONArray(KEY_ESCAPE_CHORD); + if (arr != null) { + for (int i = 0; i < arr.length(); i++) { + int code = arr.optInt(i, -1); + // A code outside the evdev key range could never be pressed, and a chord holding + // one could never match: dropped rather than kept as a chord that cannot fire. + if (code > 0 && code <= 0x2ff && !chord.contains(code)) chord.add(code); + } + } + return new KeyboardGrabConfig(obj.optBoolean(KEY_ENABLED, true), chord); + } + + @NonNull + public JSONObject toJson() throws JSONException { + var obj = new JSONObject(); + obj.put(KEY_ENABLED, enabled); + var arr = new JSONArray(); + for (var code : escapeChord) arr.put(code); + obj.put(KEY_ESCAPE_CHORD, arr); + return obj; + } + + /** The saved settings, or the defaults when there is no file or it cannot be read. */ + @NonNull + public static KeyboardGrabConfig load(@NonNull File file) { + if (!file.isFile()) return defaults(); + try { + return fromJson(new JSONObject(readFile(file))); + } catch (Exception e) { + Log.w(TAG, fmt("Cannot read %s; using the defaults", file), e); + return defaults(); + } + } + + /** + * Writes next to the target and renames, so a reader never sees half a file, and leaves it to + * the owner of the directory -- the daemon runs as root and the UI does not, and a root-owned + * file in the app's directory is one the UI could neither read nor replace. + */ + @SuppressWarnings("OctalInteger") + public void save(@NonNull File file) throws IOException { + var parent = file.getParentFile(); + if (parent == null || !parent.isDirectory()) + throw new IOException(fmt("no directory for %s", file)); + var staging = new File(parent, fmt("%s.tmp", file.getName())); + try { + var text = toJson().toString(2); + try (var out = new FileOutputStream(staging)) { + out.write(text.getBytes(StandardCharsets.UTF_8)); + out.getFD().sync(); + } + var owner = Os.stat(parent.getPath()); + Os.chmod(staging.getPath(), 0600); + Os.chown(staging.getPath(), owner.st_uid, owner.st_gid); + if (!staging.renameTo(file)) + throw new IOException(fmt("cannot replace %s", file)); + } catch (IOException e) { + throw e; + } catch (Exception e) { + throw new IOException(e.getMessage(), e); + } finally { + //noinspection ResultOfMethodCallIgnored + staging.delete(); + } + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/input/KeyboardGrabManager.java b/app/src/main/java/cn/classfun/droidvm/daemon/input/KeyboardGrabManager.java new file mode 100644 index 000000000..7fed00644 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/daemon/input/KeyboardGrabManager.java @@ -0,0 +1,782 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. +package cn.classfun.droidvm.daemon.input; + +import static cn.classfun.droidvm.lib.Constants.DATA_DIR; +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; +import static cn.classfun.droidvm.lib.utils.StringUtils.pathJoin; + +import android.os.IBinder; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.File; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; + +import cn.classfun.droidvm.daemon.server.ServerContext; +import cn.classfun.droidvm.display.IPhysicalKeyEcho; +import cn.classfun.droidvm.daemon.vm.VMInstance; +import cn.classfun.droidvm.lib.natives.UnixHelper; +import cn.classfun.droidvm.lib.store.vm.NativeDisplay; +import cn.classfun.droidvm.lib.store.vm.VMState; + +/** + * Takes the host's physical keyboards away from Android ({@code EVIOCGRAB}) and feeds their raw + * evdev to a guest, for as long as a display console asks for it. + * + *

Why a grab rather than the key events the console already forwards: Android decides what an + * Activity is allowed to see before the Activity sees it. Home, the task switcher, + * Alt+Tab and the Meta shortcuts are taken by the window manager and never dispatched, so a guest + * fed from {@code dispatchKeyEvent} can never be sent them, however the console is written. A + * grabbed descriptor is the only recipient the kernel's input core hands the device's events to: + * Android's InputReader keeps its own descriptor open and is simply never woken again, and every + * key -- including the ones the system would have kept -- arrives here instead, as scan codes, + * with no key layout in between.

+ * + *

What that costs is the reason it is not simply always on: while the grab is held, that + * keyboard cannot type into Android at all, the IME included. So the console asks for the grab + * only while it is in front and its typing surface is one that wants no IME (see + * {@code KeyboardMode}); switching the console to the system keyboard releases it, which is what + * keeps Chinese and every other IME-composed input working exactly as before. Leaving the display + * releases it too, and so does the death of the console's binder token, so no crash can leave the + * user holding a keyboard that types nowhere.

+ * + *

A console that asked stays armed even when it holds nothing: a Bluetooth keyboard drops and + * comes back as whichever {@code eventN} is free, and the kernel's uevent socket is what says so. + * The keys reach the guest through the same per-screen keyboard socket the console's on-screen + * keys use -- this daemon is the only writer either way -- so nothing downstream has to know + * where a key came from.

+ */ +public final class KeyboardGrabManager { + private static final String TAG = "KeyboardGrab"; + + /** {@code struct input_event} on this ABI: 16 bytes of timeval, then type, code, value. */ + private static final int EVENT_SIZE = 24; + private static final int EVENT_TYPE_OFFSET = 16; + /** How many events one read may return; a burst of typing is a handful. */ + private static final int READ_EVENTS = 64; + + private static final short EV_SYN = 0x00; + private static final short EV_KEY = 0x01; + private static final short EV_LED = 0x11; + private static final short SYN_REPORT = 0x00; + /** The lamps a keyboard has: LED_NUML, LED_CAPSL, LED_SCROLLL. */ + private static final int LED_COUNT = 3; + + private static final int KEY_UP = 0; + private static final int KEY_DOWN = 1; + /** Auto-repeat, generated by the host's input core; see {@link #onEvents}. */ + private static final int KEY_REPEAT = 2; + + /** Codes run 0..KEY_MAX; the pressed set is one flag each. */ + private static final int KEY_COUNT = 0x300; + + /** Bytes one uevent datagram may take; the kernel's own limit is smaller. */ + private static final int UEVENT_BUFFER = 4096; + /** + * How long the uevent reader waits after a message before rescanning. A keyboard arrives as + * several messages (the device, then its handlers), and a reconnecting Bluetooth keyboard as + * a remove followed by an add -- one scan once the batch settles is both cheaper and calmer + * than one per message. + */ + private static final int RESCAN_DEBOUNCE_MS = 200; + private static final String UEVENT_SUBSYSTEM_INPUT = "SUBSYSTEM=input"; + /** How long a stopping reader is waited for before the release stops caring. */ + private static final long READER_JOIN_MS = 500; + + /** Where the keys go while a console holds the keyboard. */ + private static final class Target { + @NonNull + final String vmId; + @NonNull + final String screenId; + @Nullable + final IBinder token; + @Nullable + IBinder.DeathRecipient death; + /** The console's copy of the keys and lamps; null when it has nothing to show. */ + @Nullable + volatile IPhysicalKeyEcho echo; + + Target(@NonNull String vmId, @NonNull String screenId, @Nullable IBinder token) { + this.vmId = vmId; + this.screenId = screenId; + this.token = token; + } + } + + /** One grabbed keyboard: the descriptor, the thread reading it, and what it holds down. */ + private final class Grab { + @NonNull + final HostKeyboard device; + final int fd; + final int wakeReadFd; + final int wakeWriteFd; + final boolean[] pressed = new boolean[KEY_COUNT]; + /** Set by whoever is taking this grab down, so the reader knows its exit is expected. */ + volatile boolean stopping; + Thread thread; + + Grab(@NonNull HostKeyboard device, int fd, int wakeReadFd, int wakeWriteFd) { + this.device = device; + this.fd = fd; + this.wakeReadFd = wakeReadFd; + this.wakeWriteFd = wakeWriteFd; + } + + void run() { + var buffer = new byte[EVENT_SIZE * READ_EVENTS]; + while (!stopping) { + int ready; + try { + ready = UnixHelper.nativePollIn2(fd, wakeReadFd, -1); + } catch (Throwable e) { + Log.w(TAG, fmt("poll failed on %s", device), e); + break; + } + // The wake pipe, an error, or a hangup: a keyboard that went away hangs up here + // and its node is gone with it, so there is nothing to retry. + if (ready < 0 || (ready & 2) != 0) break; + if ((ready & 1) == 0) continue; + int n = UnixHelper.nativeRead(fd, buffer, buffer.length); + if (n <= 0) { + if (!stopping) + Log.i(TAG, fmt("%s stopped reporting (read=%d); dropped", device, n)); + break; + } + onEvents(this, buffer, n); + } + // The ungrab belongs to this thread: it is the one that can promise nothing is being + // read out of the descriptor as it goes back to Android. + UnixHelper.nativeEvdevGrab(fd, false); + UnixHelper.nativeCloseFd(fd); + UnixHelper.nativeCloseFd(wakeReadFd); + UnixHelper.nativeCloseFd(wakeWriteFd); + // A reader that stopped on its own is one the manager still lists; one that was told + // to stop has already been taken off the list by the release, and must not touch the + // lock the release is holding while it joins this thread. + if (!stopping) forget(this); + } + + void wake() { + stopping = true; + UnixHelper.nativeWrite(wakeWriteFd, new byte[]{1}, 1); + } + } + + /** The uevent watcher's descriptors, owned by its own thread so a stop needs no join. */ + private static final class Watcher { + final int fd; + final int wakeReadFd; + final int wakeWriteFd; + volatile boolean stopping; + + Watcher(int fd, int wakeReadFd, int wakeWriteFd) { + this.fd = fd; + this.wakeReadFd = wakeReadFd; + this.wakeWriteFd = wakeWriteFd; + } + } + + @NonNull + private final ServerContext ctx; + @NonNull + private final File configFile = new File(pathJoin(DATA_DIR, "files"), KeyboardGrabConfig.NAME); + private final Object lock = new Object(); + + /** Read without the lock by every reader thread, so the delivery target is volatile. */ + @Nullable + private volatile Target target; + private final Map grabs = new LinkedHashMap<>(); + @NonNull + private volatile KeyboardGrabConfig config; + @Nullable + private Watcher watcher; + /** + * The guest's keyboard lamps: which ones it has ever reported, and their state. virtio-input + * has no way to ask, so a lamp is genuinely unknown until the guest changes it -- and what is + * known is the guest's own view, which is the only correct one, since software in the guest + * can toggle caps lock with no key involved. + */ + private volatile int ledKnown; + private volatile int ledOn; + + public KeyboardGrabManager(@NonNull ServerContext ctx) { + this.ctx = ctx; + this.config = KeyboardGrabConfig.load(configFile); + } + + @NonNull + public KeyboardGrabConfig getConfig() { + return config; + } + + /** + * Replaces the settings and writes them back. Turning the feature off releases whatever is + * held right now, because a switch that only applied to the next console would leave the + * keyboard taken by the one already up. + */ + public void setConfig(@NonNull KeyboardGrabConfig newConfig) throws IOException { + newConfig.save(configFile); + config = newConfig; + if (!newConfig.enabled) release("disabled"); + } + + /** + * Puts [vmId]'s [screenId] console in charge of the physical keyboards and grabs the ones + * that are attached. [token] is the caller's binder: the grab is dropped if it dies, so a + * console that crashed cannot leave the user without a keyboard. Returns how many keyboards + * are held -- zero means armed and holding nothing yet, because the guest is not running or + * no keyboard is attached, and both of those are answered later: by the VM state hook and by + * the uevent watcher. + */ + public int request(@NonNull String vmId, @NonNull String screenId, @Nullable IBinder token) { + if (!config.enabled) return 0; + var vm = ctx.getVMs().findById(vmId); + if (vm == null) return 0; + synchronized (lock) { + var current = target; + // The same console asking again is the grab it already has; a different VM or screen + // takes it over, because only one console can be the one in front. + if (current != null + && (!current.vmId.equals(vmId) || !current.screenId.equals(screenId))) + releaseLocked("taken over by another console"); + if (target == null) { + var t = new Target(vmId, screenId, token); + if (!linkToken(t)) return 0; + target = t; + Log.i(TAG, fmt("console for VM %s screen %s has the keyboard", vmId, screenId)); + } + // A console is usually up before the guest is: it is what starts the VM. So arming + // is not conditional on the VM running -- the keys are simply not taken until it is, + // and the state hook below grabs them the moment it gets there. Taking a keyboard + // away from Android any earlier would swallow keys with nowhere to put them. + if (vm.getState() == VMState.RUNNING) grabMissingLocked(); + startWatcherLocked(); + return grabs.size(); + } + } + + /** + * Releases only if [vmId]'s [screenId] console is the one holding the keyboard. A console + * that has just gone to the background says so after the one arriving has already asked -- + * Android starts the incoming Activity before it stops the outgoing one -- so an unconditional + * release here would take the keyboard away from whoever is now in front. + */ + public boolean releaseFor(@NonNull String vmId, @NonNull String screenId) { + synchronized (lock) { + var current = target; + if (current == null + || !current.vmId.equals(vmId) || !current.screenId.equals(screenId)) return false; + releaseLocked("console released it"); + return true; + } + } + + /** + * Registers the console's copy of the keys and lamps, or clears it with null. Only the console + * that holds the keyboard is heard: a page that is no longer in front must not keep a feed of + * what is being typed into the guest. Answers with the lamps as they stand, so a console that + * has just registered does not have to wait for the next change to draw them. + */ + public void setEcho(@NonNull String vmId, @NonNull String screenId, + @Nullable IPhysicalKeyEcho echo) { + var current = target; + if (current == null + || !current.vmId.equals(vmId) || !current.screenId.equals(screenId)) return; + current.echo = echo; + if (echo != null) sendLeds(echo); + } + + /** Hands every grabbed keyboard back to Android. Safe to call when nothing is held. */ + public void release(@NonNull String reason) { + synchronized (lock) { + releaseLocked(reason); + } + } + + /** + * VM state hook, called from every transition. A guest that stopped, rebooted or crashed has + * no keyboard socket any more and its console is about to go: the keys go back to Android on + * that edge, rather than after the next keypress finds nowhere to put itself. + */ + public void onVmState(@NonNull VMInstance vm, @NonNull VMState state) { + var current = target; + if (current == null || !current.vmId.equals(vm.getId().toString())) return; + synchronized (lock) { + // Re-read under the lock: a release may have won the race to it. + var armed = target; + if (armed == null || !armed.vmId.equals(vm.getId().toString())) return; + if (state == VMState.RUNNING) { + // The guest the console armed for is up -- including after a guest reboot, which + // is why this is not only the first start. + grabMissingLocked(); + return; + } + var reason = fmt("VM %s is %s", vm.getName(), state.name().toLowerCase()); + if (armed.token != null) { + // A console is still in front of a guest that will very likely be back (a reboot + // is a stop and a start): the keys go to Android meanwhile, and the console stays + // armed. Its token is what ends this if the console goes instead. + dropGrabsLocked(reason); + ledKnown = 0; + ledOn = 0; + var echo = armed.echo; + if (echo != null) sendLeds(echo); + } else { + // Nobody's console: a grab taken over IPC has no token to end it, so the VM + // leaving RUNNING is what does. + releaseLocked(reason); + } + } + } + + /** What a settings page or the CLI shows: the settings, what is held, and what is there. */ + @NonNull + public JSONObject status() throws JSONException { + var obj = new JSONObject(); + obj.put("config", config.toJson()); + var current = target; + obj.put("armed", current != null); + if (current != null) { + obj.put("vm_id", current.vmId); + obj.put("screen", current.screenId); + } + var held = new JSONArray(); + var heldPaths = new ArrayList(); + synchronized (lock) { + for (var grab : grabs.values()) { + held.put(grab.device.toJson()); + heldPaths.add(grab.device.path); + } + } + obj.put("held", held); + // The guest's lamps, and which of them it has said anything about at all. + var leds = new JSONObject(); + leds.put("known", ledKnown); + leds.put("on", ledOn); + leds.put("num", (ledKnown & 1) == 0 ? JSONObject.NULL : ((ledOn & 1) != 0)); + leds.put("caps", (ledKnown & 2) == 0 ? JSONObject.NULL : ((ledOn & 2) != 0)); + leds.put("scroll", (ledKnown & 4) == 0 ? JSONObject.NULL : ((ledOn & 4) != 0)); + obj.put("leds", leds); + // Everything the host has, so a page can say which keyboard is not being taken. + var available = new JSONArray(); + for (var kb : HostKeyboard.scan()) { + var entry = kb.toJson(); + entry.put("held", heldPaths.contains(kb.path)); + available.put(entry); + } + obj.put("keyboards", available); + return obj; + } + + // ---- grabbing --------------------------------------------------------------------------- + + /** Grabs every keyboard not already held. Caller holds {@link #lock}. */ + private int grabMissingLocked() { + int taken = 0; + for (var kb : HostKeyboard.scan()) { + if (grabs.containsKey(kb.path)) continue; + if (grabOneLocked(kb)) taken++; + } + return taken; + } + + private boolean grabOneLocked(@NonNull HostKeyboard kb) { + int fd = UnixHelper.nativeEvdevOpen(kb.path); + if (fd < 0) { + Log.w(TAG, fmt("cannot open %s", kb)); + return false; + } + int rc = UnixHelper.nativeEvdevGrab(fd, true); + if (rc != 0) { + // EBUSY is somebody else holding this keyboard exclusively; nothing to do but leave + // it to them and carry on with the rest. + Log.w(TAG, fmt("cannot grab %s: errno=%d", kb, -rc)); + UnixHelper.nativeCloseFd(fd); + return false; + } + var wake = UnixHelper.nativePipe(); + if (wake == null || wake.length != 2) { + Log.w(TAG, fmt("no wake pipe for %s; leaving it to Android", kb)); + UnixHelper.nativeEvdevGrab(fd, false); + UnixHelper.nativeCloseFd(fd); + return false; + } + var grab = new Grab(kb, fd, wake[0], wake[1]); + var thread = new Thread(grab::run, fmt("kbd-grab-%s", new File(kb.path).getName())); + thread.setDaemon(true); + grab.thread = thread; + grabs.put(kb.path, grab); + thread.start(); + // A keyboard that joined late (or came back after a Bluetooth drop) starts with the lamps + // the guest has already told us about, rather than whatever Android left them at. + writeLeds(grab); + Log.i(TAG, fmt("grabbed %s for the guest", kb)); + return true; + } + + /** + * Hands every grabbed keyboard back to Android but leaves the console armed. Caller holds + * {@link #lock}. + */ + private void dropGrabsLocked(@NonNull String reason) { + if (grabs.isEmpty()) return; + // Key-ups first, while the target is still there: a modifier that went down through the + // grab and never came up leaves the guest with a Ctrl held forever, outliving both the + // grab and the console. + for (var grab : grabs.values()) releaseHeldKeys(grab); + var pending = new ArrayList<>(grabs.values()); + grabs.clear(); + for (var grab : pending) grab.wake(); + for (var grab : pending) { + // Never join the thread we are on: the escape chord releases from inside its own + // reader, and that thread ends by itself as soon as this returns. + if (grab.thread == null || grab.thread == Thread.currentThread()) continue; + try { + grab.thread.join(READER_JOIN_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + Log.i(TAG, fmt("keyboard back to Android (%s)", reason)); + } + + /** The grabs and the console that asked for them. Caller holds {@link #lock}. */ + private void releaseLocked(@NonNull String reason) { + var current = target; + if (current == null && grabs.isEmpty()) return; + dropGrabsLocked(reason); + target = null; + // The lamps were the guest's, and the next console may be looking at another guest. + ledKnown = 0; + ledOn = 0; + unlinkToken(current); + stopWatcherLocked(); + } + + /** A reader that ended on its own -- its keyboard went away -- is taken off the list. */ + private void forget(@NonNull Grab grab) { + synchronized (lock) { + grabs.remove(grab.device.path, grab); + } + // The console stays armed: a Bluetooth keyboard that dropped is expected back, and the + // watcher is what grabs it again when it is. + } + + /** + * What the guest said about its keyboard, off the status queue crosvm writes back onto the + * input socket: the caps/num/scroll lamps. Two things happen with it -- the lamp on the real + * keyboard is set to match (the grabbed descriptor is opened read-write for exactly this), and + * the console is told so it can draw the state. + * + *

Reading this at all also drains a socket the daemon used to only write to; the records + * had nowhere to go and sat in its buffer.

+ */ + public void onGuestStatus(@NonNull String vmId, @NonNull String screenId, + @NonNull byte[] records) { + var current = target; + if (current == null + || !current.vmId.equals(vmId) || !current.screenId.equals(screenId)) return; + var in = ByteBuffer.wrap(records).order(ByteOrder.LITTLE_ENDIAN); + int known = ledKnown; + int on = ledOn; + boolean changed = false; + for (int offset = 0; offset + 8 <= records.length; offset += 8) { + short type = in.getShort(offset); + short code = in.getShort(offset + 2); + int value = in.getInt(offset + 4); + // EV_REP (the guest's repeat delay and period) also arrives here and is the host + // keyboard's own business, not ours: the guest repeats for itself, see onEvents. + if (type != EV_LED || code < 0 || code >= LED_COUNT) continue; + int bit = 1 << code; + known |= bit; + int next = value != 0 ? (on | bit) : (on & ~bit); + if (next != on || (known != ledKnown)) changed = true; + on = next; + } + if (!changed) return; + ledKnown = known; + ledOn = on; + Log.i(TAG, fmt("guest lamps: num=%s caps=%s scroll=%s", + lampText(known, on, 1), lampText(known, on, 2), lampText(known, on, 4))); + synchronized (lock) { + for (var grab : grabs.values()) writeLeds(grab); + } + var echo = current.echo; + if (echo != null) sendLeds(echo); + } + + private static String lampText(int known, int on, int bit) { + if ((known & bit) == 0) return "?"; + return (on & bit) != 0 ? "on" : "off"; + } + + /** Sets [grab]'s lamps to the guest's state, as far as the guest has said what it is. */ + private void writeLeds(@NonNull Grab grab) { + int known = ledKnown; + if (known == 0) return; + var out = ByteBuffer.allocate(EVENT_SIZE * (LED_COUNT + 1)).order(ByteOrder.LITTLE_ENDIAN); + for (int code = 0; code < LED_COUNT; code++) { + if ((known & (1 << code)) == 0) continue; + putInputEvent(out, EV_LED, (short) code, (ledOn & (1 << code)) != 0 ? 1 : 0); + } + if (out.position() == 0) return; + putInputEvent(out, EV_SYN, SYN_REPORT, 0); + var bytes = Arrays.copyOf(out.array(), out.position()); + UnixHelper.nativeWrite(grab.fd, bytes, bytes.length); + } + + /** One {@code struct input_event}: an empty timeval, then type, code, value. */ + private static void putInputEvent(@NonNull ByteBuffer out, short type, short code, int value) { + out.putLong(0).putLong(0); + out.putShort(type).putShort(code).putInt(value); + } + + private void sendLeds(@NonNull IPhysicalKeyEcho echo) { + try { + echo.onLeds(ledKnown, ledOn); + } catch (Exception e) { + Log.d(TAG, "console echo refused the lamps", e); + } + } + + // ---- events ----------------------------------------------------------------------------- + + /** + * Turns one read of {@code struct input_event}s into the 8-byte records crosvm's socket wants + * and sends them on. Auto-repeat is dropped: the host's input core generates it for a key + * held down and the guest's does the same for the virtio keyboard, so forwarding it would + * repeat every held key twice over. + */ + private void onEvents(@NonNull Grab grab, @NonNull byte[] buffer, int length) { + var in = ByteBuffer.wrap(buffer, 0, length).order(ByteOrder.LITTLE_ENDIAN); + var out = ByteBuffer.allocate(8 * (length / EVENT_SIZE)).order(ByteOrder.LITTLE_ENDIAN); + // The console's copy, collected alongside: same keys, same order, sent after the guest's. + var echoCodes = new int[length / EVENT_SIZE]; + var echoValues = new int[echoCodes.length]; + int echoCount = 0; + boolean escape = false; + for (int offset = 0; offset + EVENT_SIZE <= length; offset += EVENT_SIZE) { + short type = in.getShort(offset + EVENT_TYPE_OFFSET); + short code = in.getShort(offset + EVENT_TYPE_OFFSET + 2); + int value = in.getInt(offset + EVENT_TYPE_OFFSET + 4); + if (type == EV_KEY) { + if (value == KEY_REPEAT) continue; + if (code >= 0 && code < KEY_COUNT) { + synchronized (grab.pressed) { + grab.pressed[code] = value != KEY_UP; + } + if (value == KEY_DOWN && chordHeld(grab)) { + // The chord is the user talking to us, not to the guest: the key that + // completed it is not forwarded, and the keys already down are lifted by + // the release itself. + escape = true; + break; + } + } + out.putShort(type).putShort(code).putInt(value); + echoCodes[echoCount] = code; + echoValues[echoCount] = value; + echoCount++; + } else if (type == EV_SYN && code == SYN_REPORT) { + // A frame with nothing in it is not worth a write: the guest reads events, and + // the next real one brings its own SYN. + if (out.position() > 0) out.putShort(type).putShort(code).putInt(value); + } + // Everything else (EV_MSC scan codes, LED echoes, the device's own repeat settings) + // is host bookkeeping the guest keeps its own copy of. + } + if (out.position() > 0) deliver(Arrays.copyOf(out.array(), out.position())); + // After the guest, always: what a console does with these must not be in the way of + // typing. Oneway, so this returns before the console has looked at them. + if (echoCount > 0) { + var current = target; + var echo = current == null ? null : current.echo; + if (echo != null) { + try { + echo.onKeys(Arrays.copyOf(echoCodes, echoCount), + Arrays.copyOf(echoValues, echoCount)); + } catch (Exception e) { + // A console that has gone: stop echoing rather than throwing every keypress. + current.echo = null; + Log.d(TAG, "console echo is gone; keys stay between the daemon and the guest"); + } + } + } + if (escape) { + // Off this thread: the release joins reader threads, and this is one of them. + var t = new Thread(() -> release("escape chord"), "kbd-grab-escape"); + t.setDaemon(true); + t.start(); + } + } + + /** True when every key of the escape chord is down on [grab] right now. */ + private boolean chordHeld(@NonNull Grab grab) { + var chord = config.escapeChord; + if (chord.isEmpty()) return false; + synchronized (grab.pressed) { + for (var code : chord) { + if (code < 0 || code >= KEY_COUNT || !grab.pressed[code]) return false; + } + } + return true; + } + + /** Sends an up for every key [grab] is holding, so nothing is left down in the guest. */ + private void releaseHeldKeys(@NonNull Grab grab) { + var out = ByteBuffer.allocate(8 * (KEY_COUNT + 1)).order(ByteOrder.LITTLE_ENDIAN); + synchronized (grab.pressed) { + for (int code = 0; code < KEY_COUNT; code++) { + if (!grab.pressed[code]) continue; + grab.pressed[code] = false; + out.putShort(EV_KEY).putShort((short) code).putInt(KEY_UP); + } + } + if (out.position() == 0) return; + out.putShort(EV_SYN).putShort(SYN_REPORT).putInt(0); + deliver(Arrays.copyOf(out.array(), out.position())); + } + + /** Hands finished records to the guest's keyboard, or drops them if the target has gone. */ + private void deliver(@NonNull byte[] records) { + var current = target; + if (current == null) return; + var vm = ctx.getVMs().findById(current.vmId); + if (vm == null) return; + vm.writeNativeInput(current.screenId, NativeDisplay.KEYBOARD, records); + } + + // ---- the console's token ---------------------------------------------------------------- + + /** False when the token is already dead, which is a console not worth arming for. */ + private boolean linkToken(@NonNull Target t) { + if (t.token == null) return true; + t.death = () -> release("console gone"); + try { + t.token.linkToDeath(t.death, 0); + return true; + } catch (Exception e) { + Log.w(TAG, "console token is already dead; not arming", e); + t.death = null; + return false; + } + } + + private void unlinkToken(@Nullable Target t) { + if (t == null || t.token == null || t.death == null) return; + try { + t.token.unlinkToDeath(t.death, 0); + } catch (Exception ignored) { + // Already dead, which is the one case the recipient has done its job for. + } + t.death = null; + } + + // ---- hotplug ---------------------------------------------------------------------------- + + /** + * Watches the kernel's uevent socket while a console is armed, so a Bluetooth keyboard that + * drops and reconnects -- coming back as whatever {@code eventN} is free -- is grabbed again + * instead of typing into Android behind the console's back. Best effort: without it the grab + * covers the keyboards that were there when the console asked, and no more. + */ + private void startWatcherLocked() { + if (watcher != null) return; + int fd; + int[] wake; + try { + fd = UnixHelper.nativeUeventOpen(); + if (fd < 0) { + Log.w(TAG, "no uevent socket; a keyboard that reconnects will not be re-grabbed"); + return; + } + wake = UnixHelper.nativePipe(); + } catch (Throwable e) { + Log.w(TAG, "no uevent socket; a keyboard that reconnects will not be re-grabbed", e); + return; + } + if (wake == null || wake.length != 2) { + UnixHelper.nativeCloseFd(fd); + return; + } + var w = new Watcher(fd, wake[0], wake[1]); + watcher = w; + var thread = new Thread(() -> readUevents(w), "kbd-grab-uevent"); + thread.setDaemon(true); + thread.start(); + } + + /** + * Tells the watcher to stop and forgets it. Deliberately no join: the watcher's rescan wants + * this same lock, and waiting for it here -- from inside the lock -- would stall every + * release by however long the scan takes. The thread owns its descriptors and closes them. + */ + private void stopWatcherLocked() { + var w = watcher; + if (w == null) return; + watcher = null; + w.stopping = true; + UnixHelper.nativeWrite(w.wakeWriteFd, new byte[]{1}, 1); + } + + private void readUevents(@NonNull Watcher w) { + var buffer = new byte[UEVENT_BUFFER]; + boolean pending = false; + while (!w.stopping) { + int ready; + try { + ready = UnixHelper.nativePollIn2( + w.fd, w.wakeReadFd, pending ? RESCAN_DEBOUNCE_MS : -1); + } catch (Throwable e) { + Log.w(TAG, "uevent poll failed; hotplug re-grab is off for this console", e); + break; + } + if (ready < 0 || (ready & 2) != 0) break; + if (ready == 0) { + // The batch settled: one scan for all of it. + pending = false; + rescan(w); + continue; + } + if ((ready & 1) == 0) continue; + int n = UnixHelper.nativeRead(w.fd, buffer, buffer.length); + if (n <= 0) continue; + if (mentionsInput(buffer, n)) pending = true; + } + UnixHelper.nativeCloseFd(w.fd); + UnixHelper.nativeCloseFd(w.wakeReadFd); + UnixHelper.nativeCloseFd(w.wakeWriteFd); + } + + /** A uevent datagram is NUL-separated {@code KEY=VALUE}s; only the input ones are ours. */ + private static boolean mentionsInput(@NonNull byte[] buffer, int length) { + return new String(buffer, 0, length, StandardCharsets.UTF_8) + .contains(UEVENT_SUBSYSTEM_INPUT); + } + + /** Grabs keyboards that appeared since the console asked. */ + private void rescan(@NonNull Watcher w) { + synchronized (lock) { + // The release that retired this watcher may have won the race to the lock. + if (w.stopping || watcher != w || target == null) return; + int taken = grabMissingLocked(); + if (taken > 0) Log.i(TAG, fmt("%d keyboard(s) joined the grab", taken)); + } + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/basic/AuthHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/basic/AuthHandler.java index c918cbf48..400d8bb94 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/basic/AuthHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/basic/AuthHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.basic; import static cn.classfun.droidvm.lib.daemon.DaemonHelper.getTokenFile; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/basic/PingHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/basic/PingHandler.java index 989d4377e..3e9761807 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/basic/PingHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/basic/PingHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.basic; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/basic/SetAppConfig.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/basic/SetAppConfig.java new file mode 100644 index 000000000..ace7d77e0 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/basic/SetAppConfig.java @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. +package cn.classfun.droidvm.daemon.ipc.basic; + +import androidx.annotation.NonNull; + +import com.google.auto.service.AutoService; + +import cn.classfun.droidvm.daemon.server.ClientRequest; +import cn.classfun.droidvm.daemon.server.RequestHandler; +import cn.classfun.droidvm.daemon.vm.UsbAcmPool; +import cn.classfun.droidvm.lib.store.base.DataItem; + +@AutoService(RequestHandler.class) +public class SetAppConfig extends RequestHandler { + @NonNull + @Override + public String getName() { + return "set_app_config"; + } + + @Override + public void handle(@NonNull ClientRequest request) throws Exception { + var params = request.getParams(); + if (!params.has("config")) + throw new RuntimeException("missing config"); + var cfg = params.getJSONObject("config"); + var data = DataItem.fromJson(cfg); + var ctx = request.getContext(); + ctx.appConfig = data; + // The ACM pool is standing daemon state driven by this config: build or tear down + // right away so toggling the setting acts without waiting for a VM start. + UsbAcmPool.applyConfig(data); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/basic/VersionHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/basic/VersionHandler.java index 007335536..b5d64b903 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/basic/VersionHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/basic/VersionHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.basic; import static cn.classfun.droidvm.daemon.Daemon.daemonHash; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/input/KeyboardConfigHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/input/KeyboardConfigHandler.java new file mode 100644 index 000000000..a0b84a4b5 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/input/KeyboardConfigHandler.java @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. +package cn.classfun.droidvm.daemon.ipc.input; + +import androidx.annotation.NonNull; + +import com.google.auto.service.AutoService; + +import java.util.ArrayList; + +import cn.classfun.droidvm.daemon.input.KeyboardGrabConfig; +import cn.classfun.droidvm.daemon.server.ClientRequest; +import cn.classfun.droidvm.daemon.server.RequestHandler; +import cn.classfun.droidvm.lib.utils.JsonUtils; + +/** + * Changes the grab's settings and writes them back. Params, both optional so a caller can change + * one without knowing the other: {@code enabled} (bool) and {@code escape_chord} (array of Linux + * {@code KEY_*} codes, all of which must be held to hand the keyboard back; empty binds nothing). + * Answers with the same picture {@code kbd_status} gives. + */ +@AutoService(RequestHandler.class) +public final class KeyboardConfigHandler extends RequestHandler { + @NonNull + @Override + public String getName() { + return "kbd_config"; + } + + @Override + public void handle(@NonNull ClientRequest request) throws Exception { + var params = request.getParams(); + var manager = request.getContext().getKeyboard(); + var current = manager.getConfig(); + var enabled = params.optBoolean("enabled", current.enabled); + var chord = current.escapeChord; + var arr = params.optJSONArray("escape_chord"); + if (arr != null) { + var codes = new ArrayList(); + for (int i = 0; i < arr.length(); i++) codes.add(arr.optInt(i, -1)); + chord = codes; + } + // Through fromJson so one place decides what a usable chord is, whether it arrives from + // the file or from here. + manager.setConfig(KeyboardGrabConfig.fromJson( + new KeyboardGrabConfig(enabled, chord).toJson())); + JsonUtils.mergeJSONObject(request.res(), manager.status()); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/input/KeyboardGrabHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/input/KeyboardGrabHandler.java new file mode 100644 index 000000000..beff635d5 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/input/KeyboardGrabHandler.java @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. +package cn.classfun.droidvm.daemon.ipc.input; + +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; + +import androidx.annotation.NonNull; + +import com.google.auto.service.AutoService; + +import cn.classfun.droidvm.daemon.server.ClientRequest; +import cn.classfun.droidvm.daemon.server.RequestException; +import cn.classfun.droidvm.daemon.server.RequestHandler; +import cn.classfun.droidvm.lib.utils.JsonUtils; + +/** + * Takes the physical keyboard for a VM, or hands it back, without a console. Params: {@code + * vm_id}, {@code screen} (the screen whose keyboard device the keys go to) and {@code grab} + * (bool, default true). + * + *

The display console has its own path for this over binder, and prefers it because a binder + * token dies with the console and releases the grab with it. A grab taken here has no such token: + * it is held until something releases it -- this command with {@code grab=false}, the VM leaving + * RUNNING, or the feature being switched off -- which is what makes it the right shape for the + * CLI and for a test, and the wrong one for a console.

+ */ +@AutoService(RequestHandler.class) +public final class KeyboardGrabHandler extends RequestHandler { + @NonNull + @Override + public String getName() { + return "kbd_grab"; + } + + @Override + public void handle(@NonNull ClientRequest request) throws Exception { + var params = request.getParams(); + var manager = request.getContext().getKeyboard(); + var grab = params.optBoolean("grab", true); + var vmId = params.optString("vm_id", ""); + var screen = params.optString("screen", ""); + if (!grab) { + if (vmId.isEmpty()) manager.release("asked over IPC"); + else manager.releaseFor(vmId, screen); + JsonUtils.mergeJSONObject(request.res(), manager.status()); + return; + } + if (vmId.isEmpty()) + throw new RequestException("missing vm_id"); + if (request.getContext().getVMs().findById(vmId) == null) + throw new RequestException(fmt("VM not found: %s", vmId)); + manager.request(vmId, screen, null); + JsonUtils.mergeJSONObject(request.res(), manager.status()); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/input/KeyboardStatusHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/input/KeyboardStatusHandler.java new file mode 100644 index 000000000..8c40ab068 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/input/KeyboardStatusHandler.java @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. +package cn.classfun.droidvm.daemon.ipc.input; + +import androidx.annotation.NonNull; + +import com.google.auto.service.AutoService; + +import cn.classfun.droidvm.daemon.server.ClientRequest; +import cn.classfun.droidvm.daemon.server.RequestHandler; +import cn.classfun.droidvm.lib.utils.JsonUtils; + +/** + * What the physical-keyboard grab is doing: the settings, the console that is armed, the + * keyboards held, and every keyboard the host has. Read-only, so a page can be opened on it + * without taking anything from anybody. + */ +@AutoService(RequestHandler.class) +public final class KeyboardStatusHandler extends RequestHandler { + @NonNull + @Override + public String getName() { + return "kbd_status"; + } + + @Override + public void handle(@NonNull ClientRequest request) throws Exception { + JsonUtils.mergeJSONObject(request.res(), request.getContext().getKeyboard().status()); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/AddAddressHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/AddAddressHandler.java index 2fd9bb10e..231612495 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/AddAddressHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/AddAddressHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.network; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/AddInterfaceHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/AddInterfaceHandler.java index e2df64f31..640a10979 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/AddInterfaceHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/AddInterfaceHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.network; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/CreateHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/CreateHandler.java index e1ac41fca..924f77319 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/CreateHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/CreateHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.network; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/DeleteHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/DeleteHandler.java index 37b3e465b..adb641a53 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/DeleteHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/DeleteHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.network; import static java.util.UUID.fromString; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/ExistsHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/ExistsHandler.java index 5278a1113..52e54d063 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/ExistsHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/ExistsHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.network; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/InfoHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/InfoHandler.java index d2f278fa0..2db683ed7 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/InfoHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/InfoHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.network; import androidx.annotation.NonNull; @@ -32,12 +35,15 @@ public void handle(@NonNull ClientRequest request) throws Exception { var res = request.res(); var data = inst.toInfoJson(); data.put("state", inst.getState().name()); + data.put("tools", inst.listTools()); if (inst.getState() == NetworkState.RUNNING) { data.put("live_addresses", inst.listAddresses()); + var entries = inst.listAddressEntries(); + if (entries != null) data.put("address_entries", entries); + data.put("pd", inst.listPdStatus()); data.put("live_interfaces", inst.listInterfaces()); data.put("neighbors", inst.listNeighbors()); - if (inst.item.optBoolean("dhcp_enabled", false)) - data.put("dhcp_leases", inst.listDhcpLeases()); + data.put("dhcp_leases", inst.listDhcpLeases()); } res.put("data", data); } diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/ListHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/ListHandler.java index aeefda1a5..262a314d1 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/ListHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/ListHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.network; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/ListHostAddressesHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/ListHostAddressesHandler.java new file mode 100644 index 000000000..b056e965e --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/ListHostAddressesHandler.java @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. +package cn.classfun.droidvm.daemon.ipc.network; + +import androidx.annotation.NonNull; + +import com.google.auto.service.AutoService; + +import cn.classfun.droidvm.daemon.network.backend.HostAddressScan; +import cn.classfun.droidvm.daemon.server.ClientRequest; +import cn.classfun.droidvm.daemon.server.RequestHandler; + +/** + * The phone's own addresses, for a UI offering somewhere to listen. + * + *

Asked of the daemon rather than enumerated in the app, because the two addresses that must + * not appear -- a pseudo-bridged guest's IP parked on the uplink, and the host-route-only shape + * pbridge parks it in -- are netlink details no unprivileged interface enumeration can see. See + * {@link HostAddressScan#list()} for the whole policy.

+ */ +@AutoService(RequestHandler.class) +public final class ListHostAddressesHandler extends RequestHandler { + @NonNull + @Override + public String getName() { + return "network_list_host_addresses"; + } + + @Override + public void handle(@NonNull ClientRequest request) throws Exception { + request.res().put("data", HostAddressScan.list()); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/ListInterfacesHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/ListInterfacesHandler.java index 1bda4ec2d..a4f11da1e 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/ListInterfacesHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/ListInterfacesHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.network; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/ListUplinksHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/ListUplinksHandler.java new file mode 100644 index 000000000..772d2d258 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/ListUplinksHandler.java @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. +package cn.classfun.droidvm.daemon.ipc.network; + +import androidx.annotation.NonNull; + +import com.google.auto.service.AutoService; + +import cn.classfun.droidvm.daemon.network.backend.UplinkResolver; +import cn.classfun.droidvm.daemon.server.ClientRequest; +import cn.classfun.droidvm.daemon.server.RequestHandler; + +@AutoService(RequestHandler.class) +public final class ListUplinksHandler extends RequestHandler { + @NonNull + @Override + public String getName() { + return "network_list_uplinks"; + } + + @Override + public void handle(@NonNull ClientRequest request) throws Exception { + var res = request.res(); + res.put("data", UplinkResolver.listUplinksJson()); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/ModifyHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/ModifyHandler.java index e3569534e..8cbf9fa35 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/ModifyHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/ModifyHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.network; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/PdReleaseHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/PdReleaseHandler.java new file mode 100644 index 000000000..a13c8f498 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/PdReleaseHandler.java @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. +package cn.classfun.droidvm.daemon.ipc.network; + +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; + +import androidx.annotation.NonNull; + +import com.google.auto.service.AutoService; + +import cn.classfun.droidvm.daemon.server.ClientRequest; +import cn.classfun.droidvm.daemon.server.RequestException; +import cn.classfun.droidvm.daemon.server.RequestHandler; + +@AutoService(RequestHandler.class) +public final class PdReleaseHandler extends RequestHandler { + @NonNull + @Override + public String getName() { + return "network_pd_release"; + } + + @Override + public void handle(@NonNull ClientRequest request) throws Exception { + var params = request.getParams(); + var id = params.optString("network_id", ""); + if (id.isEmpty()) + throw new RequestException("missing network_id"); + var vlanId = params.optInt("vlan_id", -1); + if (vlanId < 0) + throw new RequestException("missing vlan_id"); + var inst = request.getContext().getNetworks().findById(id); + if (inst == null) + throw new RequestException(fmt("network %s not found", id)); + if (!inst.releasePd(vlanId)) + throw new RequestException("failed to release PD delegation"); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/PdRenewHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/PdRenewHandler.java new file mode 100644 index 000000000..14f775746 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/PdRenewHandler.java @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. +package cn.classfun.droidvm.daemon.ipc.network; + +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; + +import androidx.annotation.NonNull; + +import com.google.auto.service.AutoService; + +import cn.classfun.droidvm.daemon.server.ClientRequest; +import cn.classfun.droidvm.daemon.server.RequestException; +import cn.classfun.droidvm.daemon.server.RequestHandler; + +@AutoService(RequestHandler.class) +public final class PdRenewHandler extends RequestHandler { + @NonNull + @Override + public String getName() { + return "network_pd_renew"; + } + + @Override + public void handle(@NonNull ClientRequest request) throws Exception { + var params = request.getParams(); + var id = params.optString("network_id", ""); + if (id.isEmpty()) + throw new RequestException("missing network_id"); + var vlanId = params.optInt("vlan_id", -1); + if (vlanId < 0) + throw new RequestException("missing vlan_id"); + var inst = request.getContext().getNetworks().findById(id); + if (inst == null) + throw new RequestException(fmt("network %s not found", id)); + if (!inst.renewPd(vlanId)) + throw new RequestException("failed to renew PD delegation"); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/RemoveAddressHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/RemoveAddressHandler.java index e4b9c6234..62a4bc21f 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/RemoveAddressHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/RemoveAddressHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.network; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/RemoveInterfaceHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/RemoveInterfaceHandler.java index be458a26e..c98e0f02f 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/RemoveInterfaceHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/RemoveInterfaceHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.network; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/StartHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/StartHandler.java index bec5959d9..e0e920a6f 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/StartHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/StartHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.network; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/StatusHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/StatusHandler.java index 712e10ee9..ff0cb6cd4 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/StatusHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/StatusHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.network; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/StopHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/StopHandler.java index bdda0d79c..8fef1942f 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/StopHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/StopHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.network; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; @@ -26,6 +29,9 @@ public void handle(@NonNull ClientRequest request) throws Exception { var inst = request.getContext().getNetworks().findById(id); if (inst == null) throw new RequestException(fmt("network %s not found", id)); + var vm = inst.findRunningVMUsing(); + if (vm != null) + throw new RequestException(fmt("network is in use by running VM %s", vm)); if (!inst.stop()) throw new RequestException("failed to stop network"); } diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/ToolLogHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/ToolLogHandler.java new file mode 100644 index 000000000..86dd97779 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/ToolLogHandler.java @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. +package cn.classfun.droidvm.daemon.ipc.network; + +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; + +import androidx.annotation.NonNull; + +import com.google.auto.service.AutoService; + +import org.json.JSONArray; + +import cn.classfun.droidvm.daemon.server.ClientRequest; +import cn.classfun.droidvm.daemon.server.RequestException; +import cn.classfun.droidvm.daemon.server.RequestHandler; + +/** Returns the captured stdout/stderr of one of a network's helper tools. */ +@AutoService(RequestHandler.class) +public final class ToolLogHandler extends RequestHandler { + @NonNull + @Override + public String getName() { + return "network_tool_log"; + } + + @Override + public void handle(@NonNull ClientRequest request) throws Exception { + var params = request.getParams(); + var id = params.optString("network_id", ""); + if (id.isEmpty()) + throw new RequestException("missing network_id"); + var tool = params.optString("tool", ""); + if (tool.isEmpty()) + throw new RequestException("missing tool"); + var inst = request.getContext().getNetworks().findById(id); + if (inst == null) + throw new RequestException(fmt("network %s not found", id)); + var lines = inst.toolLog(tool); + if (lines == null) + throw new RequestException(fmt("tool %s not available", tool)); + request.res().put("lines", new JSONArray(lines)); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/usb/UsbAttachHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/usb/UsbAttachHandler.java new file mode 100644 index 000000000..edb2bfed7 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/usb/UsbAttachHandler.java @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. +package cn.classfun.droidvm.daemon.ipc.usb; + +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; + +import androidx.annotation.NonNull; + +import com.google.auto.service.AutoService; + +import org.json.JSONObject; + +import cn.classfun.droidvm.daemon.server.ClientRequest; +import cn.classfun.droidvm.daemon.server.RequestException; +import cn.classfun.droidvm.daemon.server.RequestHandler; + +@AutoService(RequestHandler.class) +public final class UsbAttachHandler extends RequestHandler { + @NonNull + @Override + public String getName() { + return "usb_attach"; + } + + @Override + public void handle(@NonNull ClientRequest request) throws Exception { + var params = request.getParams(); + var vmId = params.optString("vm_id", ""); + if (vmId.isEmpty()) + throw new RequestException("missing vm_id"); + var vms = request.getContext().getVMs(); + var inst = vms.findById(vmId); + if (inst == null) + throw new RequestException(fmt("VM not found: %s", vmId)); + var device = params.optString("device", ""); + if (device.isEmpty()) + throw new RequestException("missing device"); + var manager = request.getContext().getUsb(); + var port = manager.attach(inst, device); + var controller = manager.attachedController(device); + var res = request.res(); + res.put("vm_id", vmId); + res.put("device", device); + res.put("port", port); + // Which xHCI it landed on: the rule named one or it did not, and only the attach knows. + res.put("controller", controller == null ? JSONObject.NULL : controller); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/usb/UsbDetachHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/usb/UsbDetachHandler.java new file mode 100644 index 000000000..8733106a3 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/usb/UsbDetachHandler.java @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. +package cn.classfun.droidvm.daemon.ipc.usb; + +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; + +import androidx.annotation.NonNull; + +import com.google.auto.service.AutoService; + +import cn.classfun.droidvm.daemon.server.ClientRequest; +import cn.classfun.droidvm.daemon.server.RequestException; +import cn.classfun.droidvm.daemon.server.RequestHandler; + +@AutoService(RequestHandler.class) +public final class UsbDetachHandler extends RequestHandler { + @NonNull + @Override + public String getName() { + return "usb_detach"; + } + + @Override + public void handle(@NonNull ClientRequest request) throws Exception { + var params = request.getParams(); + var vmId = params.optString("vm_id", ""); + if (vmId.isEmpty()) + throw new RequestException("missing vm_id"); + var vms = request.getContext().getVMs(); + var inst = vms.findById(vmId); + if (inst == null) + throw new RequestException(fmt("VM not found: %s", vmId)); + var device = params.optString("device", ""); + // Either address works: the sysfs name the caller attached, or the port it was given back. + // A JSON null is a caller that sent the key without a value, which is not an address. + Integer port = params.has("port") && !params.isNull("port") + ? params.optInt("port", -1) : null; + if (device.isEmpty() && port == null) + throw new RequestException("missing device or port"); + request.getContext().getUsb().detach(inst, device.isEmpty() ? null : device, port); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/usb/UsbHostListHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/usb/UsbHostListHandler.java new file mode 100644 index 000000000..ae6ebd6bc --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/usb/UsbHostListHandler.java @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. +package cn.classfun.droidvm.daemon.ipc.usb; + +import androidx.annotation.NonNull; + +import com.google.auto.service.AutoService; + +import cn.classfun.droidvm.daemon.server.ClientRequest; +import cn.classfun.droidvm.daemon.server.RequestHandler; + +@AutoService(RequestHandler.class) +public final class UsbHostListHandler extends RequestHandler { + @NonNull + @Override + public String getName() { + return "usb_host_list"; + } + + @Override + public void handle(@NonNull ClientRequest request) throws Exception { + var manager = request.getContext().getUsb(); + var res = request.res(); + res.put("devices", manager.hostList()); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/usb/UsbRulesGetHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/usb/UsbRulesGetHandler.java new file mode 100644 index 000000000..9b3b758d2 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/usb/UsbRulesGetHandler.java @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. +package cn.classfun.droidvm.daemon.ipc.usb; + +import androidx.annotation.NonNull; + +import com.google.auto.service.AutoService; + +import cn.classfun.droidvm.daemon.server.ClientRequest; +import cn.classfun.droidvm.daemon.server.RequestHandler; + +@AutoService(RequestHandler.class) +public final class UsbRulesGetHandler extends RequestHandler { + @NonNull + @Override + public String getName() { + return "usb_rules_get"; + } + + @Override + public void handle(@NonNull ClientRequest request) throws Exception { + var res = request.res(); + res.put("rules", request.getContext().getUsb().getRules().toJson()); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/usb/UsbRulesSetHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/usb/UsbRulesSetHandler.java new file mode 100644 index 000000000..2dd34af9a --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/usb/UsbRulesSetHandler.java @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. +package cn.classfun.droidvm.daemon.ipc.usb; + +import androidx.annotation.NonNull; + +import com.google.auto.service.AutoService; + +import cn.classfun.droidvm.daemon.server.ClientRequest; +import cn.classfun.droidvm.daemon.server.RequestException; +import cn.classfun.droidvm.daemon.server.RequestHandler; + +@AutoService(RequestHandler.class) +public final class UsbRulesSetHandler extends RequestHandler { + @NonNull + @Override + public String getName() { + return "usb_rules_set"; + } + + @Override + public void handle(@NonNull ClientRequest request) throws Exception { + var rules = request.getParams().optJSONObject("rules"); + if (rules == null) + throw new RequestException("missing rules"); + var applied = request.getContext().getUsb().setRules(rules); + var res = request.res(); + res.put("applied", applied); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/usb/UsbRulesTestHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/usb/UsbRulesTestHandler.java new file mode 100644 index 000000000..3223d4aa4 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/usb/UsbRulesTestHandler.java @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. +package cn.classfun.droidvm.daemon.ipc.usb; + +import androidx.annotation.NonNull; + +import com.google.auto.service.AutoService; + +import cn.classfun.droidvm.daemon.server.ClientRequest; +import cn.classfun.droidvm.daemon.server.RequestHandler; + +/** A dry run of the rules over every plugged device: reports what each would do, attaches none. */ +@AutoService(RequestHandler.class) +public final class UsbRulesTestHandler extends RequestHandler { + @NonNull + @Override + public String getName() { + return "usb_rules_test"; + } + + @Override + public void handle(@NonNull ClientRequest request) throws Exception { + var res = request.res(); + var usb = request.getContext().getUsb(); + // The switch beside the dry run, because off it is the answer to every row: a rule set + // that matches nothing and one that is not allowed to run read the same in the table. + res.put("enabled", usb.getRules().isEnabled()); + res.put("devices", usb.testRules()); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/usb/UsbSetTargetHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/usb/UsbSetTargetHandler.java new file mode 100644 index 000000000..a1efa48bd --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/usb/UsbSetTargetHandler.java @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. +package cn.classfun.droidvm.daemon.ipc.usb; + +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; + +import androidx.annotation.NonNull; + +import com.google.auto.service.AutoService; + +import cn.classfun.droidvm.daemon.server.ClientRequest; +import cn.classfun.droidvm.daemon.server.RequestException; +import cn.classfun.droidvm.daemon.server.RequestHandler; +import cn.classfun.droidvm.daemon.usb.UsbRules; + +/** + * Puts one device where the caller says, now: the management page's direct action. It is not a + * rules edit and does not run the rules -- what it does is hand this one device to a VM, to the + * host drivers, or to nobody, and lock it so the next pass leaves it alone. A move to a VM is + * the one that unlocks instead: the attachment is the shield from then on. + */ +@AutoService(RequestHandler.class) +public final class UsbSetTargetHandler extends RequestHandler { + @NonNull + @Override + public String getName() { + return "usb_set_target"; + } + + @Override + public void handle(@NonNull ClientRequest request) throws Exception { + var params = request.getParams(); + // The same addressing as usb_attach: the sysfs name, never the guest port, because two + // of the three targets are about a device no VM holds. + var device = params.optString("device", ""); + if (device.isEmpty()) + throw new RequestException("missing device"); + var wanted = params.optString("target", ""); + var target = UsbRules.Target.fromKey(wanted); + if (target == null) + throw new RequestException(fmt("unknown target: %s", wanted)); + var vms = request.getContext().getVMs(); + var vmId = params.optString("vm_id", ""); + var inst = vmId.isEmpty() ? null : vms.findById(vmId); + if (target == UsbRules.Target.VM) { + if (vmId.isEmpty()) + throw new RequestException("missing vm_id"); + if (inst == null) + throw new RequestException(fmt("VM not found: %s", vmId)); + } + var controller = params.optString("controller", ""); + var result = request.getContext().getUsb().setTarget(device, target, + target == UsbRules.Target.VM ? inst : null, + controller.isEmpty() ? null : controller); + var res = request.res(); + var keys = result.keys(); + while (keys.hasNext()) { + var key = keys.next(); + res.put(key, result.get(key)); + } + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/usb/UsbVmListHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/usb/UsbVmListHandler.java new file mode 100644 index 000000000..f31e63d4a --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/usb/UsbVmListHandler.java @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. +package cn.classfun.droidvm.daemon.ipc.usb; + +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; + +import androidx.annotation.NonNull; + +import com.google.auto.service.AutoService; + +import cn.classfun.droidvm.daemon.server.ClientRequest; +import cn.classfun.droidvm.daemon.server.RequestException; +import cn.classfun.droidvm.daemon.server.RequestHandler; + +@AutoService(RequestHandler.class) +public final class UsbVmListHandler extends RequestHandler { + @NonNull + @Override + public String getName() { + return "usb_vm_list"; + } + + @Override + public void handle(@NonNull ClientRequest request) throws Exception { + var params = request.getParams(); + var vmId = params.optString("vm_id", ""); + if (vmId.isEmpty()) + throw new RequestException("missing vm_id"); + var vms = request.getContext().getVMs(); + if (vms.findById(vmId) == null) + throw new RequestException(fmt("VM not found: %s", vmId)); + var res = request.res(); + res.put("vm_id", vmId); + res.put("devices", request.getContext().getUsb().vmList(vmId)); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/BootScanHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/BootScanHandler.java new file mode 100644 index 000000000..07fd26cc1 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/BootScanHandler.java @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. +package cn.classfun.droidvm.daemon.ipc.vm; + +import androidx.annotation.NonNull; + +import com.google.auto.service.AutoService; + +import cn.classfun.droidvm.daemon.server.ClientRequest; +import cn.classfun.droidvm.daemon.server.RequestException; +import cn.classfun.droidvm.daemon.server.RequestHandler; +import cn.classfun.droidvm.daemon.vm.BootPlan; + +/** + * Scans a guest disk image for boot entries (lbx) on behalf of the UI: + * the edit tab's detection card and the boot menu both need entry lists, + * and disk images are typically only readable by the daemon. + */ +@AutoService(RequestHandler.class) +public final class BootScanHandler extends RequestHandler { + @NonNull + @Override + public String getName() { + return "vm_bootscan"; + } + + @Override + public void handle(@NonNull ClientRequest request) throws Exception { + var params = request.getParams(); + var image = params.optString("image", ""); + if (image.isEmpty()) + throw new RequestException("missing image"); + var entries = BootPlan.scanEntries(image); + request.res().put("entries", entries); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ConsoleClearHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ConsoleClearHandler.java index e75e2d680..a966ff706 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ConsoleClearHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ConsoleClearHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.vm; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ConsoleHistoryHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ConsoleHistoryHandler.java index 2a8460c14..70c8bbb0d 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ConsoleHistoryHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ConsoleHistoryHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.vm; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ConsoleInfoHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ConsoleInfoHandler.java index d3ab3bae9..fb571fa6d 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ConsoleInfoHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ConsoleInfoHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.vm; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ConsoleListHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ConsoleListHandler.java index 7eceecba2..ccbbeb51c 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ConsoleListHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ConsoleListHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.vm; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ConsoleWriteHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ConsoleWriteHandler.java index c3694081b..2ce19763e 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ConsoleWriteHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ConsoleWriteHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.vm; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ControlHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ControlHandler.java index d5616a46c..82a8d62e2 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ControlHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ControlHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.vm; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/CreateHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/CreateHandler.java index 6373cd38f..3a97efed3 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/CreateHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/CreateHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.vm; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/DeleteHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/DeleteHandler.java index 4ca0bd72d..b10cb148b 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/DeleteHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/DeleteHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.vm; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; @@ -27,10 +30,16 @@ public void handle(@NonNull ClientRequest request) throws Exception { throw new RequestException("missing vm_id"); var vms = request.getContext().getVMs(); var inst = vms.findById(vmId); - if (inst == null) - throw new RequestException(fmt("VM not found: %s", vmId)); + // A VM the daemon never managed (created in the app but never started) has nothing to + // stop and nothing to remove: deleting it is a no-op, not an error - the app deletes + // its disks on our word that no process of ours holds them. + if (inst == null) { + request.res().put("existed", false); + return; + } if (inst.getState() != VMState.STOPPED && inst.stop()) throw new RequestException(fmt("Failed to stop VM: %s", vmId)); vms.removeById(inst.getId()); + request.res().put("existed", true); } } diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/DisplayAttachHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/DisplayAttachHandler.java new file mode 100644 index 000000000..e20c94e55 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/DisplayAttachHandler.java @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. +package cn.classfun.droidvm.daemon.ipc.vm; + +import androidx.annotation.NonNull; + +import com.google.auto.service.AutoService; + +import cn.classfun.droidvm.daemon.display.NativeDisplayBinder; +import cn.classfun.droidvm.daemon.server.ClientRequest; +import cn.classfun.droidvm.daemon.server.RequestException; +import cn.classfun.droidvm.daemon.server.RequestHandler; + +/** + * Hands the daemon's native-display binder to the UI. A live IBinder can't ride this JSON-RPC + * channel, so the daemon broadcasts it (through system_server) to the requesting Activity instead; + * the Activity proves which broadcast is its own with [nonce]. Params: nonce (per-attach token). + */ +@AutoService(RequestHandler.class) +public final class DisplayAttachHandler extends RequestHandler { + @NonNull + @Override + public String getName() { + return "display_attach"; + } + + @Override + public void handle(@NonNull ClientRequest request) throws Exception { + var nonce = request.getParams().optString("nonce", ""); + if (nonce.isEmpty()) + throw new RequestException("missing nonce"); + NativeDisplayBinder.attach(request.getContext(), nonce); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ExistsHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ExistsHandler.java index 2bd6c5703..63fdea58a 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ExistsHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ExistsHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.vm; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ExportHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ExportHandler.java new file mode 100644 index 000000000..43f13bcef --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ExportHandler.java @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. +package cn.classfun.droidvm.daemon.ipc.vm; + +import androidx.annotation.NonNull; + +import com.google.auto.service.AutoService; + +import java.io.IOException; + +import cn.classfun.droidvm.daemon.server.ClientRequest; +import cn.classfun.droidvm.daemon.server.RequestException; +import cn.classfun.droidvm.daemon.server.RequestHandler; +import cn.classfun.droidvm.daemon.vm.pkg.VMExportTask; +import cn.classfun.droidvm.lib.pkg.PackageConstants; + +@AutoService(RequestHandler.class) +public final class ExportHandler extends RequestHandler { + @NonNull + @Override + public String getName() { + return "vm_export"; + } + + @Override + public void handle(@NonNull ClientRequest request) throws Exception { + var params = request.getParams(); + var destPath = params.optString("dest_path", ""); + if (!params.has("vm_id")) + throw new RequestException("missing vm_id"); + if (destPath.isEmpty()) + throw new RequestException("missing dest_path"); + var volumeSize = params.optLong("volume_size", 0); + if (volumeSize > 0 && volumeSize < PackageConstants.MIN_VOLUME_SIZE) + throw new RequestException(String.format( + "volume_size too small: %d (min %d)", + volumeSize, PackageConstants.MIN_VOLUME_SIZE + )); + var store = request.getContext().getExportTaskStore(); + var server = request.getClient().getServer(); + VMExportTask task; + try { + task = new VMExportTask(server, params); + } catch (IOException e) { + // Building the task walks the disks' backing chains, and the reason one cannot be + // walked (which image, which missing base) is the whole answer for the caller - + // a plain exception here would reach it as "internal error". + throw new RequestException(e.getMessage()); + } + store.put(task.taskId, task); + task.startAsync(); + request.res().put("task_id", task.taskId.toString()); + } +} \ No newline at end of file diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/GetHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/GetHandler.java new file mode 100644 index 000000000..f7ae0c49c --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/GetHandler.java @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. +package cn.classfun.droidvm.daemon.ipc.vm; + +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; + +import androidx.annotation.NonNull; + +import com.google.auto.service.AutoService; + +import cn.classfun.droidvm.daemon.server.ClientRequest; +import cn.classfun.droidvm.daemon.server.RequestException; +import cn.classfun.droidvm.daemon.server.RequestHandler; + +@AutoService(RequestHandler.class) +public final class GetHandler extends RequestHandler { + @NonNull + @Override + public String getName() { + return "vm_get"; + } + + @Override + public void handle(@NonNull ClientRequest request) throws Exception { + var params = request.getParams(); + var vmId = params.optString("vm_id", ""); + if (vmId.isEmpty()) + throw new RequestException("missing vm_id"); + var vms = request.getContext().getVMs(); + var inst = vms.findById(vmId); + if (inst == null) + throw new RequestException(fmt("VM not found: %s", vmId)); + var res = request.res(); + res.put("data", inst.toJson()); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ImportHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ImportHandler.java new file mode 100644 index 000000000..388293442 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ImportHandler.java @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. +package cn.classfun.droidvm.daemon.ipc.vm; + +import androidx.annotation.NonNull; + +import com.google.auto.service.AutoService; + +import cn.classfun.droidvm.daemon.server.ClientRequest; +import cn.classfun.droidvm.daemon.server.RequestException; +import cn.classfun.droidvm.daemon.server.RequestHandler; +import cn.classfun.droidvm.daemon.vm.pkg.VMImportTask; + +@AutoService(RequestHandler.class) +public final class ImportHandler extends RequestHandler { + @NonNull + @Override + public String getName() { + return "vm_import"; + } + + @Override + public void handle(@NonNull ClientRequest request) throws Exception { + var params = request.getParams(); + if (params.optString("src_path", "").isEmpty()) + throw new RequestException("missing src_path"); + if (params.optString("target_dir", "").isEmpty()) + throw new RequestException("missing target_dir"); + var store = request.getContext().getImportTaskStore(); + var server = request.getClient().getServer(); + var task = new VMImportTask(server, params); + store.put(task.taskId, task); + task.startAsync(); + request.res().put("task_id", task.taskId.toString()); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/InputHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/InputHandler.java new file mode 100644 index 000000000..04e692143 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/InputHandler.java @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. +package cn.classfun.droidvm.daemon.ipc.vm; + +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; + +import android.util.Base64; + +import androidx.annotation.NonNull; + +import com.google.auto.service.AutoService; + +import cn.classfun.droidvm.daemon.server.ClientRequest; +import cn.classfun.droidvm.daemon.server.RequestException; +import cn.classfun.droidvm.daemon.server.RequestHandler; + +/** + * Forwards native-display input from the UI to the per-VM crosvm process. The daemon is the only + * listener on crosvm's --input sockets (it pre-binds them before exec'ing crosvm), so it is the + * only process that can deliver evdev to the guest; the UI sends bytes here instead of writing a + * socket directly. Params: vm_id, screen (the screen the sending console shows, which picks + * between two screens' absolute devices; ignored by the VM-wide keyboard and relative pointer), + * channel (NativeDisplay constants), data (base64 evdev records). + */ +@AutoService(RequestHandler.class) +public final class InputHandler extends RequestHandler { + @NonNull + @Override + public String getName() { + return "vm_input"; + } + + @Override + public void handle(@NonNull ClientRequest request) throws Exception { + var params = request.getParams(); + var vmId = params.optString("vm_id", ""); + if (vmId.isEmpty()) + throw new RequestException("missing vm_id"); + var screenId = params.optString("screen", ""); + var channel = params.optInt("channel", -1); + var data = Base64.decode(params.optString("data", ""), Base64.NO_WRAP); + var inst = request.getContext().getVMs().findById(vmId); + if (inst == null) + throw new RequestException(fmt("VM not found: %s", vmId)); + // Report whether the bytes actually reached crosvm so the UI can tell a silent drop (peer + // not connected yet, bad channel, no absolute device on that screen, VM not running) from + // a real delivery. + request.res().put("delivered", inst.writeNativeInput(screenId, channel, data)); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ListHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ListHandler.java index 83eaf1e4f..ef049838e 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ListHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ListHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.vm; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ModifyHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ModifyHandler.java index 8d7b92200..dd8e9ab67 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ModifyHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ModifyHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.vm; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/RebootHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/RebootHandler.java new file mode 100644 index 000000000..1d131dd73 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/RebootHandler.java @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. +package cn.classfun.droidvm.daemon.ipc.vm; + +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; + +import androidx.annotation.NonNull; + +import com.google.auto.service.AutoService; + +import cn.classfun.droidvm.daemon.server.ClientRequest; +import cn.classfun.droidvm.daemon.server.RequestException; +import cn.classfun.droidvm.daemon.server.RequestHandler; + +@AutoService(RequestHandler.class) +public final class RebootHandler extends RequestHandler { + @NonNull + @Override + public String getName() { + return "vm_reboot"; + } + + @Override + public void handle(@NonNull ClientRequest request) throws Exception { + var params = request.getParams(); + var vmId = params.optString("vm_id", ""); + if (vmId.isEmpty()) + throw new RequestException("missing vm_id"); + var vms = request.getContext().getVMs(); + var inst = vms.findById(vmId); + if (inst == null) + throw new RequestException(fmt("VM not found: %s", vmId)); + if (!inst.reboot()) + throw new RequestException("failed to reboot VM"); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ResumeHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ResumeHandler.java index dfd9991dd..aca06f6d1 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ResumeHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ResumeHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.vm; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/StartHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/StartHandler.java index 06c126125..b7bb2ccb5 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/StartHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/StartHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.vm; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; @@ -9,6 +12,7 @@ import cn.classfun.droidvm.daemon.server.ClientRequest; import cn.classfun.droidvm.daemon.server.RequestException; import cn.classfun.droidvm.daemon.server.RequestHandler; +import cn.classfun.droidvm.lib.store.vm.VMState; @AutoService(RequestHandler.class) public final class StartHandler extends RequestHandler { @@ -28,6 +32,12 @@ public void handle(@NonNull ClientRequest request) throws Exception { var inst = vms.findById(vmId); if (inst == null) throw new RequestException(fmt("VM not found: %s", vmId)); + if (params.optBoolean("clear_logs_before_start", false) && + inst.getState() == VMState.STOPPED) + inst.clearLogs(); + // one-shot boot menu selection; empty = follow the stored config + var bootEntry = params.optString("boot_entry", ""); + inst.setBootEntryOverride(bootEntry.isEmpty() ? null : bootEntry); if (!inst.start()) throw new RequestException("failed to start VM"); } diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/StatusHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/StatusHandler.java index 650af3562..7aaaf6867 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/StatusHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/StatusHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.vm; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/StopAllHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/StopAllHandler.java index 305d89168..174f69c34 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/StopAllHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/StopAllHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.vm; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/StopHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/StopHandler.java index 06af66173..1a072c5b6 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/StopHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/StopHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.vm; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/SuspendHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/SuspendHandler.java index a70a1c98f..24d5b3a06 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/SuspendHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/SuspendHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.vm; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/VncInfoHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/VncInfoHandler.java index ba9215068..abd6e86c8 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/VncInfoHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/VncInfoHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.vm; import androidx.annotation.NonNull; @@ -7,6 +10,8 @@ import cn.classfun.droidvm.daemon.server.ClientRequest; import cn.classfun.droidvm.daemon.server.RequestException; import cn.classfun.droidvm.daemon.server.RequestHandler; +import cn.classfun.droidvm.lib.store.vm.DisplayExporter; +import cn.classfun.droidvm.lib.store.vm.VMScreenConfig; @AutoService(RequestHandler.class) public final class VncInfoHandler extends RequestHandler { @@ -25,12 +30,41 @@ public void handle(@NonNull ClientRequest request) throws Exception { var inst = request.getContext().getVMs().findById(vmId); if (inst == null) throw new RequestException("VM not found"); - if (!inst.item.optBoolean("vnc_enabled", false)) + // Which screen's server. A client that names none gets the first VNC-bound screen, which + // is the only one a single-screen VM has and the one its default view opens. + var screenId = params.optString("screen", ""); + VMScreenConfig screen = null; + for (var candidate : VMScreenConfig.listOf(inst.item)) { + if (!candidate.isEnabled() || candidate.getExporter() != DisplayExporter.VNC) continue; + if (screenId.isEmpty() || screenId.equals(candidate.id)) { + screen = candidate; + break; + } + } + if (screen == null) throw new RequestException("VNC is not enabled for this VM"); var res = request.res(); - var host = inst.item.optString("vnc_host", ""); + var host = screen.getVncHost(); + res.put("screen", screen.id); res.put("host", !host.isEmpty() ? host : "127.0.0.1"); - res.put("port", inst.item.optLong("vnc_port", -1)); - res.put("password", inst.item.optString("vnc_password", "")); + res.put("port", screen.getVncPort()); + res.put("password", screen.getVncPassword()); + // What this binding's transport ceiling permits. A permit and not a promise, which is why + // there is no port beside it any more: whether an encoder is actually standing there is + // answered on the RFB connection itself, by the capabilities rect, and a second port for + // the console to be told about is exactly what that change removed. + res.put("transport_cap", screen.getTransportCap().getToken()); + // When VNC binds to the IPv4 wildcard, resolve the phone's own LAN + // address here from the router watcher's filtered host-IP set, which + // already drops pbridge offload-proxy addresses parked on the uplink. + // The client cannot exclude those itself: the offload tag is a netlink + // route metric invisible to java.net.NetworkInterface, so its naive + // interface enumeration would pick a guest proxy IP instead. + if ("0.0.0.0".equals(host)) { + var hostIps = request.getContext().getRouterWatcher().getHostIpv4Addresses(); + var it = hostIps.iterator(); + if (it.hasNext()) + res.put("remote_host", it.next()); + } } } diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/network/NetworkInstance.java b/app/src/main/java/cn/classfun/droidvm/daemon/network/NetworkInstance.java index dd0548069..fccaf77f3 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/network/NetworkInstance.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/network/NetworkInstance.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.network; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; @@ -5,25 +8,34 @@ import android.util.Log; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; -import cn.classfun.droidvm.daemon.network.backend.Dnsmasq; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import cn.classfun.droidvm.daemon.network.backend.BridgeBackend; +import cn.classfun.droidvm.daemon.network.backend.LinuxBridgeBackend; +import cn.classfun.droidvm.daemon.network.backend.LinuxNetwork; +import cn.classfun.droidvm.daemon.network.backend.gvisor.GvisorBridgeBackend; import cn.classfun.droidvm.lib.network.IPNetwork; -import cn.classfun.droidvm.lib.network.IPv4Network; import cn.classfun.droidvm.lib.network.IPv6Network; -import cn.classfun.droidvm.lib.store.base.DataItem; +import cn.classfun.droidvm.lib.store.network.BridgeType; import cn.classfun.droidvm.lib.store.network.NetworkConfig; import cn.classfun.droidvm.lib.store.network.NetworkState; +import cn.classfun.droidvm.lib.store.vm.VMNicConfig; +import cn.classfun.droidvm.lib.store.vm.VMState; @SuppressWarnings({"unused", "BooleanMethodIsAlwaysInverted"}) public final class NetworkInstance extends NetworkConfig { private static final String TAG = "NetworkInstance"; private NetworkState state = NetworkState.STOPPED; private final NetworkInstanceStore store; - private Dnsmasq dnsmasq = null; + private BridgeBackend backend = null; public NetworkInstance( @NonNull NetworkInstanceStore store @@ -42,6 +54,11 @@ public NetworkInstance( state = NetworkState.valueOf(obj.getString("state")); } + @NonNull + public NetworkInstanceStore getStore() { + return store; + } + @NonNull public NetworkState getState() { return state; @@ -51,14 +68,16 @@ public void setState(@NonNull NetworkState state) { this.state = state; } - public boolean isDnsmasqRunning() { - if (dnsmasq == null) return false; - return dnsmasq.isDnsmasqRunning(); + @NonNull + private BridgeBackend createBackend() { + if (getBridgeType() == BridgeType.GVISOR) + return new GvisorBridgeBackend(this); + return new LinuxBridgeBackend(this); } - public int getDnsmasqExitCode() { - if (dnsmasq == null) return -1; - return dnsmasq.getDnsmasqExitCode(); + @Nullable + public BridgeBackend getBackend() { + return backend; } public boolean start() { @@ -66,104 +85,184 @@ public boolean start() { Log.w(TAG, fmt("Network %s not stopped, cannot start", getName())); return false; } - var br = item.optString("bridge_name", ""); try { state = NetworkState.STARTING; - if (store.backend.isInterfaceExists(br)) { - Log.w(TAG, fmt("Interface %s already exists, deleting", br)); - store.backend.deleteBridge(br); - } - if (!store.backend.createBridge(br)) - throw new RuntimeException(fmt("Failed to create bridge %s", br)); - if (!store.backend.isInterfaceExists(br)) - throw new RuntimeException(fmt("Interface %s is not a bridge", br)); - var mac = item.optString("mac_address", ""); - if (!mac.isEmpty() && !store.backend.setMacAddress(br, mac)) - Log.w(TAG, fmt("Failed to set MAC %s on %s", mac, br)); - store.backend.setStp(br, item.optBoolean("stp", false)); - item.get("ipv4_addresses").forEachArray(a -> { - try { - var addr = IPv4Network.parse(a.asString()); - if (!store.backend.addAddress(br, addr)) - Log.w(TAG, fmt("Failed to add IPv4 %s to %s", addr, br)); - } catch (Exception ignored) { - } - }); - item.get("ipv6_addresses").forEachArray(a -> { - try { - var addr = IPv6Network.parse(a.asString()); - if (!store.backend.addAddress(br, addr)) - Log.w(TAG, fmt("Failed to add IPv6 %s to %s", addr, br)); - } catch (Exception ignored) { - } - }); - if (!store.backend.setLinkState(br, true)) - throw new RuntimeException(fmt("Failed to bring up bridge %s", br)); - store.firewall.initNetwork(this); - store.backend.populateRuleRoute(this); - store.context.getRouterWatcher().setForNewNetwork(this); - var dhcpEnabled = item.optBoolean("dhcp_enabled", false); - var dhcpStart = item.optString("dhcp_range_start", ""); - var dhcpEnd = item.optString("dhcp_range_end", ""); - if (dhcpEnabled && !dhcpStart.isEmpty() && !dhcpEnd.isEmpty()) { - if (dnsmasq == null) dnsmasq = new Dnsmasq(this); - dnsmasq.startDnsmasq(); - } + backend = createBackend(); + backend.start(); state = NetworkState.RUNNING; - Log.i(TAG, fmt("Network %s started on bridge %s", getName(), br)); + store.ensureWatchdog(); + Log.i(TAG, fmt( + "Network %s started on bridge %s", getName(), getBridgeName() + )); return true; } catch (Exception e) { Log.e(TAG, fmt("Failed to start network %s", getName()), e); + if (backend != null) { + try { + backend.stop(); + } catch (Exception cleanup) { + Log.w(TAG, "Cleanup after failed start failed", cleanup); + } + backend = null; + } state = NetworkState.STOPPED; - store.backend.deleteBridge(br); return false; } } public boolean stop() { + return stop(false); + } + + public boolean stop(boolean force) { if (state != NetworkState.RUNNING) { Log.w(TAG, fmt("Network %s not running, cannot stop", getName())); return false; } + if (!force) { + var vm = findRunningVMUsing(); + if (vm != null) { + Log.w(TAG, fmt( + "Network %s is in use by running VM %s, cannot stop", + getName(), vm + )); + return false; + } + } state = NetworkState.STOPPING; - var br = item.optString("bridge_name", ""); - if (dnsmasq != null) - dnsmasq.stopDnsmasq(); - store.firewall.deinitNetwork(this); - store.backend.setLinkState(br, false); - if (!store.backend.deleteBridge(br)) - Log.w(TAG, fmt("Failed to delete bridge %s", br)); + if (backend != null) { + backend.stop(); + backend = null; + } state = NetworkState.STOPPED; Log.i(TAG, fmt("Network %s stopped", getName())); return true; } - public boolean addAddress(@NonNull IPNetwork cidr) { - if (!store.backend.addAddress(item.optString("bridge_name", ""), cidr)) return false; - var key = (cidr instanceof IPv6Network) ? "ipv6_addresses" : "ipv4_addresses"; - var arr = item.get(key); - if (arr.is(DataItem.Type.NULL)) { - arr = DataItem.newArray(); - item.set(key, arr); + /** + * Watchdog entry point: while the network is RUNNING, ask the backend to + * restart and re-initialize any helper that has died. No-op otherwise, so a + * stopping/stopped network is never resurrected. + */ + public void reconcile() { + var b = backend; + if (state != NetworkState.RUNNING || b == null) return; + try { + b.reconcile(); + } catch (Exception e) { + Log.w(TAG, fmt("Reconcile failed for network %s", getName()), e); } - arr.append(DataItem.newString(cidr.toString())); - return true; + } + + /** Name of a non-stopped VM with a NIC on this network, or null. */ + @Nullable + public String findRunningVMUsing() { + var vms = store.context.getVMs(); + var netId = getId().toString(); + var found = new String[1]; + vms.forEach((vmId, vm) -> { + if (found[0] != null) return; + if (vm.getState() == VMState.STOPPED) return; + vm.forEachNic(nic -> { + if (netId.equals(nic.getNetworkId())) + found[0] = vm.getName(); + }); + }); + return found[0]; + } + + public void attachNic(@NonNull VMNicConfig nic, @NonNull String tapName) throws Exception { + if (state != NetworkState.RUNNING || backend == null) + throw new IllegalStateException(fmt("Network %s is not running", getName())); + // The NIC may carry L3-only options (DHCP static lease / port forwards) + // kept in its saved config for when it moves back to an L3 network. + // Strip the ones this network can't honor into a throwaway copy so the + // VM still boots; the stored config is untouched, and genuine + // misconfigurations still fail validation below. + var dropped = new ArrayList(); + var effective = nic.sanitizedFor(this, dropped); + if (!dropped.isEmpty()) + Log.i(TAG, fmt("NIC %s on network %s: ignoring unsupported options: %s", + tapName, getName(), String.join(", ", dropped))); + effective.validate(this); + backend.attachNic(effective, tapName); + } + + public void detachNic(@NonNull VMNicConfig nic, @NonNull String tapName) { + if (backend == null) return; + try { + backend.detachNic(nic, tapName); + } catch (Exception e) { + Log.w(TAG, fmt("Failed to detach NIC %s", tapName), e); + } + } + + @NonNull + public Map getLiveV6Networks() { + return backend != null ? backend.getLiveV6Networks() : Map.of(); + } + + /** Runtime-only address tools used by the info screen (Linux only). */ + public boolean addAddress(@NonNull IPNetwork cidr) { + if (getBridgeType() != BridgeType.LINUX || state != NetworkState.RUNNING) + return false; + return store.backend.addAddress(item.optString("bridge_name", ""), cidr); } public boolean removeAddress(@NonNull IPNetwork cidr) { + if (getBridgeType() != BridgeType.LINUX || state != NetworkState.RUNNING) + return false; return store.backend.removeAddress(item.optString("bridge_name", ""), cidr); } public boolean addInterface(@NonNull String ifname) { + if (getBridgeType() != BridgeType.LINUX || state != NetworkState.RUNNING) + return false; return store.backend.addInterface(item.optString("bridge_name", ""), ifname); } public boolean removeInterface(@NonNull String ifname) { + if (getBridgeType() != BridgeType.LINUX) return false; return store.backend.removeInterface(ifname); } public JSONArray listAddresses() { - return store.backend.listAddresses(item.optString("bridge_name", "")); + if (backend == null) return new JSONArray(); + return backend.listAddresses(); + } + + /** Three-dimension address rows (VLAN/bound/source), or null if unsupported. */ + @Nullable + public JSONArray listAddressEntries() { + return backend == null ? null : backend.listAddressEntries(); + } + + /** Per-VLAN DHCPv6-PD client state. */ + public JSONArray listPdStatus() { + if (backend == null) return new JSONArray(); + return backend.listPdStatus(); + } + + /** Forces a PD renew on one VLAN's client. */ + public boolean renewPd(int vlanId) { + return backend != null && backend.renewPd(vlanId); + } + + /** Releases and re-solicits one VLAN's PD delegation. */ + public boolean releasePd(int vlanId) { + return backend != null && backend.releasePd(vlanId); + } + + /** Helper tools this network runs, as [{key, running}]. */ + public JSONArray listTools() { + if (backend == null) return new JSONArray(); + return backend.listTools(); + } + + /** Captured stdout/stderr of a named tool, or null when unknown. */ + @Nullable + public List toolLog(@NonNull String key) { + return backend == null ? null : backend.toolLog(key); } public JSONArray listBridges() { @@ -179,24 +278,49 @@ public JSONArray listAvailableInterfaces() { } public JSONArray listInterfaces() { - var vms = store.context.getVMs(); - return store.backend.listInterfaces(vms, item.optString("bridge_name", "")); + if (backend == null) return new JSONArray(); + return backend.listInterfaces(store.context.getVMs()); } public JSONArray listNeighbors() { - return store.backend.listNeighbors(item.optString("bridge_name", "")); + if (backend == null) return new JSONArray(); + return backend.listNeighbors(); } public JSONArray listDhcpLeases() { - return store.backend.listDhcpLeases(item.optString("bridge_name", "")); + if (backend == null) return new JSONArray(); + return backend.listDhcpLeases(); + } + + /** + * Devices the host routes for this network: the main bridge for the + * untagged VLAN-0 domain plus the per-VLAN bridges of the tagged + * VLANs (L3 mode only). + */ + @NonNull + public List getL3Devices() { + var out = new ArrayList(); + var br = getBridgeName(); + if (br == null) return out; + switch (getUplinkMode()) { + case L3: + for (var vlan : getVlans()) { + var dev = LinuxNetwork.vlanDevice(br, vlan.getVlanId()); + if (!out.contains(dev)) out.add(dev); + } + break; + default: + break; + } + return out; } @NonNull public JSONObject toInfoJson() throws JSONException { var obj = item.toJson(); obj.put("state", state.name().toLowerCase()); - obj.put("dnsmasq_running", isDnsmasqRunning()); - obj.put("dnsmasq_exit_code", getDnsmasqExitCode()); + if (backend != null) backend.appendInfo(obj); return obj; } + } diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/network/NetworkInstanceStore.java b/app/src/main/java/cn/classfun/droidvm/daemon/network/NetworkInstanceStore.java index a3f31396e..cecfb4ff8 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/network/NetworkInstanceStore.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/network/NetworkInstanceStore.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.network; import static cn.classfun.droidvm.lib.store.network.NetworkState.RUNNING; @@ -16,20 +19,23 @@ import java.util.ArrayList; -import cn.classfun.droidvm.daemon.network.backend.BackendBase; import cn.classfun.droidvm.daemon.network.backend.FirewallHelper; -import cn.classfun.droidvm.daemon.network.backend.iptables.IptablesBackend; import cn.classfun.droidvm.daemon.network.backend.LinuxNetwork; +import cn.classfun.droidvm.daemon.network.backend.iptables.IptablesBackend; import cn.classfun.droidvm.daemon.server.ServerContext; -import cn.classfun.droidvm.lib.store.network.NetworkConfig; import cn.classfun.droidvm.lib.store.base.DataStore; +import cn.classfun.droidvm.lib.store.network.BridgeType; +import cn.classfun.droidvm.lib.store.network.NetworkConfig; +import cn.classfun.droidvm.lib.store.network.NetworkConfigValidator; +import cn.classfun.droidvm.lib.utils.JsonUtils; @SuppressWarnings("BooleanMethodIsAlwaysInverted") public final class NetworkInstanceStore extends DataStore { private static final String TAG = "NetworkInstanceStore"; public final FirewallHelper firewall = new IptablesBackend(); - public final BackendBase backend = new LinuxNetwork(); + public final LinuxNetwork backend = new LinuxNetwork(); public final ServerContext context; + private final NetworkWatchdog watchdog = new NetworkWatchdog(this); public NetworkInstanceStore(@NonNull ServerContext context) { super(); @@ -37,6 +43,42 @@ public NetworkInstanceStore(@NonNull ServerContext context) { Log.i(TAG, "Network instance store initialized"); } + /** Starts the helper-process watchdog (idempotent); called when a network comes up. */ + public void ensureWatchdog() { + watchdog.start(); + } + + @Override + protected boolean load(@NonNull DataStore store, @NonNull JSONObject obj) { + try { + store.clear(); + JsonUtils.forEachArray(obj, getTypeName(), (JSONObject entry) -> { + var migrated = NetworkConfig.migrate(entry); + if (migrated == null) { + Log.w(TAG, fmt("Skipping network config with unsupported schema: %s", entry.optString("name"))); + return; + } + if (migrated != entry) { + // A config we just upgraded from a legacy schema: only keep + // it if the migrated result validates (e.g. a DHCP pool + // offset that lands outside the network is rejected here). + try { + NetworkConfigValidator.validate(new NetworkConfig(migrated)); + } catch (Exception e) { + Log.w(TAG, fmt("Skipping legacy network that failed migration/validation: %s", entry.optString("name")), e); + return; + } + } + store.addObject(migrated); + }); + return true; + } catch (Exception e) { + Log.e(TAG, "Failed to load network configs", e); + store.clear(); + return false; + } + } + @Nullable public String createNetwork(@NonNull NetworkConfig config) { var netId = config.getId(); @@ -49,6 +91,7 @@ public String createNetwork(@NonNull NetworkConfig config) { Log.w(TAG, fmt("Network %s already exists", netIdStr)); return null; } + NetworkConfigValidator.validate(config); var inst = getNetworkInstance(config, netIdStr); add(inst); Log.i(TAG, fmt("Created network: %s [%s] bridge=%s", @@ -73,6 +116,7 @@ public String modifyNetwork(@NonNull NetworkConfig config) { Log.w(TAG, fmt("Network %s is not stopped, cannot modify", netIdStr)); return null; } + NetworkConfigValidator.validate(config); removeById(netId); var inst = getNetworkInstance(config, netIdStr); add(inst); @@ -107,7 +151,10 @@ public JSONArray listNetworks() { try { var obj = inst.toInfoJson(); var s = inst.getState(); - if (s == RUNNING) { + // Only a Linux-bridge network has a real kernel device to query + // via netbox; gvisor is a userspace data path (gvswitch) with no + // kernel interface, so querying it just spams ENODEV warnings. + if (s == RUNNING && inst.getBridgeType() == BridgeType.LINUX) { var br = inst.item.optString("bridge_name", ""); obj.put("live_interfaces", backend.listBridgeInterfaces(br)); obj.put("live_addresses", backend.listAddresses(br)); @@ -122,6 +169,7 @@ public JSONArray listNetworks() { public void stopAll() { Log.i(TAG, "Stopping all networks..."); + watchdog.stop(); var toStop = new ArrayList(); forEach((id, inst) -> { var s = inst.getState(); @@ -129,7 +177,7 @@ public void stopAll() { toStop.add(inst); }); for (var inst : toStop) - inst.stop(); + inst.stop(true); clear(); } diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/network/NetworkWatchdog.java b/app/src/main/java/cn/classfun/droidvm/daemon/network/NetworkWatchdog.java new file mode 100644 index 000000000..cd7e5ad47 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/daemon/network/NetworkWatchdog.java @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. +package cn.classfun.droidvm.daemon.network; + +import android.util.Log; + +import androidx.annotation.NonNull; + +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +/** + * Periodically reconciles every running network's helper processes (gvswitch / + * pbridge / bridgedhcp): if a network is meant to be RUNNING but a helper has + * died, the backend restarts it and re-initialises its state. Runs as a single + * daemon thread polling every {@value #INTERVAL_MS}ms, the same way + * DefaultRouterWatcher polls. + */ +final class NetworkWatchdog { + private static final String TAG = "NetworkWatchdog"; + private static final long INTERVAL_MS = 5000; + + private final NetworkInstanceStore store; + private ScheduledExecutorService exec = null; + + NetworkWatchdog(@NonNull NetworkInstanceStore store) { + this.store = store; + } + + synchronized void start() { + if (exec != null) return; + exec = Executors.newSingleThreadScheduledExecutor(r -> { + var t = new Thread(r, "net-watchdog"); + t.setDaemon(true); + return t; + }); + exec.scheduleWithFixedDelay( + this::tick, INTERVAL_MS, INTERVAL_MS, TimeUnit.MILLISECONDS); + Log.i(TAG, "Network watchdog started"); + } + + synchronized void stop() { + if (exec == null) return; + exec.shutdownNow(); + exec = null; + Log.i(TAG, "Network watchdog stopped"); + } + + private void tick() { + store.forEach((id, inst) -> safeReconcile(inst)); + } + + private void safeReconcile(@NonNull NetworkInstance inst) { + try { + inst.reconcile(); + } catch (Exception e) { + Log.w(TAG, "Reconcile failed", e); + } + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/BackendBase.java b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/BackendBase.java index 9d3fd00b2..82c4c1bce 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/BackendBase.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/BackendBase.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.network.backend; import androidx.annotation.NonNull; @@ -46,7 +49,5 @@ public abstract class BackendBase { public abstract JSONArray listNeighbors(String bridge); - public abstract JSONArray listDhcpLeases(String bridge); - public abstract void populateRuleRoute(@NonNull NetworkInstance inst); } diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/BridgeBackend.java b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/BridgeBackend.java new file mode 100644 index 000000000..a9bf3996c --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/BridgeBackend.java @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. +package cn.classfun.droidvm.daemon.network.backend; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.List; +import java.util.Map; + +import cn.classfun.droidvm.daemon.network.NetworkInstance; +import cn.classfun.droidvm.daemon.vm.VMInstanceStore; +import cn.classfun.droidvm.lib.network.IPv6Network; +import cn.classfun.droidvm.lib.store.vm.VMNicConfig; + +/** + * Per-network runtime backing one bridge type: owns the data path (Linux + * bridge or gvswitch), per-VLAN L3 services, NIC attach/detach including + * static leases and port forwards, plus the live queries the info UI uses. + */ +public abstract class BridgeBackend { + protected final NetworkInstance inst; + + protected BridgeBackend(@NonNull NetworkInstance inst) { + this.inst = inst; + } + + /** Brings the whole network up; throws with a user-readable reason. */ + public abstract void start() throws Exception; + + /** Tears everything down; must be safe to call after a failed start. */ + public abstract void stop(); + + /** + * Watchdog tick (every 5s while the network is RUNNING): restart any helper + * process that has died and re-initialise its state. Must be idempotent and + * a no-op when everything is healthy. Default: nothing to supervise. + */ + public void reconcile() {} + + /** + * Creates the tap device for one VM NIC and applies the per-port + * settings (VLAN, isolation, MAC security), static DHCP leases and + * port forwards. + */ + public abstract void attachNic(@NonNull VMNicConfig nic, @NonNull String tapName) + throws Exception; + + public abstract void detachNic(@NonNull VMNicConfig nic, @NonNull String tapName); + + public abstract JSONArray listAddresses(); + + public abstract JSONArray listInterfaces(VMInstanceStore vms); + + public abstract JSONArray listNeighbors(); + + public abstract JSONArray listDhcpLeases(); + + /** + * Address rows for network_info, one per address with three dimensions: + * {@code {addr, family, vlan, bound, source}} where {@code source} is + * {@code "configured"} (VMM-managed) or {@code "auto"} (link-local, RA, + * DHCP-PD, ...). Returns {@code null} when the backend does not support + * the richer model, in which case the UI falls back to its config + live + * rendering. Default: unsupported. + */ + @Nullable + public JSONArray listAddressEntries() { + return null; + } + + /** Per-VLAN DHCPv6-PD client state for network_info; empty when none. */ + @NonNull + public JSONArray listPdStatus() { + return new JSONArray(); + } + + /** Builds one {@code {addr,family,vlan,bound,source}} row for {@link #listAddressEntries()}. */ + @NonNull + protected static JSONObject addressEntry( + @NonNull String cidr, int vlan, boolean bound, @NonNull String source + ) { + var o = new JSONObject(); + try { + o.put("addr", cidr); + o.put("family", cidr.contains(":") ? 6 : 4); + o.put("vlan", vlan); + o.put("bound", bound); + o.put("source", source); + } catch (JSONException ignored) { + } + return o; + } + + /** Forces a PD renew on one VLAN's client; false when unsupported. */ + public boolean renewPd(int vlanId) { + return false; + } + + /** Releases and re-solicits one VLAN's PD delegation; false when unsupported. */ + public boolean releasePd(int vlanId) { + return false; + } + + /** + * Helper tools this network runs (gvswitch / pbridge / bridgedhcp), as + * {@code [{key, running}]}, so the info UI can list them and open each + * one's captured log. Default: none. + */ + @NonNull + public JSONArray listTools() { + return new JSONArray(); + } + + /** Captured stdout/stderr lines of a named tool, or null if unknown. */ + @Nullable + public List toolLog(@NonNull String key) { + return null; + } + + /** Builds one {@code {key, running}} tool descriptor for {@link #listTools()}. */ + @NonNull + protected static JSONObject toolEntry(@NonNull String key, boolean running) { + var o = new JSONObject(); + try { + o.put("key", key); + o.put("running", running); + } catch (JSONException ignored) { + } + return o; + } + + /** Backend health/status fields for network_info. */ + public abstract void appendInfo(@NonNull JSONObject obj) throws JSONException; + + /** Live per-VLAN IPv6 networks (DHCP-PD delegations); gateway-addressed. */ + @NonNull + public Map getLiveV6Networks() { + return Map.of(); + } + + /** Emits per-tap port-forward install failures into network_info. */ + protected static void appendForwardFailures( + @NonNull JSONObject obj, @NonNull Map> failures + ) throws JSONException { + if (failures.isEmpty()) return; + var ff = new JSONObject(); + for (var e : failures.entrySet()) + ff.put(e.getKey(), new JSONArray(e.getValue())); + obj.put("forward_failures", ff); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/BridgeDhcp.java b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/BridgeDhcp.java new file mode 100644 index 000000000..f7bf966df --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/BridgeDhcp.java @@ -0,0 +1,512 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. +package cn.classfun.droidvm.daemon.network.backend; + +import static cn.classfun.droidvm.lib.Constants.DATA_DIR; +import static cn.classfun.droidvm.lib.utils.AssetUtils.getAssetBinaryPath; +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; +import static cn.classfun.droidvm.lib.utils.StringUtils.pathJoin; + +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.File; +import java.io.FileWriter; +import java.security.SecureRandom; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.zip.CRC32; + +import cn.classfun.droidvm.daemon.network.NetworkInstance; +import cn.classfun.droidvm.daemon.network.backend.gvisor.GvswitchClient; +import cn.classfun.droidvm.daemon.network.backend.pd.Duid; +import cn.classfun.droidvm.lib.network.IPv6Network; +import cn.classfun.droidvm.lib.store.network.Ipv6Source; +import cn.classfun.droidvm.lib.store.network.VlanConfig; +import cn.classfun.droidvm.lib.utils.NetUtils; + +/** + * DHCPv4/DHCPv6/RA (and DHCPv6-PD) service for one Linux-bridge network, + * backed by the bridgedhcp helper: one process per network serving every + * per-VLAN bridge. Pools and static leases are offset-based, so a PD + * renumbering needs no config rewrite; static leases are pushed through + * the unix-socket API instead of dnsmasq's hostsfile/SIGHUP dance, and PD + * prefix changes flow back as JSON event lines on the child's stdout. + */ +public final class BridgeDhcp { + private static final String TAG = "BridgeDhcp"; + private static final String[] DEFAULT_DNS4 = {"8.8.8.8", "1.1.1.1"}; + private static final String[] DEFAULT_DNS6 = {"2001:4860:4860::8888"}; + private static final SecureRandom random = new SecureRandom(); + + /** PD delegation events; address is null when the prefix was lost. */ + public interface PdListener { + void onPdAddressChanged(int vlanId, @Nullable IPv6Network address); + } + + private final NetworkInstance inst; + private final String br; + private final ManagedProcess process; + private final PdListener pdListener; + private final String apiKey; + private volatile boolean active = false; + private GvswitchClient client = null; + + public BridgeDhcp(@NonNull NetworkInstance inst, @Nullable PdListener pdListener) { + this.inst = inst; + this.br = inst.item.optString("bridge_name", ""); + this.pdListener = pdListener; + this.process = new ManagedProcess("bridgedhcp", br); + var key = new byte[24]; + random.nextBytes(key); + var sb = new StringBuilder(); + for (var b : key) sb.append(fmt("%02x", b)); + this.apiKey = sb.toString(); + } + + /** True when any VLAN needs DHCPv4, DHCPv6, RA or a PD client. */ + public static boolean isNeeded(@NonNull List vlans) { + for (var vlan : vlans) { + if (vlan.isDhcp4Enabled() || vlan.isDhcp6Enabled() || vlan.isSlaacEnabled()) + return true; + if (vlan.getIpv6Source() == Ipv6Source.DHCP_PD && vlan.getPdUplink() != null) + return true; + } + return false; + } + + public boolean start() { + active = true; + try { + writeKeyFile(); + writeConf(); + } catch (Exception e) { + Log.e(TAG, fmt("Failed to write bridgedhcp config for %s", br), e); + return false; + } + client = new GvswitchClient(getSocketFile(br), apiKey); + process.setLineListener(this::onEventLine); + var args = new ArrayList(); + args.add(getAssetBinaryPath("bridgedhcp")); + args.add("--config"); + args.add(getConfFile(br)); + return process.start(args); + } + + public void stop() { + active = false; + process.stop(); + client = null; + } + + /** + * Restarts bridgedhcp if it died. The helper reloads its persisted state + * file on start, so active DHCP leases and PD delegations survive the + * restart; config and static leases are rebuilt deterministically. Called + * on the watchdog's 5s tick while the network is running. Returns true when + * healthy. + */ + @SuppressWarnings("UnusedReturnValue") + public synchronized boolean reconcile() { + if (!active || process.isRunning()) return true; + Log.w(TAG, fmt( + "bridgedhcp for %s is down (exit=%d), restarting", br, process.getExitCode() + )); + return restart(); + } + + /** + * Rewrites the config (re-resolving PD uplinks) and restarts the helper. + * Used when a PD uplink that was unavailable at start later appears, so + * the now-resolvable interface gets a PD client. Persisted leases and PD + * delegations survive via the state file. + */ + public synchronized boolean restart() { + stop(); + return start(); + } + + public boolean isRunning() { + return process.isRunning(); + } + + @SuppressWarnings("unused") + public int getExitCode() { + return process.getExitCode(); + } + + @NonNull + public List getLog() { + return process.getLog(); + } + + /** Child stdout: single-line JSON events (logs go to stderr). */ + private void onEventLine(@NonNull String line) { + if (!line.startsWith("{")) return; + try { + var ev = new JSONObject(line); + var type = ev.optString("event", ""); + if (!type.equals("pd_prefix") && !type.equals("pd_lost")) return; + if (pdListener == null) return; + int vlanId = Integer.parseInt(ev.optString("tag", "-1")); + if (vlanId < 0) return; + IPv6Network address = null; + if (type.equals("pd_prefix")) + address = IPv6Network.parse(ev.getString("address")); + pdListener.onPdAddressChanged(vlanId, address); + } catch (Exception e) { + Log.w(TAG, fmt("Bad bridgedhcp event: %s", line), e); + } + } + + /** Pushes the current static lease set (both families) for every VLAN. */ + public void reloadStaticLeases() { + var c = client; + if (c == null) return; + for (var vlan : inst.getVlans()) { + var dev = LinuxNetwork.vlanDevice(br, vlan.getVlanId()); + try { + var statics = buildStatics(vlan); + c.put(fmt("/v1/ifaces/%s/statics/4", dev), + new JSONObject().put("statics", statics[0])); + c.put(fmt("/v1/ifaces/%s/statics/6", dev), + new JSONObject().put("statics", statics[1])); + } catch (Exception e) { + Log.w(TAG, fmt("Failed to push static leases for %s", dev), e); + } + } + } + + /** Active leases of every VLAN in the legacy dnsmasq-derived shape. */ + @NonNull + public JSONArray getLeases() { + var out = new JSONArray(); + var c = client; + if (c == null) return out; + for (var vlan : inst.getVlans()) { + var dev = LinuxNetwork.vlanDevice(br, vlan.getVlanId()); + for (var family : new int[]{4, 6}) { + try { + var resp = c.get(fmt("/v1/ifaces/%s/leases/%d", dev, family)); + if (!resp.isSuccess()) continue; + var leases = resp.json().optJSONArray("leases"); + if (leases == null) continue; + for (int i = 0; i < leases.length(); i++) { + var l = leases.optJSONObject(i); + if (l == null) continue; + var obj = new JSONObject(); + obj.put("expires", parseExpiry(l.optString("expiry", ""))); + obj.put("mac", l.optString("mac", "")); + obj.put("ip", l.optString("ip", "")); + obj.put("hostname", l.optString("hostname", "")); + out.put(obj); + } + } catch (Exception e) { + Log.w(TAG, fmt("Failed to list leases for %s/%d", dev, family), e); + } + } + } + return out; + } + + private static long parseExpiry(@NonNull String rfc3339) { + try { + return OffsetDateTime.parse(rfc3339).toEpochSecond(); + } catch (Exception e) { + return 0; + } + } + + /** Full status document of the helper (for network info). */ + @Nullable + public JSONObject getStatus() { + var c = client; + if (c == null) return null; + try { + var resp = c.get("/v1/status"); + if (resp.isSuccess()) return resp.json(); + } catch (Exception e) { + Log.w(TAG, "Failed to query bridgedhcp status", e); + } + return null; + } + + /** + * Per-VLAN DHCPv6-PD client state: {@code [{vlan, state, prefix}]} for + * every served interface that runs a PD client (empty prefix while still + * searching). Derived from the helper's {@code /v1/status} document. + */ + @NonNull + public JSONArray getPdStatus() { + var out = new JSONArray(); + var status = getStatus(); + if (status == null) return out; + var ifaces = status.optJSONArray("ifaces"); + if (ifaces == null) return out; + for (int i = 0; i < ifaces.length(); i++) { + var iface = ifaces.optJSONObject(i); + if (iface == null) continue; + var state = iface.optString("pd_state", ""); + if (state.isEmpty()) continue; // no PD client on this interface + try { + out.put(new JSONObject() + .put("vlan", Integer.parseInt(iface.optString("tag", "-1"))) + .put("state", state) + // the raw delegation P/L (context) ... + .put("prefix", iface.optString("pd_prefix", "")) + // ... and the composed host address P::1/L actually bound + // on the bridge -- what the UI shows and what the address + // union deduplicates against. + .put("address", iface.optString("pd_address", ""))); + } catch (Exception e) { + Log.w(TAG, "Bad PD status entry", e); + } + } + return out; + } + + /** Forces a PD renew (RENEW/REBIND, same prefix) on one VLAN's client. */ + public boolean renewPd(int vlanId) { + return pdAction(vlanId, "renew"); + } + + /** Releases and re-solicits the PD delegation on one VLAN's client. */ + public boolean releasePd(int vlanId) { + return pdAction(vlanId, "release"); + } + + private boolean pdAction(int vlanId, @NonNull String action) { + var c = client; + if (c == null) return false; + var dev = LinuxNetwork.vlanDevice(br, vlanId); + try { + return c.post(fmt("/v1/ifaces/%s/pd/%s", dev, action), null).isSuccess(); + } catch (Exception e) { + Log.w(TAG, fmt("PD %s failed for %s", action, dev), e); + return false; + } + } + + private void writeKeyFile() throws Exception { + var file = new File(getKeyFile(br)); + var parent = file.getParentFile(); + if (parent != null && !parent.exists() && !parent.mkdirs()) + throw new RuntimeException(fmt("Failed to create directory %s", parent)); + try (var writer = new FileWriter(file)) { + writer.write(apiKey); + } + if (!file.setReadable(false, false) || !file.setReadable(true, true)) + Log.w(TAG, "Failed to restrict key file permissions"); + } + + private void writeConf() throws Exception { + var cfg = new JSONObject(); + cfg.put("api_socket", getSocketFile(br)); + cfg.put("api_key_file", getKeyFile(br)); + cfg.put("state_file", getStateFile(br)); + var ifaces = new JSONArray(); + for (var vlan : inst.getVlans()) + ifaces.put(buildIface(vlan)); + cfg.put("interfaces", ifaces); + var file = new File(getConfFile(br)); + try (var writer = new FileWriter(file)) { + writer.write(cfg.toString(2)); + } + } + + @NonNull + private JSONObject buildIface(@NonNull VlanConfig vlan) throws Exception { + var obj = new JSONObject(); + obj.put("name", LinuxNetwork.vlanDevice(br, vlan.getVlanId())); + obj.put("tag", String.valueOf(vlan.getVlanId())); + // Served prefixes are authoritative config -- bridgedhcp no longer + // derives the pools/SLAAC prefix from the bridge's live addresses. + // v6 is sent only for a static source; DHCP-PD drives it dynamically. + var cidr4 = vlan.getIpv4Cidr(); + if (cidr4 != null) obj.put("prefix4", cidr4); + if (vlan.getIpv6Source() == Ipv6Source.STATIC) { + var cidr6 = vlan.getIpv6Cidr(); + if (cidr6 != null) obj.put("prefix6", cidr6); + } + if (vlan.isDhcp4Enabled()) { + var dns = new JSONArray(); + for (var s : vlan.getDnsServers()) + if (!s.contains(":")) dns.put(s); + if (dns.length() == 0) + for (var s : DEFAULT_DNS4) dns.put(s); + obj.put("dhcp4", new JSONObject() + .put("pool_offset_start", vlan.getDhcp4OffsetStart()) + .put("pool_offset_end", vlan.getDhcp4OffsetEnd()) + .put("lease_time", "12h") + .put("dns", dns)); + } + if (vlan.isDhcp6Enabled()) { + var dns = new JSONArray(); + for (var s : vlan.getDnsServers()) + if (s.contains(":")) dns.put(s); + if (dns.length() == 0) + for (var s : DEFAULT_DNS6) dns.put(s); + obj.put("dhcp6", new JSONObject() + .put("pool_offset_start", vlan.getDhcp6OffsetStart()) + .put("pool_offset_end", vlan.getDhcp6OffsetEnd()) + .put("lease_time", "12h") + .put("dns", dns)); + } + if (vlan.isSlaacEnabled()) obj.put("slaac", true); + if (vlan.getIpv6Source() == Ipv6Source.DHCP_PD && vlan.getPdUplink() != null) { + var uplink = UplinkResolver.resolve(vlan.getPdUplink()); + if (uplink == null) { + Log.w(TAG, fmt( + "PD uplink \"%s\" not found, skipping PD for vlan %d", + vlan.getPdUplink(), vlan.getVlanId() + )); + } else { + var crc = new CRC32(); + crc.update(inst.getId().toString().getBytes()); + crc.update(vlan.getVlanId()); + // CRC32 is an unsigned 32-bit value; write it as-is. Casting + // to int makes it negative when bit 31 is set, and bridgedhcp + // parses "iaid" into a Go uint32, which rejects a negative + // number -- the whole helper then fails to start. + // No prefix_len: bridgedhcp binds the host at the delegation's + // own length L (suffix ::1), and gates SLAAC/DHCPv6 on L. + obj.put("pd", new JSONObject() + .put("uplink", uplink) + .put("duid", Duid.format(resolvePdDuid(vlan))) + .put("iaid", crc.getValue()) + .put("suffix", "::1") + .put("route_table", resolvePdRouteTable(vlan))); + } + } + var statics = buildStatics(vlan); + if (statics[0].length() > 0) obj.put("statics4", statics[0]); + if (statics[1].length() > 0) obj.put("statics6", statics[1]); + return obj; + } + + /** + * Builds the [v4, v6] static binding arrays of one VLAN from the VM NIC + * configs (mac -> offset, matching the NIC's DHCP lease settings). + */ + @NonNull + private JSONArray[] buildStatics(@NonNull VlanConfig vlan) { + var v4 = new JSONArray(); + var v6 = new JSONArray(); + var netId = inst.getId().toString(); + var vms = inst.getStore().context.getVMs(); + vms.forEach((vmId, vm) -> { + final int[] idx = {0}; + var failed = new Exception[1]; + vm.forEachNic(nic -> { + int nicIdx = idx[0]++; + try { + if (!netId.equals(nic.getNetworkId())) return; + var mac = nic.getMacAddress(); + if (mac == null) return; + var nicVlan = nic.resolveDhcpVlan(inst); + if (nicVlan == null || nicVlan.getVlanId() != vlan.getVlanId()) return; + var id = fmt("%s-%d", vmId, nicIdx); + if (nic.isDhcp4LeaseEnabled() && vlan.isDhcp4Enabled()) { + v4.put(new JSONObject() + .put("id", id) + .put("mac", mac) + .put("offset", nic.getDhcp4Offset())); + } + if (nic.isDhcp6LeaseEnabled() && vlan.isDhcp6Enabled()) { + v6.put(new JSONObject() + .put("id", id) + .put("mac", mac) + .put("offset", nic.getDhcp6Offset())); + } + } catch (Exception e) { + failed[0] = e; + } + }); + if (failed[0] != null) + Log.w(TAG, fmt("Bad static lease config for VM %s", vmId), failed[0]); + }); + return new JSONArray[]{v4, v6}; + } + + /** First id of the range handed out to bridgedhcp PD route tables. */ + private static final long PD_ROUTE_TABLE_BASE = 9991; + /** Serializes allocations across concurrently starting networks. */ + private static final Object routeTableLock = new Object(); + + /** + * The PD routing table of one VLAN: the persisted allocation when + * present, else the lowest id from PD_ROUTE_TABLE_BASE upward that no + * VLAN of any network (running or stopped) holds, persisted + * immediately. Stability matters: the id is bridgedhcp's crash-cleanup + * key -- a bridge that comes back under a different id would leave the + * previous run's rules behind, and reusing a *live* network's id would + * wipe its rules at startup. + */ + private long resolvePdRouteTable(@NonNull VlanConfig vlan) { + synchronized (routeTableLock) { + var existing = vlan.getPdRouteTable(); + if (existing != 0) return existing; + var used = new HashSet(); + inst.getStore().forEach((id, other) -> { + for (var v : other.getVlans()) + used.add(v.getPdRouteTable()); + }); + var table = PD_ROUTE_TABLE_BASE; + while (used.contains(table)) table++; + vlan.setPdRouteTable(table); + Log.i(TAG, fmt("Allocated PD route table %d for %s vlan %d", + table, br, vlan.getVlanId())); + return table; + } + } + + /** + * The PD DUID: the configured one, else a generated DUID-LL persisted + * back into the VLAN config so the delegation survives restarts. + */ + @NonNull + private byte[] resolvePdDuid(@NonNull VlanConfig vlan) { + var configured = vlan.getPdDuid(); + if (configured != null && !configured.isEmpty()) { + try { + return Duid.parse(configured); + } catch (Exception e) { + Log.w(TAG, fmt("Invalid DUID \"%s\", generating one", configured), e); + } + } + var mac = inst.getBridgeMacAddress(); + if (mac == null) mac = inst.item.optString("generated_mac", ""); + if (mac == null || mac.isEmpty()) mac = NetUtils.generateRandomMac(); + var duid = Duid.fromLinkLayer(mac); + vlan.setPdDuid(Duid.format(duid)); + return duid; + } + + @NonNull + public static String getConfFile(@NonNull String br) { + return pathJoin(DATA_DIR, "run", fmt("bridgedhcp-%s.json", br)); + } + + @NonNull + public static String getKeyFile(@NonNull String br) { + return pathJoin(DATA_DIR, "run", fmt("bridgedhcp-%s.key", br)); + } + + @NonNull + public static String getSocketFile(@NonNull String br) { + return pathJoin(DATA_DIR, "run", fmt("bridgedhcp-%s.sock", br)); + } + + @NonNull + public static String getStateFile(@NonNull String br) { + return pathJoin(DATA_DIR, "run", fmt("bridgedhcp-%s.state.json", br)); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/DefaultRouterWatcher.java b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/DefaultRouterWatcher.java index c84cf33c8..38a2f5e21 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/DefaultRouterWatcher.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/DefaultRouterWatcher.java @@ -1,109 +1,470 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.network.backend; -import static cn.classfun.droidvm.lib.utils.RunUtils.run; -import static cn.classfun.droidvm.lib.utils.RunUtils.runListQuiet; +import static cn.classfun.droidvm.lib.utils.FileUtils.shellReadFile; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; import android.util.Log; import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; -import cn.classfun.droidvm.daemon.network.NetworkInstance; import cn.classfun.droidvm.daemon.server.ServerContext; +import cn.classfun.droidvm.lib.Constants; +import cn.classfun.droidvm.lib.store.network.BridgeType; +import cn.classfun.droidvm.lib.store.network.NetworkState; +import cn.classfun.droidvm.lib.store.network.UplinkMode; +import cn.classfun.droidvm.lib.utils.RunUtils; public final class DefaultRouterWatcher { private static final String TAG = "DefaultRouterWatcher"; private static final long POLL_INTERVAL_SEC = 5; + /** + * FRA_PRIORITY of the first "iif {bridge} lookup {table}" rule; rule i in a + * bridge's chain gets {@code RULE_PRIORITY_BASE + i}. Sits above the local + * table (priority 0) and below netd's lowest rule (10000), so a guest packet + * is matched by the local table first, then our chain, before netd's + * "from all" fallbacks. Leaves headroom for far more tables than any device + * has networks. + */ + private static final int RULE_PRIORITY_BASE = 9000; + /** + * The prefixes whose addresses count as the phone's own, for port-forward + * DNAT scoping. Passed to netbox, which also drops bridge devices and + * pbridge-offload addresses. Shared with {@link HostAddressScan}, which + * answers the same question for the VNC listen-address picker and applies + * the rest of the same policy in Java; the list lives there. + */ + private static final List HOST_IFACE_PREFIXES = + HostAddressScan.HOST_IFACE_PREFIXES; private final ServerContext context; - private final List rules = new ArrayList<>(); - private ScheduledExecutorService scheduler; - private String lastDefault = null; + private final List listeners = new CopyOnWriteArrayList<>(); + private final List hostIpListeners = new CopyOnWriteArrayList<>(); + private volatile ScheduledExecutorService scheduler; + private List lastTables4 = List.of(); + private List lastTables6 = List.of(); + /** Phone's own IPv4 addresses; the netbox host-ips policy decides the set. */ + private volatile Set hostIpv4 = Set.of(); + /** netbox monitor-addr: pushes host-IP changes the instant they happen. */ + private ManagedProcess hostIpMonitor = null; + /** Gates monitor (re)spawn so a post-stop tick can't resurrect it. */ + private volatile boolean monitorEnabled = false; public DefaultRouterWatcher(@NonNull ServerContext context) { this.context = context; } - @Nullable - private String getCurrentDefault() { - var result = runListQuiet("ip", "rule", "show"); - if (!result.isSuccess()) { - result.printLog("ip-rule"); - throw new RuntimeException("Failed to list IP rules"); + /** + * The ordered routing tables a default-network app's locally-generated + * traffic would traverse, per family -- the egress half of guest routing. + * Forwarded guest packets carry no fwmark, so they never match netd's + * uid/netId-keyed "iif lo" rules; we mirror the tables those rules select + * into "iif {bridge}" rules instead (see {@link #reconcileRules}). + * + *

A default app's packet has fwmark 0 (no explicitly selected network, + * not VPN-protected), so it is selected by exactly the "iif lo" lookup + * rules whose fwmark is unset/0: + *

    + *
  • the secure-VPN rule ("from all fwmark 0x0/0x20000 iif lo ... lookup + * {vpn}", priority 13000) -> the VPN table holding a split tunnel's + * routes (e.g. 10.0.0.0/8);
  • + *
  • the default-network rule ("from all fwmark 0x0/0xffff iif lo lookup + * {default}") -> the underlying network's table (e.g. wlan0).
  • + *
+ * netId explicit/implicit rules carry a nonzero fwmark and are skipped. + * Returned in netd priority order so a split-tunnel VPN's table is consulted + * before the default one: a guest packet to 10.x matches the VPN table; + * anything else finds no route there and falls through to the default table + * (kernel policy-rule fall-through). With no VPN the list is just the single + * default table; a full-tunnel VPN's table has a default route, so the trailing + * default table is simply never reached. + * + *

Matching is on what netbox exposes (priority, iif, table, fwmark, + * fwmask -- not oif/uidrange), so the filter must single out the fwmark + * selection rules: + *

    + *
  • {@code lookup} -- it routes to a table (not unreachable/blackhole);
  • + *
  • {@code iif == "lo"} -- locally-generated selection rules, not the + * "from all" legacy/local-network rules that forwarded packets already + * traverse;
  • + *
  • {@code fwmark} unset/0 -- a default app's mark (the kernel omits + * FRA_FWMARK when the mark is 0); netId explicit/implicit rules carry a + * nonzero mark and are skipped;
  • + *
  • {@code fwmask} present -- this is the key discriminator. A real + * fwmark selector always emits FRA_FWMASK (the kernel sends it whenever + * the mask is nonzero, e.g. 0x20000/0xffff), even when the mark value + * is 0. The "iif lo oif <iface> ... lookup" output-interface rules + * (priorities 11000/17000, incl. dummy0 blackholes) carry no mark or + * mask, so their fwmask is null and they are excluded -- without this + * check those oif rules would be picked up (their oif is invisible to + * us) and wrongly rank wlan0/dummy0 ahead of the VPN table.
  • + *
+ */ + @NonNull + private List currentLocalAppTables(boolean ipv6) { + // ruleList is in kernel order = ascending priority, so collecting in + // iteration order preserves netd's precedence (secure-VPN before default). + var tables = new LinkedHashSet(); + var rows = Netbox.ruleList(ipv6); + for (int i = 0; i < rows.length(); i++) { + var r = rows.optJSONObject(i); + if (r == null) continue; + if (!r.optBoolean("lookup", false)) continue; + if (!"lo".equals(r.optString("iif", ""))) continue; + if (r.isNull("fwmask")) continue; + if (!r.isNull("fwmark") && r.optInt("fwmark", 0) != 0) continue; + tables.add(String.valueOf(r.optInt("table", 0))); } - var data = result.getOutString(); - for (var line : data.split("\n")) { - if (!line.contains("fwmark 0x0/0xffff")) continue; - var idx = line.indexOf(" lookup "); - if (idx < 0) continue; - return line.substring(idx + (" lookup ".length())).trim(); - } - return null; + return new ArrayList<>(tables); } - private void clearRule() { - for (var rule : rules) - run(fmt("ip rule del %s", rule)); - rules.clear(); + private void ruleDel(boolean ipv6, @NonNull String dev, @NonNull String table, int priority) { + Netbox.ruleDel(dev, table, priority, ipv6); } - private void addRule(@NonNull String table, @NonNull String br) { - var rule = fmt("from all iif %s lookup %s", br, table); - run(fmt("ip rule add %s", rule)); - rules.add(rule); + private void ruleAdd(boolean ipv6, @NonNull String dev, @NonNull String table, int priority) { + Netbox.ruleAdd(dev, table, priority, ipv6); } - private void setDefaultRouterForNetworks(@NonNull String net) { - context.getNetworks().forEach((uuid, inst) -> - addRule(net, inst.item.optString("bridge_name", ""))); + /** + * Reconciles one family's per-bridge "iif {bridge} priority {p} lookup + * {table}" rule chains against {@code tables} (the egress half of guest + * routing: forwarded packets carry no fwmark and Android's netd rules end + * in "from all unreachable", so each bridge needs explicit rules into the + * tables a local app would use). Each running Linux-L3 bridge gets the + * whole ordered {@code tables} chain so split-tunnel works: the VPN table + * is tried before the default one and the kernel falls through when it has + * no matching route -- see {@link #currentLocalAppTables}. + * + *

Order matters (a packet to 10.x must hit the VPN table before the + * default table's 0.0.0.0/0). We pin it with explicit FRA_PRIORITY rather + * than insertion order: rule i gets priority {@code RULE_PRIORITY_BASE + i} + * (ascending = tables[0] first). Insertion order is unreliable -- the kernel + * matches same-priority rules FIFO or LIFO depending on version (observed + * LIFO on-device, which silently reversed the chain). The band sits below + * netd's lowest rule (10000) and above the local table (0), so a guest + * packet hits local first, then our chain. + * + *

Declarative per chain: a bridge whose installed rules (each as a + * "priority table" spec, sorted by priority) exactly equal the desired + * chain is left untouched; any other bridge -- wrong tables/priorities + * (incl. the pre-priority builds' priority-0 rules), [detached] from a + * recreated bridge, or no longer desired -- has all its rules deleted (each + * by its own priority) and, if still desired, the full chain re-added. An + * empty {@code tables} (no default network, or stop()) desires no bridge, + * so every leftover rule is swept. + */ + private void reconcileRules(boolean ipv6, @NonNull List tables) { + var desiredDevs = new LinkedHashSet(); + var bridges = new ArrayList(); + context.getNetworks().forEach((uuid, inst) -> { + var br = inst.getBridgeName(); + if (br != null && !br.isEmpty()) bridges.add(br); + // Only a running Linux-bridge L3 network has a real kernel bridge + // that forwarded guest traffic needs "iif lookup " + // rules for. gvisor is a userspace data path (gvswitch + AF_XDP) + // with no kernel device for its bridge name, so a rule on it would + // sit [detached] and flap every tick; a stopped network has no + // bridge either. Whitelist the one case that needs rules -- any + // other backend is excluded by default. Both still go into + // `bridges` above so their leftover rules are swept here. + if (!tables.isEmpty() + && inst.getState() == NetworkState.RUNNING + && inst.getBridgeType() == BridgeType.LINUX + && inst.getUplinkMode() == UplinkMode.L3) + desiredDevs.addAll(inst.getL3Devices()); + }); + + // Desired chain as "priority table" specs in ascending priority; the + // same list for every desired bridge. tables[0] (the VPN table) gets the + // lowest priority so it is evaluated first. + var desired = new ArrayList(); + for (int i = 0; i < tables.size(); i++) + desired.add(fmt("%d %s", RULE_PRIORITY_BASE + i, tables.get(i))); + + // Group our installed rules per device as "priority table" specs. Ours + // are the plain rules: an iif owned by a bridge, a lookup action, no + // fwmark/mask (netd's are all marked or oif/uid scoped). Sort each + // device's specs by priority so the comparison is order-canonical. + var installed = new LinkedHashMap>(); + var detached = new HashSet(); + var rows = Netbox.ruleList(ipv6); + for (int i = 0; i < rows.length(); i++) { + var r = rows.optJSONObject(i); + if (r == null) continue; + var dev = r.optString("iif", ""); + if (dev.isEmpty()) continue; + if (!r.optBoolean("lookup", false)) continue; + if (!r.isNull("fwmark") || !r.isNull("fwmask")) continue; + boolean ours = false; + for (var br : bridges) + if (dev.startsWith(br)) { ours = true; break; } + if (!ours) continue; + installed.computeIfAbsent(dev, k -> new ArrayList<>()) + .add(fmt("%d %d", r.optInt("priority", 0), r.optInt("table", 0))); + if (r.optBoolean("detached", false)) detached.add(dev); + } + installed.values().forEach(specs -> specs.sort(Comparator.comparingInt( + s -> Integer.parseInt(s.substring(0, s.indexOf(' ')))))); + + // A device is already correct iff it is still desired, has no detached + // rule, and its installed specs equal the desired chain (same tables AND + // priorities). Everything else is torn down -- each rule deleted by its + // own priority -- and desired-but-incorrect devices are rebuilt below. + var correct = new HashSet(); + installed.forEach((dev, specs) -> { + if (desiredDevs.contains(dev) + && !detached.contains(dev) + && specs.equals(desired)) { + correct.add(dev); + return; + } + Log.i(TAG, fmt("Removing stale chain: iif %s [%s] (v%d)", + dev, specs, ipv6 ? 6 : 4)); + for (var spec : specs) { + int sp = spec.indexOf(' '); + ruleDel(ipv6, dev, spec.substring(sp + 1), + Integer.parseInt(spec.substring(0, sp))); + } + }); + + for (var dev : desiredDevs) { + if (correct.contains(dev)) continue; + Log.i(TAG, fmt("Adding chain: iif %s [%s] (v%d)", + dev, desired, ipv6 ? 6 : 4)); + for (int i = 0; i < tables.size(); i++) + ruleAdd(ipv6, dev, tables.get(i), RULE_PRIORITY_BASE + i); + } } private synchronized void runOnce() { + ensureHostIpMonitor(); + updateHostIps(); + updateDefaultRouter(); + reassertForwarding(); + } + + /** + * Re-asserts the kernel forwarding sysctls that guest L3 routing depends + * on. netd owns these globally and clears them whenever it retunes + * tethering, which silently black-holes every forwarded guest packet. + * {@link cn.classfun.droidvm.daemon.network.backend.iptables.IptablesBackend} + * enables them once at init; this keeps them enabled for as long as a + * Linux-bridge L3 network is running. + * + *

Gated on {@link #hasL3LinuxBridge()} -- the same "running LINUX + L3" + * condition the routing rules use. With no such network there is nothing + * to forward, so netd's value is left alone rather than forcing global + * forwarding on the phone. Both families are covered because netd flips + * v4 and v6 together (as does {@code enableIpForward}). Reads before + * writing: an untouched value costs one cheap cat and stays silent; only a + * value netd actually reset is corrected and logged (it means netd raced + * us). Never throws out of the tick -- a transient shell failure must not + * tear down the routing rules. + */ + private void reassertForwarding() { try { - var newDefault = getCurrentDefault(); - if (newDefault == null) return; - if (lastDefault == null) { - Log.i(TAG, fmt("Found default router changed %s", newDefault)); - } else { - if (newDefault.equals(lastDefault)) return; - Log.i(TAG, fmt("Default router changed from %s to %s", lastDefault, newDefault)); - } - clearRule(); - setDefaultRouterForNetworks(newDefault); - lastDefault = newDefault; + if (!hasL3LinuxBridge()) return; + ensureSysctlOne("/proc/sys/net/ipv4/ip_forward", "net.ipv4.ip_forward"); + ensureSysctlOne("/proc/sys/net/ipv6/conf/all/forwarding", + "net.ipv6.conf.all.forwarding"); + } catch (Exception e) { + Log.w(TAG, "Failed to re-assert forwarding sysctls", e); + } + } + + /** + * Writes 1 to {@code path} only if it is not already 1; logs corrections. + * The read ({@code shellReadFile}) and the write ({@code runQuiet}) are both + * quiet, so the 5s poll stays out of logcat -- {@code RunContext.run} would + * otherwise log a "Running command: cat ..." line every tick. The only line + * this emits on a normal tick is the warning below, and only when netd + * actually reset the value. + */ + private void ensureSysctlOne(@NonNull String path, @NonNull String name) { + var cur = shellReadFile(path); + if ("1".equals(cur)) return; + Log.w(TAG, fmt("%s=%s (netd reset it); re-enabling forwarding", + name, cur.isEmpty() ? "?" : cur)); + RunUtils.runQuiet(fmt("echo 1 > %s", path)); + } + + /** True while any RUNNING Linux-bridge L3 network needs kernel forwarding. */ + private boolean hasL3LinuxBridge() { + var found = new boolean[]{false}; + context.getNetworks().forEach((uuid, inst) -> { + if (inst.getState() == NetworkState.RUNNING + && inst.getBridgeType() == BridgeType.LINUX + && inst.getUplinkMode() == UplinkMode.L3) + found[0] = true; + }); + return found[0]; + } + + private void updateDefaultRouter() { + try { + var new4 = currentLocalAppTables(false); + var new6 = currentLocalAppTables(true); + reconcileRules(false, new4); + reconcileRules(true, new6); + boolean changed = !new4.equals(lastTables4) || !new6.equals(lastTables6); + if (changed) + Log.i(TAG, fmt("Local-app route tables now v4=%s v6=%s", new4, new6)); + lastTables4 = new4; + lastTables6 = new6; + // notify only on appearance/change, not on loss: a reconnect to + // the same network re-triggers because the loss emptied the state + if (changed && (!new4.isEmpty() || !new6.isEmpty())) + notifyChange(); } catch (Exception e) { Log.w(TAG, "Failed to update default router", e); stop(); } } - public synchronized void setForNewNetwork(@NonNull NetworkInstance inst) { - if (lastDefault == null) return; - addRule(lastDefault, inst.item.optString("bridge_name", "")); + private void notifyChange() { + for (var listener : listeners) { + try { + listener.run(); + } catch (Exception e) { + Log.w(TAG, "Default-network listener failed", e); + } + } + } + + /** + * Backstop poll of the phone's own IPv4 addresses for port-forward DNAT + * scoping. The {@code netbox monitor-addr} stream is the primary, instant + * source ({@link #onMonitorSet}); this tick covers a missed event or a dead + * monitor. On a transient query failure the previous set is kept (returning + * empty would flap every forward), so this never throws out of the tick. + */ + private void updateHostIps() { + try { + applyHostIps(Netbox.hostIpv4(HOST_IFACE_PREFIXES, Constants.PBRIDGE_OFFLOAD_MAGIC)); + } catch (Exception e) { + Log.w(TAG, "Failed to poll host IPv4 addresses", e); + } + } + + /** Diffs the fresh set against the cache and fires listeners if it moved. */ + private void applyHostIps(@NonNull Set fresh) { + if (fresh.equals(hostIpv4)) return; + Log.i(TAG, fmt("Host IPv4 set changed %s -> %s", hostIpv4, fresh)); + hostIpv4 = Set.copyOf(fresh); + for (var listener : hostIpListeners) { + try { + listener.run(); + } catch (Exception e) { + Log.w(TAG, "Host-IP listener failed", e); + } + } + } + + /** + * Receives a new host-IP set from the netbox monitor (on its process + * thread). Hops onto the scheduler so all host-IP state mutates on one + * thread, shared with the backstop tick; dropped if we're stopping. + */ + private void onMonitorSet(@NonNull Set fresh) { + var s = scheduler; + if (s == null) return; + try { + s.execute(() -> applyHostIps(fresh)); + } catch (RejectedExecutionException ignored) { + // scheduler shutting down; the next start() reseeds + } + } + + /** Starts the monitor, or restarts it if it died (called each tick). */ + private void ensureHostIpMonitor() { + if (!monitorEnabled) return; + if (hostIpMonitor != null && hostIpMonitor.isRunning()) return; + if (hostIpMonitor != null) + Log.w(TAG, "host-IP monitor exited; restarting"); + hostIpMonitor = Netbox.startHostIpMonitor( + HOST_IFACE_PREFIXES, Constants.PBRIDGE_OFFLOAD_MAGIC, this::onMonitorSet); + } + + /** Current snapshot of the phone's own IPv4 addresses (immutable). */ + @NonNull + public Set getHostIpv4Addresses() { + return hostIpv4; + } + + /** Notified (on the watcher thread) when the host IPv4 set changes. */ + public void addHostIpListener(@NonNull Runnable listener) { + hostIpListeners.add(listener); + } + + public void removeHostIpListener(@NonNull Runnable listener) { + hostIpListeners.remove(listener); + } + + /** Notified (on the watcher thread) when the default network changes. */ + @SuppressWarnings("unused") + public void addListener(@NonNull Runnable listener) { + listeners.add(listener); + } + + @SuppressWarnings("unused") + public void removeListener(@NonNull Runnable listener) { + listeners.remove(listener); + } + + /** Installs the new network's rules immediately (the tick would lag 5s). */ + public synchronized void setForNewNetwork() { + try { + reconcileRules(false, currentLocalAppTables(false)); + reconcileRules(true, currentLocalAppTables(true)); + } catch (Exception e) { + Log.w(TAG, "Failed to install rules for new network", e); + } } public void start() { + monitorEnabled = true; scheduler = Executors.newSingleThreadScheduledExecutor(r -> { var t = new Thread(r, TAG); t.setDaemon(true); return t; }); + // the first tick (delay 0) spawns the monitor via ensureHostIpMonitor scheduler.scheduleWithFixedDelay( this::runOnce, 0, POLL_INTERVAL_SEC, TimeUnit.SECONDS ); } - public void stop() { + public synchronized void stop() { + monitorEnabled = false; + if (hostIpMonitor != null) { + hostIpMonitor.stop(); + hostIpMonitor = null; + } if (scheduler != null) { scheduler.shutdownNow(); scheduler = null; } - clearRule(); + // sweep every rule of ours, both families + try { + reconcileRules(false, List.of()); + reconcileRules(true, List.of()); + } catch (Exception e) { + Log.w(TAG, "Failed to sweep rules on stop", e); + } } } diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/Dnsmasq.java b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/Dnsmasq.java deleted file mode 100644 index 0c41b7127..000000000 --- a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/Dnsmasq.java +++ /dev/null @@ -1,217 +0,0 @@ -package cn.classfun.droidvm.daemon.network.backend; - -import static cn.classfun.droidvm.lib.Constants.DATA_DIR; -import static cn.classfun.droidvm.lib.utils.AssetUtils.getPrebuiltBinaryPath; -import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; -import static cn.classfun.droidvm.lib.utils.StringUtils.pathJoin; - -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import java.io.BufferedReader; -import java.io.File; -import java.io.InputStreamReader; -import java.io.InterruptedIOException; -import java.util.ArrayList; -import java.util.concurrent.TimeUnit; - -import cn.classfun.droidvm.daemon.network.NetworkInstance; -import cn.classfun.droidvm.lib.network.IPv4Address; -import cn.classfun.droidvm.lib.network.IPv4Network; -import cn.classfun.droidvm.lib.store.network.NetworkState; -import cn.classfun.droidvm.lib.utils.ProcessUtils; - -public final class Dnsmasq { - private static final String TAG = "Dnsmasq"; - private final NetworkInstance inst; - private Process dnsmasqProcess = null; - private Thread dnsmasqMonitor = null; - private volatile boolean dnsmasqRunning = false; - private int dnsmasqExitCode = -1; - - public Dnsmasq(NetworkInstance inst) { - this.inst = inst; - } - - @NonNull - private static String resolveDnsmasqBinary() { - var prebuilt = getPrebuiltBinaryPath("dnsmasq"); - var file = new File(prebuilt); - if (file.isFile() && file.canExecute()) - return prebuilt; - return "dnsmasq"; - } - - private void startDnsmasqProcess( - ) { - var bridge = inst.item.optString("bridge_name", ""); - var rangeStart = inst.item.optString("dhcp_range_start", ""); - var rangeEnd = inst.item.optString("dhcp_range_end", ""); - var router = findRouterAddress(rangeStart); - var dnsmasq = resolveDnsmasqBinary(); - Log.i(TAG, fmt("Starting dnsmasq on %s (%s - %s) using %s", - bridge, rangeStart, rangeEnd, dnsmasq)); - var pidFile = getDnsmasqPidFile(); - var leaseFile = getDnsmasqLeaseFile(); - try { - var args = new ArrayList(); - args.add(dnsmasq); - args.add(fmt("--interface=%s", bridge)); - args.add("--bind-interfaces"); - args.add(fmt("--dhcp-range=%s,%s,12h", rangeStart, rangeEnd)); - if (router != null) - args.add(fmt("--dhcp-option=option:router,%s", router)); - var dnsServers = new ArrayList(); - for (var iter : inst.item.get("dns_servers")) { - var s = iter.getValue().asString(); - if (s != null && !s.isEmpty()) dnsServers.add(s); - } - if (!dnsServers.isEmpty()) - args.add(fmt("--dhcp-option=option:dns-server,%s", String.join(",", dnsServers))); - args.add("--port=0"); - args.add("--no-resolv"); - args.add("--no-daemon"); - args.add("--keep-in-foreground"); - args.add(fmt("--pid-file=%s", pidFile)); - args.add(fmt("--dhcp-leasefile=%s", leaseFile)); - args.add("--log-queries"); - args.add("--log-dhcp"); - var pb = new ProcessBuilder(args); - pb.redirectErrorStream(true); - dnsmasqExitCode = 0; - dnsmasqProcess = pb.start(); - Log.i(TAG, fmt("dnsmasq started on %s", bridge)); - } catch (Exception e) { - Log.e(TAG, fmt("Failed to start dnsmasq on %s", bridge), e); - } - } - - @Nullable - private String findRouterAddress(@NonNull String rangeStart) { - IPv4Address dhcpStart = null; - try { - if (!rangeStart.isEmpty()) dhcpStart = IPv4Address.parse(rangeStart); - } catch (Exception ignored) { - } - - IPv4Network fallback = null; - for (var iter : inst.item.get("ipv4_addresses")) { - try { - var cidr = IPv4Network.parse(iter.getValue().asString()); - if (fallback == null) fallback = cidr; - if (dhcpStart != null && cidr.contains(dhcpStart)) - return cidr.address().toString(); - } catch (Exception ignored) { - } - } - return fallback != null ? fallback.address().toString() : null; - } - - private void stopDnsmasqProcess(@Nullable Process process) { - var bridge = inst.item.optString("bridge_name", ""); - Log.i(TAG, fmt("Stopping dnsmasq on %s", bridge)); - if (process != null) { - process.destroy(); - try { - process.waitFor(3, TimeUnit.SECONDS); - } catch (InterruptedException ignored) { - Thread.currentThread().interrupt(); - } - process.destroyForcibly(); - try { - process.waitFor(); - } catch (InterruptedException ignored) { - Thread.currentThread().interrupt(); - } - } - var pidFile = getDnsmasqPidFile(); - if (new File(pidFile).exists()) - ProcessUtils.shellKillProcessFile(pidFile); - } - - public void startDnsmasq() { - stopDnsmasq(); - var br = inst.item.optString("bridge_name", ""); - startDnsmasqProcess(); - if (dnsmasqProcess == null) { - Log.e(TAG, fmt("dnsmasq failed to start on %s", br)); - dnsmasqRunning = false; - return; - } - dnsmasqRunning = true; - Log.i(TAG, fmt("dnsmasq running on %s", br)); - dnsmasqMonitor = new Thread(this::dnsmasqMonitorThread, fmt("dnsmasq-mon-%s", br)); - dnsmasqMonitor.setDaemon(true); - dnsmasqMonitor.start(); - } - - private void dnsmasqMonitorThread() { - var br = inst.item.optString("bridge_name", ""); - try { - try (var reader = new BufferedReader( - new InputStreamReader(dnsmasqProcess.getInputStream()))) { - String line; - while ((line = reader.readLine()) != null) - Log.d(fmt("dnsmasq-%s", br), line); - } - int code = dnsmasqProcess.waitFor(); - dnsmasqExitCode = code; - dnsmasqRunning = false; - if (inst.getState() == NetworkState.RUNNING) { - Log.w(TAG, fmt("dnsmasq on %s exited unexpectedly (code %d)", br, code)); - } else { - Log.i(TAG, fmt("dnsmasq on %s exited (code %d)", br, code)); - } - } catch (InterruptedException | InterruptedIOException e) { - Thread.currentThread().interrupt(); - Log.d(TAG, fmt("dnsmasq monitor on %s interrupted", br)); - } catch (Exception e) { - Log.e(TAG, fmt("dnsmasq monitor on %s failed", br), e); - } finally { - dnsmasqRunning = false; - } - } - - public void stopDnsmasq() { - if (!dnsmasqRunning) return; - if (dnsmasqMonitor != null) { - dnsmasqMonitor.interrupt(); - dnsmasqMonitor = null; - } - if (dnsmasqProcess != null || inst.item.optBoolean("dhcp_enabled", false)) { - stopDnsmasqProcess(dnsmasqProcess); - dnsmasqProcess = null; - } - dnsmasqRunning = false; - } - - @NonNull - public String getDnsmasqLeaseFile() { - return getDnsmasqLeaseFile(inst.item.optString("bridge_name", "")); - } - - @NonNull - public String getDnsmasqPidFile() { - return getDnsmasqPidFile(inst.item.optString("bridge_name", "")); - } - - @NonNull - public static String getDnsmasqLeaseFile(@NonNull String br) { - return pathJoin(DATA_DIR, "run", fmt("dnsmasq-%s.leases", br)); - } - - @NonNull - public static String getDnsmasqPidFile(@NonNull String br) { - return pathJoin(DATA_DIR, "run", fmt("dnsmasq-%s.pid", br)); - } - - public int getDnsmasqExitCode() { - return dnsmasqExitCode; - } - - public boolean isDnsmasqRunning() { - return dnsmasqRunning; - } -} diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/FirewallHelper.java b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/FirewallHelper.java index 81433bd7e..4f973d9e6 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/FirewallHelper.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/FirewallHelper.java @@ -1,15 +1,61 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.network.backend; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + import cn.classfun.droidvm.daemon.network.NetworkInstance; +import cn.classfun.droidvm.lib.network.IPv6Network; @SuppressWarnings({"BooleanMethodIsAlwaysInverted", "unused", "UnusedReturnValue"}) public abstract class FirewallHelper { + @SuppressWarnings("SameReturnValue") public abstract boolean initialize(); + @SuppressWarnings("SameReturnValue") public abstract boolean shutdown(); public abstract void initNetwork(NetworkInstance inst); public abstract void deinitNetwork(NetworkInstance inst); + + /** Allow traffic for a delegated prefix acquired while the network runs. */ + public abstract void addLiveV6Subnet( + @NonNull NetworkInstance inst, @NonNull String dev, @NonNull IPv6Network net); + + public abstract void removeLiveV6Subnet( + @NonNull NetworkInstance inst, @NonNull String dev, @NonNull IPv6Network net); + + /** + * Installs the host-IP-independent half of a port forward: the FORWARD + * ACCEPT for the guest target and, when hairpinSubnet is set, the + * NAT-loopback MASQUERADE so a guest in that subnet reaching this forward + * via the host has its source masqueraded (reply returns via the gateway). + * The actual DNAT is installed per host IP via {@link #applyDnat}. + */ + public abstract boolean applyForwardBase( + @NonNull String bridge, @NonNull String guestIp, @NonNull String protocol, + int guestStart, int guestEnd, @Nullable String hairpinSubnet); + + public abstract boolean removeForwardBase( + @NonNull String bridge, @NonNull String guestIp, @NonNull String protocol, + int guestStart, int guestEnd, @Nullable String hairpinSubnet); + + /** + * Installs one DNAT rule scoped to a specific host IP ({@code -d hostIp}), + * so only traffic actually destined to the phone is redirected -- transit + * traffic (other networks, tethered clients) is left alone. Called once + * per current host IP; rules are added/removed incrementally as the host + * IP set changes. + */ + public abstract boolean applyDnat( + @NonNull String bridge, @NonNull String guestIp, @NonNull String protocol, + @NonNull String hostIp, int hostStart, int hostEnd, int guestStart, int guestEnd); + + public abstract boolean removeDnat( + @NonNull String bridge, @NonNull String guestIp, @NonNull String protocol, + @NonNull String hostIp, int hostStart, int hostEnd, int guestStart, int guestEnd); } diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/HostAddressScan.java b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/HostAddressScan.java new file mode 100644 index 000000000..a1964e9c4 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/HostAddressScan.java @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. +package cn.classfun.droidvm.daemon.network.backend; + +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; + +import android.util.Log; + +import androidx.annotation.NonNull; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import cn.classfun.droidvm.lib.Constants; + +/** + * The phone's own addresses, as a list something can be asked to listen on. + * + *

Both families, unlike {@link Netbox#hostIpv4}, and one shot rather than a watched set: this + * answers a picker that is open for a few seconds, not a firewall rule that has to follow the + * network. The policy is otherwise the same one, and lives here because the daemon is the only + * side that can apply it -- see the two exclusions below, neither of which + * {@code java.net.NetworkInterface} can see.

+ */ +public final class HostAddressScan { + private static final String TAG = "HostAddressScan"; + + /** + * Interface-name prefixes whose addresses count as the phone's own reachable IPs: Wi-Fi, + * cellular, VPN, ethernet, and hotspot/USB/BT tethering. Cellular names other than + * {@code rmnet_data} (ccmni, pdp_ip...) are intentionally not matched -- the same assumption + * the iptables EXT_IFACES list already makes. + * + *

One list, two readers: the port-forward DNAT scoping this was written for (through + * netbox's own {@code host-ips} policy) and the VNC listen-address picker. They are asking the + * same question, so they must not answer it from two lists.

+ */ + public static final List HOST_IFACE_PREFIXES = List.of( + "wlan", "rmnet_data", "tun", "eth", + "ap", "swlan", "softap", "rndis", "usb", "bt-pan" + ); + + private HostAddressScan() { + } + + /** + * Every address the phone itself holds, as {@code [{addr, ifname, family}]} -- v4 and v6, + * global scope only, in netlink's dump order. Empty on any failure, which the caller shows as + * "nothing was found" rather than as an error: the picker's two fixed entries and its custom + * dialog work without this list. + * + *

Four things are dropped, and the first two are the whole reason this is not done in the + * app process:

+ * + *
    + *
  • pbridge's offload-proxy addresses. An L2 pseudo-bridged guest's IP is parked on the + * phone's uplink so the Wi-Fi firmware answers ARP/NS for it, which makes it look exactly + * like an address of the phone's own. It is tagged with {@link Constants#PBRIDGE_OFFLOAD_MAGIC} + * as IFA_RT_PRIORITY for precisely this -- and that tag is netlink-only, invisible to + * {@code java.net.NetworkInterface} and to OEM {@code ip -j} builds too old to emit it.
  • + *
  • a host-route-only address ({@code noprefixroute} on a /32 or /128), which is the shape + * pbridge parks, so a proxy address from a build that predates the tag is caught anyway. Both + * halves are required: a plain /32 is how plenty of cellular interfaces are configured, and + * dropping those would lose the phone's real address.
  • + *
  • IPv6 privacy addresses ({@code temporary}). There are normally several at once -- + * the live one plus however many are still inside their valid lifetime and deprecated -- and + * every one of them is replaced within hours, so naming one in a config is naming something + * that will be gone. The address kept is the stable one the phone keeps alongside them. The + * flag is IFA_F_SECONDARY, which on v4 means an ordinary secondary address and is not + * dropped; netbox reports the v6 reading only.
  • + *
  • bridge devices -- ours, holding a VM network's gateway address rather than the + * phone's.
  • + *
  • anything outside {@link #HOST_IFACE_PREFIXES}, and any address that is not global + * scope: that is where link-local (fe80::/10, 169.254/16) and loopback go, neither of which + * is an address the phone can be reached at.
  • + *
+ */ + @NonNull + public static JSONArray list() { + var out = new JSONArray(); + var bridges = bridgeNames(); + var rows = Netbox.addrList(null, null); + for (int i = 0; i < rows.length(); i++) { + var r = rows.optJSONObject(i); + if (r == null) continue; + var addr = r.optString("local", ""); + var ifname = r.optString("ifname", ""); + if (addr.isEmpty() || ifname.isEmpty()) continue; + if (!"global".equals(r.optString("scope", ""))) continue; + if (bridges.contains(ifname)) continue; + if (!isHostIface(ifname)) continue; + if (r.optLong("metric", 0) == Constants.PBRIDGE_OFFLOAD_MAGIC) continue; + var family = r.optInt("family", 0); + if (family != 4 && family != 6) continue; + if (r.optBoolean("temporary", false)) continue; + var prefixlen = r.optInt("prefixlen", 0); + if (r.optBoolean("noprefixroute", false) + && prefixlen == (family == 4 ? 32 : 128)) continue; + try { + var entry = new JSONObject(); + entry.put("addr", addr); + entry.put("ifname", ifname); + entry.put("family", family); + out.put(entry); + } catch (Exception e) { + Log.w(TAG, fmt("Failed to build host address entry for %s", addr), e); + } + } + return out; + } + + private static boolean isHostIface(@NonNull String ifname) { + for (var prefix : HOST_IFACE_PREFIXES) + if (ifname.startsWith(prefix)) return true; + return false; + } + + @NonNull + private static Set bridgeNames() { + var names = new HashSet(); + var rows = Netbox.linkList(null, true); + for (int i = 0; i < rows.length(); i++) { + var r = rows.optJSONObject(i); + if (r == null) continue; + var name = r.optString("ifname", ""); + if (!name.isEmpty()) names.add(name); + } + return names; + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/LinuxBridgeBackend.java b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/LinuxBridgeBackend.java new file mode 100644 index 000000000..c35df5588 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/LinuxBridgeBackend.java @@ -0,0 +1,770 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. +package cn.classfun.droidvm.daemon.network.backend; + +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; + +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import cn.classfun.droidvm.daemon.network.NetworkInstance; +import cn.classfun.droidvm.daemon.vm.VMInstanceStore; +import cn.classfun.droidvm.lib.Constants; +import cn.classfun.droidvm.lib.network.IPNetwork; +import cn.classfun.droidvm.lib.network.IPv4Network; +import cn.classfun.droidvm.lib.network.IPv6Network; +import cn.classfun.droidvm.lib.store.network.Ipv6Source; +import cn.classfun.droidvm.lib.store.network.UplinkMode; +import cn.classfun.droidvm.lib.store.network.VlanConfig; +import cn.classfun.droidvm.lib.store.vm.VMNicConfig; +import cn.classfun.droidvm.lib.utils.NetUtils; + +/** + * Linux-bridge data path: kernel bridge (+ VLAN filtering and per-VLAN + * 802.1q subinterfaces for L3 mode), bridgedhcp for DHCP/RA/DHCPv6-PD, + * iptables for SNAT and port forwards, pbridge for Wi-Fi L2 uplinks. + */ +public final class LinuxBridgeBackend extends BridgeBackend { + private static final String TAG = "LinuxBridgeBackend"; + private final LinuxNetwork net; + private final FirewallHelper firewall; + private final String br; + private BridgeDhcp dhcp = null; + private Pbridge pbridge = null; + private String resolvedUplink = null; + private boolean uplinkEnslaved = false; + /** vlanId -> gateway-addressed /64 from a live PD delegation. */ + private final Map liveV6 = new ConcurrentHashMap<>(); + private final Map macSecurityActive = new ConcurrentHashMap<>(); + /** tapName -> forwards this NIC actually installed (for exact teardown). */ + private final Map> installedForwards = new ConcurrentHashMap<>(); + /** tapName -> human-readable forward install failures. */ + private final Map> forwardFailures = new ConcurrentHashMap<>(); + /** vids whose per-VLAN bridge + trunk leg exist (configured or on-demand). */ + private final Set createdVlanBridges = ConcurrentHashMap.newKeySet(); + /** DHCP-PD vlanId -> uplink device written into the running bridgedhcp + * config ("" when it was unresolved). Lets us reload when it appears. */ + private final Map pdConfiguredUplink = new ConcurrentHashMap<>(); + /** Host IPv4 set the installed DNAT rules currently reflect; kept in sync. */ + private final Set appliedHostIps = new LinkedHashSet<>(); + /** Re-scopes DNAT rules when the phone's host IPv4 set changes. */ + private Runnable hostIpListener = null; + + /** Records one installed forward so detach removes exactly what was added. */ + private static final class InstalledForward { + final String guestIp, proto, hairpin; + final int hs, he, gs, ge; + InstalledForward(String guestIp, String proto, int hs, int he, int gs, int ge, String hairpin) { + this.guestIp = guestIp; this.proto = proto; this.hairpin = hairpin; + this.hs = hs; this.he = he; this.gs = gs; this.ge = ge; + } + } + + public LinuxBridgeBackend(@NonNull NetworkInstance inst) { + super(inst); + this.net = inst.getStore().backend; + this.firewall = inst.getStore().firewall; + this.br = inst.item.optString("bridge_name", ""); + } + + @Override + public void start() throws Exception { + if (net.isInterfaceExists(br)) { + Log.w(TAG, fmt("Interface %s already exists, deleting", br)); + net.deleteBridge(br); + } + if (!net.createBridge(br)) + throw new RuntimeException(fmt("Failed to create bridge %s", br)); + var mac = resolveBridgeMac(); + if (!net.setMacAddress(br, mac)) + Log.w(TAG, fmt("Failed to set MAC %s on %s", mac, br)); + net.setStp(br, inst.isStp()); + switch (inst.getUplinkMode()) { + case L2: + startL2(); + break; + case L3: + startL3(); + break; + } + if (!net.setLinkState(br, true)) + throw new RuntimeException(fmt("Failed to bring up bridge %s", br)); + if (inst.getUplinkMode() == UplinkMode.L3) + startL3Services(); + } + + /** + * The bridge needs a stable MAC: the configured one for L3/none, an + * auto-generated persisted one otherwise. Without it the bridge MAC + * would follow whichever port is enslaved. + */ + @NonNull + private String resolveBridgeMac() { + var mac = inst.getBridgeMacAddress(); + if (mac != null) return mac; + var generated = inst.item.optString("generated_mac", ""); + if (generated == null || generated.isEmpty()) { + generated = NetUtils.generateRandomMac(); + inst.item.set("generated_mac", generated); + } + return generated; + } + + private void startL2() { + var configured = inst.getL2Uplink(); + if (configured == null) + throw new RuntimeException("L2 network has no uplink configured"); + var uplink = UplinkResolver.resolve(configured); + if (uplink == null) + throw new RuntimeException(fmt("Uplink \"%s\" not found", configured)); + resolvedUplink = uplink; + // honor the configured toggle, but force pseudo-bridging when the + // resolved device can't be enslaved (Wi-Fi STA / non-ethernet) + boolean pseudo = inst.isL2PseudoBridge() + || UplinkResolver.assumeDontBridge(uplink); + if (pseudo) { + pbridge = new Pbridge(uplink, br); + if (!pbridge.start()) + throw new RuntimeException(fmt( + "Failed to start pseudo-bridge on %s", uplink)); + } else { + uplinkEnslaved = net.addInterface(br, uplink); + if (!uplinkEnslaved) throw new RuntimeException(fmt( + "Uplink %s cannot be bridged (IFF_DONT_BRIDGE?); enable pseudo-bridge", + uplink + )); + } + } + + private void startL3() { + // Stock GKI has no bridge VLAN filtering: each tagged VLAN gets its + // own bridge ({br}v{hex}) plus an 802.1q trunk leg ({br}.{hex}) on + // the main bridge. Access ports attach to the per-VLAN bridge, + // trunk ports to the main bridge; L3 lives on the per-VLAN bridge. + // VLAN 0 is the untagged domain: its L3 lives directly on the main + // bridge (no per-VLAN bridge, no VID-0 leg -- that would only catch + // priority-tagged frames, not untagged traffic). + // + // These bridges and legs face the VMs, so harden them as a router's + // LAN side: disable IPv6 RA acceptance so a VM's RAs can't + // autoconfigure an address or default route on the host (the kernel + // default accept_ra=1 would otherwise allow it). + net.disableAcceptRa(br); // main bridge (VLAN 0 / trunk backbone) + for (var vlan : inst.getVlans()) { + int vid = vlan.getVlanId(); + var dev = LinuxNetwork.vlanDevice(br, vid); + if (vid != 0) { + if (!net.createVlanBridge(br, vid)) + Log.w(TAG, fmt("Failed to create per-VLAN bridge for vid %d", vid)); + createdVlanBridges.add(vid); + net.disableAcceptRa(LinuxNetwork.vlanTrunkLeg(br, vid)); // vlan if + net.disableAcceptRa(dev); // per-VLAN bridge + } + var net4 = vlan.getIpv4Network(); + if (net4 != null && !net.addAddress(dev, net4)) + Log.w(TAG, fmt("Failed to add IPv4 %s to %s", net4, dev)); + for (var cidr : vlan.getIpv4Secondary()) + addAddressSafe(dev, cidr, false); + var net6 = vlan.getIpv6Network(); + if (net6 != null && !net.addAddress(dev, net6)) + Log.w(TAG, fmt("Failed to add IPv6 %s to %s", net6, dev)); + for (var cidr : vlan.getIpv6Secondary()) + addAddressSafe(dev, cidr, true); + } + } + + private void addAddressSafe(@NonNull String dev, @NonNull String cidr, boolean v6) { + try { + if (v6) net.addAddress(dev, IPv6Network.parse(cidr)); + else net.addAddress(dev, IPv4Network.parse(cidr)); + } catch (Exception e) { + Log.w(TAG, fmt("Skipping invalid CIDR %s on %s", cidr, dev), e); + } + } + + private void startL3Services() { + firewall.initNetwork(inst); + net.populateRuleRoute(inst); + var watcher = inst.getStore().context.getRouterWatcher(); + watcher.setForNewNetwork(); + // seed the host-IP set DNAT rules are scoped to, then track changes + synchronized (this) { + appliedHostIps.clear(); + appliedHostIps.addAll(watcher.getHostIpv4Addresses()); + } + hostIpListener = this::onHostIpsChanged; + watcher.addHostIpListener(hostIpListener); + if (BridgeDhcp.isNeeded(inst.getVlans())) { + dhcp = new BridgeDhcp(inst, this::onPdAddressChanged); + if (!dhcp.start()) + Log.e(TAG, fmt("Failed to start bridgedhcp for %s", br)); + snapshotPdUplinks(); + } + } + + /** Records the uplink each DHCP-PD VLAN resolved to in the running config. */ + private synchronized void snapshotPdUplinks() { + pdConfiguredUplink.clear(); + for (var vlan : inst.getVlans()) { + if (vlan.getIpv6Source() != Ipv6Source.DHCP_PD) continue; + var id = vlan.getPdUplink(); + if (id == null) continue; + var dev = UplinkResolver.resolve(id); + pdConfiguredUplink.put(vlan.getVlanId(), dev == null ? "" : dev); + } + } + + /** + * Reloads bridgedhcp when a DHCP-PD VLAN's uplink now resolves to a + * different device than the running config has -- most importantly when it + * was unresolved at start (so PD was skipped) and the interface (e.g. + * Wi-Fi) has since come up. A still-unresolved uplink is left alone: a + * running PD client already retries on its own. Invoked on host-network + * changes, so the freshly-connected uplink gets a PD client. + */ + private synchronized void reconcilePdUplinks() { + if (dhcp == null) return; + boolean changed = false; + for (var vlan : inst.getVlans()) { + if (vlan.getIpv6Source() != Ipv6Source.DHCP_PD) continue; + var id = vlan.getPdUplink(); + if (id == null) continue; + var dev = UplinkResolver.resolve(id); + if (dev == null) continue; // still down: bridgedhcp retries itself + if (!dev.equals(pdConfiguredUplink.get(vlan.getVlanId()))) { + changed = true; + break; + } + } + if (!changed) return; + Log.i(TAG, fmt("PD uplink now available for %s; reloading bridgedhcp", br)); + dhcp.restart(); + snapshotPdUplinks(); + } + + /** + * PD event from bridgedhcp: the helper already plumbed the address on + * the per-VLAN bridge (and DHCPv6/RA follow it automatically); what is + * left is the host side -- firewall accept rules for the delegated + * subnet and the policy-routing entries for return traffic. + */ + private synchronized void onPdAddressChanged(int vlanId, @Nullable IPv6Network address) { + var dev = LinuxNetwork.vlanDevice(br, vlanId); + var old = liveV6.remove(vlanId); + if (old != null) + firewall.removeLiveV6Subnet(inst, dev, old); + if (address != null) { + liveV6.put(vlanId, address); + firewall.addLiveV6Subnet(inst, dev, address); + net.populateRouteForNetwork(dev, address); + } + } + + @Override + public void stop() { + if (hostIpListener != null) { + var watcher = inst.getStore().context.getRouterWatcher(); + watcher.removeHostIpListener(hostIpListener); + hostIpListener = null; + } + synchronized (this) { + appliedHostIps.clear(); + } + liveV6.clear(); + if (dhcp != null) { + dhcp.stop(); + dhcp = null; + } + if (pbridge != null) { + pbridge.stop(); + pbridge = null; + } + if (uplinkEnslaved && resolvedUplink != null) { + net.removeInterface(resolvedUplink); + uplinkEnslaved = false; + } + if (inst.getUplinkMode() == UplinkMode.L3) { + firewall.deinitNetwork(inst); + for (var vid : createdVlanBridges) + net.deleteVlanBridge(br, vid); + createdVlanBridges.clear(); + } + net.setLinkState(br, false); + if (net.isInterfaceExists(br) && !net.deleteBridge(br)) + Log.w(TAG, fmt("Failed to delete bridge %s", br)); + macSecurityActive.clear(); + installedForwards.clear(); + forwardFailures.clear(); + } + + /** + * Restarts pbridge / bridgedhcp if they died. The kernel bridge is the data + * path, so these are auxiliary: pbridge (Wi-Fi uplink offload) is stateless, + * and bridgedhcp recovers its leases/PD from its state file. The tap ports + * stay enslaved to the bridge throughout, so nothing here touches them. + */ + @Override + public void reconcile() { + var p = pbridge; + if (p != null) p.reconcile(); + var d = dhcp; + if (d != null) d.reconcile(); + } + + @Override + public void attachNic(@NonNull VMNicConfig nic, @NonNull String tapName) { + if (net.isInterfaceExists(tapName)) net.deleteTap(tapName); + if (!net.createTap(tapName)) + throw new RuntimeException(fmt("Failed to create TAP %s", tapName)); + try { + // an access port may reference a VLAN the network didn't configure; + // create its per-VLAN bridge on demand so the VM can still boot + if (inst.getUplinkMode() == UplinkMode.L3 && nic.getVlanId() != null) + ensureVlanBridge(nic.getVlanId()); + var target = resolveAttachBridge(nic); + if (!net.addInterface(target, tapName)) + throw new RuntimeException(fmt( + "Failed to add TAP %s to bridge %s", tapName, target)); + // The guest MAC is passed to the hypervisor only. Setting it on + // the host-side tap would duplicate the guest's MAC on the host + // and break L2 forwarding. + if (nic.isIsolated() && !net.setPortIsolated(tapName, true)) + Log.w(TAG, fmt("Failed to isolate port %s", tapName)); + applyMacSecurity(nic, tapName); + if (!net.setLinkState(tapName, true)) + Log.w(TAG, fmt("Failed to bring up TAP %s", tapName)); + if (inst.getUplinkMode() == UplinkMode.L3) { + if (dhcp != null) dhcp.reloadStaticLeases(); + installNicForwards(nic, tapName); + } + } catch (Exception e) { + net.deleteTap(tapName); + throw e; + } + } + + private void applyMacSecurity(@NonNull VMNicConfig nic, @NonNull String tapName) { + if (!nic.isMacSecurity()) return; + var mac = nic.getMacAddress(); + if (mac == null) { + Log.w(TAG, fmt("MAC security on %s skipped: no MAC configured", tapName)); + macSecurityActive.put(tapName, false); + return; + } + // "locked" bridge ports need kernel >= 5.16; degrade with a warning + if (net.setPortLocked(tapName, true) && net.fdbAddStatic(mac, tapName)) { + macSecurityActive.put(tapName, true); + } else { + Log.w(TAG, fmt( + "MAC security unavailable for %s (kernel without locked bridge ports?)", + tapName + )); + net.setPortLocked(tapName, false); + macSecurityActive.put(tapName, false); + } + } + + /** + * Ensures the per-VLAN bridge + trunk leg for an access port's VLAN exist, + * creating them on demand for a VLAN the network config didn't declare + * (an L2-only segment, no gateway/DHCP). Recorded so stop() removes it. + * No-op for the trunk marker (4095) and for 0: a VID-0 leg must never + * be created (it would catch only priority-tagged frames). + */ + private synchronized void ensureVlanBridge(int vid) { + if (vid == 0 || vid == 4095) return; + var perBr = LinuxNetwork.perVlanBridge(br, vid); + if (net.isInterfaceExists(perBr)) { + createdVlanBridges.add(vid); + return; + } + if (!net.createVlanBridge(br, vid)) + Log.w(TAG, fmt("Failed to create on-demand per-VLAN bridge for vid %d", vid)); + createdVlanBridges.add(vid); + } + + /** + * The bridge a NIC's tap attaches to: a trunk port (no VLAN id / the + * 4095 marker) or any non-L3 network joins the main bridge; a tagged + * access port joins its VLAN's per-VLAN bridge. VLAN 0 (untagged-only) + * ports don't exist on a Linux bridge -- validation rejects them, since + * without VLAN filtering they could only behave as trunks. + */ + @NonNull + private String resolveAttachBridge(@NonNull VMNicConfig nic) { + if (inst.getUplinkMode() != UplinkMode.L3) return br; + var vlanId = nic.getVlanId(); + if (vlanId == null || vlanId == 4095) return br; + return LinuxNetwork.perVlanBridge(br, vlanId); + } + + private synchronized void installNicForwards(@NonNull VMNicConfig nic, @NonNull String tapName) { + var vlan = nic.resolveDhcpVlan(inst); + if (vlan == null) return; + if (!nic.isDhcp4LeaseEnabled() || !vlan.isDhcp4Enabled() || !vlan.isIpv4Snat()) + return; + var net4 = vlan.getIpv4Network(); + if (net4 == null) return; + String guestIp; + try { + guestIp = net4.addressAtOffset(nic.getDhcp4Offset()).toString(); + } catch (Exception e) { + Log.w(TAG, "Invalid DHCPv4 lease offset, skipping forwards", e); + return; + } + // NAT-loopback: a guest in this subnet reaching the forward gets + // masqueraded so the target's reply returns via the gateway + var hairpinSubnet = net4.toNetworkString(); + var installed = new ArrayList(); + var failures = new ArrayList(); + for (var fwd : nic.getDhcp4Forwards()) { + try { + fwd.validate(); + // "any" installs one rule per concrete protocol (tcp+udp) + for (var proto : fwd.protocols()) { + // host-IP-independent half (FORWARD ACCEPT + hairpin) once... + boolean ok = firewall.applyForwardBase( + br, guestIp, proto, fwd.guestStart(), fwd.guestEnd(), hairpinSubnet); + // ...then a DNAT per current host IP (scoped with -d) + for (var hostIp : appliedHostIps) + firewall.applyDnat(br, guestIp, proto, hostIp, + fwd.hostStart(), fwd.hostEnd(), fwd.guestStart(), fwd.guestEnd()); + if (ok) installed.add(new InstalledForward(guestIp, proto, + fwd.hostStart(), fwd.hostEnd(), fwd.guestStart(), fwd.guestEnd(), + hairpinSubnet)); + else failures.add(fmt("%s %s:%s", proto, fwd.host, fwd.guest)); + } + } catch (Exception e) { + failures.add(fmt("%s:%s (%s)", fwd.host, fwd.guest, e.getMessage())); + } + } + if (!installed.isEmpty()) installedForwards.put(tapName, installed); + if (!failures.isEmpty()) { + forwardFailures.put(tapName, failures); + Log.w(TAG, fmt("Forward install failures on %s: %s", tapName, failures)); + } + } + + /** Removes exactly the forwards this tap installed (DNAT per host IP, then base). */ + private synchronized void removeNicForwards(@NonNull String tapName) { + forwardFailures.remove(tapName); + var installed = installedForwards.remove(tapName); + if (installed == null) return; + for (var f : installed) { + for (var hostIp : appliedHostIps) + firewall.removeDnat(br, f.guestIp, f.proto, hostIp, + f.hs, f.he, f.gs, f.ge); + firewall.removeForwardBase(br, f.guestIp, f.proto, f.gs, f.ge, f.hairpin); + } + } + + /** + * Re-scopes DNAT rules when the phone's host IPv4 set changes: adds rules + * for newly-seen addresses, removes them for vanished ones, leaving the + * FORWARD/hairpin rules (host-IP-independent) untouched. + */ + private synchronized void onHostIpsChanged() { + // a host-network change (e.g. Wi-Fi connecting) is also the cue to + // pick up a PD uplink that was unavailable when the network started + reconcilePdUplinks(); + var watcher = inst.getStore().context.getRouterWatcher(); + var target = watcher.getHostIpv4Addresses(); + var added = new ArrayList(); + var removed = new ArrayList(); + for (var ip : target) + if (!appliedHostIps.contains(ip)) added.add(ip); + for (var ip : appliedHostIps) + if (!target.contains(ip)) removed.add(ip); + if (added.isEmpty() && removed.isEmpty()) return; + for (var entry : installedForwards.values()) + for (var f : entry) { + for (var ip : added) + firewall.applyDnat(br, f.guestIp, f.proto, ip, + f.hs, f.he, f.gs, f.ge); + for (var ip : removed) + firewall.removeDnat(br, f.guestIp, f.proto, ip, + f.hs, f.he, f.gs, f.ge); + } + appliedHostIps.clear(); + appliedHostIps.addAll(target); + } + + @Override + public void detachNic(@NonNull VMNicConfig nic, @NonNull String tapName) { + if (inst.getUplinkMode() == UplinkMode.L3) + removeNicForwards(tapName); + net.removeInterface(tapName); + net.deleteTap(tapName); + macSecurityActive.remove(tapName); + if (dhcp != null) dhcp.reloadStaticLeases(); + } + + @Override + public JSONArray listAddresses() { + var arr = net.listAddresses(br); + for (var vlan : inst.getVlans()) { + if (vlan.getVlanId() == 0) continue; // untagged = the main bridge + var sub = net.listAddresses( + LinuxNetwork.perVlanBridge(br, vlan.getVlanId())); + for (int i = 0; i < sub.length(); i++) + arr.put(sub.opt(i)); + } + return arr; + } + + /** + * One row per address with three dimensions -- VLAN, bound/unbound, + * configured/auto -- over the union, in every mode, of every address the + * network carries: the live addresses on each bridge device (the main + * bridge = VLAN 0, every per-VLAN bridge = its VID) and every configured + * CIDR. A configured address that is also live appears once (configured + + * bound); a configured one not yet on a device is configured + unbound; a + * live one with no config is auto (link-local, RA, the L2 uplink mirror + * pbridge parks on the main bridge, ...). The main bridge is always + * scanned -- including L2/none mode, where it is the only device and carries + * that mirror; per-VLAN bridges include on-demand ones, not just configured. + */ + @NonNull + @Override + public JSONArray listAddressEntries() { + var out = new JSONArray(); + // VID -> configured (VMM-managed) CIDRs; VLAN 0 included if declared, + // empty in L2/none mode (getVlans() is L3-only). + var configured = new LinkedHashMap>(); + for (var vlan : inst.getVlans()) + configured.put(vlan.getVlanId(), configuredCidrs(vlan)); + // Every VID whose L3 device may carry addresses, in a stable order: + // VLAN 0 (the main bridge, always -- the L2 mirror lands here), the + // configured VLANs in config order, then any on-demand per-VLAN bridge. + var vids = new LinkedHashSet(); + vids.add(0); + vids.addAll(configured.keySet()); + vids.addAll(createdVlanBridges); + for (var vid : vids) { + var dev = LinuxNetwork.vlanDevice(br, vid); + // canonical key -> raw "ip/plen" of every live address on the device + var live = new LinkedHashMap(); + var liveArr = net.listAddresses(dev); + for (int i = 0; i < liveArr.length(); i++) { + var raw = liveArr.optString(i, ""); + if (!raw.isEmpty()) live.put(canonAddr(raw), raw); + } + for (var cidr : Objects.requireNonNull(configured.getOrDefault(vid, List.of()))) { + boolean bound = live.remove(canonAddr(cidr)) != null; + out.put(addressEntry(cidr, vid, bound, "configured")); + } + // The DHCP-PD host address is owned by the dedicated PD entry (it + // carries the release/renew actions and the Searching state), so + // drop it from the live set instead of re-listing it as "auto". + var pdAddr = liveV6.get(vid); + if (pdAddr != null) live.remove(canonAddr(pdAddr.toString())); + for (var raw : live.values()) + out.put(addressEntry(raw, vid, true, "auto")); + } + // L2 pseudo-bridge: pbridge parks each learned guest address on the + // uplink tagged with the offload magic (so the Wi-Fi firmware answers + // ARP/NS for them). Those tagged addresses are exactly the pseudo-bridged + // guests' IPs -- surface them under their own source; the metric + // filter drops the host's own (untagged) uplink addresses. + if (pbridge != null && resolvedUplink != null) { + var guests = net.listAddresses(resolvedUplink, Constants.PBRIDGE_OFFLOAD_MAGIC); + for (int i = 0; i < guests.length(); i++) { + var raw = guests.optString(i, ""); + if (!raw.isEmpty()) + out.put(addressEntry(raw, 0, true, "pbridge_guest")); + } + } + return out; + } + + /** Configured (VMM-managed) CIDRs of one VLAN: primary + secondary v4/v6. */ + @NonNull + private static List configuredCidrs(@NonNull VlanConfig vlan) { + var list = new ArrayList(); + var v4 = vlan.getIpv4Cidr(); + if (v4 != null) list.add(v4); + list.addAll(vlan.getIpv4Secondary()); + if (vlan.getIpv6Source() == Ipv6Source.STATIC) { + var v6 = vlan.getIpv6Cidr(); + if (v6 != null) list.add(v6); + } + list.addAll(vlan.getIpv6Secondary()); + return list; + } + + /** Canonical "address/prefix" key so config and live forms compare equal. */ + @NonNull + private static String canonAddr(@NonNull String cidr) { + try { + var n = IPNetwork.parse(cidr); + return fmt("%s/%d", n.address().toString(), n.prefix()); + } catch (Exception e) { + return cidr; + } + } + + @NonNull + @Override + public JSONArray listPdStatus() { + // bridgedhcp's runtime PD state, keyed by VLAN id + var runtime = new java.util.HashMap(); + if (dhcp != null) { + var rt = dhcp.getPdStatus(); + for (int i = 0; i < rt.length(); i++) { + var o = rt.optJSONObject(i); + if (o != null) runtime.put(o.optInt("vlan", -1), o); + } + } + // One row per VLAN configured for DHCP-PD, so the UI always reflects + // the intent: when bridgedhcp has no client for it (the PD uplink was + // unresolved at start), synthesize a "no_uplink" row rather than + // hiding the VLAN entirely. + var out = new JSONArray(); + for (var vlan : inst.getVlans()) { + if (vlan.getIpv6Source() != Ipv6Source.DHCP_PD) continue; + int vid = vlan.getVlanId(); + var rt = runtime.get(vid); + if (rt != null) { + out.put(rt); + continue; + } + // No runtime client: tell apart a genuinely unresolved uplink from + // a helper that just isn't serving this VLAN yet (starting/down). + var id = vlan.getPdUplink(); + var resolvable = id != null && UplinkResolver.resolve(id) != null; + out.put(pdEntry(vid, resolvable ? "searching" : "no_uplink", "")); + } + return out; + } + + @NonNull + private static JSONObject pdEntry(int vlan, @NonNull String state, @NonNull String prefix) { + var o = new JSONObject(); + try { + o.put("vlan", vlan); + o.put("state", state); + o.put("prefix", prefix); + } catch (JSONException ignored) { + } + return o; + } + + @Override + public boolean renewPd(int vlanId) { + return dhcp != null && dhcp.renewPd(vlanId); + } + + @Override + public boolean releasePd(int vlanId) { + return dhcp != null && dhcp.releasePd(vlanId); + } + + @NonNull + @Override + public JSONArray listTools() { + var out = new JSONArray(); + if (pbridge != null) out.put(toolEntry("pbridge", pbridge.isRunning())); + if (dhcp != null) out.put(toolEntry("bridgedhcp", dhcp.isRunning())); + return out; + } + + @Nullable + @Override + public List toolLog(@NonNull String key) { + if (key.equals("pbridge") && pbridge != null) return pbridge.getLog(); + if (key.equals("bridgedhcp") && dhcp != null) return dhcp.getLog(); + return null; + } + + @Override + public JSONArray listInterfaces(VMInstanceStore vms) { + return net.listInterfaces(vms, br); + } + + @Override + public JSONArray listNeighbors() { + var arr = net.listNeighbors(br); + for (var vlan : inst.getVlans()) { + if (vlan.getVlanId() == 0) continue; // untagged = the main bridge + var sub = net.listNeighbors( + LinuxNetwork.perVlanBridge(br, vlan.getVlanId())); + for (int i = 0; i < sub.length(); i++) + arr.put(sub.opt(i)); + } + return arr; + } + + @Override + public JSONArray listDhcpLeases() { + if (dhcp == null) return new JSONArray(); + return dhcp.getLeases(); + } + + @Override + public void appendInfo(@NonNull JSONObject obj) throws JSONException { + if (dhcp != null) { + var status = dhcp.getStatus(); + if (status != null) { + var pdStatus = extractPdStatus(status); + if (pdStatus.length() > 0) obj.put("pd_status", pdStatus); + } + } + if (resolvedUplink != null) obj.put("resolved_uplink", resolvedUplink); + if (!macSecurityActive.isEmpty()) { + var sec = new JSONObject(); + for (var entry : macSecurityActive.entrySet()) + sec.put(entry.getKey(), entry.getValue()); + obj.put("mac_security_active", sec); + } + appendForwardFailures(obj, forwardFailures); + } + + /** + * Converts bridgedhcp's status document into the legacy pd_status + * array shape ({vlan_id, state, prefix}). + */ + @NonNull + private static JSONArray extractPdStatus(@NonNull JSONObject status) { + var out = new JSONArray(); + var ifaces = status.optJSONArray("ifaces"); + if (ifaces == null) return out; + for (int i = 0; i < ifaces.length(); i++) { + var iface = ifaces.optJSONObject(i); + if (iface == null) continue; + var state = iface.optString("pd_state", ""); + if (state.isEmpty()) continue; + try { + var entry = new JSONObject(); + entry.put("vlan_id", Integer.parseInt(iface.optString("tag", "-1"))); + entry.put("state", state); + var prefix = iface.optString("pd_prefix", ""); + if (!prefix.isEmpty()) entry.put("prefix", prefix); + out.put(entry); + } catch (Exception ignored) { + } + } + return out; + } + + @NonNull + @Override + public Map getLiveV6Networks() { + return Map.copyOf(liveV6); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/LinuxNetwork.java b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/LinuxNetwork.java index 1ef9c6bc8..5ace9abd3 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/LinuxNetwork.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/LinuxNetwork.java @@ -1,13 +1,15 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.network.backend; -import static cn.classfun.droidvm.lib.utils.FileUtils.readFile; -import static cn.classfun.droidvm.lib.utils.RunUtils.runList; -import static cn.classfun.droidvm.lib.utils.RunUtils.runListQuiet; +import static cn.classfun.droidvm.lib.utils.RunUtils.run; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; import android.util.Log; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import org.json.JSONArray; import org.json.JSONException; @@ -15,14 +17,20 @@ import java.io.File; import java.util.LinkedHashSet; -import java.util.List; import java.util.Set; import cn.classfun.droidvm.daemon.network.NetworkInstance; import cn.classfun.droidvm.daemon.vm.VMInstanceStore; import cn.classfun.droidvm.lib.network.IPNetwork; import cn.classfun.droidvm.lib.network.IPv4Network; +import cn.classfun.droidvm.lib.network.IPv6Network; +/** + * Linux data-path operations. Every netlink op goes through {@link Netbox} + * (our bundled static rtnetlink CLI) instead of the system {@code ip}/{@code + * bridge}, so behaviour is identical across OEMs. The only direct shell-outs + * left are procfs writes (sysctl) and a sysfs existence check. + */ @SuppressWarnings({"BooleanMethodIsAlwaysInverted", "unused", "UnusedReturnValue"}) public final class LinuxNetwork extends BackendBase { private static final String TAG = "BridgeManager"; @@ -30,80 +38,158 @@ public final class LinuxNetwork extends BackendBase { @Override public boolean createBridge(@NonNull String name) { Log.i(TAG, fmt("Creating bridge: %s", name)); - var result = runList("ip", "link", "add", name, "type", "bridge"); - result.printLog(TAG); - return result.isSuccess(); + return Netbox.linkAddBridge(name); + } + + /** + * Disables IPv6 RA acceptance on a host L3 device. The main bridge, + * per-VLAN bridges and trunk legs are router LAN-side interfaces facing + * the VMs; with the kernel default ({@code accept_ra=1}) a VM emitting + * RAs could make the host autoconfigure an address or default route. + * Best effort (the node is absent when IPv6 is disabled on the device). + */ + public void disableAcceptRa(@NonNull String dev) { + run("echo 0 > /proc/sys/net/ipv6/conf/%s/accept_ra", dev); } @Override public boolean deleteBridge(@NonNull String name) { Log.i(TAG, fmt("Deleting bridge: %s", name)); - var result = runList("ip", "link", "delete", name, "type", "bridge"); - result.printLog(TAG); - return result.isSuccess(); + return Netbox.linkDel(name); + } + + /** base64url alphabet (A-Z a-z 0-9 - _); all legal in interface names. */ + private static final char[] B64URL = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_".toCharArray(); + + /** + * Encodes a VLAN id as a fixed 2-char base64url code (the 12-bit VLAN + * space packs into two 6-bit digits), keeping {@code {br}.{code}} / + * {@code {br}v{code}} well within IFNAMSIZ (a 10-char bridge name + 3 = 13). + */ + @NonNull + public static String vlanCode(int vid) { + int v = vid & 0xFFF; + return new String(new char[]{B64URL[(v >> 6) & 0x3F], B64URL[v & 0x3F]}); + } + + /** + * Per-VLAN Linux bridge that carries a tagged VLAN's L3 (gateway + * address, DHCP, SNAT). Stock Android GKI has no bridge VLAN filtering, + * so each tagged VLAN gets its own bridge instead of per-port VIDs on + * one bridge. Never used for VLAN 0: see {@link #vlanDevice}. + */ + @NonNull + public static String perVlanBridge(@NonNull String bridge, int vid) { + return fmt("%sv%s", bridge, vlanCode(vid)); + } + + /** + * The device carrying a VLAN's L3. VLAN 0 is the untagged domain: its + * traffic lives on the main bridge itself (the IP stack has no trunk + * semantics -- untagged frames are simply what the bridge device sees), + * so no per-VLAN bridge and no 802.1q leg exist for it. An 802.1q + * subinterface with VID 0 would receive only priority-tagged frames, + * never the untagged traffic it is meant to serve. + */ + @NonNull + public static String vlanDevice(@NonNull String bridge, int vid) { + return vid == 0 ? bridge : perVlanBridge(bridge, vid); + } + + /** + * 802.1q trunk leg: a subinterface on the main bridge (the tagged trunk + * backbone) enslaved into the per-VLAN bridge, so trunk ports on the main + * bridge exchange this VLAN's frames with the per-VLAN bridge's access + * ports while staying isolated from other VLANs. + */ + @NonNull + public static String vlanTrunkLeg(@NonNull String bridge, int vid) { + return fmt("%s.%s", bridge, vlanCode(vid)); + } + + /** + * Brings up the per-VLAN bridge and its trunk leg: creates {br}v{hex}, + * the {br}.{hex} 802.1q subinterface on the main bridge, enslaves the leg + * into the per-VLAN bridge and brings both up. + */ + public boolean createVlanBridge(@NonNull String mainBr, int vid) { + var perBr = perVlanBridge(mainBr, vid); + var leg = vlanTrunkLeg(mainBr, vid); + Log.i(TAG, fmt("Creating per-VLAN bridge %s + trunk leg %s (vid %d)", + perBr, leg, vid)); + boolean ok = createBridge(perBr); + ok &= Netbox.linkAddVlan(mainBr, leg, vid); + ok &= addInterface(perBr, leg); + ok &= setLinkState(leg, true); + ok &= setLinkState(perBr, true); + return ok; + } + + /** Tears down the trunk leg and per-VLAN bridge created by createVlanBridge. */ + public void deleteVlanBridge(@NonNull String mainBr, int vid) { + var perBr = perVlanBridge(mainBr, vid); + var leg = vlanTrunkLeg(mainBr, vid); + if (isInterfaceExists(leg)) Netbox.linkDel(leg); + if (isInterfaceExists(perBr)) deleteBridge(perBr); + } + + public boolean setPortIsolated(@NonNull String dev, boolean isolated) { + return Netbox.linkSetIsolated(dev, isolated); + } + + /** + * Locks a bridge port so only static fdb entries may send (MAC + * security). Requires kernel >= 5.16; callers degrade gracefully. + */ + public boolean setPortLocked(@NonNull String dev, boolean locked) { + return Netbox.linkSetLocked(dev, locked); + } + + public boolean fdbAddStatic(@NonNull String mac, @NonNull String dev) { + return Netbox.fdbAdd(mac, dev); } @Override public boolean createTap(@NonNull String name) { Log.i(TAG, fmt("Creating TAP: %s", name)); - var result = runList( - "ip", "tuntap", "add", "dev", name, "mode", "tap", "vnet_hdr" - ); - result.printLog(TAG); - return result.isSuccess(); + return Netbox.linkAddTap(name); } @Override public boolean deleteTap(@NonNull String name) { Log.i(TAG, fmt("Deleting TAP: %s", name)); - var result = runList("ip", "link", "delete", name); - result.printLog(TAG); - return result.isSuccess(); + return Netbox.linkDel(name); } @Override public boolean setLinkState(@NonNull String name, boolean up) { - var state = up ? "up" : "down"; - Log.i(TAG, fmt("Setting %s %s", name, state)); - var result = runList("ip", "link", "set", name, state); - result.printLog(TAG); - return result.isSuccess(); + Log.i(TAG, fmt("Setting %s %s", name, up ? "up" : "down")); + return Netbox.linkSetState(name, up); } @Override public boolean addInterface(@NonNull String bridge, @NonNull String iface) { Log.i(TAG, fmt("Adding %s to bridge %s", iface, bridge)); - var result = runList("ip", "link", "set", iface, "master", bridge); - result.printLog(TAG); - return result.isSuccess(); + return Netbox.linkSetMaster(iface, bridge); } @Override public boolean removeInterface(@NonNull String iface) { Log.i(TAG, fmt("Removing %s from bridge", iface)); - var result = runList("ip", "link", "set", iface, "nomaster"); - result.printLog(TAG); - return result.isSuccess(); + return Netbox.linkSetNomaster(iface); } @Override public boolean setMacAddress(@NonNull String dev, @NonNull String mac) { Log.i(TAG, fmt("Setting MAC %s on %s", mac, dev)); - var result = runList("ip", "link", "set", "dev", dev, "address", mac); - result.printLog(TAG); - return result.isSuccess(); + return Netbox.linkSetMac(dev, mac); } @Override public boolean setStp(@NonNull String bridge, boolean enabled) { - var val = enabled ? "1" : "0"; - Log.i(TAG, fmt("Setting STP %s on %s", val, bridge)); - var result = runList( - "ip", "link", "set", "dev", bridge, - "type", "bridge", "stp_state", val - ); - result.printLog(TAG); - return result.isSuccess(); + Log.i(TAG, fmt("Setting STP %s on %s", enabled ? "1" : "0", bridge)); + return Netbox.linkSetStp(bridge, enabled); } @Override @@ -114,65 +200,59 @@ public boolean isInterfaceExists(@NonNull String name) { @Override public boolean addAddress(@NonNull String dev, @NonNull IPNetwork cidr) { Log.i(TAG, fmt("Adding address %s to %s", cidr, dev)); - var result = runList("ip", "addr", "add", cidr.toString(), "dev", dev); - result.printLog(TAG); - return result.isSuccess(); + return Netbox.addrAdd(dev, cidr.toString()); } @Override public boolean removeAddress(@NonNull String dev, @NonNull IPNetwork cidr) { Log.i(TAG, fmt("Removing address %s from %s", cidr, dev)); - var result = runList("ip", "addr", "del", cidr.toString(), "dev", dev); - result.printLog(TAG); - return result.isSuccess(); + return Netbox.addrDel(dev, cidr.toString()); } @NonNull @Override public JSONArray listAddresses(@NonNull String dev) { - var addr = new JSONArray(); - var result = runListQuiet("ip", "-j", "addr", "show", "dev", dev); - if (!result.isSuccess()) return addr; - try { - var arr = new JSONArray(result.getOutString()); - for (int i = 0; i < arr.length(); i++) { - var link = arr.getJSONObject(i); - var addrInfo = link.optJSONArray("addr_info"); - if (addrInfo == null) continue; - for (int j = 0; j < addrInfo.length(); j++) { - var ai = addrInfo.getJSONObject(j); - var local = ai.optString("local", ""); - var prefixLen = ai.optInt("prefixlen", 0); - if (!local.isEmpty()) - addr.put(fmt("%s/%d", local, prefixLen)); - } - } - } catch (JSONException e) { - Log.e(TAG, fmt("Failed to parse addresses of %s", dev), e); + return listAddresses(dev, null); + } + + /** + * Live addresses on {@code dev} as {@code "ip/plen"} strings; when + * {@code metric} is non-null, only addresses tagged with that + * IFA_RT_PRIORITY (pbridge's offload-proxy magic) are returned. + */ + @NonNull + public JSONArray listAddresses(@NonNull String dev, @Nullable Long metric) { + var out = new JSONArray(); + var rows = Netbox.addrList(dev, metric); + for (int i = 0; i < rows.length(); i++) { + var r = rows.optJSONObject(i); + if (r == null) continue; + var local = r.optString("local", ""); + if (!local.isEmpty()) + out.put(fmt("%s/%d", local, r.optInt("prefixlen", 0))); } - return addr; + return out; } @NonNull @Override public JSONArray listBridges() { - var result = runListQuiet("ip", "-j", "link", "show", "type", "bridge"); var bridges = new JSONArray(); - if (!result.isSuccess()) return bridges; - try { - var arr = new JSONArray(result.getOutString()); - for (int i = 0; i < arr.length(); i++) { - var link = arr.getJSONObject(i); + var rows = Netbox.linkList(null, true); + for (int i = 0; i < rows.length(); i++) { + var r = rows.optJSONObject(i); + if (r == null) continue; + var name = r.optString("ifname", ""); + try { var br = new JSONObject(); - var name = link.optString("ifname", ""); br.put("name", name); - br.put("state", link.optString("operstate", "UNKNOWN").toUpperCase()); - br.put("mtu", link.optInt("mtu", 1500)); + br.put("state", r.optString("operstate", "UNKNOWN").toUpperCase()); + br.put("mtu", r.optInt("mtu", 1500)); br.put("interfaces", listBridgeInterfaces(name)); bridges.put(br); + } catch (JSONException e) { + Log.e(TAG, fmt("Failed to build bridge entry for %s", name), e); } - } catch (JSONException e) { - Log.e(TAG, "Failed to parse bridge list", e); } return bridges; } @@ -181,16 +261,10 @@ public JSONArray listBridges() { @Override public JSONArray listBridgeInterfaces(@NonNull String bridge) { var members = new JSONArray(); - var result = runListQuiet("ip", "-j", "link", "show", "master", bridge); - if (!result.isSuccess()) return members; - try { - var arr = new JSONArray(result.getOutString()); - for (int i = 0; i < arr.length(); i++) { - var link = arr.getJSONObject(i); - members.put(link.optString("ifname", "")); - } - } catch (JSONException e) { - Log.e(TAG, fmt("Failed to parse interfaces of %s", bridge), e); + var rows = Netbox.linkList(bridge, false); + for (int i = 0; i < rows.length(); i++) { + var r = rows.optJSONObject(i); + if (r != null) members.put(r.optString("ifname", "")); } return members; } @@ -199,25 +273,22 @@ public JSONArray listBridgeInterfaces(@NonNull String bridge) { @Override public JSONArray listAvailableInterfaces() { var iface = new JSONArray(); - var result = runListQuiet("ip", "-j", "link", "show"); - if (!result.isSuccess()) return iface; - try { - var arr = new JSONArray(result.getOutString()); - for (int i = 0; i < arr.length(); i++) { - var link = arr.getJSONObject(i); - var name = link.optString("ifname", ""); - if (name.equals("lo")) continue; - var info = link.optJSONObject("linkinfo"); - if (info != null && info.optString("info_kind", "").equals("bridge")) - continue; + var rows = Netbox.linkList(null, false); + for (int i = 0; i < rows.length(); i++) { + var r = rows.optJSONObject(i); + if (r == null) continue; + var name = r.optString("ifname", ""); + if (name.equals("lo")) continue; + if (r.optString("kind", "").equals("bridge")) continue; + try { var obj = new JSONObject(); obj.put("name", name); - obj.put("state", link.optString("operstate", "UNKNOWN").toUpperCase()); - obj.put("master", link.optString("master", "")); + obj.put("state", r.optString("operstate", "UNKNOWN").toUpperCase()); + obj.put("master", r.optString("master", "")); iface.put(obj); + } catch (JSONException e) { + Log.e(TAG, "Failed to build interface entry", e); } - } catch (JSONException e) { - Log.e(TAG, "Failed to parse interface list", e); } return iface; } @@ -226,32 +297,25 @@ public JSONArray listAvailableInterfaces() { @Override public JSONArray listInterfaces(VMInstanceStore vms, String bridge) { var iFaces = new JSONArray(); - try { - var result = runListQuiet("ip", "-j", "link", "show", "master", bridge); - if (!result.isSuccess()) { - Log.w(TAG, fmt("Failed to list neighbors for bridge %s", bridge)); - result.printLog("ip-neigh"); - return iFaces; - } - var data = result.getOutString(); - if (data.isEmpty()) return iFaces; - var arr = new JSONArray(data); - for (int i = 0; i < arr.length(); i++) { - var link = arr.getJSONObject(i); + var rows = Netbox.linkList(bridge, false); + for (int i = 0; i < rows.length(); i++) { + var r = rows.optJSONObject(i); + if (r == null) continue; + var ifname = r.optString("ifname", ""); + try { var obj = new JSONObject(); - var ifname = link.optString("ifname", ""); obj.put("name", ifname); - obj.put("address", link.optString("address", "")); - obj.put("state", link.optString("operstate", "UNKNOWN")); + obj.put("address", r.optString("address", "")); + obj.put("state", r.optString("operstate", "UNKNOWN")); var vmInfo = vms.findVMByTap(ifname); if (vmInfo != null) { obj.put("vm_id", vmInfo.optString("vm_id", "")); obj.put("vm_name", vmInfo.optString("vm_name", "")); } iFaces.put(obj); + } catch (Exception e) { + Log.w(TAG, fmt("Error building interface entry for %s", ifname), e); } - } catch (Exception e) { - Log.w(TAG, fmt("Error while list interfaces for bridge %s", bridge), e); } return iFaces; } @@ -259,95 +323,53 @@ public JSONArray listInterfaces(VMInstanceStore vms, String bridge) { @NonNull @Override public JSONArray listNeighbors(String bridge) { - var neighbors = new JSONArray(); - try { - var result = runListQuiet("ip", "neigh", "show", "dev", bridge); - if (!result.isSuccess()) { - Log.w(TAG, fmt("Failed to list neighbors for bridge %s", bridge)); - result.printLog("ip-neigh"); - return neighbors; - } - var data = result.getOutString().trim(); - if (data.isEmpty()) return neighbors; - for (var line : data.split("\n")) { - var parts = line.trim().split("\\s+"); - if (parts.length < 1) continue; - var obj = new JSONObject(); - obj.put("dst", parts[0]); - for (int i = 1; i < parts.length - 1; i++) { - if (parts[i].equals("lladdr")) - obj.put("lladdr", parts[++i]); - else if (parts[i].equals("dev")) - obj.put("dev", parts[++i]); - } - obj.put("state", List.of(parts[parts.length - 1])); - neighbors.put(obj); - } - } catch (Exception e) { - Log.w(TAG, fmt("Error while list neighbors for bridge %s", bridge), e); - } - return neighbors; - } - - @NonNull - @Override - public JSONArray listDhcpLeases(String bridge) { - var leases = new JSONArray(); - var leaseFile = new File(Dnsmasq.getDnsmasqLeaseFile(bridge)); - if (!leaseFile.exists()) { - Log.w(TAG, fmt("DHCP lease file not found for bridge %s: %s", bridge, leaseFile)); - return leases; - } - try { - var leaseResult = readFile(leaseFile); - for (var line : leaseResult.split("\n")) { - if (line.trim().isEmpty()) continue; - var parts = line.trim().split("\\s+"); - if (parts.length >= 4) { - var lease = new JSONObject(); - lease.put("expires", parts[0]); - lease.put("mac", parts[1]); - lease.put("ip", parts[2]); - lease.put("hostname", parts.length >= 5 ? parts[3] : ""); - leases.put(lease); - } - } - } catch (Exception e) { - Log.w(TAG, fmt("Error while read DHCP leases %s", leaseFile), e); - } - return leases; + // netbox's neigh-list already emits {dst,lladdr,dev,state[]}. + return Netbox.neighList(bridge); } + /** Routing-table ids consulted by any "lookup" policy rule, for {@link #populateRouteForNetwork}. */ @NonNull - private Set listRouteTables() { + public Set listRouteTables(boolean ipv6) { var set = new LinkedHashSet(); - var result = runListQuiet("ip", "rule", "show"); - if (!result.isSuccess()) { - Log.w(TAG, "Failed to list IP rules"); - result.printLog("ip-rule"); - return set; - } - var data = result.getOutString(); - for (var line : data.split("\n")) { - var idx = line.indexOf(" lookup "); - if (idx < 0) continue; - set.add(line.substring(idx + (" lookup ".length())).trim()); + var rows = Netbox.ruleList(ipv6); + for (int i = 0; i < rows.length(); i++) { + var r = rows.optJSONObject(i); + if (r == null || !r.optBoolean("lookup", false)) continue; + set.add(String.valueOf(r.optInt("table", 0))); } return set; } + /** Adds one subnet to every policy routing table so host replies route back. */ + public void populateRouteForNetwork(@NonNull String dev, @NonNull IPNetwork net) { + boolean ipv6 = net instanceof IPv6Network; + var spec = net.toNetworkString(); + for (var table : listRouteTables(ipv6)) + Netbox.routeAdd(dev, spec, table, ipv6); + } + @Override public void populateRuleRoute(@NonNull NetworkInstance inst) { - var br = inst.item.optString("bridge_name", ""); - var list = listRouteTables(); - inst.item.get("ipv4_addresses").forEachArray(a -> { - try { - var addr = IPv4Network.parse(a.asString()); - var net = addr.network(); - for (var table : list) - runList("ip", "route", "add", net.toString(), "dev", br, "table", table); - } catch (Exception ignored) { + var br = inst.getBridgeName(); + if (br == null) return; + for (var vlan : inst.getVlans()) { + var dev = vlanDevice(br, vlan.getVlanId()); + var net4 = vlan.getIpv4Network(); + if (net4 != null) populateRouteForNetwork(dev, net4); + for (var cidr : vlan.getIpv4Secondary()) { + try { + populateRouteForNetwork(dev, IPv4Network.parse(cidr)); + } catch (Exception ignored) { + } } - }); + var net6 = vlan.getIpv6Network(); + if (net6 != null) populateRouteForNetwork(dev, net6); + for (var cidr : vlan.getIpv6Secondary()) { + try { + populateRouteForNetwork(dev, IPv6Network.parse(cidr)); + } catch (Exception ignored) { + } + } + } } } diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/ManagedProcess.java b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/ManagedProcess.java new file mode 100644 index 000000000..379c8ea92 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/ManagedProcess.java @@ -0,0 +1,216 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. +package cn.classfun.droidvm.daemon.network.backend; + +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; + +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import cn.classfun.droidvm.lib.natives.NativeProcess; +import cn.classfun.droidvm.lib.utils.ProcessUtils; + +/** + * Supervises one external helper daemon (dnsmasq, gvswitch, pbridge): + * spawns it in the foreground, pumps its output to the log, tracks + * liveness/exit code and stops it with SIGTERM then SIGKILL. + */ +public class ManagedProcess { + public interface LineListener { + void onLine(@NonNull String line); + } + + private final String tag; + private final String logTag; + private NativeProcess process = null; + private Thread monitor = null; + private Thread errPump = null; + private volatile boolean running = false; + private volatile int exitCode = -1; + private Runnable onUnexpectedExit = null; + private volatile LineListener lineListener = null; + /** Serialises line delivery across the stdout/stderr pumps. */ + private final Object listenerLock = new Object(); + /** Recent merged stdout+stderr lines, surfaced to the network info UI. */ + private static final int LOG_CAP = 1000; + private final ArrayDeque logBuffer = new ArrayDeque<>(); + + public ManagedProcess(@NonNull String name, @NonNull String instance) { + this.tag = fmt("%s-%s", name, instance); + this.logTag = tag.length() > 23 ? tag.substring(0, 23) : tag; + } + + /** Called from the monitor thread when the process dies without stop(). */ + public void setOnUnexpectedExit(@Nullable Runnable callback) { + onUnexpectedExit = callback; + } + + /** + * Receives every output line from the monitor thread (in addition to + * logging) -- used for child event streams (e.g. bridgedhcp JSON lines). + */ + public void setLineListener(@Nullable LineListener listener) { + lineListener = listener; + } + + public synchronized boolean start(@NonNull List args) { + stop(); + Log.i(logTag, fmt("Starting: %s", String.join(" ", args))); + appendLog(fmt("=== starting: %s ===", String.join(" ", args))); + NativeProcess proc; + try { + exitCode = -1; + proc = new NativeProcess.Builder(args.toArray(new String[0])).start(); + } catch (Exception e) { + Log.e(logTag, "Failed to start process", e); + process = null; + running = false; + return false; + } + process = proc; + running = true; + // NativeProcess keeps stdout/stderr on separate pipes (no + // redirectErrorStream); pump both into the same log/listener so the + // merged-output behaviour callers rely on is preserved. + var ep = new Thread(() -> pump(proc.getErrorStream()), fmt("err-%s", tag)); + ep.setDaemon(true); + errPump = ep; + monitor = new Thread(() -> monitorThread(proc, ep), fmt("mon-%s", tag)); + monitor.setDaemon(true); + ep.start(); + monitor.start(); + return true; + } + + private void monitorThread(@NonNull NativeProcess proc, @NonNull Thread errThread) { + try { + // stdout drains until the child closes it (i.e. exits / is killed) + pump(proc.getInputStream()); + // flush any tail of stderr before reporting the exit + errThread.join(); + int code = proc.waitFor(); + exitCode = code; + boolean unexpected = running; + running = false; + appendLog(fmt("=== process exited (code %d) ===", code)); + if (unexpected) { + Log.w(logTag, fmt("Process exited unexpectedly (code %d)", code)); + var cb = onUnexpectedExit; + if (cb != null) cb.run(); + } else { + Log.i(logTag, fmt("Process exited (code %d)", code)); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + Log.d(logTag, "Process monitor interrupted"); + } catch (Exception e) { + Log.e(logTag, "Process monitor failed", e); + } finally { + running = false; + proc.close(); + } + } + + /** Reads one output stream line-by-line until EOF. */ + private void pump(@NonNull InputStream in) { + try (var reader = new BufferedReader(new InputStreamReader(in))) { + String line; + while ((line = reader.readLine()) != null) + handleLine(line); + } catch (Exception e) { + // Expected when stop() kills the child and the pipe closes mid-read. + Log.d(logTag, fmt("output stream closed: %s", e.getMessage())); + } + } + + /** Logs, buffers and dispatches one line; serialised so listeners see one + * line at a time (as they did when stderr was merged into stdout). */ + private void handleLine(@NonNull String line) { + Log.d(logTag, line); + appendLog(line); + var listener = lineListener; + if (listener == null) return; + synchronized (listenerLock) { + try { + listener.onLine(line); + } catch (Exception e) { + Log.w(logTag, "Line listener failed", e); + } + } + } + + public synchronized void stop() { + // Diagnostic: a live helper being stopped is either a legitimate + // teardown or the still-unexplained SIGTERM source -- log who asked so + // the caller is identifiable in logcat. Restarts go through start() + // only after the process already died, so this stays quiet for those. + if (running) + Log.w(logTag, fmt("stop() on live process; caller:\n%s", + Log.getStackTraceString(new Throwable()))); + running = false; + if (monitor != null) { + monitor.interrupt(); + monitor = null; + } + if (errPump != null) { + errPump.interrupt(); + errPump = null; + } + var proc = process; + process = null; + if (proc == null) return; + Log.i(logTag, "Stopping process"); + proc.destroy(); + try { + proc.waitFor(3, TimeUnit.SECONDS); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + proc.destroyForcibly(); + proc.waitFor(); + proc.close(); + } + + /** Sends a signal (e.g. SIGHUP for config reload) to the running process. */ + public synchronized boolean signal(int signal) { + var proc = process; + if (proc == null || !proc.isAlive()) return false; + int pid = proc.pid(); + if (pid <= 0) return false; + return ProcessUtils.shellKillProcess(pid, signal); + } + + private void appendLog(@NonNull String line) { + synchronized (logBuffer) { + while (logBuffer.size() >= LOG_CAP) logBuffer.removeFirst(); + logBuffer.addLast(line); + } + } + + /** Snapshot of the last {@value #LOG_CAP} captured output lines. */ + @NonNull + public List getLog() { + synchronized (logBuffer) { + return new ArrayList<>(logBuffer); + } + } + + public boolean isRunning() { + return running && process != null && process.isAlive(); + } + + public int getExitCode() { + return exitCode; + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/Netbox.java b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/Netbox.java new file mode 100644 index 000000000..0a0e58ba3 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/Netbox.java @@ -0,0 +1,288 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. +package cn.classfun.droidvm.daemon.network.backend; + +import static cn.classfun.droidvm.lib.utils.AssetUtils.getAssetBinaryPath; +import static cn.classfun.droidvm.lib.utils.RunUtils.runListQuiet; +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; + +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Consumer; + +/** + * The single entry point for every netlink operation the daemon performs. + * + *

The daemon used to shell out to the system {@code ip}/{@code bridge} + * (iproute2), which vary per OEM -- some are too old to even emit + * {@code IFA_RT_PRIORITY} in {@code -j} JSON, the field pbridge tags its + * offload-proxy addresses with. {@code netbox} is our bundled, static Rust tool + * that does the same operations straight over rtnetlink with a fixed JSON + * schema, so behavior is identical on every device. All argv building, binary + * resolution and JSON parsing live here; callers use the typed methods only. + * + *

Mutations return {@code true} on success (the tool's exit code); queries + * return the tool's parsed JSON ({@link JSONArray}, empty on any failure). + */ +final class Netbox { + private static final String TAG = "Netbox"; + + private Netbox() { + } + + // ---------------------------------------------------------- link mutations + + static boolean linkAddBridge(@NonNull String name) { + return run("link-add-bridge", name); + } + + static boolean linkAddVlan(@NonNull String parent, @NonNull String name, int vid) { + return run("link-add-vlan", "--link", parent, "--name", name, "--id", String.valueOf(vid)); + } + + static boolean linkAddTap(@NonNull String name) { + return run("link-add-tap", name); + } + + static boolean linkDel(@NonNull String name) { + return run("link-del", name); + } + + static boolean linkSetState(@NonNull String name, boolean up) { + return run("link-set-state", name, up ? "up" : "down"); + } + + static boolean linkSetMaster(@NonNull String iface, @NonNull String master) { + return run("link-set-master", iface, master); + } + + static boolean linkSetNomaster(@NonNull String iface) { + return run("link-set-nomaster", iface); + } + + static boolean linkSetMac(@NonNull String dev, @NonNull String mac) { + return run("link-set-mac", dev, mac); + } + + static boolean linkSetStp(@NonNull String bridge, boolean on) { + return run("link-set-stp", bridge, on ? "on" : "off"); + } + + static boolean linkSetIsolated(@NonNull String dev, boolean on) { + return run("link-set-isolated", dev, on ? "on" : "off"); + } + + static boolean linkSetLocked(@NonNull String dev, boolean on) { + return run("link-set-locked", dev, on ? "on" : "off"); + } + + // ---------------------------------------------------------- addr mutations + + static boolean addrAdd(@NonNull String dev, @NonNull String cidr) { + return run("addr-add", dev, cidr); + } + + static boolean addrDel(@NonNull String dev, @NonNull String cidr) { + return run("addr-del", dev, cidr); + } + + // ------------------------------------------------------- route/rule/fdb + + @SuppressWarnings("UnusedReturnValue") + static boolean routeAdd(@NonNull String dev, @NonNull String dst, @NonNull String table, boolean v6) { + return run(v6Args("route-add", v6, "--dev", dev, "--dst", dst, "--table", table)); + } + + @SuppressWarnings("UnusedReturnValue") + static boolean ruleAdd(@NonNull String iif, @NonNull String table, int priority, boolean v6) { + return run(v6Args("rule-add", v6, "--iif", iif, "--table", table, + "--priority", String.valueOf(priority))); + } + + @SuppressWarnings("UnusedReturnValue") + static boolean ruleDel(@NonNull String iif, @NonNull String table, int priority, boolean v6) { + return run(v6Args("rule-del", v6, "--iif", iif, "--table", table, + "--priority", String.valueOf(priority))); + } + + static boolean fdbAdd(@NonNull String mac, @NonNull String dev) { + return run("fdb-add", mac, dev); + } + + // ---------------------------------------------------------------- queries + + /** Addresses as {@code [{ifname,family,local,prefixlen,scope,metric,noprefixroute}]}. */ + @NonNull + static JSONArray addrList(@Nullable String dev, @Nullable Long metric) { + var args = new ArrayList(); + args.add("addr-list"); + if (dev != null) { + args.add("--dev"); + args.add(dev); + } + if (metric != null) { + args.add("--metric"); + args.add(String.valueOf(metric)); + } + return query(args); + } + + /** Links as {@code [{ifname,address,operstate,master,mtu,kind}]}. */ + @NonNull + static JSONArray linkList(@Nullable String master, boolean bridgeOnly) { + var args = new ArrayList(); + args.add("link-list"); + if (master != null) { + args.add("--master"); + args.add(master); + } + if (bridgeOnly) args.add("--type-bridge"); + return query(args); + } + + /** Neighbors of one device as {@code [{dst,lladdr,dev,state[]}]}. */ + @NonNull + static JSONArray neighList(@NonNull String dev) { + return query(List.of("neigh-list", dev)); + } + + /** Policy rules as {@code [{priority,iif,table,fwmark,fwmask,detached}]}. */ + @NonNull + static JSONArray ruleList(boolean v6) { + return query(v6 ? List.of("rule-list", "--v6") : List.of("rule-list")); + } + + // -------------------------------------------------------------- host IPs + + /** + * The phone's own reachable IPv4 addresses (one-shot). netbox applies the + * whole policy itself: only addresses on interfaces whose name starts with + * one of {@code prefixes} (the Wi-Fi/cellular/VPN/ethernet/tethering + * allowlist), with bridge devices and {@code excludeMetric}-tagged + * (pbridge offload) addresses dropped. Empty on any failure. + */ + @NonNull + static Set hostIpv4(@NonNull List prefixes, @Nullable Long excludeMetric) { + var cmd = new ArrayList(); + cmd.add(getAssetBinaryPath("netbox")); + cmd.addAll(hostIpArgs("host-ips", prefixes, excludeMetric)); + var result = runListQuiet(cmd); + if (!result.isSuccess()) { + Log.w(TAG, fmt("netbox host-ips failed (exit %d): %s", + result.getCode(), result.getErrString())); + return Set.of(); + } + return parseHostIps(result.getOutString()); + } + + /** + * Streams host-IPv4 changes: starts {@code netbox monitor-addr} and calls + * {@code onChange} with the new set on every change (the same policy as + * {@link #hostIpv4}). netbox emits only on an actual change, so the + * callback fires only when the set really moved. Returns the supervised + * process; the caller owns its lifecycle (and should {@code stop()} it). + */ + @NonNull + static ManagedProcess startHostIpMonitor( + @NonNull List prefixes, @Nullable Long excludeMetric, + @NonNull Consumer> onChange + ) { + var cmd = new ArrayList(); + cmd.add(getAssetBinaryPath("netbox")); + cmd.addAll(hostIpArgs("monitor-addr", prefixes, excludeMetric)); + var proc = new ManagedProcess("netbox", "hostip-mon"); + proc.setLineListener(line -> { + var trimmed = line.trim(); + // skip any non-JSON diagnostics merged from stderr + if (trimmed.isEmpty() || trimmed.charAt(0) != '{') return; + onChange.accept(parseHostIps(trimmed)); + }); + proc.start(cmd); + return proc; + } + + @NonNull + private static List hostIpArgs( + @NonNull String sub, @NonNull List prefixes, @Nullable Long excludeMetric + ) { + var args = new ArrayList(); + args.add(sub); + for (var p : prefixes) { + args.add("--iface"); + args.add(p); + } + if (excludeMetric != null) { + args.add("--exclude-metric"); + args.add(String.valueOf(excludeMetric)); + } + return args; + } + + /** Parse one {@code {"v4":[...]}} line into a set; empty on any failure. */ + @NonNull + private static Set parseHostIps(@NonNull String json) { + var out = new LinkedHashSet(); + try { + var arr = new JSONObject(json).optJSONArray("v4"); + if (arr != null) + for (int i = 0; i < arr.length(); i++) out.add(arr.getString(i)); + } catch (Exception e) { + Log.w(TAG, fmt("Failed to parse netbox host-ips output: %s", json), e); + } + return out; + } + + // ----------------------------------------------------------------- plumbing + + @NonNull + private static String[] v6Args(@NonNull String sub, boolean v6, @NonNull String... rest) { + var args = new ArrayList(); + args.add(sub); + Collections.addAll(args, rest); + if (v6) args.add("--v6"); + return args.toArray(new String[0]); + } + + private static boolean run(@NonNull String... args) { + var cmd = new ArrayList(args.length + 1); + cmd.add(getAssetBinaryPath("netbox")); + Collections.addAll(cmd, args); + var result = runListQuiet(cmd); + if (!result.isSuccess()) { + Log.w(TAG, fmt("netbox %s failed (exit %d): %s", + args[0], result.getCode(), result.getErrString())); + } + return result.isSuccess(); + } + + @NonNull + private static JSONArray query(@NonNull List args) { + var cmd = new ArrayList(args.size() + 1); + cmd.add(getAssetBinaryPath("netbox")); + cmd.addAll(args); + var result = runListQuiet(cmd); + if (!result.isSuccess()) { + Log.w(TAG, fmt("netbox %s failed (exit %d): %s", + args.get(0), result.getCode(), result.getErrString())); + return new JSONArray(); + } + try { + return new JSONArray(result.getOutString()); + } catch (Exception e) { + Log.e(TAG, fmt("Failed to parse netbox %s output", args.get(0)), e); + return new JSONArray(); + } + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/Pbridge.java b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/Pbridge.java new file mode 100644 index 000000000..5a2684bd7 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/Pbridge.java @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. +package cn.classfun.droidvm.daemon.network.backend; + +import static cn.classfun.droidvm.lib.Constants.*; +import static cn.classfun.droidvm.lib.utils.AssetUtils.getAssetBinaryPath; +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; + +import android.util.Log; + +import androidx.annotation.NonNull; + +import java.util.List; + +/** + * pseudo-bridge-rs daemon for L2 networks whose uplink cannot be enslaved + * into a Linux bridge (Wi-Fi STA / IFF_DONT_BRIDGE): performs MAC-NAT in + * kernel via eBPF and bridges through a veth pair it manages itself. + */ +public final class Pbridge { + private static final String TAG = "Pbridge"; + private final ManagedProcess process; + private final String uplink; + private final String bridge; + private volatile boolean active = false; + + public Pbridge(@NonNull String uplink, @NonNull String bridge) { + this.uplink = uplink; + this.bridge = bridge; + this.process = new ManagedProcess("pbridge", bridge); + } + + @NonNull + private List buildArgs() { + return List.of( + getAssetBinaryPath("pbridge"), + "-i", uplink, + "-e", "ebpf", + "-m", "fwd-with-offload", + "-b", bridge, + "--offload-workaround", "v4,v6", + // pin the proxy-address tag so host-IP discovery can exclude them + "--offload-workaround-magic", + String.valueOf(PBRIDGE_OFFLOAD_MAGIC), + "--arp-keepalive", "10", + "--loglevel", "info" + ); + } + + public boolean start() { + active = true; + return process.start(buildArgs()); + } + + public void stop() { + active = false; + process.stop(); + } + + /** + * Restarts pbridge if it died (stateless eBPF forwarder; a fresh process + * re-establishes the data path). Called on the watchdog's 5s tick while the + * network is running. Returns true when healthy. + */ + @SuppressWarnings("UnusedReturnValue") + public synchronized boolean reconcile() { + if (!active || process.isRunning()) return true; + Log.w(TAG, fmt( + "pbridge for %s is down (exit=%d), restarting", bridge, process.getExitCode() + )); + return process.start(buildArgs()); + } + + public boolean isRunning() { + return process.isRunning(); + } + + @SuppressWarnings("unused") + public int getExitCode() { + return process.getExitCode(); + } + + @NonNull + public List getLog() { + return process.getLog(); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/UplinkResolver.java b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/UplinkResolver.java new file mode 100644 index 000000000..00744e255 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/UplinkResolver.java @@ -0,0 +1,257 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. +package cn.classfun.droidvm.daemon.network.backend; + +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; + +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.File; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +/** + * Enumerates host L2 uplink candidates and resolves the logical + * "Wi-Fi"/"Ethernet"/"Tethering" identifiers to a concrete interface at + * network start: device names drift across reboots (wlan0 <-> wlan1), so the + * identifier is stored and re-resolved live; a literal interface name is + * pinned and used as-is. + */ +public final class UplinkResolver { + private static final String TAG = "UplinkResolver"; + public static final String ID_WIFI = "WiFi"; + public static final String ID_ETHERNET = "Ethernet"; + public static final String ID_TETHERING = "Tethering"; + + /** ARPHRD_ETHER: only L2 ethernet-type devices can join a Linux bridge. */ + private static final int ARPHRD_ETHER = 1; + /** + * Android hotspot / USB-tether interface name prefixes. Deliberately NOT + * rmnet: cellular rawip is an L3 device and cannot be bridged. + */ + private static final String[] TETHER_PREFIXES = + {"ap", "swlan", "softap", "rndis", "usb", "bt-pan"}; + + public static final class Iface { + public final String name; + public final boolean wireless, up, ether, physical, leaf, virtual; + + Iface(@NonNull String name, boolean wireless, boolean up, boolean ether, + boolean physical, boolean leaf, boolean virtual) { + this.name = name; + this.wireless = wireless; + this.up = up; + this.ether = ether; + this.physical = physical; + this.leaf = leaf; + this.virtual = virtual; + } + + /** + * A real, non-stacked L2 interface (excludes dummy/ifb/veth/vlan/ + * tunnels): ethernet-type, a leaf netdev (iflink == ifindex), not a + * virtual DEVTYPE, and either up or backed by hardware. A USB-tether + * (rndis) or modem-ethernet has no backing device, so being up admits + * it; a real but unplugged NIC stays via its backing device. + */ + boolean realL2() { + return ether && leaf && !virtual && (up || physical); + } + } + + private UplinkResolver() { + } + + /** All non-loopback, non-bridge, non-tun interfaces with their properties. */ + @NonNull + private static List scan() { + var out = new ArrayList(); + var entries = new File("/sys/class/net").listFiles(); + if (entries == null) return out; + for (var entry : entries) { + var name = entry.getName(); + if (name.equals("lo")) continue; + if (new File(entry, "bridge").exists()) continue; // a bridge + if (new File(entry, "tun_flags").exists()) continue; // tun/tap + boolean wireless = new File(entry, "phy80211").exists() + || new File(entry, "wireless").exists(); + boolean physical = new File(entry, "device").exists(); + boolean ether = readInt(new File(entry, "type")) == ARPHRD_ETHER; + // leaf = not stacked on another netdev (drops veth/vlan/rmnet_data@...) + boolean leaf = readInt(new File(entry, "ifindex")) + == readInt(new File(entry, "iflink")); + out.add(new Iface(name, wireless, isUp(entry), ether, + physical, leaf, isVirtualDevtype(entry))); + } + return out; + } + + private static boolean isUp(@NonNull File entry) { + try { + var s = Files.readAllLines(new File(entry, "operstate").toPath()); + return !s.isEmpty() && s.get(0).trim().equals("up"); + } catch (Exception e) { + return false; + } + } + + private static int readInt(@NonNull File f) { + try { + var s = Files.readAllLines(f.toPath()); + if (s.isEmpty()) return -1; + return Integer.parseInt(s.get(0).trim()); + } catch (Exception e) { + return -1; + } + } + + /** DEVTYPEs that are virtual L2 constructs, never a real uplink. */ + private static final Set VIRTUAL_DEVTYPES = + Set.of("vlan", "vxlan", "gretap", "erspan"); + + private static boolean isVirtualDevtype(@NonNull File entry) { + try { + for (var line : Files.readAllLines(new File(entry, "uevent").toPath())) + if (line.startsWith("DEVTYPE=")) + return VIRTUAL_DEVTYPES.contains(line.substring(8).trim()); + } catch (Exception ignored) { + } + return false; + } + + /** + * Concrete physical L2 devices for the picker: ethernet-type, state UP and + * backed by real hardware (excludes br/veth/tap/vlan/vxlan/gretap, which + * have no backing device or are filtered above). + */ + @NonNull + private static List concreteDevices() { + var out = new ArrayList(); + for (var i : scan()) + if (i.realL2() && i.up) out.add(i); + return out; + } + + @Nullable + private static String resolveWifi() { + String fallback = null; + for (var i : scan()) { + if (!i.wireless || !i.leaf || i.virtual) continue; + if (isTetherName(i.name)) continue; // an AP/tether iface, not STA + if (i.up) return i.name; + if (fallback == null) fallback = i.name; + } + return fallback; + } + + @Nullable + private static String resolveEthernet() { + String fallback = null; + for (var i : scan()) { + if (!i.realL2() || i.wireless || isTetherName(i.name)) continue; + if (i.up) return i.name; + if (fallback == null) fallback = i.name; + } + return fallback; + } + + @Nullable + private static String resolveTethering() { + String fallback = null; + for (var i : scan()) { + if (!isTetherName(i.name)) continue; + if (i.up) return i.name; + if (fallback == null) fallback = i.name; + } + return fallback; + } + + private static boolean isTetherName(@NonNull String name) { + for (var p : TETHER_PREFIXES) + if (name.startsWith(p)) return true; + return false; + } + + /** + * Resolves a configured uplink (a logical identifier or a literal + * interface name) to a concrete interface, or null when nothing matches. + */ + @Nullable + public static String resolve(@NonNull String configured) { + if (configured.equalsIgnoreCase(ID_WIFI)) return resolveWifi(); + if (configured.equalsIgnoreCase(ID_ETHERNET)) return resolveEthernet(); + if (configured.equalsIgnoreCase(ID_TETHERING)) return resolveTethering(); + if (new File(fmt("/sys/class/net/%s/uevent", configured)).exists()) + return configured; + Log.w(TAG, fmt("Uplink interface not found: %s", configured)); + return null; + } + + /** Whether a logical identifier's uplink can be bridged (Wi-Fi STA can't). */ + @SuppressWarnings("unused") + public static boolean identifierBridgeable(@NonNull String id) { + return !id.equalsIgnoreCase(ID_WIFI); + } + + /** + * Whether the interface is assumed to carry IFF_DONT_BRIDGE (not exposed + * via sysfs): a Wi-Fi STA or any non-ethernet-type device cannot be + * enslaved into a Linux bridge, so pseudo-bridging is mandatory. + */ + public static boolean assumeDontBridge(@NonNull String iface) { + var entry = new File("/sys/class/net", iface); + boolean wireless = new File(entry, "phy80211").exists() + || new File(entry, "wireless").exists(); + boolean ether = readInt(new File(entry, "type")) == ARPHRD_ETHER; + return wireless || !ether; + } + + /** + * Picker payload: the three logical identifiers with their live-resolved + * name (empty when none present) and whether they can be bridged, plus the + * concrete physical L2 devices with their per-device bridgeability. + */ + @NonNull + public static JSONObject listUplinksJson() { + var root = new JSONObject(); + try { + var ids = new JSONArray(); + ids.put(identifierJson(ID_WIFI, resolveWifi(), false)); + ids.put(identifierJson(ID_ETHERNET, resolveEthernet(), true)); + ids.put(identifierJson(ID_TETHERING, resolveTethering(), true)); + root.put("identifiers", ids); + var devices = new JSONArray(); + for (var i : concreteDevices()) { + var obj = new JSONObject(); + obj.put("name", i.name); + obj.put("bridgeable", !i.wireless); + devices.put(obj); + } + root.put("devices", devices); + } catch (Exception e) { + Log.w(TAG, "Failed to build uplink list", e); + } + return root; + } + + @NonNull + private static JSONObject identifierJson( + @NonNull String id, @Nullable String name, boolean bridgeable + ) throws JSONException { + var obj = new JSONObject(); + obj.put("id", id); + obj.put("name", name == null ? "" : name); + obj.put("bridgeable", bridgeable); + return obj; + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/gvisor/GvisorBridgeBackend.java b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/gvisor/GvisorBridgeBackend.java new file mode 100644 index 000000000..95e42cc53 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/gvisor/GvisorBridgeBackend.java @@ -0,0 +1,577 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. +package cn.classfun.droidvm.daemon.network.backend.gvisor; + +import static cn.classfun.droidvm.lib.Constants.DATA_DIR; +import static cn.classfun.droidvm.lib.utils.AssetUtils.getAssetBinaryPath; +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; +import static cn.classfun.droidvm.lib.utils.StringUtils.pathJoin; + +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.File; +import java.io.FileWriter; +import java.math.BigInteger; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import cn.classfun.droidvm.daemon.network.NetworkInstance; +import cn.classfun.droidvm.daemon.network.backend.BridgeBackend; +import cn.classfun.droidvm.daemon.network.backend.ManagedProcess; +import cn.classfun.droidvm.daemon.vm.VMInstanceStore; +import cn.classfun.droidvm.lib.network.IPv4Network; +import cn.classfun.droidvm.lib.network.IPv6Network; +import cn.classfun.droidvm.lib.store.network.UplinkMode; +import cn.classfun.droidvm.lib.store.network.VlanConfig; +import cn.classfun.droidvm.lib.store.vm.VMNicConfig; + +/** + * gVisor vswitch data path: one gvswitch process per network listening on + * a private unix socket. VM NICs are tap devices that gvswitch takes over + * via AF_XDP; per-VLAN gateways inside gvswitch provide DHCP, SLAAC, SNAT + * (including IPv6) and port forwards. + */ +public final class GvisorBridgeBackend extends BridgeBackend { + private static final String TAG = "GvisorBridgeBackend"; + /** gvswitch forwards take single ports; ranges are expanded with this cap. */ + private static final int MAX_FORWARD_EXPANSION = 128; + private static final SecureRandom random = new SecureRandom(); + private final ManagedProcess process; + private final String id8; + private GvswitchClient client = null; + /** Set once stop() runs so the watchdog never relaunches a torn-down switch. */ + private volatile boolean stopped = false; + /** tapName -> NIC config of currently attached ports. */ + private final Map attached = new ConcurrentHashMap<>(); + /** tapName -> installed gvswitch forward refs ("vlan/id"). */ + private final Map> installedForwards = new ConcurrentHashMap<>(); + /** tapName -> human-readable forward install failures (e.g. port in use). */ + private final Map> forwardFailures = new ConcurrentHashMap<>(); + + public GvisorBridgeBackend(@NonNull NetworkInstance inst) { + super(inst); + this.id8 = inst.getId().toString().substring(0, 8); + this.process = new ManagedProcess("gvswitch", id8); + } + + @NonNull + private String socketPath() { + return pathJoin(DATA_DIR, "run", fmt("gvswitch-%s.sock", id8)); + } + + @NonNull + private String configPath() { + return pathJoin(DATA_DIR, "run", fmt("gvswitch-%s.json", id8)); + } + + @Override + public synchronized void start() throws Exception { + launch(); + } + + /** (Re)writes the config, spawns gvswitch and waits for its API. */ + private synchronized void launch() throws Exception { + var bytes = new byte[16]; + random.nextBytes(bytes); + var sb = new StringBuilder(); + for (var b : bytes) sb.append(fmt("%02x", b)); + var token = sb.toString(); + var config = GvswitchConfigBuilder.build(inst); + var configFile = new File(configPath()); + var parent = configFile.getParentFile(); + if (parent != null && !parent.exists() && !parent.mkdirs()) + throw new RuntimeException(fmt("Failed to create %s", parent)); + try (var writer = new FileWriter(configFile)) { + writer.write(config.toString(2)); + } + var sock = new File(socketPath()); + if (sock.exists() && !sock.delete()) + Log.w(TAG, fmt("Failed to remove stale socket %s", sock)); + var args = new ArrayList(); + args.add(getAssetBinaryPath("gvswitch")); + args.add("-listen"); + args.add(socketPath()); + args.add("-auth-token"); + args.add(token); + args.add("-config"); + args.add(configPath()); + if (inst.isStp()) args.add("-stp"); + if (!process.start(args)) + throw new RuntimeException("Failed to start gvswitch"); + client = new GvswitchClient(socketPath(), token); + waitReady(); + } + + /** + * Watchdog tick: gvswitch IS the data path here, so if it died the whole + * network is down. Relaunch it and re-attach every port that was attached + * -- the taps are daemon-owned and still exist (crosvm holds them open), so + * we only re-POST the af_xdp ports + their static leases and forwards; the + * taps are never touched. Dynamic gvswitch state (learned FDB, DHCP leases) + * is rebuilt by the guests reconnecting through the restored switch. + */ + @Override + public synchronized void reconcile() { + if (stopped || process.isRunning()) return; + var nics = new LinkedHashMap<>(attached); + Log.w(TAG, fmt( + "gvswitch %s is down (exit=%d), restarting and re-attaching %d port(s)", + id8, process.getExitCode(), nics.size() + )); + // The previous process's installs are gone; rebuild them from scratch. + installedForwards.clear(); + forwardFailures.clear(); + try { + launch(); + } catch (Exception e) { + Log.e(TAG, fmt("gvswitch %s relaunch failed", id8), e); + return; + } + for (var e : nics.entrySet()) { + try { + postPort(e.getValue(), e.getKey()); + } catch (Exception ex) { + Log.w(TAG, fmt("Re-attach of port %s failed", e.getKey()), ex); + } + } + Log.i(TAG, fmt("gvswitch %s recovered", id8)); + } + + private void waitReady() throws Exception { + var deadline = System.currentTimeMillis() + 5000; + Exception last = null; + while (System.currentTimeMillis() < deadline) { + if (!process.isRunning()) + throw new RuntimeException(fmt( + "gvswitch exited during startup (code %d)", process.getExitCode())); + try { + var response = client.get("/api/v1/ports"); + if (response.isSuccess()) return; + last = new RuntimeException(fmt( + "gvswitch API returned %d: %s", response.code, response.body)); + } catch (Exception e) { + last = e; + } + //noinspection BusyWait + Thread.sleep(200); + } + throw new RuntimeException("gvswitch API did not become ready", last); + } + + @Override + public synchronized void stop() { + stopped = true; + attached.clear(); + installedForwards.clear(); + forwardFailures.clear(); + process.stop(); + var sock = new File(socketPath()); + if (sock.exists() && !sock.delete()) + Log.w(TAG, fmt("Failed to remove socket %s", sock)); + client = null; + } + + @Override + public synchronized void attachNic(@NonNull VMNicConfig nic, @NonNull String tapName) throws Exception { + if (client == null) throw new IllegalStateException("gvswitch not running"); + var net = inst.getStore().backend; + if (net.isInterfaceExists(tapName)) net.deleteTap(tapName); + if (!net.createTap(tapName)) + throw new RuntimeException(fmt("Failed to create TAP %s", tapName)); + try { + if (!net.setLinkState(tapName, true)) + throw new RuntimeException(fmt("Failed to bring up TAP %s", tapName)); + postPort(nic, tapName); + } catch (Exception e) { + attached.remove(tapName); + try { + client.delete(fmt("/api/v1/ports/%s", tapName)); + } catch (Exception ignored) { + } + net.deleteTap(tapName); + throw e; + } + } + + /** + * Creates the gvswitch af_xdp port over the (already existing, up) tap plus + * its static leases and forwards. Never creates or deletes the tap, so it + * is reusable for watchdog re-attach without disturbing crosvm. + */ + @SuppressWarnings("ExtractMethodRecommender") + private void postPort(@NonNull VMNicConfig nic, @NonNull String tapName) throws Exception { + if (client == null) throw new IllegalStateException("gvswitch not running"); + var port = new JSONObject(); + port.put("identifier", tapName); + var vlanId = nic.getVlanId(); + port.put("vlan", vlanId == null ? 4095 : vlanId); + if (nic.isIsolated()) port.put("isolated", true); + var mac = nic.getMacAddress(); + if (nic.isMacSecurity() && mac != null) + port.put("port_security", mac); + port.put("mode", "client"); + port.put("transport", "af_xdp"); + port.put("interface", tapName); + port.put("bpdu_guard", true); + var response = client.post("/api/v1/ports", port); + if (!response.isSuccess()) + throw new RuntimeException(fmt( + "gvswitch port create failed (%d): %s", response.code, response.body)); + attached.put(tapName, nic); + applyStaticLeases(nic, tapName); + installNicForwards(nic, tapName); + } + + private void applyStaticLeases(@NonNull VMNicConfig nic, @NonNull String tapName) + throws Exception { + var vlan = nic.resolveDhcpVlan(inst); + if (vlan == null) return; + var mac = nic.getMacAddress(); + if (nic.isDhcp4LeaseEnabled() && vlan.isDhcp4Enabled() && mac != null) { + var net4 = vlan.getIpv4Network(); + if (net4 != null) { + var binding = new JSONObject(); + binding.put("id", tapName); + binding.put("mac", mac); + binding.put("ip", + net4.addressAtOffset(nic.getDhcp4Offset()).toString()); + check(client.put(fmt( + "/api/v1/gateways/%d/dhcp4/static/%s", vlan.getVlanId(), tapName + ), binding), "dhcp4 static lease"); + } + } + if (nic.isDhcp6LeaseEnabled() && vlan.isDhcp6Enabled()) { + var net6 = vlan.getIpv6Network(); + if (net6 != null) { + var binding = new JSONObject(); + binding.put("id", tapName); + // guest DUIDs are unknown; bind by port (and MAC when present) + binding.put("port_identifier", tapName); + if (mac != null) binding.put("mac", mac); + binding.put("ip", net6.addressAtOffset( + BigInteger.valueOf(nic.getDhcp6Offset())).toString()); + check(client.put(fmt( + "/api/v1/gateways/%d/dhcp6/static/%s", vlan.getVlanId(), tapName + ), binding), "dhcp6 static lease"); + } + } + } + + private void removeStaticLeases(@NonNull VMNicConfig nic, @NonNull String tapName) { + var vlan = nic.resolveDhcpVlan(inst); + if (vlan == null || client == null) return; + try { + client.delete(fmt( + "/api/v1/gateways/%d/dhcp4/static/%s", vlan.getVlanId(), tapName)); + client.delete(fmt( + "/api/v1/gateways/%d/dhcp6/static/%s", vlan.getVlanId(), tapName)); + } catch (Exception e) { + Log.w(TAG, fmt("Failed to remove static leases for %s", tapName), e); + } + } + + /** + * Installs this NIC's port forwards one at a time so a single bind + * failure (host port already in use -- gvswitch local forwards listen + * on the host) only drops that one rule. Successfully installed refs + * are recorded per tap so detach removes exactly what this NIC added; + * failures are surfaced via network_info. + */ + private void installNicForwards(@NonNull VMNicConfig nic, @NonNull String tapName) { + var ids = new ArrayList(); + var failures = new ArrayList(); + var vlan = nic.resolveDhcpVlan(inst); + if (vlan != null) { + if (nic.isDhcp4LeaseEnabled() && vlan.isDhcp4Enabled() && vlan.isIpv4Snat()) { + var net4 = vlan.getIpv4Network(); + if (net4 != null) { + var guestIp = guestIpAtOffset(net4, nic.getDhcp4Offset()); + if (guestIp != null) + for (var fwd : nic.getDhcp4Forwards()) + postForward(vlan.getVlanId(), fwd, guestIp, false, ids, failures); + } + } + if (nic.isDhcp6LeaseEnabled() && vlan.isDhcp6Enabled() && vlan.isIpv6Snat()) { + var net6 = vlan.getIpv6Network(); + if (net6 != null) { + var guestIp = guestIpAtOffset(net6, nic.getDhcp6Offset()); + if (guestIp != null) + for (var fwd : nic.getDhcp6Forwards()) + postForward(vlan.getVlanId(), fwd, guestIp, true, ids, failures); + } + } + } + if (!ids.isEmpty()) installedForwards.put(tapName, ids); + if (!failures.isEmpty()) { + forwardFailures.put(tapName, failures); + Log.w(TAG, fmt("Forward install failures on %s: %s", tapName, failures)); + } + } + + @Nullable + private static String guestIpAtOffset( + @NonNull IPv4Network net, long offset + ) { + try { + return net.addressAtOffset(offset).toString(); + } catch (Exception e) { + return null; + } + } + + @Nullable + private static String guestIpAtOffset( + @NonNull IPv6Network net, long offset + ) { + try { + return net.addressAtOffset(BigInteger.valueOf(offset)).toString(); + } catch (Exception e) { + return null; + } + } + + /** + * POSTs each concrete (proto, port) of one forward; gvswitch takes + * single ports, so ranges are expanded (capped). Each accepted rule's + * "vlan/id" ref is recorded; rejections (e.g. bind in use) are noted. + */ + private void postForward( + int vlanId, @NonNull VMNicConfig.PortForward fwd, @NonNull String guestIp, + boolean v6, @NonNull List ids, @NonNull List failures + ) { + try { + fwd.validate(); + } catch (Exception e) { + failures.add(fmt("%s %s:%s (%s)", fwd.proto, fwd.host, fwd.guest, e.getMessage())); + return; + } + int count = fwd.hostEnd() - fwd.hostStart() + 1; + if (count > MAX_FORWARD_EXPANSION) { + Log.w(TAG, fmt( + "Forward range %s exceeds %d ports on a gVisor bridge, truncating", + fwd.host, MAX_FORWARD_EXPANSION)); + count = MAX_FORWARD_EXPANSION; + } + var bindHost = v6 ? "[::]" : "0.0.0.0"; + var guestHost = v6 ? fmt("[%s]", guestIp) : guestIp; + for (var proto : fwd.protocols()) { + for (int i = 0; i < count; i++) { + int hostPort = fwd.hostStart() + i; + var bind = fmt("%s:%d", bindHost, hostPort); + try { + var rule = new JSONObject(); + rule.put("type", "local"); + rule.put("network", proto); + rule.put("bind", bind); + rule.put("host", fmt("%s:%d", guestHost, fwd.guestStart() + i)); + var resp = client.post( + fmt("/api/v1/gateways/%d/forwards", vlanId), rule); + if (resp.isSuccess()) { + var id = resp.json().optString("id", ""); + if (!id.isEmpty()) ids.add(fmt("%d/%s", vlanId, id)); + } else { + failures.add(fmt("%s %s (%s)", proto, bind, briefBody(resp.body))); + } + } catch (Exception e) { + failures.add(fmt("%s %s (%s)", proto, bind, e.getMessage())); + } + } + } + } + + /** Removes exactly the forwards this tap installed. */ + private void removeNicForwards(@NonNull String tapName) { + forwardFailures.remove(tapName); + var ids = installedForwards.remove(tapName); + if (ids == null || client == null) return; + for (var ref : ids) { + int slash = ref.indexOf('/'); + if (slash < 0) continue; + try { + client.delete(fmt("/api/v1/gateways/%s/forwards/%s", + ref.substring(0, slash), ref.substring(slash + 1))); + } catch (Exception e) { + Log.w(TAG, fmt("Failed to delete forward %s", ref), e); + } + } + } + + @NonNull + private static String briefBody(@NonNull String body) { + var b = body.trim(); + return b.length() > 80 ? b.substring(0, 80) : b; + } + + private static void check(@NonNull GvswitchClient.Response response, @NonNull String what) { + if (!response.isSuccess()) throw new RuntimeException(fmt( + "gvswitch %s failed (%d): %s", what, response.code, response.body)); + } + + @Override + public synchronized void detachNic(@NonNull VMNicConfig nic, @NonNull String tapName) { + attached.remove(tapName); + if (client != null) { + try { + client.delete(fmt("/api/v1/ports/%s", tapName)); + } catch (Exception e) { + Log.w(TAG, fmt("Failed to delete gvswitch port %s", tapName), e); + } + removeStaticLeases(nic, tapName); + removeNicForwards(tapName); + } + inst.getStore().backend.deleteTap(tapName); + } + + @Override + public JSONArray listAddresses() { + // gateway addresses live inside gvswitch, not on host interfaces + var arr = new JSONArray(); + if (inst.getUplinkMode() != UplinkMode.L3) return arr; + for (var vlan : inst.getVlans()) { + var net4 = vlan.getIpv4Network(); + if (net4 != null) arr.put(net4.toString()); + var net6 = vlan.getIpv6Network(); + if (net6 != null) arr.put(net6.toString()); + } + return arr; + } + + /** + * Same three-dimension shape as the Linux bridge for a consistent UI: the + * gvswitch gateway addresses are the VMM's configured L3 endpoints, so + * every row is configured and (while running) bound. + */ + @NonNull + @Override + public JSONArray listAddressEntries() { + var out = new JSONArray(); + if (inst.getUplinkMode() != UplinkMode.L3) return out; + for (var vlan : inst.getVlans()) { + int vid = vlan.getVlanId(); + var net4 = vlan.getIpv4Network(); + if (net4 != null) out.put(addressEntry(net4.toString(), vid, true, "configured")); + var net6 = vlan.getIpv6Network(); + if (net6 != null) out.put(addressEntry(net6.toString(), vid, true, "configured")); + } + return out; + } + + @Override + public JSONArray listInterfaces(VMInstanceStore vms) { + var arr = new JSONArray(); + if (client == null) return arr; + try { + var response = client.get("/api/v1/ports"); + if (!response.isSuccess()) return arr; + var ports = response.jsonArray(); + for (int i = 0; i < ports.length(); i++) { + var port = ports.getJSONObject(i); + var obj = new JSONObject(); + var name = port.optString("identifier", ""); + obj.put("name", name); + obj.put("state", port.optBoolean("online", false) ? "UP" : "DOWN"); + obj.put("vlan", port.opt("vlan")); + if (vms != null) { + var vmInfo = vms.findVMByTap(name); + if (vmInfo != null) { + obj.put("vm_id", vmInfo.optString("vm_id", "")); + obj.put("vm_name", vmInfo.optString("vm_name", "")); + } + } + arr.put(obj); + } + } catch (Exception e) { + Log.w(TAG, "Failed to list gvswitch ports", e); + } + return arr; + } + + @Override + public JSONArray listNeighbors() { + var arr = new JSONArray(); + if (client == null) return arr; + try { + var response = client.get("/api/v1/fdb"); + if (!response.isSuccess()) return arr; + var entries = response.jsonArray(); + for (int i = 0; i < entries.length(); i++) { + var entry = entries.getJSONObject(i); + var obj = new JSONObject(); + obj.put("lladdr", entry.optString("mac", "")); + obj.put("dev", entry.optString("port", "")); + obj.put("dst", ""); + obj.put("state", List.of( + entry.optBoolean("static", false) ? "STATIC" : "LEARNED")); + arr.put(obj); + } + } catch (Exception e) { + Log.w(TAG, "Failed to list gvswitch fdb", e); + } + return arr; + } + + @Override + public JSONArray listDhcpLeases() { + var arr = new JSONArray(); + if (client == null) return arr; + for (var vlan : inst.getVlans()) { + collectLeases(arr, vlan, "dhcp4"); + collectLeases(arr, vlan, "dhcp6"); + } + return arr; + } + + private void collectLeases(@NonNull JSONArray out, @NonNull VlanConfig vlan, @NonNull String family) { + try { + var response = client.get(fmt( + "/api/v1/gateways/%d/%s/leases", vlan.getVlanId(), family)); + if (!response.isSuccess()) return; + var leases = response.jsonArray(); + for (int i = 0; i < leases.length(); i++) { + var lease = leases.getJSONObject(i); + var obj = new JSONObject(); + obj.put("ip", lease.optString("ip", "")); + obj.put("mac", lease.optString("mac", "")); + obj.put("hostname", lease.optString("port_identifier", "")); + obj.put("expires", lease.optString("expires_at", "")); + obj.put("vlan", vlan.getVlanId()); + out.put(obj); + } + } catch (Exception e) { + Log.d(TAG, fmt("No %s leases for VLAN %d", family, vlan.getVlanId())); + } + } + + @Override + public void appendInfo(@NonNull JSONObject obj) throws JSONException { + obj.put("gvswitch_exit_code", process.getExitCode()); + obj.put("gvswitch_socket", socketPath()); + appendForwardFailures(obj, forwardFailures); + } + + @NonNull + @Override + public JSONArray listTools() { + var out = new JSONArray(); + out.put(toolEntry("gvswitch", process.isRunning())); + return out; + } + + @Nullable + @Override + public List toolLog(@NonNull String key) { + return key.equals("gvswitch") ? process.getLog() : null; + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/gvisor/GvswitchClient.java b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/gvisor/GvswitchClient.java new file mode 100644 index 000000000..379107f35 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/gvisor/GvswitchClient.java @@ -0,0 +1,217 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. +package cn.classfun.droidvm.daemon.network.backend.gvisor; + +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; + +import android.net.LocalSocket; +import android.net.LocalSocketAddress; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +/** + * Minimal HTTP/1.1 client for the gvswitch REST API over its unix control + * socket. One request per connection (Connection: close); responses are + * small JSON documents framed by Content-Length. + */ +public final class GvswitchClient { + private final String socketPath; + private final String token; + + public GvswitchClient(@NonNull String socketPath, @NonNull String token) { + this.socketPath = socketPath; + this.token = token; + } + + public static final class Response { + public final int code; + public final String body; + + Response(int code, @NonNull String body) { + this.code = code; + this.body = body; + } + + public boolean isSuccess() { + return code >= 200 && code < 300; + } + + @NonNull + public JSONObject json() throws Exception { + return new JSONObject(body); + } + + @NonNull + public JSONArray jsonArray() throws Exception { + return new JSONArray(body); + } + } + + @NonNull + public Response get(@NonNull String path) throws IOException { + return request("GET", path, null); + } + + @NonNull + public Response post(@NonNull String path, @Nullable Object body) throws IOException { + return request("POST", path, body); + } + + @NonNull + public Response put(@NonNull String path, @Nullable Object body) throws IOException { + return request("PUT", path, body); + } + + @NonNull + @SuppressWarnings("UnusedReturnValue") + public Response delete(@NonNull String path) throws IOException { + return request("DELETE", path, null); + } + + @NonNull + public Response request( + @NonNull String method, @NonNull String path, @Nullable Object body + ) throws IOException { + var payload = body == null + ? new byte[0] + : body.toString().getBytes(StandardCharsets.UTF_8); + try (var socket = new LocalSocket()) { + socket.connect(new LocalSocketAddress( + socketPath, LocalSocketAddress.Namespace.FILESYSTEM)); + socket.setSoTimeout(10_000); + var sb = new StringBuilder(); + sb.append(fmt("%s %s HTTP/1.1\r\n", method, path)); + sb.append("Host: gvswitch\r\n"); + sb.append(fmt("Authorization: Bearer %s\r\n", token)); + sb.append("Connection: close\r\n"); + if (payload.length > 0) + sb.append("Content-Type: application/json\r\n"); + sb.append(fmt("Content-Length: %d\r\n", payload.length)); + sb.append("\r\n"); + var out = socket.getOutputStream(); + out.write(sb.toString().getBytes(StandardCharsets.UTF_8)); + if (payload.length > 0) out.write(payload); + out.flush(); + return readResponse(socket.getInputStream()); + } + } + + @NonNull + private static Response readResponse(@NonNull InputStream in) throws IOException { + var head = readHead(in); + var lines = head.split("\r\n"); + if (lines.length == 0) + throw new IOException("Empty HTTP response"); + var status = lines[0].split(" ", 3); + if (status.length < 2) + throw new IOException(fmt("Bad HTTP status line: %s", lines[0])); + int code; + try { + code = Integer.parseInt(status[1]); + } catch (NumberFormatException e) { + throw new IOException(fmt("Bad HTTP status code: %s", lines[0])); + } + int contentLength = -1; + boolean chunked = false; + for (int i = 1; i < lines.length; i++) { + var idx = lines[i].indexOf(':'); + if (idx < 0) continue; + var name = lines[i].substring(0, idx).trim().toLowerCase(); + var value = lines[i].substring(idx + 1).trim(); + if (name.equals("content-length")) { + try { + contentLength = Integer.parseInt(value); + } catch (NumberFormatException ignored) { + } + } else if (name.equals("transfer-encoding") + && value.toLowerCase().contains("chunked")) { + chunked = true; + } + } + byte[] body; + if (chunked) body = readChunked(in); + else if (contentLength >= 0) body = readN(in, contentLength); + else body = readAll(in); + return new Response(code, new String(body, StandardCharsets.UTF_8)); + } + + @NonNull + private static String readHead(@NonNull InputStream in) throws IOException { + var buf = new ByteArrayOutputStream(); + int state = 0; // counts \r\n\r\n progress + int c; + while ((c = in.read()) >= 0) { + buf.write(c); + if (c == '\r' && (state == 0 || state == 2)) state++; + else if (c == '\n' && (state == 1 || state == 3)) state++; + else state = 0; + if (state == 4) break; + } + var head = buf.toString(StandardCharsets.UTF_8); + return head.endsWith("\r\n\r\n") + ? head.substring(0, head.length() - 4) : head; + } + + @NonNull + private static byte[] readN(@NonNull InputStream in, int n) throws IOException { + var buf = new byte[n]; + int off = 0; + while (off < n) { + int read = in.read(buf, off, n - off); + if (read < 0) break; + off += read; + } + return buf; + } + + @NonNull + private static byte[] readAll(@NonNull InputStream in) throws IOException { + var buf = new ByteArrayOutputStream(); + var chunk = new byte[4096]; + int read; + while ((read = in.read(chunk)) >= 0) + buf.write(chunk, 0, read); + return buf.toByteArray(); + } + + @NonNull + private static byte[] readChunked(@NonNull InputStream in) throws IOException { + var out = new ByteArrayOutputStream(); + while (true) { + var line = readLine(in); + int size; + try { + var semi = line.indexOf(';'); + size = Integer.parseInt( + (semi >= 0 ? line.substring(0, semi) : line).trim(), 16); + } catch (NumberFormatException e) { + throw new IOException(fmt("Bad chunk size: %s", line)); + } + if (size == 0) break; + out.write(readN(in, size)); + readLine(in); // trailing CRLF + } + return out.toByteArray(); + } + + @NonNull + private static String readLine(@NonNull InputStream in) throws IOException { + var buf = new StringBuilder(); + int c; + while ((c = in.read()) >= 0) { + if (c == '\n') break; + if (c != '\r') buf.append((char) c); + } + return buf.toString(); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/gvisor/GvswitchConfigBuilder.java b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/gvisor/GvswitchConfigBuilder.java new file mode 100644 index 000000000..e48a55773 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/gvisor/GvswitchConfigBuilder.java @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. +package cn.classfun.droidvm.daemon.network.backend.gvisor; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.math.BigInteger; +import java.util.List; + +import cn.classfun.droidvm.lib.store.network.NetworkConfig; +import cn.classfun.droidvm.lib.store.network.UplinkMode; +import cn.classfun.droidvm.lib.store.network.VlanConfig; + +/** + * Generates the gvswitch startup config (gateways only; VM ports, static + * leases and forwards are managed over the REST API as VMs attach). + */ +final class GvswitchConfigBuilder { + private GvswitchConfigBuilder() { + } + + @NonNull + static JSONObject build(@NonNull NetworkConfig config) throws JSONException { + var root = new JSONObject(); + if (config.getUplinkMode() != UplinkMode.L3) return root; + var gateways = new JSONArray(); + var bridgeMac = config.getBridgeMacAddress(); + for (var vlan : config.getVlans()) { + var gw = buildGateway(vlan, vlan.isUntagged() ? bridgeMac : null); + if (gw != null) gateways.put(gw); + } + if (gateways.length() > 0) root.put("gateways", gateways); + return root; + } + + @Nullable + private static JSONObject buildGateway( + @NonNull VlanConfig vlan, @Nullable String mac + ) throws JSONException { + var net4 = vlan.getIpv4Network(); + var net6 = vlan.getIpv6Network(); + if (net4 == null && net6 == null) return null; + var gw = new JSONObject(); + gw.put("vlan", vlan.getVlanId()); + if (mac != null) gw.put("mac", mac); + // gvswitch has a single internet-routing switch for both families; + // per-family SNAT toggles are expressed via deny ranges + boolean snat4 = net4 != null && vlan.isIpv4Snat(); + boolean snat6 = net6 != null && vlan.isIpv6Snat(); + boolean routing = snat4 || snat6; + gw.put("enable_internet_routing", routing); + // The single routing switch serves both families, so once it's on we + // deny each family that isn't SNAT-enabled. !snat covers both cases: + // SNAT toggled off, and the family having no network at all. + var deny = new JSONArray(); + if (routing) { + if (!snat4) deny.put("0.0.0.0/0"); + // slirpnetstack's range parser splits on bare ':' outside + // brackets, so an IPv6 CIDR must be bracketed ("[::]/0"); a + // plain "::/0" fails with "error parsing ipportrange". + if (!snat6) deny.put("[::]/0"); + } + if (deny.length() > 0) gw.put("deny", deny); + // No explicit DNS: gvswitch advertises the gateway IP via DHCP and + // the dns_proxy answers it through the Android system resolver + // (dnsproxyd/resnsend), so guest lookups behave like the host's + if (vlan.getDnsServers().isEmpty()) gw.put("dns_proxy", true); + if (net4 != null) { + var v4 = new JSONObject(); + v4.put("address", net4.address().toString()); + v4.put("prefix_len", net4.prefix()); + gw.put("ipv4", v4); + if (vlan.isDhcp4Enabled()) { + var dhcp = new JSONObject(); + dhcp.put("enabled", true); + dhcp.put("pool_start", + net4.addressAtOffset(vlan.getDhcp4OffsetStart()).toString()); + dhcp.put("pool_end", + net4.addressAtOffset(vlan.getDhcp4OffsetEnd()).toString()); + var dns = dnsOf(vlan.getDnsServers(), false); + if (dns.length() > 0) dhcp.put("dns", dns); + gw.put("dhcp4", dhcp); + } + } + if (net6 != null) { + var v6 = new JSONObject(); + v6.put("address", net6.address().toString()); + v6.put("prefix_len", net6.prefix()); + gw.put("ipv6", v6); + if (vlan.isDhcp6Enabled()) { + var dhcp = new JSONObject(); + dhcp.put("enabled", true); + dhcp.put("pool_start", net6.addressAtOffset( + BigInteger.valueOf(vlan.getDhcp6OffsetStart())).toString()); + dhcp.put("pool_end", net6.addressAtOffset( + BigInteger.valueOf(vlan.getDhcp6OffsetEnd())).toString()); + var dns = dnsOf(vlan.getDnsServers(), true); + if (dns.length() > 0) dhcp.put("dns", dns); + gw.put("dhcp6", dhcp); + } + if (vlan.isSlaacEnabled()) { + var slaac = new JSONObject(); + slaac.put("enabled", true); + slaac.put("managed", vlan.isDhcp6Enabled()); + slaac.put("other", vlan.isDhcp6Enabled()); + var prefix = new JSONObject(); + prefix.put("prefix", net6.toNetworkString()); + prefix.put("on_link", true); + prefix.put("autonomous", true); + var prefixes = new JSONArray(); + prefixes.put(prefix); + slaac.put("prefixes", prefixes); + gw.put("slaac", slaac); + } + } + return gw; + } + + @NonNull + private static JSONArray dnsOf(@NonNull List servers, boolean v6) { + // empty list = omitted = gvswitch advertises the gateway IP, which + // the backend serves by forwarding gateway:53 to the Android + // system's DNS (see GvisorBridgeBackend.dnsForwardRules) + var arr = new JSONArray(); + for (var s : servers) + if (s.contains(":") == v6) arr.put(s); + return arr; + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/iptables/ChainInfo.java b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/iptables/ChainInfo.java index 9e1d3c9ce..37db8748f 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/iptables/ChainInfo.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/iptables/ChainInfo.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.network.backend.iptables; import androidx.annotation.NonNull; @@ -8,12 +11,18 @@ final class ChainInfo { final IptablesBackend backend; + final boolean ipv6; final String table; final String parent; final String chain; ChainInfo(IptablesBackend backend, String table, String parent, String chain) { + this(backend, false, table, parent, chain); + } + + ChainInfo(IptablesBackend backend, boolean ipv6, String table, String parent, String chain) { this.backend = backend; + this.ipv6 = ipv6; this.table = table; this.parent = parent; this.chain = chain; @@ -21,52 +30,60 @@ final class ChainInfo { ChainInfo(IptablesBackend backend, @NonNull ChainInfo parent, String chain) { this.backend = backend; + this.ipv6 = parent.ipv6; this.table = parent.table; this.parent = parent.chain; this.chain = chain; } + @NonNull + String binary() { + return ipv6 ? "ip6tables" : "iptables"; + } + public void init(boolean insert) { - backend.iptablesCmd(table, "-N", chain); - backend.iptablesCmd(table, "-F", chain); + backend.iptablesCmd(binary(), table, "-N", chain); + backend.iptablesCmd(binary(), table, "-F", chain); if (parent != null && chain != null) { if (insert) - backend.iptablesCmd(table, "-I", parent, "1", "-j", chain); + backend.iptablesCmd(binary(), table, "-I", parent, "1", "-j", chain); else - backend.iptablesCmd(table, "-A", parent, "-j", chain); + backend.iptablesCmd(binary(), table, "-A", parent, "-j", chain); } } public void deinit() { if (table != null && parent != null && chain != null) - backend.iptablesCmd(table, "-D", parent, "-j", chain); + backend.iptablesCmd(binary(), table, "-D", parent, "-j", chain); if (table != null && chain != null) { - backend.iptablesCmd(table, "-F", chain); - backend.iptablesCmd(table, "-X", chain); + backend.iptablesCmd(binary(), table, "-F", chain); + backend.iptablesCmd(binary(), table, "-X", chain); } } - public boolean append(@NonNull String... args) { + private boolean rule(@NonNull String op, @Nullable String pos, @NonNull String... args) { var cmd = new ArrayList(); - cmd.add("iptables"); + cmd.add(binary()); cmd.add("-t"); cmd.add(table); - cmd.add("-A"); + cmd.add(op); cmd.add(chain); + if (pos != null) cmd.add(pos); cmd.addAll(Arrays.asList(args)); return backend.iptables(cmd.toArray(new String[0])); } + public boolean append(@NonNull String... args) { + return rule("-A", null, args); + } + + public boolean insert(@NonNull String... args) { + return rule("-I", "1", args); + } + @SuppressWarnings("unused") public boolean delete(@NonNull String... args) { - var cmd = new ArrayList(); - cmd.add("iptables"); - cmd.add("-t"); - cmd.add(table); - cmd.add("-D"); - cmd.add(chain); - cmd.addAll(Arrays.asList(args)); - return backend.iptables(cmd.toArray(new String[0])); + return rule("-D", null, args); } @SuppressWarnings("UnusedReturnValue") diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/iptables/IptablesBackend.java b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/iptables/IptablesBackend.java index c526aa7df..0df24ede0 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/iptables/IptablesBackend.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/iptables/IptablesBackend.java @@ -1,14 +1,21 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.network.backend.iptables; +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; + import android.util.Log; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import java.util.ArrayList; import java.util.Arrays; import cn.classfun.droidvm.daemon.network.NetworkInstance; import cn.classfun.droidvm.daemon.network.backend.FirewallHelper; +import cn.classfun.droidvm.lib.network.IPv6Network; import cn.classfun.droidvm.lib.utils.RunUtils; @SuppressWarnings("UnusedReturnValue") @@ -29,6 +36,11 @@ public final class IptablesBackend extends FirewallHelper { final ChainInfo NAT_PRE = new ChainInfo(this, "nat", "PREROUTING", "droidvm_pre"); final ChainInfo RAW_OUT = new ChainInfo(this, "raw", "OUTPUT", "droidvm_out"); final ChainInfo RAW_PRE = new ChainInfo(this, "raw", "PREROUTING", "droidvm_pre"); + final ChainInfo FIL6_NET_EXTNET_IN = new ChainInfo(this, true, "filter", null, "droidvm_ext_in"); + final ChainInfo FIL6_NET_EXTNET_OUT = new ChainInfo(this, true, "filter", null, "droidvm_ext_out"); + final ChainInfo FIL6_IN = new ChainInfo(this, true, "filter", "INPUT", "droidvm_in"); + final ChainInfo FIL6_OUT = new ChainInfo(this, true, "filter", "OUTPUT", "droidvm_out"); + final ChainInfo FIL6_FWD = new ChainInfo(this, true, "filter", "FORWARD", "droidvm_fwd"); final ChainInfo[] DEFS = new ChainInfo[]{ FIL_NET_EXTNET_IN, FIL_NET_EXTNET_OUT, @@ -45,7 +57,18 @@ public final class IptablesBackend extends FirewallHelper { NAT_PRE, RAW_OUT, RAW_PRE, + FIL6_NET_EXTNET_IN, + FIL6_NET_EXTNET_OUT, + FIL6_IN, + FIL6_OUT, + FIL6_FWD, }; + // Uplink interfaces guest traffic may be forwarded out of (and replies in + // from); anything else hits the trailing DROP. "tun+" covers the system VPN + // so a guest routed to the VPN table (see DefaultRouterWatcher) is actually + // forwarded out tun0 instead of dropped -- it mirrors the "tun" prefix in + // that watcher's HOST_IFACE_PREFIXES, which this list is meant to track. + private static final String[] EXT_IFACES = {"wlan+", "eth+", "rmnet+", "tun+"}; synchronized boolean iptables(@NonNull String... args) { int retry = 0; @@ -60,9 +83,9 @@ synchronized boolean iptables(@NonNull String... args) { } } - boolean iptablesCmd(@NonNull String table, @NonNull String... args) { + boolean iptablesCmd(@NonNull String binary, @NonNull String table, @NonNull String... args) { var cmd = new ArrayList(); - cmd.add("iptables"); + cmd.add(binary); cmd.add("-t"); cmd.add(table); cmd.addAll(Arrays.asList(args)); @@ -72,29 +95,30 @@ boolean iptablesCmd(@NonNull String table, @NonNull String... args) { @Override public boolean initialize() { Log.i(TAG, "Initializing iptables firewall chains"); - boolean ok = true; for (var def : DEFS) def.init(true); FIL_FWD.append("-m", "state", "--state", "RELATED,ESTABLISHED", "-j", "ACCEPT"); - FIL_NET_EXTNET_OUT.append("-o", "wlan+", "-j", "ACCEPT"); - FIL_NET_EXTNET_OUT.append("-o", "eth+", "-j", "ACCEPT"); - FIL_NET_EXTNET_OUT.append("-o", "rmnet_data+", "-j", "ACCEPT"); + FIL6_FWD.append("-m", "state", "--state", "RELATED,ESTABLISHED", "-j", "ACCEPT"); + for (var iface : EXT_IFACES) { + FIL_NET_EXTNET_OUT.append("-o", iface, "-j", "ACCEPT"); + FIL_NET_EXTNET_IN.append("-i", iface, "-j", "ACCEPT"); + FIL6_NET_EXTNET_OUT.append("-o", iface, "-j", "ACCEPT"); + FIL6_NET_EXTNET_IN.append("-i", iface, "-j", "ACCEPT"); + } FIL_NET_EXTNET_OUT.append("-j", "DROP"); - FIL_NET_EXTNET_IN.append("-i", "wlan+", "-j", "ACCEPT"); - FIL_NET_EXTNET_IN.append("-i", "eth+", "-j", "ACCEPT"); - FIL_NET_EXTNET_IN.append("-i", "rmnet_data+", "-j", "ACCEPT"); FIL_NET_EXTNET_IN.append("-j", "DROP"); + FIL6_NET_EXTNET_OUT.append("-j", "DROP"); + FIL6_NET_EXTNET_IN.append("-j", "DROP"); enableIpForward(); - return ok; + return true; } @Override public boolean shutdown() { Log.i(TAG, "Shutting down iptables firewall chains"); - boolean ok = true; for (var def : DEFS) def.deinit(); - return ok; + return true; } public void initNetwork(NetworkInstance inst) { @@ -108,9 +132,150 @@ public void deinitNetwork(NetworkInstance inst) { net.deinit(); } + @Override + public void addLiveV6Subnet( + @NonNull NetworkInstance inst, @NonNull String dev, @NonNull IPv6Network net + ) { + new IptablesNetworkInstance(this, inst).addLiveV6Subnet(dev, net); + } + + @Override + public void removeLiveV6Subnet( + @NonNull NetworkInstance inst, @NonNull String dev, @NonNull IPv6Network net + ) { + new IptablesNetworkInstance(this, inst).removeLiveV6Subnet(dev, net); + } + + @Override + public boolean applyForwardBase( + @NonNull String bridge, @NonNull String guestIp, @NonNull String protocol, + int guestStart, int guestEnd, @Nullable String hairpinSubnet + ) { + var fwd = new ChainInfo(this, "filter", null, fmt("droidvm_fwd_%s", bridge)); + boolean ok = fwd.insert(fwdSpec(bridge, guestIp, protocol, guestStart, guestEnd)); + if (hairpinSubnet != null) { + var pst = new ChainInfo(this, "nat", null, fmt("droidvm_pst_%s", bridge)); + ok &= pst.append(hairpinSpec( + protocol, hairpinSubnet, guestIp, guestStart, guestEnd)); + } + Log.i(TAG, fmt("Apply forward base %s -> %s:%s on %s (ok=%b)", + protocol, guestIp, portRange(guestStart, guestEnd, ':'), bridge, ok)); + return ok; + } + + @Override + public boolean removeForwardBase( + @NonNull String bridge, @NonNull String guestIp, @NonNull String protocol, + int guestStart, int guestEnd, @Nullable String hairpinSubnet + ) { + var fwd = new ChainInfo(this, "filter", null, fmt("droidvm_fwd_%s", bridge)); + boolean ok = fwd.delete(fwdSpec(bridge, guestIp, protocol, guestStart, guestEnd)); + if (hairpinSubnet != null) { + var pst = new ChainInfo(this, "nat", null, fmt("droidvm_pst_%s", bridge)); + ok &= pst.delete(hairpinSpec( + protocol, hairpinSubnet, guestIp, guestStart, guestEnd)); + } + Log.i(TAG, fmt("Remove forward base %s -> %s:%s on %s (ok=%b)", + protocol, guestIp, portRange(guestStart, guestEnd, ':'), bridge, ok)); + return ok; + } + + @Override + public boolean applyDnat( + @NonNull String bridge, @NonNull String guestIp, @NonNull String protocol, + @NonNull String hostIp, int hostStart, int hostEnd, int guestStart, int guestEnd + ) { + var pre = new ChainInfo(this, "nat", null, fmt("droidvm_pre_%s", bridge)); + boolean ok = pre.append( + dnatSpec(protocol, hostIp, hostStart, hostEnd, guestIp, guestStart, guestEnd)); + Log.i(TAG, fmt("Apply DNAT %s %s:%s -> %s:%s on %s (ok=%b)", + protocol, hostIp, portRange(hostStart, hostEnd, ':'), + guestIp, portRange(guestStart, guestEnd, ':'), bridge, ok)); + return ok; + } + + @Override + public boolean removeDnat( + @NonNull String bridge, @NonNull String guestIp, @NonNull String protocol, + @NonNull String hostIp, int hostStart, int hostEnd, int guestStart, int guestEnd + ) { + var pre = new ChainInfo(this, "nat", null, fmt("droidvm_pre_%s", bridge)); + boolean ok = pre.delete( + dnatSpec(protocol, hostIp, hostStart, hostEnd, guestIp, guestStart, guestEnd)); + Log.i(TAG, fmt("Remove DNAT %s %s:%s -> %s:%s on %s (ok=%b)", + protocol, hostIp, portRange(hostStart, hostEnd, ':'), + guestIp, portRange(guestStart, guestEnd, ':'), bridge, ok)); + return ok; + } + + /** + * NAT loopback: a guest in hairpinSubnet hitting the forward's guest + * target gets its source masqueraded to the gateway, so the target's + * reply routes back through the gateway instead of straight over L2 + * (where the originator would see an unexpected source). + */ + @NonNull + private static String[] hairpinSpec( + @NonNull String protocol, @NonNull String hairpinSubnet, + @NonNull String guestIp, int guestStart, int guestEnd + ) { + return new String[]{ + "-s", hairpinSubnet, "-d", guestIp, "-p", protocol, + "--dport", portRange(guestStart, guestEnd, ':'), "-j", "MASQUERADE" + }; + } + + @NonNull + private static String portRange(int start, int end, char sep) { + return start == end + ? String.valueOf(start) + : fmt("%d%c%d", start, sep, end); + } + + @NonNull + private static String[] dnatSpec( + @NonNull String protocol, @Nullable String hostIp, int hostStart, int hostEnd, + @NonNull String guestIp, int guestStart, int guestEnd + ) { + var spec = new ArrayList(); + spec.add("-p"); + spec.add(protocol); + if (isSpecificHost(hostIp)) { + spec.add("-d"); + spec.add(hostIp); + } + spec.add("--dport"); + spec.add(portRange(hostStart, hostEnd, ':')); + spec.add("-j"); + spec.add("DNAT"); + spec.add("--to-destination"); + // iptables DNAT port ranges use a dash: ip:8000-9000 + spec.add(fmt("%s:%s", guestIp, portRange(guestStart, guestEnd, '-'))); + return spec.toArray(new String[0]); + } + + @NonNull + private static String[] fwdSpec( + @NonNull String bridge, @NonNull String guestIp, + @NonNull String protocol, int guestStart, int guestEnd + ) { + return new String[]{ + "-o", fmt("%s+", bridge), "-d", guestIp, "-p", protocol, + "--dport", portRange(guestStart, guestEnd, ':'), "-j", "ACCEPT" + }; + } + + /** A wildcard host_ip (empty / 0.0.0.0[/0] / ::[/0]) must omit -d, or the DNAT never matches. */ + private static boolean isSpecificHost(@Nullable String hostIp) { + return hostIp != null && !hostIp.isEmpty() + && !hostIp.equals("0.0.0.0") && !hostIp.equals("0.0.0.0/0") + && !hostIp.equals("::") && !hostIp.equals("::/0"); + } + private boolean enableIpForward() { - Log.i(TAG, "Enabling IPv4 forwarding"); + Log.i(TAG, "Enabling IP forwarding"); var result = RunUtils.run("echo 1 > /proc/sys/net/ipv4/ip_forward"); + RunUtils.run("echo 1 > /proc/sys/net/ipv6/conf/all/forwarding"); return result.isSuccess(); } } diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/iptables/IptablesNetworkInstance.java b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/iptables/IptablesNetworkInstance.java index 8c95dbb54..b8e8424dc 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/iptables/IptablesNetworkInstance.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/iptables/IptablesNetworkInstance.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.network.backend.iptables; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; @@ -5,9 +8,16 @@ import androidx.annotation.NonNull; import cn.classfun.droidvm.daemon.network.NetworkInstance; +import cn.classfun.droidvm.daemon.network.backend.LinuxNetwork; import cn.classfun.droidvm.lib.network.IPv4Network; -import cn.classfun.droidvm.lib.store.base.DataItem; +import cn.classfun.droidvm.lib.network.IPv6Network; +import cn.classfun.droidvm.lib.store.network.VlanConfig; +/** + * Per-network iptables/ip6tables chains for an L3-routed Linux bridge: + * per-VLAN subnet accept rules, MASQUERADE for SNAT-enabled VLANs and the + * per-bridge chains that hold DNAT port forwards. + */ final class IptablesNetworkInstance { final IptablesBackend backend; final NetworkInstance inst; @@ -24,6 +34,9 @@ final class IptablesNetworkInstance { final ChainInfo magPst; final ChainInfo rawOut; final ChainInfo rawPre; + final ChainInfo filIn6; + final ChainInfo filOut6; + final ChainInfo filFwd6; IptablesNetworkInstance(IptablesBackend backend, @NonNull NetworkInstance inst) { this.backend = backend; @@ -41,6 +54,9 @@ final class IptablesNetworkInstance { magPst = new ChainInfo(backend, backend.MAG_PST, fmt("droidvm_pst_%s", br)); rawOut = new ChainInfo(backend, backend.RAW_OUT, fmt("droidvm_out_%s", br)); rawPre = new ChainInfo(backend, backend.RAW_PRE, fmt("droidvm_pre_%s", br)); + filIn6 = new ChainInfo(backend, backend.FIL6_IN, fmt("droidvm_in_%s", br)); + filOut6 = new ChainInfo(backend, backend.FIL6_OUT, fmt("droidvm_out_%s", br)); + filFwd6 = new ChainInfo(backend, backend.FIL6_FWD, fmt("droidvm_fwd_%s", br)); } void init() { @@ -56,6 +72,9 @@ void init() { magPst.init(false); rawOut.init(false); rawPre.init(false); + filIn6.init(false); + filOut6.init(false); + filFwd6.init(false); } void deinit() { @@ -71,30 +90,107 @@ void deinit() { magPst.deinit(); rawOut.deinit(); rawPre.deinit(); + filIn6.deinit(); + filOut6.deinit(); + filFwd6.deinit(); + } + + @NonNull + private String deviceOf(@NonNull VlanConfig vlan) { + return LinuxNetwork.vlanDevice(br, vlan.getVlanId()); } void initBasic() { - boolean canIn = inst.item.optBoolean("nat", false) || inst.item.optBoolean("access", false); - boolean canOut = inst.item.optBoolean("access", false); var extnetIn = backend.FIL_NET_EXTNET_IN.chain; var extnetOut = backend.FIL_NET_EXTNET_OUT.chain; + var extnetIn6 = backend.FIL6_NET_EXTNET_IN.chain; + var extnetOut6 = backend.FIL6_NET_EXTNET_OUT.chain; var input = backend.FIL_NET_INPUT.chain; - filFwd.addChain(br, br, null, null, "ACCEPT", null); - filOut.addChain(null, br, null, null, "ACCEPT", null); - for (var iter : inst.item.opt("ipv4_addresses", DataItem.newArray())) { - IPv4Network addr = IPv4Network.parse(iter.getValue().asString()); - String router = new IPv4Network(addr.address(), 32).toString(); - String network = addr.network().toString(); - filIn.addChain(br, null, network, router, input, null); - filFwd.addChain(br, null, network, null, canIn ? extnetOut : "DROP", null); - filFwd.addChain(null, br, null, network, canOut ? extnetIn : "DROP", null); - if (inst.item.optBoolean("nat", false)) { - natPst.addChain(br, null, network, null, "MASQUERADE", null); - natPst.addChain(null, br, null, network, "MASQUERADE", null); + // bridged + inter-VLAN routed traffic within this network + var wildcard = fmt("%s+", br); + filFwd.addChain(wildcard, wildcard, null, null, "ACCEPT", null); + filFwd6.addChain(wildcard, wildcard, null, null, "ACCEPT", null); + filOut.addChain(null, wildcard, null, null, "ACCEPT", null); + filOut6.addChain(null, wildcard, null, null, "ACCEPT", null); + for (var vlan : inst.getVlans()) { + var dev = deviceOf(vlan); + boolean snat4 = vlan.isIpv4Snat(); + var net4 = vlan.getIpv4Network(); + if (net4 != null) + addV4Subnet(dev, net4, snat4, input, extnetIn, extnetOut); + for (var cidr : vlan.getIpv4Secondary()) { + try { + addV4Subnet(dev, IPv4Network.parse(cidr), + snat4, input, extnetIn, extnetOut); + } catch (Exception ignored) { + } + } + var net6 = vlan.getIpv6Network(); + if (net6 != null) addV6Subnet(dev, net6, extnetIn6, extnetOut6); + for (var cidr : vlan.getIpv6Secondary()) { + try { + addV6Subnet(dev, IPv6Network.parse(cidr), extnetIn6, extnetOut6); + } catch (Exception ignored) { + } } } - magPre.addChain(br, null, null, null, "ACCEPT", null); - filFwd.addChain(br, null, null, null, "DROP", null); - filFwd.addChain(null, br, null, null, "DROP", null); + magPre.addChain(wildcard, null, null, null, "ACCEPT", null); + filFwd.addChain(wildcard, null, null, null, "DROP", null); + filFwd.addChain(null, wildcard, null, null, "DROP", null); + filFwd6.addChain(wildcard, null, null, null, "DROP", null); + filFwd6.addChain(null, wildcard, null, null, "DROP", null); + } + + private void addV4Subnet( + @NonNull String dev, @NonNull IPv4Network addr, boolean snat, + String input, String extnetIn, String extnetOut + ) { + var router = new IPv4Network(addr.address(), 32).toString(); + var network = addr.toNetworkString(); + filIn.addChain(dev, null, network, router, input, null); + filFwd.addChain(dev, null, network, null, extnetOut, null); + filFwd.addChain(null, dev, null, network, extnetIn, null); + if (snat) { + // SNAT guest -> external only. POSTROUTING has no input + // interface (`-i` is invalid there), so match on the source + // subnet leaving via a non-bridge interface; `! -o {br}+` + // excludes inter-VLAN traffic, which keeps its real source. + natPst.append("-s", network, "!", "-o", fmt("%s+", br), "-j", "MASQUERADE"); + } + } + + private void addV6Subnet( + @NonNull String dev, @NonNull IPv6Network addr, + String extnetIn6, String extnetOut6 + ) { + var network = addr.toNetworkString(); + var router = fmt("%s/128", addr.address()); + filIn6.addChain(dev, null, network, router, "ACCEPT", null); + filFwd6.addChain(dev, null, network, null, extnetOut6, null); + filFwd6.addChain(null, dev, null, network, extnetIn6, null); + } + + /** + * Adds rules for a delegated prefix acquired after network start; + * inserted at the top so they precede the trailing DROP rules. + */ + void addLiveV6Subnet(@NonNull String dev, @NonNull IPv6Network addr) { + var network = addr.toNetworkString(); + var router = fmt("%s/128", addr.address()); + filIn6.insert("-i", dev, "-s", network, "-d", router, "-j", "ACCEPT"); + filFwd6.insert("-i", dev, "-s", network, + "-j", backend.FIL6_NET_EXTNET_OUT.chain); + filFwd6.insert("-o", dev, "-d", network, + "-j", backend.FIL6_NET_EXTNET_IN.chain); + } + + void removeLiveV6Subnet(@NonNull String dev, @NonNull IPv6Network addr) { + var network = addr.toNetworkString(); + var router = fmt("%s/128", addr.address()); + filIn6.delete("-i", dev, "-s", network, "-d", router, "-j", "ACCEPT"); + filFwd6.delete("-i", dev, "-s", network, + "-j", backend.FIL6_NET_EXTNET_OUT.chain); + filFwd6.delete("-o", dev, "-d", network, + "-j", backend.FIL6_NET_EXTNET_IN.chain); } } diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/pd/Duid.java b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/pd/Duid.java new file mode 100644 index 000000000..3ee600f2a --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/pd/Duid.java @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. +package cn.classfun.droidvm.daemon.network.backend.pd; + +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** + * DHCPv6 DUID helpers. The default is DUID-LL (type 3, hw type 1) + * derived from a MAC address; user-supplied DUIDs are colon-hex strings. + */ +public final class Duid { + private Duid() { + } + + @NonNull + public static byte[] fromLinkLayer(@NonNull String mac) { + var parts = mac.split(":"); + if (parts.length != 6) + throw new IllegalArgumentException(fmt("Invalid MAC for DUID-LL: %s", mac)); + var out = new byte[4 + 6]; + out[0] = 0x00; + out[1] = 0x03; // DUID-LL + out[2] = 0x00; + out[3] = 0x01; // hw type: ethernet + for (int i = 0; i < 6; i++) + out[4 + i] = (byte) Integer.parseInt(parts[i], 16); + return out; + } + + @NonNull + public static byte[] parse(@NonNull String hex) { + var parts = hex.split(":"); + var out = new byte[parts.length]; + for (int i = 0; i < parts.length; i++) + out[i] = (byte) Integer.parseInt(parts[i], 16); + return out; + } + + @NonNull + public static String format(@Nullable byte[] duid) { + if (duid == null) return ""; + var sb = new StringBuilder(); + for (var b : duid) { + if (sb.length() > 0) sb.append(':'); + sb.append(fmt("%02x", b)); + } + return sb.toString(); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/server/ClientHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/server/ClientHandler.java index c5474f241..bd0a01b82 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/server/ClientHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/server/ClientHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.server; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/server/ClientRequest.java b/app/src/main/java/cn/classfun/droidvm/daemon/server/ClientRequest.java index c5286302b..273b21dab 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/server/ClientRequest.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/server/ClientRequest.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.server; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/server/ClientResponse.java b/app/src/main/java/cn/classfun/droidvm/daemon/server/ClientResponse.java index b57ba0ddc..4fafb2262 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/server/ClientResponse.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/server/ClientResponse.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.server; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/server/RequestException.java b/app/src/main/java/cn/classfun/droidvm/daemon/server/RequestException.java index bb5bbde8d..39a68e4f9 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/server/RequestException.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/server/RequestException.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.server; public final class RequestException extends RuntimeException { diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/server/RequestHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/server/RequestHandler.java index 5e0a1b6b2..373938c16 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/server/RequestHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/server/RequestHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.server; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/server/RequestHandlerStore.java b/app/src/main/java/cn/classfun/droidvm/daemon/server/RequestHandlerStore.java index 0aa60ebc9..516cc8dfa 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/server/RequestHandlerStore.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/server/RequestHandlerStore.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.server; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/server/Server.java b/app/src/main/java/cn/classfun/droidvm/daemon/server/Server.java index 5ad2e19a0..937247043 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/server/Server.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/server/Server.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.server; import static android.system.Os.stat; @@ -46,6 +49,11 @@ public Server() { Log.w(TAG, "Failed to broadcast VM event", e); } }); + try { + context.getUsb().start(this::broadcastEvent); + } catch (Exception e) { + Log.w(TAG, "usb: manager failed to start", e); + } } @NonNull @@ -106,6 +114,13 @@ public void run() { return; } writePortFile(sockAddr.getPort()); + // Only now, with the port file written and a listener up. Auto-start waits on the + // huge-page reserve, and holding the daemon's whole reason for existing behind VMs that + // have not booted yet is what it used to do from the ServerContext constructor. By here + // the event callback is wired (our own constructor) and INT/TERM have handlers (Daemon's + // main, before this call), so the sweep's VMs report their states and a shutdown arriving + // mid-sweep is answered rather than ignored. + context.getVMs().autoUpAsync(); try { Log.d(TAG, fmt("DroidVM Daemon is listening on %s", sockAddr.toString())); runningThread = Thread.currentThread(); @@ -146,6 +161,9 @@ public boolean isRunning() { private void handleClient(@NonNull Socket client) { try { + // Disable Nagle on the accepted socket so daemon->UI responses (incl. input ACKs) + // aren't delayed; pairs with the client-side setTcpNoDelay in DaemonClient. + client.setTcpNoDelay(true); var handler = new ClientHandler(this, client); synchronized (clients) { clients.add(handler); diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/server/ServerContext.java b/app/src/main/java/cn/classfun/droidvm/daemon/server/ServerContext.java index 585309f3b..51b26665c 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/server/ServerContext.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/server/ServerContext.java @@ -1,6 +1,10 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.server; import static cn.classfun.droidvm.lib.Constants.DATA_DIR; +import static cn.classfun.droidvm.lib.utils.AssetUtils.getPrebuiltBinaryPath; import static cn.classfun.droidvm.lib.utils.RunUtils.run; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; import static cn.classfun.droidvm.lib.utils.StringUtils.pathJoin; @@ -10,25 +14,59 @@ import androidx.annotation.NonNull; import java.io.File; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import cn.classfun.droidvm.daemon.input.KeyboardGrabManager; import cn.classfun.droidvm.daemon.network.NetworkInstanceStore; import cn.classfun.droidvm.daemon.network.backend.DefaultRouterWatcher; +import cn.classfun.droidvm.daemon.usb.UsbPassthroughManager; +import cn.classfun.droidvm.daemon.vm.VMInstance; import cn.classfun.droidvm.daemon.vm.VMInstanceStore; +import cn.classfun.droidvm.daemon.vm.pkg.VMExportTask; +import cn.classfun.droidvm.daemon.vm.pkg.VMImportTask; +import cn.classfun.droidvm.lib.store.base.DataItem; public final class ServerContext { private static final String TAG = "ServerContext"; private final VMInstanceStore vms = new VMInstanceStore(this); private final NetworkInstanceStore networks = new NetworkInstanceStore(this); private final DefaultRouterWatcher routerWatcher = new DefaultRouterWatcher(this); + private final UsbPassthroughManager usb = new UsbPassthroughManager(this); + private final KeyboardGrabManager keyboard = new KeyboardGrabManager(this); + private final Map exportTasks = new ConcurrentHashMap<>(); + private final Map importTasks = new ConcurrentHashMap<>(); + public DataItem appConfig = DataItem.newObject(); + /** + * Where VM events go. Here rather than on the store because loading vms.json builds every + * VMInstance against a throwaway store (see VMInstanceStore.createEmpty) and the callback is + * installed after the load -- so a store-owned field reached none of the VMs that existed at + * startup, which is all of them, and every state change, reboot and exit was fired into a + * null. The context is the one object both stores share, so putting it here cannot go stale. + */ + public volatile VMInstance.VMEventCallback vmEventCallback = null; public ServerContext() { Log.i(TAG, "loading config files..."); var filesDir = pathJoin(DATA_DIR, "files"); - vms.load(new File(filesDir, vms.getFileName())); + // Networks first, and wire the store in BEFORE loading VMs: loading builds each VMInstance + // against a throwaway store (see VMInstanceStore.createEmpty), so the link has to exist by + // then or the instances never see it. networks.load(new File(filesDir, networks.getFileName())); - Log.i(TAG, fmt("config files loaded: %d VMs, %d networks", vms.size(), networks.size())); vms.setNetworkStore(networks); + vms.load(new File(filesDir, vms.getFileName())); + Log.i(TAG, fmt("config files loaded: %d VMs, %d networks", vms.size(), networks.size())); + // Strays survive a daemon crash or a forced (SIGKILL) takeover: the + // children are orphaned, not killed. Reap any left over from a previous + // daemon before we start fresh and auto-up. VM backends are matched by + // their full prebuilt path so unrelated processes are never hit. run("pkill dnsmasq"); + run("pkill gvswitch"); + run("pkill pbridge"); + run("pkill netbox"); + run("pkill -f %s", getPrebuiltBinaryPath("crosvm")); + run("pkill -f %s", getPrebuiltBinaryPath("qemu-system-aarch64")); try { networks.firewall.initialize(); } catch (Exception e) { @@ -39,11 +77,10 @@ public ServerContext() { } catch (Exception e) { Log.w(TAG, "Failed to auto up networks", e); } - try { - vms.autoUp(); - } catch (Exception e) { - Log.w(TAG, "Failed to auto up VMs", e); - } + // VMs are NOT started here. Their sweep waits on the huge-page reserve -- seconds a VM, and + // the pkill above is what makes the reserve short -- and everything the daemon is for comes + // after this constructor: the socket, the signal handlers, the VM event callback. It runs + // from Server.run() instead, behind all three. See VMInstanceStore.autoUpAsync. try { routerWatcher.start(); } catch (Exception e) { @@ -65,4 +102,24 @@ public NetworkInstanceStore getNetworks() { public DefaultRouterWatcher getRouterWatcher() { return routerWatcher; } + + @NonNull + public UsbPassthroughManager getUsb() { + return usb; + } + + @NonNull + public KeyboardGrabManager getKeyboard() { + return keyboard; + } + + @NonNull + public Map getExportTaskStore() { + return exportTasks; + } + + @NonNull + public Map getImportTaskStore() { + return importTasks; + } } diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/usb/CrosvmUsbControl.java b/app/src/main/java/cn/classfun/droidvm/daemon/usb/CrosvmUsbControl.java new file mode 100644 index 000000000..7fbb0f9c0 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/daemon/usb/CrosvmUsbControl.java @@ -0,0 +1,272 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. +package cn.classfun.droidvm.daemon.usb; + +import static cn.classfun.droidvm.lib.Constants.DATA_DIR; +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; +import static cn.classfun.droidvm.lib.utils.StringUtils.pathJoin; +import static cn.classfun.droidvm.lib.utils.StringUtils.streamToString; + +import android.os.Build; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; + +import cn.classfun.droidvm.lib.natives.NativeProcess; + +/** + * The {@code crosvm usb} sub-commands, run against one VM's control socket. + * + *

Attaching cannot be done over the control socket ourselves: crosvm opens the usbfs node in + * its own process and passes the fd across, so the fd has to come from a crosvm process. The + * bundled CLI is that process, and it prints exactly one line per command.

+ */ +public final class CrosvmUsbControl { + private static final String TAG = "CrosvmUsbControl"; + /** A control-socket round trip that has not answered by now is not going to. */ + private static final long TIMEOUT_SECONDS = 15; + /** How long a killed child gets to be reaped before we stop waiting for it. */ + private static final long TIMEOUT_KILL_SECONDS = 2; + /** What vm_control prints when the CLI cannot reach the control socket at all. */ + private static final String CONNECT_FAILED = "failed to connect to socket"; + /** + * One line of crosvm's log: {@code [