diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000..9f3910d --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,5 @@ +# The emulator e2e needs a self-hosted Apple-silicon runner carrying the +# HarmonyOS toolchain and image; actionlint cannot know custom labels. +self-hosted-runner: + labels: + - harmonyos diff --git a/.github/actions/rust-core/action.yml b/.github/actions/rust-core/action.yml new file mode 100644 index 0000000..2580090 --- /dev/null +++ b/.github/actions/rust-core/action.yml @@ -0,0 +1,67 @@ +name: Set up the shared Rust core +description: > + Clones the shadowsocks-rust checkout this crate path-depends on, installs the + Rust toolchain and restores the cargo cache. Every Rust workflow starts here, + so the core's pinned ref lives in exactly one place. + +inputs: + ref: + description: > + shadowsocks-rust tag to build against. Keep in step with the + core/src/main/rust/shadowsocks-rust submodule pin in shadowsocks-android, + so both platforms ship the same core. + required: false + default: v1.23.4 + targets: + description: Extra rustup targets, comma-separated. + required: false + default: '' + components: + description: Extra rustup components, comma-separated. + required: false + default: '' + +outputs: + ref: + description: The shadowsocks-rust ref that was checked out. + value: ${{ inputs.ref }} + +runs: + using: composite + steps: + - name: Check out the shared Rust core + shell: bash + run: | + set -euo pipefail + # Cargo.toml path-depends on ../../../core/... relative to + # native/sslocal-ffi, which is a *sibling* of this checkout — the + # layout this subproject has inside shadowsocks-android. + core_dir="$(dirname "$GITHUB_WORKSPACE")/core/src/main/rust" + mkdir -p "$core_dir" + if [[ ! -d "$core_dir/shadowsocks-rust/.git" ]]; then + git clone --depth 1 --branch "${{ inputs.ref }}" \ + https://github.com/shadowsocks/shadowsocks-rust.git \ + "$core_dir/shadowsocks-rust" + else + # Self-hosted runners keep the workspace parent between runs. + git -C "$core_dir/shadowsocks-rust" fetch --depth 1 origin \ + "refs/tags/${{ inputs.ref }}:refs/tags/${{ inputs.ref }}" -f + git -C "$core_dir/shadowsocks-rust" checkout -f "${{ inputs.ref }}" + fi + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ inputs.targets }} + components: ${{ inputs.components }} + + - name: Cargo cache + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + native/sslocal-ffi/target + # Shared across the Rust workflows: they build the same crate, and a + # cache written by one is a valid restore for the others. + key: cargo-${{ runner.os }}-${{ inputs.ref }}-${{ hashFiles('native/sslocal-ffi/Cargo.lock') }} + restore-keys: cargo-${{ runner.os }}-${{ inputs.ref }}- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index d5f0630..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,89 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - workflow_dispatch: - -permissions: - contents: read - -# A new push supersedes the run still going for the same ref. -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -env: - # The Rust core is a path dependency on the shadowsocks-rust checkout that - # the Android app builds (see native/sslocal-ffi/Cargo.toml). Keep this in - # step with the `core/src/main/rust/shadowsocks-rust` submodule pin in - # shadowsocks-android, so both platforms ship the same core. - SHADOWSOCKS_RUST_REF: v1.23.4 - CARGO_TERM_COLOR: always - -jobs: - # Everything that can run without the HarmonyOS SDK: the Rust core's unit - # tests, both host e2e tunnels, the OpenHarmony cross-compile check and the - # tun packet-routing e2e. The ArkTS unit tests and the HAP build are not - # covered — they need the DevEco command-line tools, which are not publicly - # downloadable (see docs/hos-emulator-vpn.md); run them locally with - # `hvigorw test` / `hvigorw assembleHap`. - core: - name: Core tests, e2e tunnels and tun routing - runs-on: ubuntu-latest - # Generous for a cold cache: clippy and the three test-e2e-host.sh steps - # each build shadowsocks-service (different profiles/targets). - timeout-minutes: 60 - steps: - - uses: actions/checkout@v4 - - - name: Check out the shared Rust core - run: | - # Cargo.toml path-depends on ../../../core/... relative to - # native/sslocal-ffi, which is a *sibling* of this checkout — the - # layout this subproject has inside shadowsocks-android. - core_dir="$(dirname "$GITHUB_WORKSPACE")/core/src/main/rust" - mkdir -p "$core_dir" - git clone --depth 1 --branch "$SHADOWSOCKS_RUST_REF" \ - https://github.com/shadowsocks/shadowsocks-rust.git \ - "$core_dir/shadowsocks-rust" - - - uses: dtolnay/rust-toolchain@stable - with: - targets: aarch64-unknown-linux-ohos - components: clippy, rustfmt - - # C cross-compiler for the OpenHarmony check below: the OHOS native SDK - # is not publicly downloadable, so native/ohos-cc-wrapper.sh shims the C - # bits (blake3) through `zig cc` against the equivalent musl target. - - uses: mlugg/setup-zig@v2 - - - name: Cargo cache - uses: actions/cache@v4 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - native/sslocal-ffi/target - key: cargo-${{ runner.os }}-${{ env.SHADOWSOCKS_RUST_REF }}-${{ hashFiles('native/sslocal-ffi/Cargo.lock') }} - restore-keys: cargo-${{ runner.os }}-${{ env.SHADOWSOCKS_RUST_REF }}- - - - name: rustfmt - working-directory: native/sslocal-ffi - run: cargo fmt --all -- --check - - # --no-deps: shadowsocks-rust is a path dependency and would otherwise be - # linted too, failing on upstream warnings this repo cannot fix. - - name: clippy - working-directory: native/sslocal-ffi - run: cargo clippy --no-deps --all-targets -- -D warnings - - # test-e2e-host.sh skips the tun e2e when the device is absent. In CI - # that must be a hard failure instead: it is the only test that drives - # real IP packets through the tun stack. - - name: Assert the runner can create tun devices - run: test -c /dev/net/tun - - - name: Core verification (unit + e2e + cross-compile + tun routing) - run: ./test-e2e-host.sh diff --git a/.github/workflows/harmonyos-build.yml b/.github/workflows/harmonyos-build.yml new file mode 100644 index 0000000..f441899 --- /dev/null +++ b/.github/workflows/harmonyos-build.yml @@ -0,0 +1,145 @@ +name: HarmonyOS build + +# The HAP build and debug signing — what needs the HarmonyOS SDK but neither a +# device nor a GUI. The ArkTS unit tests are *not* here: their runner drives the +# previewer, a GUI component that does not work on Linux (the specs silently do +# not execute), so they live in harmonyos-unit-tests.yml on macOS. +# +# Huawei's DevEco command-line tools are behind an account + region gate +# (docs/hos-emulator-vpn.md §4) and cannot be redistributed, so they are +# streamed from a private S3/R2 bucket populated by +# ci/package-hos-toolchain.sh, authenticated with the repository secrets +# R2_API_TOKEN (a Cloudflare API token) and R2_ENDPOINT. Secrets are not +# exposed to pull requests from forks, which is why this runs on pushes to +# main and on demand, while the host-side workflows gate every PR. +on: + push: + branches: [main] + paths-ignore: ['**.md', 'docs/**'] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + # Where ci/package-hos-toolchain.sh put the bundle. The account lives in the + # R2_ENDPOINT secret, so a bucket name on its own gives nothing away. + R2_BUCKET: shadowsocks + HOS_BUNDLE_PREFIX: harmonyos-6.1.1 + CARGO_TERM_COLOR: always + +jobs: + build: + name: HAP build, signing and ArkTS unit tests + runs-on: ubuntu-latest + timeout-minutes: 60 + if: github.repository == 'shadowsocks/shadowsocks-ohos' + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/rust-core + with: + targets: aarch64-unknown-linux-ohos + + # 352 bytes, so this runs even on a cache hit: the archive's sha256 from + # the manifest is the cache key, which means re-uploading a bundle + # invalidates the cache by itself, with nothing to bump by hand. + - name: Resolve the bundle version + env: + R2_API_TOKEN: ${{ secrets.R2_API_TOKEN }} + R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }} + run: | + set -euo pipefail + source ci/r2-env.sh + sha="$(aws s3 cp --endpoint-url "$R2_ENDPOINT" \ + "s3://$R2_BUCKET/$HOS_BUNDLE_PREFIX/manifest.txt" - \ + | awk '/hos-tools-linux-x64\.zip/ { print $1 }')" + [[ -n "$sha" ]] || { echo "no hos-tools checksum in the manifest"; exit 1; } + echo "HOS_TOOLS_SHA=$sha" >> "$GITHUB_ENV" + + - name: Cache the HarmonyOS toolchain + id: tools-cache + uses: actions/cache@v4 + with: + path: ~/hos-tools + key: hos-tools-${{ runner.os }}-${{ env.HOS_TOOLS_SHA }} + + # Huawei's zip verbatim, because it holds 19 pairs of paths that differ + # only in case (linux/netfilter headers) — repacking it on a + # case-insensitive filesystem silently drops files. It cannot be streamed + # either: unzip needs to seek, so it lands on disk and is deleted after. + # Only the tools are fetched; the emulator image belongs to the + # self-hosted e2e runner, which has it locally. + - name: Fetch the HarmonyOS toolchain + if: steps.tools-cache.outputs.cache-hit != 'true' + env: + R2_API_TOKEN: ${{ secrets.R2_API_TOKEN }} + R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }} + run: | + set -euo pipefail + source ci/r2-env.sh + mkdir -p "$HOME/hos-tools" + aws s3 cp --endpoint-url "$R2_ENDPOINT" \ + "s3://$R2_BUCKET/$HOS_BUNDLE_PREFIX/hos-tools-linux-x64.zip" \ + /tmp/hos-tools.zip + unzip -q /tmp/hos-tools.zip -d "$HOME/hos-tools" + rm -f /tmp/hos-tools.zip + + - name: Locate the toolchain + run: | + set -euo pipefail + # The archive keeps the directory name it was packed from. + tools="$(find "$HOME/hos-tools" -maxdepth 1 -mindepth 1 -type d | head -1)" + [[ -x "$tools/bin/hvigorw" ]] || { echo "no hvigorw under $tools"; exit 1; } + { + echo "HOS_TOOLS=$tools" + echo "DEVECO_SDK_HOME=$tools/sdk" + echo "OHOS_SDK_HOME=$tools/sdk/default/openharmony" + echo "OHOS_NDK_HOME=$tools/sdk/default/openharmony/native" + } >> "$GITHUB_ENV" + df -h / + + # Small (tens of MB) but fetched from Huawei's registries on every build: + # ohpm's package store and the hvigor plugins hvigorw pulls on first run. + - name: Cache ohpm and hvigor dependencies + uses: actions/cache@v4 + with: + path: | + ~/.ohpm + ~/.hvigor + oh_modules + .hvigor + key: hos-deps-${{ runner.os }}-${{ env.HOS_TOOLS_SHA }}-${{ hashFiles('oh-package-lock.json5', 'oh-package.json5', 'entry/oh-package.json5') }} + restore-keys: hos-deps-${{ runner.os }}-${{ env.HOS_TOOLS_SHA }}- + + # restool's libimage_transcoder_shared.so links against libGL, which the + # runner image does not ship — @CompileResource dies without it. + - name: Install the resource compiler's dependency + run: sudo apt-get update -qq && sudo apt-get install -y -qq libgl1 + + - name: Resolve ohpm dependencies + run: | + "$HOS_TOOLS/bin/ohpm" install + + - name: Build the native core + run: native/build-ohos.sh + + - name: Build and sign both HAPs + run: | + set -euo pipefail + "$HOS_TOOLS/bin/hvigorw" --no-daemon assembleHap --mode module \ + -p product=default -p buildMode=debug + "$HOS_TOOLS/bin/hvigorw" --no-daemon assembleHap --mode module \ + -p module=entry@ohosTest -p product=default -p buildMode=debug + native/sign-hap-debug.sh entry/build/default/outputs/default/entry-default-unsigned.hap + native/sign-hap-debug.sh entry/build/default/outputs/ohosTest/entry-ohosTest-unsigned.hap + + - name: Upload the signed HAPs + uses: actions/upload-artifact@v4 + with: + name: haps + path: entry/build/default/outputs/*/*-signed.hap + if-no-files-found: error diff --git a/.github/workflows/harmonyos-e2e.yml b/.github/workflows/harmonyos-e2e.yml new file mode 100644 index 0000000..31813b1 --- /dev/null +++ b/.github/workflows/harmonyos-e2e.yml @@ -0,0 +1,68 @@ +name: HarmonyOS on-device e2e + +# The ohosTest suites against a booted HarmonyOS emulator, driven by +# ci/hos-emulator-e2e.sh (build → sign → boot → unlock → install → run). +# +# **Cannot run on a GitHub-hosted runner**: the Emulator binary is arm64-only +# and the image is phone_all_arm, so it needs an Apple-silicon host exposing +# HVF — GitHub's Apple-silicon runners do not support nested virtualization, +# and their Intel runners (which do have HVF) cannot execute an arm64 emulator +# at all. It therefore targets a self-hosted Apple-silicon runner. +# +# To enable it, register a self-hosted runner on an Apple-silicon Mac that has +# the tools and the emulator image installed, label it `harmonyos`, and set the +# repository variables: +# +# HOS_SELF_HOSTED = true +# HOS_TOOLS_PATH = /path/to/command-line-tools +# HOS_IMAGES_PATH = /path/to/image root (the parent of system-image/) +# +# Without them the job is skipped, so pushes are never left queued against an +# offline runner. ci/hos-emulator-e2e.sh is the same entry point either way, so +# the e2e can always be run by hand on any Mac that has both. +on: + push: + branches: [main] + paths-ignore: ['**.md', 'docs/**'] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +jobs: + emulator-e2e: + name: On-device e2e on the HarmonyOS emulator + runs-on: [self-hosted, macOS, ARM64, harmonyos] + timeout-minutes: 90 + if: vars.HOS_SELF_HOSTED == 'true' + steps: + - uses: actions/checkout@v4 + # Keeps its checkout and cargo cache between runs, so ssserver below is + # usually a no-op rebuild. + - uses: ./.github/actions/rust-core + + - name: Build ssserver for the host side of the tunnel + run: | + core="$(dirname "$GITHUB_WORKSPACE")/core/src/main/rust/shadowsocks-rust" + cargo build --release --manifest-path "$core/Cargo.toml" --bin ssserver + echo "SSSERVER=$core/target/release/ssserver" >> "$GITHUB_ENV" + + - name: On-device e2e + env: + HOS_TOOLS: ${{ vars.HOS_TOOLS_PATH }} + HOS_IMAGES: ${{ vars.HOS_IMAGES_PATH }} + # Its own instance, so a developer's emulator on the same machine is + # left alone. + HOS_INSTANCE: ss_ci + run: ci/hos-emulator-e2e.sh + + - name: Emulator log + if: failure() + run: tail -200 "$HOME/.Huawei/Emulator/deployed/ss_ci/Log/"*.log 2>/dev/null || true diff --git a/.github/workflows/harmonyos-unit-tests.yml b/.github/workflows/harmonyos-unit-tests.yml new file mode 100644 index 0000000..36b5d2f --- /dev/null +++ b/.github/workflows/harmonyos-unit-tests.yml @@ -0,0 +1,132 @@ +name: ArkTS unit tests + +# `hvigorw test` on a hosted macOS runner. +# +# **Not on Linux**, even though harmonyos-build.yml builds there happily: the +# task that produces the results (@GenerateUnitTestResult) is what actually +# *runs* the specs, by driving the SDK's previewer — a GUI component. On Linux +# it dies with "Cannot read properties of null" in a container, and hangs +# forever in a full VM with the amd64 GL libraries installed; either way the +# specs never execute, and a deliberately failing spec produced no output at +# all. So this job takes the macOS toolchain bundle instead. +on: + push: + branches: [main] + paths-ignore: ['**.md', 'docs/**'] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + R2_BUCKET: shadowsocks + HOS_BUNDLE_PREFIX: harmonyos-6.1.1 + CARGO_TERM_COLOR: always + +jobs: + unit-tests: + name: ArkTS unit tests + runs-on: macos-15 + timeout-minutes: 45 + if: github.repository == 'shadowsocks/shadowsocks-ohos' + steps: + - uses: actions/checkout@v4 + + # ~14 GB free; the toolchain alone is ~6 GB unpacked. Keep the *selected* + # Xcode — nothing else here needs the others. + - name: Free disk space + run: | + df -h / + keep="$(xcode-select -p | sed 's|/Contents/Developer.*||')" + for app in /Applications/Xcode_*.app; do + [[ "$app" == "$keep" ]] || sudo rm -rf "$app" + done + sudo rm -rf ~/Library/Developer/CoreSimulator/Caches \ + /Library/Developer/CoreSimulator/Profiles/Runtimes || true + df -h / + + - name: Ensure aws and zstd are present + run: | + command -v aws >/dev/null || brew install awscli + command -v zstd >/dev/null || brew install zstd + + # 352 bytes, so this runs even on a cache hit: the archive's sha256 from + # the manifest is the cache key, so re-uploading a bundle invalidates the + # cache by itself. + - name: Resolve the bundle version + env: + R2_API_TOKEN: ${{ secrets.R2_API_TOKEN }} + R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }} + run: | + set -euo pipefail + source ci/r2-env.sh + sha="$(aws s3 cp --endpoint-url "$R2_ENDPOINT" \ + "s3://$R2_BUCKET/$HOS_BUNDLE_PREFIX/manifest.txt" - \ + | awk '/hos-tools\.tar\.zst/ { print $1 }')" + [[ -n "$sha" ]] || { echo "no hos-tools checksum in the manifest"; exit 1; } + echo "HOS_TOOLS_SHA=$sha" >> "$GITHUB_ENV" + + - name: Cache the HarmonyOS toolchain + id: tools-cache + uses: actions/cache@v4 + with: + path: ~/hos-tools + key: hos-tools-${{ runner.os }}-${{ env.HOS_TOOLS_SHA }} + + # The macOS bundle, streamed straight into place: holding the archive and + # its expanded contents at once would not fit. + - name: Fetch the HarmonyOS toolchain + if: steps.tools-cache.outputs.cache-hit != 'true' + env: + R2_API_TOKEN: ${{ secrets.R2_API_TOKEN }} + R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }} + run: | + set -euo pipefail + source ci/r2-env.sh + mkdir -p "$HOME/hos-tools" + aws s3 cp --endpoint-url "$R2_ENDPOINT" \ + "s3://$R2_BUCKET/$HOS_BUNDLE_PREFIX/hos-tools.tar.zst" - \ + | zstd -dc | tar -C "$HOME/hos-tools" -xf - + + - name: Locate the toolchain + run: | + set -euo pipefail + tools="$(find "$HOME/hos-tools" -maxdepth 1 -mindepth 1 -type d | head -1)" + [[ -x "$tools/bin/hvigorw" ]] || { echo "no hvigorw under $tools"; exit 1; } + { + echo "HOS_TOOLS=$tools" + echo "DEVECO_SDK_HOME=$tools/sdk" + echo "OHOS_SDK_HOME=$tools/sdk/default/openharmony" + } >> "$GITHUB_ENV" + + - name: Cache ohpm and hvigor dependencies + uses: actions/cache@v4 + with: + path: | + ~/.ohpm + ~/.hvigor + oh_modules + .hvigor + key: hos-deps-${{ runner.os }}-${{ env.HOS_TOOLS_SHA }}-${{ hashFiles('oh-package-lock.json5', 'oh-package.json5', 'entry/oh-package.json5') }} + restore-keys: hos-deps-${{ runner.os }}-${{ env.HOS_TOOLS_SHA }}- + + - name: Resolve ohpm dependencies + run: | + "$HOS_TOOLS/bin/ohpm" install + + # hvigor exits 0 even when specs fail — it only prints "ERROR: Error in + # " — so the failure has to be grepped out of the log. The second + # grep is the guard against the opposite mistake: if the run produced no + # test task at all, an absent failure line would look like a pass. + - name: ArkTS unit tests + run: | + set -o pipefail + "$HOS_TOOLS/bin/hvigorw" --no-daemon test --mode module \ + -p module=entry -p product=default 2>&1 | tee unit-tests.log + grep -q "Finished :entry:default@GenerateUnitTestResult" unit-tests.log \ + || { echo "the test task never completed"; exit 1; } + ! grep -q "ERROR: Error in" unit-tests.log diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..beddfdc --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,50 @@ +name: Lint + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +jobs: + rust: + name: rustfmt and clippy + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/rust-core + with: + components: clippy, rustfmt + + - name: rustfmt + working-directory: native/sslocal-ffi + run: cargo fmt --all -- --check + + # --no-deps: shadowsocks-rust is a path dependency and would otherwise be + # linted too, failing on upstream warnings this repo cannot fix. + - name: clippy + working-directory: native/sslocal-ffi + run: cargo clippy --no-deps --all-targets -- -D warnings + + shell: + name: shellcheck + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + # The build, signing, e2e and packaging entry points are all shell. + # -S warning: which info-level checks exist varies with the shellcheck + # version in the runner image, so an image bump must not turn CI red on + # its own. + - run: shellcheck -S warning test-e2e-host.sh native/*.sh ci/*.sh diff --git a/.github/workflows/test-core.yml b/.github/workflows/test-core.yml new file mode 100644 index 0000000..c3afc95 --- /dev/null +++ b/.github/workflows/test-core.yml @@ -0,0 +1,31 @@ +name: Core tests + +# The Rust suite: unit tests plus tests/e2e.rs and tests/e2e_plugin.rs, which +# drive sslocal through the same C ABI the NAPI bridge uses and round-trip +# SOCKS5 through an in-process shadowsocks server. +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + name: Unit tests and host e2e tunnels + runs-on: ubuntu-latest + # Generous for a cold cache: this builds shadowsocks-service from scratch. + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/rust-core + - run: ./test-e2e-host.sh tests diff --git a/.github/workflows/test-cross.yml b/.github/workflows/test-cross.yml new file mode 100644 index 0000000..7a08e01 --- /dev/null +++ b/.github/workflows/test-cross.yml @@ -0,0 +1,37 @@ +name: OpenHarmony cross-compile + +# Proves the whole core still compiles for the device target. Cheap insurance +# against a change that only builds on the host. +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +jobs: + check: + name: cargo check for aarch64-unknown-linux-ohos + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/rust-core + with: + targets: aarch64-unknown-linux-ohos + + # C cross-compiler for the check below: the OHOS native SDK is not + # publicly downloadable, so native/ohos-cc-wrapper.sh shims the C bits + # (blake3) through `zig cc` against the equivalent musl target. + - uses: mlugg/setup-zig@v2 + + - run: ./test-e2e-host.sh cross diff --git a/.github/workflows/test-tun.yml b/.github/workflows/test-tun.yml new file mode 100644 index 0000000..8664335 --- /dev/null +++ b/.github/workflows/test-tun.yml @@ -0,0 +1,35 @@ +name: Tun routing e2e + +# The only test that drives real IP packets through the tun stack: a TCP flow +# into a tun device, asserted to round-trip through the tunnel. Needs Linux, +# /dev/net/tun and CAP_NET_ADMIN, which a GitHub Linux runner has natively. +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +jobs: + tun: + name: Tun packet routing + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/rust-core + + # test-e2e-host.sh skips the tun e2e when the prerequisites are missing. + # Here that must be a hard failure instead of a silent pass. + - run: ./test-e2e-host.sh tun + env: + TUN_E2E_REQUIRED: '1' diff --git a/.gitignore b/.gitignore index 14f4e69..18ee401 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,6 @@ native/.tun-e2e *.keystore *.csr *.cer + +# CI toolchain bundle built by ci/package-hos-toolchain.sh +hos-bundle/ diff --git a/AGENTS.md b/AGENTS.md index 3004570..28b0301 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,8 +54,9 @@ entry/ main (and only) HAP module (incl. SIP003 plugin query), SOCKS and tun config serialization (incl. ACL injection, plugin fields), subscription body parsing - src/ohosTest/ on-device tests exercising the NAPI surface - (including startTunFd) on emulator/device + src/ohosTest/ on-device tests: NAPI surface (incl. startTunFd), + SOCKS tunnel e2e (emulator-capable, run by CI), + VPN/tun e2e (real devices only) native/ sslocal-ffi/ Rust crate: C ABI over shadowsocks-service src/lib.rs extern "C" API: sslocal_start, sslocal_start_tun_fd, @@ -84,6 +85,14 @@ native/ tun-e2e-linux.sh tun packet-routing e2e (Linux + root) run-tun-e2e-docker.sh runs the tun e2e in a privileged container (works from macOS; needs cargo-zigbuild + Docker) +ci/ + hos-emulator-e2e.sh on-device e2e driver: builds + signs both HAPs, + boots/unlocks the emulator, installs, runs the + ohosTest suites against a host-side ssserver + package-hos-toolchain.sh packs the DevEco tools + emulator image and + uploads them to the private CI bucket + r2-env.sh sourced helper: derives S3 credentials for that + bucket from R2_API_TOKEN + R2_ENDPOINT test-e2e-host.sh host-side verification entry point (see Testing) ``` @@ -194,7 +203,9 @@ Steps (order matters — the CMake build fails if the staticlib is missing): ## Testing -- **`./test-e2e-host.sh`** — host-side verification, no HarmonyOS SDK needed: +- **`./test-e2e-host.sh [tests|cross|tun ...]`** — host-side verification, no + HarmonyOS SDK needed. All three steps by default, or any subset by name (CI + runs one per workflow, so this stays the single definition of each): 1. `cargo test` in `native/sslocal-ffi` (includes `tests/e2e.rs`: a genuine end-to-end SOCKS5 round-trip through an in-process shadowsocks server, driving sslocal through the same C ABI the NAPI layer uses). @@ -206,26 +217,80 @@ Steps (order matters — the CMake build fails if the staticlib is missing): passwordless sudo (the script escalates itself); on macOS run it via `native/run-tun-e2e-docker.sh` (Docker + cargo-zigbuild), which builds the helper for the host architecture so the container runs it natively. -- **CI** — `.github/workflows/ci.yml` runs rustfmt, clippy and all three - `test-e2e-host.sh` steps (including the tun e2e, which a GitHub Linux - runner can do natively) on every push and pull request. Because the Rust - crate path-depends on the shared `shadowsocks-rust` checkout *outside* this - repository, the workflow clones it to the sibling path - `../core/src/main/rust/shadowsocks-rust` at the ref in `SHADOWSOCKS_RUST_REF` - — keep that in step with shadowsocks-android's submodule pin. The ArkTS - tests and the HAP build are **not** in CI: they need the DevEco - command-line tools, which are not publicly downloadable (see - `docs/hos-emulator-vpn.md` §4); run those locally. +- **CI** — one workflow per surface, so a failure names what broke instead of + pointing at one big job: + - `lint.yml` — rustfmt, clippy (`--no-deps`; the path-dependency would + otherwise be linted too) and shellcheck over every script. + - `test-core.yml` — `./test-e2e-host.sh tests`. + - `test-cross.yml` — `./test-e2e-host.sh cross` (with `mlugg/setup-zig`). + - `test-tun.yml` — `./test-e2e-host.sh tun` with `TUN_E2E_REQUIRED=1`, so a + missing `/dev/net/tun` fails the job instead of skipping quietly. + - `harmonyos-build.yml` — HAP build and debug signing, on a hosted **Linux** + runner (Huawei ships linux-x64 command-line tools). + - `harmonyos-unit-tests.yml` — the ArkTS unit tests, on a hosted **macOS** + runner: their runner drives the SDK previewer, which on Linux throws in a + container and hangs in a VM, with specs never executing. + - `harmonyos-e2e.yml` — the on-device suites, self-hosted (see below). + + The first four gate every push and pull request. Shared setup — the sibling + `shadowsocks-rust` checkout (this crate path-depends on it), the Rust + toolchain and the cargo cache — lives in the composite action + `.github/actions/rust-core`, whose `ref` input is the single place the core's + pin is defined; keep it in step with shadowsocks-android's submodule. + + The three HarmonyOS workflows need Huawei's DevEco command-line tools, which + are neither publicly downloadable (`docs/hos-emulator-vpn.md` §4) nor + redistributable, so they stream them from a private S3/R2 bucket using the + secrets `R2_API_TOKEN` (a Cloudflare API token) and `R2_ENDPOINT`; the S3 + keypair is derived from them at runtime by `ci/r2-env.sh` (token ID from + `/tokens/verify`, secret = SHA-256 of the token value) and masked. Two + toolchains live there: `hos-tools-linux-x64.zip` (Huawei's Linux zip, + verbatim — it holds 19 paths differing only in case, so repacking it on a + case-insensitive filesystem drops files) for the build job, and + `hos-tools.tar.zst` (macOS) for the unit tests. Only the 352-byte manifest is + fetched on a normal run: the unpacked toolchain is cached under that + archive's sha256 from the manifest, so a re-upload invalidates the cache by + itself. `ci/package-hos-toolchain.sh` builds and uploads the macOS bundle and + the emulator image; the Linux zip is uploaded as-is. + + Linux quirks the build job handles: `restool`'s + `libimage_transcoder_shared.so` links against libGL, so `libgl1` is installed + before `@CompileResource` runs; signing needs a JDK, which the runner image + ships. + + `harmonyos-e2e.yml` runs `ci/hos-emulator-e2e.sh` on a **self-hosted** + Apple-silicon runner labelled `harmonyos`. It cannot be hosted — the Emulator + binary and the image are both arm64, so it needs HVF, which GitHub's + Apple-silicon runners do not expose (no nested virtualization) and whose + Intel runners cannot run an arm64 emulator at all. It is skipped unless the + repository variables `HOS_SELF_HOSTED=true`, `HOS_TOOLS_PATH` and + `HOS_IMAGES_PATH` are set, so pushes never queue against an offline runner. + Secrets are unavailable to fork pull requests, which is why the host-side + workflows remain the gate for every PR. - **ArkTS unit tests** — `entry/src/test` (hypium): `ss://` URL parsing, both SOCKS and tun config serialization (including ACL injection), subscription body parsing. Run from DevEco Studio or headless: `hvigorw test --mode module -p module=entry -p product=default` (failures show up as `Error in ` lines). -- **On-device tests** — `entry/src/ohosTest`: exercises the NAPI surface - (including `startTunFd`) on a HarmonyOS emulator/device from DevEco Studio, - or headless: build the ohosTest HAP (`-p module=entry@ohosTest`), sign and - install both HAPs, then - `hdc shell aa test -b com.xbt.project -m entry_test -s unittest OpenHarmonyTestRunner`. +- **On-device tests** — `entry/src/ohosTest`, run from DevEco Studio or + headless via **`ci/hos-emulator-e2e.sh`** (`HOS_TOOLS= + HOS_IMAGES=~/Library/Huawei/Sdk ci/hos-emulator-e2e.sh`), which builds and + signs both HAPs, boots + unlocks the emulator, installs, starts a host-side + `ssserver` and marker page, and runs the suites. `SKIP_EMULATOR=1` reuses an + already-running emulator, `KEEP_EMULATOR=1` leaves it up. + - `SslocalNativeTest` — NAPI surface, including `startTunFd` rejection paths. + - `SocksE2eTest` — the emulator-capable e2e: the core is started in SOCKS + mode and a marker page is fetched *through* the tunnel, addressed as + `127.0.0.1:18800` so only the host-side `ssserver` can resolve it; a + second spec asserts the guest cannot reach it directly. + - `VpnE2eTest` — excluded from CI: the emulator never delivers guest traffic + to `vpn-tun` (`docs/hos-emulator-vpn.md` §2a), so it needs real hardware. + Writing on-device tests: raise hypium's 5s per-spec timeout with + `-s timeout ` on `aa test`, **never** `Hypium.setTimeConfig()` (it sets a + system-time provider, and a number there hangs the whole run); never close a + `TCPSocket` whose `connect()` is still in flight (it kills the test process + silently); a freshly booted image must be unlocked before `aa test` can + launch anything. ## Code style and conventions diff --git a/README.md b/README.md index 7d892af..2ca3af8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,11 @@ # Shadowsocks for HarmonyOS NEXT -[![CI](https://github.com/shadowsocks/shadowsocks-ohos/actions/workflows/ci.yml/badge.svg)](https://github.com/shadowsocks/shadowsocks-ohos/actions/workflows/ci.yml) +[![Lint](https://github.com/shadowsocks/shadowsocks-ohos/actions/workflows/lint.yml/badge.svg)](https://github.com/shadowsocks/shadowsocks-ohos/actions/workflows/lint.yml) +[![Core tests](https://github.com/shadowsocks/shadowsocks-ohos/actions/workflows/test-core.yml/badge.svg)](https://github.com/shadowsocks/shadowsocks-ohos/actions/workflows/test-core.yml) +[![Cross-compile](https://github.com/shadowsocks/shadowsocks-ohos/actions/workflows/test-cross.yml/badge.svg)](https://github.com/shadowsocks/shadowsocks-ohos/actions/workflows/test-cross.yml) +[![Tun e2e](https://github.com/shadowsocks/shadowsocks-ohos/actions/workflows/test-tun.yml/badge.svg)](https://github.com/shadowsocks/shadowsocks-ohos/actions/workflows/test-tun.yml) +[![HarmonyOS build](https://github.com/shadowsocks/shadowsocks-ohos/actions/workflows/harmonyos-build.yml/badge.svg)](https://github.com/shadowsocks/shadowsocks-ohos/actions/workflows/harmonyos-build.yml) +[![ArkTS unit tests](https://github.com/shadowsocks/shadowsocks-ohos/actions/workflows/harmonyos-unit-tests.yml/badge.svg)](https://github.com/shadowsocks/shadowsocks-ohos/actions/workflows/harmonyos-unit-tests.yml) A native HarmonyOS NEXT (ArkTS/ArkUI, Stage model) client, sharing the Rust core (`shadowsocks-rust`) with the Android app through a C ABI + NAPI bridge. @@ -75,24 +80,87 @@ HarmonyOS emulator needs a system image installed via DevEco / `Emulator ## Testing -* **Host e2e (no HarmonyOS SDK needed)** — `./test-e2e-host.sh`: - 1. Rust test suite, including `tests/e2e.rs`: an in-process shadowsocks - server, an sslocal instance driven through the same C ABI the NAPI bridge - uses, and a SOCKS5 round-trip through the encrypted tunnel. - 2. Cross-compile check that the whole core builds for +* **Host e2e (no HarmonyOS SDK needed)** — `./test-e2e-host.sh`, which runs + three independent steps; pass names to run a subset (`./test-e2e-host.sh + cross tun`): + 1. `tests` — Rust test suite, including `tests/e2e.rs`: an in-process + shadowsocks server, an sslocal instance driven through the same C ABI the + NAPI bridge uses, and a SOCKS5 round-trip through the encrypted tunnel. + 2. `cross` — cross-compile check that the whole core builds for `aarch64-unknown-linux-ohos` (real SDK clang if present, else a zig cc shim for the C bits). - 3. **Tun packet-routing e2e** (Linux, `/dev/net/tun`, root or passwordless - sudo): sends a real TCP flow into a tun device and asserts it round-trips - through the tunnel. On non-Linux hosts run it in a privileged container - with `native/run-tun-e2e-docker.sh`. - - All three steps, plus rustfmt and clippy, run in CI on every push and pull - request — see `.github/workflows/ci.yml`. + 3. `tun` — **tun packet-routing e2e** (Linux, `/dev/net/tun`, root or + passwordless sudo): sends a real TCP flow into a tun device and asserts it + round-trips through the tunnel. On non-Linux hosts run it in a privileged + container with `native/run-tun-e2e-docker.sh`. + + Each step is also its own CI workflow, so a red badge names the surface that + broke: `test-core.yml`, `test-cross.yml`, `test-tun.yml`, plus `lint.yml` for + rustfmt/clippy/shellcheck. All four gate every pull request. * **ArkTS unit tests** — `entry/src/test` (hypium) covers `ss://` URL parsing - and both SOCKS and tun config serialization; run from DevEco Studio. -* **On-device tests** — `entry/src/ohosTest` exercises the NAPI surface - (including `startTunFd`) on a HarmonyOS emulator/device from DevEco Studio. + and both SOCKS and tun config serialization. Run from DevEco Studio, or + headless with `hvigorw test --mode module -p module=entry -p product=default` + — **on macOS or Windows**: the runner drives the SDK's previewer, which does + not work on Linux. +* **On-device e2e (emulator)** — `ci/hos-emulator-e2e.sh` builds and debug-signs + both HAPs, boots the HarmonyOS emulator, installs them and runs + `entry/src/ohosTest` against a shadowsocks server on the host: + + ```sh + HOS_TOOLS=~/workspace/command-line-tools HOS_IMAGES=~/Library/Huawei/Sdk \ + ci/hos-emulator-e2e.sh + ``` + + `SocksE2e.test.ets` is the real end-to-end case: it starts the core in SOCKS + mode through the NAPI bridge and fetches a marker page that is only reachable + from the far end of the tunnel (a companion spec asserts it is unreachable + without it). `SslocalNativeTest` covers the rest of the NAPI surface. + `VpnE2e.test.ets` is skipped here — the public emulator image never delivers + guest traffic to `vpn-tun`, so it is a real-device test (see + `docs/hos-emulator-vpn.md` §2a). +* **CI** — one workflow per surface, so a failure names what broke: + + | workflow | what it runs | where | + |---|---|---| + | `lint.yml` | rustfmt, clippy, shellcheck | hosted Linux | + | `test-core.yml` | Rust unit tests + host e2e tunnels | hosted Linux | + | `test-cross.yml` | `aarch64-unknown-linux-ohos` build check | hosted Linux | + | `test-tun.yml` | tun packet-routing e2e | hosted Linux | + | `harmonyos-build.yml` | HAP build and debug signing | hosted Linux | + | `harmonyos-unit-tests.yml` | ArkTS unit tests | hosted macOS | + | `harmonyos-e2e.yml` | on-device suites on the emulator | self-hosted macOS | + + The first four gate every pull request. The HarmonyOS trio needs the DevEco + toolchain, which cannot be downloaded by a runner or redistributed, so it is + streamed from a private bucket populated by `ci/package-hos-toolchain.sh` and + authenticated with the repository secrets `R2_API_TOKEN` (a Cloudflare API + token) and `R2_ENDPOINT` — R2's S3 API takes that token as its ID plus the + SHA-256 of its value, which `ci/r2-env.sh` derives at runtime. The unpacked + toolchain is cached between runs, keyed on the archive's checksum from the + bundle manifest, so re-uploading a bundle invalidates it on its own. Since + secrets are not exposed to fork pull requests, those three run on pushes to + `main` and on demand. + + Huawei ships the command-line tools for Linux x64 as well, so the HAP build + runs on a Linux runner — more free disk, no Xcode eviction, faster start. Two + things keep the Mac in the picture: + + * The **ArkTS unit tests** cannot run on Linux. The task that emits the + results is what actually executes the specs, by driving the SDK's previewer + (a GUI component); on Linux it either throws or hangs, and a deliberately + failing spec produces no output at all. So `harmonyos-unit-tests.yml` uses + the macOS bundle. + * The **emulator** is macOS/Windows-only, and its binary and guest are both + arm64, so it needs HVF — which GitHub's Apple-silicon runners do not expose + (no nested virtualization) and whose Intel runners cannot use for an arm64 + guest. `harmonyos-e2e.yml` therefore targets a self-hosted Apple-silicon + runner, and is skipped unless the repository variables `HOS_SELF_HOSTED`, + `HOS_TOOLS_PATH` and `HOS_IMAGES_PATH` are set, so pushes are never left + queued against an offline runner. + + Common setup — the shared `shadowsocks-rust` checkout, the toolchain and the + cargo cache — lives in the composite action `.github/actions/rust-core`, + which is also where the core's pinned ref is defined. ## Tun mode diff --git a/ci/hos-emulator-e2e.sh b/ci/hos-emulator-e2e.sh new file mode 100755 index 0000000..6a1f40c --- /dev/null +++ b/ci/hos-emulator-e2e.sh @@ -0,0 +1,180 @@ +#!/usr/bin/env bash +# +# On-device verification of the HarmonyOS NEXT app against the HarmonyOS +# emulator: builds and debug-signs both HAPs, boots an emulator instance, +# installs, and runs the ohosTest suites against a host-side shadowsocks +# server. +# +# Everything the guest talks to is bound to the **host's loopback**, which the +# emulator reaches as 10.0.2.2 through QEMU's user-mode network: +# +# ssserver 127.0.0.1:18388 the tunnel endpoint +# http.server 127.0.0.1:18800 the marker page, only reachable through +# that tunnel (see SocksE2e.test.ets) +# +# The VPN e2e (VpnE2e.test.ets) is deliberately not run: the public emulator +# image never delivers guest traffic to vpn-tun (docs/hos-emulator-vpn.md §2a), +# so it is a real-device test. +# +# Requires (all provided by ci/package-hos-toolchain.sh on a CI runner): +# HOS_TOOLS the DevEco command-line-tools directory (hvigorw, ohpm, sdk, +# emulator, toolchains/hdc) +# HOS_IMAGES emulator image root, i.e. the parent of system-image/ +# ssserver on PATH, or SSSERVER pointing at the binary +# +# Env knobs: +# HOS_INSTANCE emulator instance name (default: ss_ci) +# HOS_OS_VERSION image version (default: HarmonyOS 6.1.1(24)) +# HOS_TARGET hdc target (default: 127.0.0.1:5555) +# KEEP_EMULATOR=1 leave the emulator running on exit +# SKIP_EMULATOR=1 use an already-running emulator (local iteration) +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$SCRIPT_DIR" + +: "${HOS_TOOLS:?set HOS_TOOLS to the command-line-tools directory}" +HOS_INSTANCE="${HOS_INSTANCE:-ss_ci}" +HOS_OS_VERSION="${HOS_OS_VERSION:-HarmonyOS 6.1.1(24)}" +HOS_TARGET="${HOS_TARGET:-127.0.0.1:5555}" +BUNDLE="com.xbt.project" + +export DEVECO_SDK_HOME="$HOS_TOOLS/sdk" +export OHOS_SDK_HOME="$HOS_TOOLS/sdk/default/openharmony" +export OHOS_NDK_HOME="${OHOS_NDK_HOME:-$OHOS_SDK_HOME/native}" +HDC="$OHOS_SDK_HOME/toolchains/hdc" +EMULATOR="$HOS_TOOLS/emulator/Emulator" +HVIGORW="$HOS_TOOLS/bin/hvigorw" + +WORK="$(mktemp -d)" +PIDS=() + +cleanup() { + local status=$? + for pid in ${PIDS[@]+"${PIDS[@]}"}; do + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + done + if [[ $status -ne 0 && -f "$WORK/hilog.txt" ]]; then + echo "=== device log (tail) ===" + tail -100 "$WORK/hilog.txt" || true + fi + if [[ -z "${SKIP_EMULATOR:-}" && -z "${KEEP_EMULATOR:-}" ]]; then + "$EMULATOR" -stop "$HOS_INSTANCE" >/dev/null 2>&1 || true + fi + rm -rf "$WORK" + exit $status +} +trap cleanup EXIT + +step() { echo ""; echo "=== $* ==="; } + +# -------------------------------------------------------------------------- +step "1/6 build the native core" +native/build-ohos.sh + +step "2/6 build and sign both HAPs" +[[ -d oh_modules ]] || "$HOS_TOOLS/bin/ohpm" install +"$HVIGORW" --no-daemon assembleHap --mode module -p product=default -p buildMode=debug +"$HVIGORW" --no-daemon assembleHap --mode module -p module=entry@ohosTest -p product=default -p buildMode=debug +APP_HAP="entry/build/default/outputs/default/entry-default-unsigned.hap" +TEST_HAP="entry/build/default/outputs/ohosTest/entry-ohosTest-unsigned.hap" +native/sign-hap-debug.sh "$APP_HAP" +native/sign-hap-debug.sh "$TEST_HAP" + +# -------------------------------------------------------------------------- +step "3/6 host-side shadowsocks server and marker page" +printf 'shadowsocks-ohos e2e OK\n' > "$WORK/e2e.txt" +SSSERVER="${SSSERVER:-$(command -v ssserver)}" +"$SSSERVER" -s 127.0.0.1:18388 -k shadowsocks-ohos-e2e -m aes-256-gcm -v \ + > "$WORK/ssserver.log" 2>&1 & +PIDS+=($!) +python3 -m http.server 18800 --bind 127.0.0.1 --directory "$WORK" \ + > "$WORK/http.log" 2>&1 & +PIDS+=($!) +sleep 1 +# Sanity-check the host side before blaming the guest for a failed fetch. +curl -sf -m 5 http://127.0.0.1:18800/e2e.txt | grep -q 'e2e OK' +echo "marker page served on 127.0.0.1:18800, ssserver on 127.0.0.1:18388" + +# -------------------------------------------------------------------------- +if [[ -z "${SKIP_EMULATOR:-}" ]]; then + step "4/6 boot the emulator" + "$EMULATOR" -license accept >/dev/null + # Where the (large, signed) system images live; the emulator defaults to + # ~/Library/Huawei/Sdk, which is not where CI unpacks them. + IMAGE_ROOT=() + [[ -n "${HOS_IMAGES:-}" ]] && IMAGE_ROOT=(-imageRoot "$HOS_IMAGES") + if ! "$EMULATOR" -list 2>/dev/null | grep -qx "$HOS_INSTANCE"; then + "$EMULATOR" -create "$HOS_INSTANCE" -deviceType Phone \ + -osVersion "$HOS_OS_VERSION" -memory 4 -storage 6 \ + ${IMAGE_ROOT[@]+"${IMAGE_ROOT[@]}"} + fi + "$EMULATOR" -start "$HOS_INSTANCE" -bootmode coldboot \ + ${IMAGE_ROOT[@]+"${IMAGE_ROOT[@]}"} > "$WORK/emulator.log" 2>&1 & + PIDS+=($!) +else + step "4/6 using the already-running emulator" +fi + +# The emulator often fails to register with hdc on its own; connect explicitly. +# (`tconn` says "Connect OK" the first time and "Target is connected, repeat +# operation" afterwards, so the target list is what we poll.) +step "5/6 wait for the device" +started=$SECONDS +until "$HDC" list targets 2>/dev/null | grep -q "$HOS_TARGET"; do + [[ $((SECONDS - started)) -lt 300 ]] || { echo "emulator did not come up"; exit 1; } + "$HDC" tconn "$HOS_TARGET" >/dev/null 2>&1 || true + sleep 5 +done +# ... and for the package manager to be ready to take an install. +until "$HDC" -t "$HOS_TARGET" shell "bm dump -a" 2>/dev/null | grep -q "ID:"; do + [[ $((SECONDS - started)) -lt 300 ]] || { echo "device never became ready"; exit 1; } + sleep 5 +done +"$HDC" -t "$HOS_TARGET" shell hilog > "$WORK/hilog.txt" 2>&1 & +PIDS+=($!) + +# A freshly booted image comes up locked, and `aa test` refuses to launch the +# test ability then ("The device screen is locked ... cannot be unlocked +# automatically" in developer mode). Wake it, stop it dimming again mid-run, +# and swipe the lock screen away (coordinates suit the phone profile's +# 1320x2856 screen). +"$HDC" -t "$HOS_TARGET" shell "power-shell wakeup" >/dev/null +"$HDC" -t "$HOS_TARGET" shell "power-shell timeout -o 2147483647" >/dev/null +"$HDC" -t "$HOS_TARGET" shell "uinput -T -m 660 2400 660 900 200" >/dev/null +sleep 2 + +"$HDC" -t "$HOS_TARGET" install -r "${APP_HAP%-unsigned.hap}-signed.hap" +"$HDC" -t "$HOS_TARGET" install -r "${TEST_HAP%-unsigned.hap}-signed.hap" + +# -------------------------------------------------------------------------- +step "6/6 run the on-device suites" +# SslocalNativeTest: NAPI surface. SocksE2eTest: the tunnel round-trip. +# VpnE2eTest is excluded on purpose (real hardware only, see the header). +# +# -s timeout raises hypium's 5s per-spec default; -w bounds `aa test` itself so +# a wedged test process fails the job instead of hanging it. Note that +# Hypium.setTimeConfig() is *not* a timeout setter — it installs a system-time +# provider that hypium calls .getRealTime() on, and passing a number there +# breaks the reporter and hangs the run. +RESULT="$WORK/aa-test.log" +"$HDC" -t "$HOS_TARGET" shell "aa test -b $BUNDLE -m entry_test \ + -s unittest OpenHarmonyTestRunner \ + -s class SslocalNativeTest,SocksE2eTest \ + -s timeout 60000 -w 300" 2>&1 | tee "$RESULT" + +grep -q "^OHOS_REPORT_CODE: 0" "$RESULT" \ + || { echo "on-device tests FAILED"; grep -E "OHOS_REPORT_RESULT|Timeout" "$RESULT"; exit 1; } +# A run that reports zero tests must not pass silently. +grep -qE "OHOS_REPORT_RESULT: stream=Tests run: [1-9]" "$RESULT" \ + || { echo "no tests ran"; exit 1; } + +echo "" +echo "host-side ssserver saw:" +grep -c "established tcp tunnel" "$WORK/ssserver.log" || true +echo "" +echo "========================================" +echo " HarmonyOS on-device e2e PASSED" +echo "========================================" diff --git a/ci/package-hos-toolchain.sh b/ci/package-hos-toolchain.sh new file mode 100755 index 0000000..5a09908 --- /dev/null +++ b/ci/package-hos-toolchain.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# +# Packages the HarmonyOS toolchain CI needs into two archives and uploads them +# to an S3-compatible bucket (Cloudflare R2), because Huawei's DevEco +# command-line tools and emulator images are behind an account + region-gated +# download (docs/hos-emulator-vpn.md §4) and cannot be fetched by a runner — +# nor redistributed publicly, which is why the bucket must be private. +# +# Run this on a Mac that already has both installed: +# +# HOS_TOOLS=~/workspace/command-line-tools \ +# HOS_IMAGES=~/Library/Huawei/Sdk \ +# R2_BUCKET=shadowsocks R2_ENDPOINT=https://.r2.cloudflarestorage.com \ +# R2_API_TOKEN= \ +# ci/package-hos-toolchain.sh +# +# R2_API_TOKEN/R2_ENDPOINT are the same two values the workflow reads from +# repository secrets; an AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY pair is used +# instead when exported. +# +# Produces (under $OUT, default ./hos-bundle) and uploads to +# s3://$R2_BUCKET/$PREFIX/: +# +# hos-tools.tar.zst the **macOS** command-line-tools, minus what a CLI +# build never uses. Used by harmonyos-unit-tests.yml +# (the ArkTS test runner only works on macOS). +# hos-images.tar.zst the emulator system image. CI does not fetch this — +# the emulator only runs on a self-hosted Apple-silicon +# runner, which has the image locally; it is here to +# provision such a machine (or a replacement) without +# going through Huawei's region-gated download again. +# manifest.txt versions and sha256 of every archive; the workflows +# key their toolchain cache on the relevant one. +# +# The **Linux** toolchain is not built here. Upload Huawei's zip verbatim: +# +# aws s3 cp --endpoint-url "$R2_ENDPOINT" --checksum-algorithm CRC32 \ +# commandline-tools-linux-x64-.zip \ +# "s3://$R2_BUCKET//hos-tools-linux-x64.zip" +# +# then add its sha256 to manifest.txt as ` hos-tools-linux-x64.zip`. +# It must not be repacked on macOS: it holds 19 pairs of paths differing only +# in case (linux/netfilter headers), which a case-insensitive filesystem +# silently collapses. +# +# Set NO_UPLOAD=1 to only build the archives locally. +# +set -euo pipefail + +HOS_TOOLS="${HOS_TOOLS:-$HOME/workspace/command-line-tools}" +HOS_IMAGES="${HOS_IMAGES:-$HOME/Library/Huawei/Sdk}" +IMAGE_SUBPATH="${IMAGE_SUBPATH:-system-image/HarmonyOS-6.1.1/phone_all_arm}" +OUT="${OUT:-$PWD/hos-bundle}" +PREFIX="${PREFIX:-harmonyos-6.1.1}" +ZSTD_LEVEL="${ZSTD_LEVEL:-10}" + +[[ -x "$HOS_TOOLS/bin/hvigorw" ]] || { echo "no hvigorw under $HOS_TOOLS"; exit 1; } +[[ -d "$HOS_IMAGES/$IMAGE_SUBPATH" ]] || { echo "no image at $HOS_IMAGES/$IMAGE_SUBPATH"; exit 1; } +command -v zstd >/dev/null || { echo "zstd required"; exit 1; } + +mkdir -p "$OUT" + +# Dropped: codelinter (~190 MB, only DevEco Studio's linter) and any *.orig +# backup copies of the emulator binary. The previewers look equally droppable +# but are not: hvigor validates the SDK's component list on every build and +# fails with "SDK component missing" if they are absent. +EXCLUDES=( + --exclude "codelinter" + --exclude "emulator/*.orig" + --exclude "*/.DS_Store" +) + +echo "=== packing tools from $HOS_TOOLS ===" +tar -C "$(dirname "$HOS_TOOLS")" "${EXCLUDES[@]}" -cf - "$(basename "$HOS_TOOLS")" \ + | zstd "-$ZSTD_LEVEL" -T0 -f -o "$OUT/hos-tools.tar.zst" + +echo "=== packing image from $HOS_IMAGES/$IMAGE_SUBPATH ===" +# Keep the system-image/... prefix: the emulator locates images by that layout +# under whatever -imageRoot it is given. +tar -C "$HOS_IMAGES" --exclude "*/.DS_Store" -cf - "$IMAGE_SUBPATH" \ + | zstd "-$ZSTD_LEVEL" -T0 -f -o "$OUT/hos-images.tar.zst" + +{ + echo "packaged: $(date -u +%Y-%m-%dT%H:%M:%SZ)" + echo "image: $IMAGE_SUBPATH" + grep -E "Version:|SDK:|apiVersion" "$HOS_TOOLS/version.txt" 2>/dev/null || true + echo "" + shasum -a 256 "$OUT/hos-tools.tar.zst" "$OUT/hos-images.tar.zst" | sed "s|$OUT/||" +} > "$OUT/manifest.txt" + +ls -lh "$OUT" +cat "$OUT/manifest.txt" + +if [[ -n "${NO_UPLOAD:-}" ]]; then + echo "NO_UPLOAD set; skipping upload" + exit 0 +fi + +: "${R2_BUCKET:?set R2_BUCKET}" +: "${R2_ENDPOINT:?set R2_ENDPOINT}" +command -v aws >/dev/null || { echo "aws CLI required (brew install awscli)"; exit 1; } + +# Same credential derivation the workflow uses. +# shellcheck source=ci/r2-env.sh +source "$(dirname "$0")/r2-env.sh" + +echo "=== uploading to s3://$R2_BUCKET/$PREFIX/ ===" +for file in hos-tools.tar.zst hos-images.tar.zst manifest.txt; do + aws s3 cp --endpoint-url "$R2_ENDPOINT" --checksum-algorithm CRC32 \ + "$OUT/$file" "s3://$R2_BUCKET/$PREFIX/$file" +done +echo "done; point the workflow's HOS_BUNDLE_PREFIX at $PREFIX" diff --git a/ci/r2-env.sh b/ci/r2-env.sh new file mode 100644 index 0000000..e5b4d40 --- /dev/null +++ b/ci/r2-env.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +# +# Sourced, not executed. Derives S3 credentials for the CI bucket from the two +# values CI stores: R2_API_TOKEN (a Cloudflare API token) and R2_ENDPOINT. +# +# R2's S3 API accepts a Cloudflare API token as the token's *ID* — which +# /tokens/verify returns, and the account is the first label of the endpoint +# host — plus the SHA-256 of the token value. An explicit AWS keypair wins if +# one is already exported, so a plain S3 credential still works everywhere. +# +# Safe to source more than once. + +if [[ -n "${R2_API_TOKEN:-}" && -z "${AWS_ACCESS_KEY_ID:-}" ]]; then + : "${R2_ENDPOINT:?set R2_ENDPOINT alongside R2_API_TOKEN}" + _r2_account="$(echo "$R2_ENDPOINT" | sed -E 's|https?://([^.]+)\..*|\1|')" + AWS_ACCESS_KEY_ID="$(curl -fsS \ + "https://api.cloudflare.com/client/v4/accounts/$_r2_account/tokens/verify" \ + -H "Authorization: Bearer $R2_API_TOKEN" \ + | python3 -c 'import json,sys; print(json.load(sys.stdin)["result"]["id"])')" + # sha256sum on Linux, shasum on macOS — this runs on both. + if command -v sha256sum >/dev/null; then + AWS_SECRET_ACCESS_KEY="$(printf '%s' "$R2_API_TOKEN" | sha256sum | cut -d' ' -f1)" + else + AWS_SECRET_ACCESS_KEY="$(printf '%s' "$R2_API_TOKEN" | shasum -a 256 | cut -d' ' -f1)" + fi + export AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY + unset _r2_account + # The ID is not secret by itself, but it is half a credential. + if [[ -n "${GITHUB_ACTIONS:-}" ]]; then + echo "::add-mask::$AWS_ACCESS_KEY_ID" + echo "::add-mask::$AWS_SECRET_ACCESS_KEY" + fi +fi + +export AWS_DEFAULT_REGION="${AWS_DEFAULT_REGION:-auto}" diff --git a/docs/hos-emulator-vpn.md b/docs/hos-emulator-vpn.md index e73b524..665989c 100644 --- a/docs/hos-emulator-vpn.md +++ b/docs/hos-emulator-vpn.md @@ -129,8 +129,10 @@ guest traffic is ever delivered to the tun on the public image**: So the missing consent app is not the only emulator gap: the policy routing that should steer app traffic into the VPN interface does not take effect -(system-side; the same flow works on real devices). An HTTP-level e2e -through the tunnel can therefore only pass on real hardware. +(system-side; the same flow works on real devices). An HTTP-level e2e **over +the tun path** can therefore only pass on real hardware. Traffic handed to the +core directly — SOCKS mode — is unaffected, and that is what CI exercises on +the emulator (see below). ### On-device e2e recipe (works on a real device) @@ -149,12 +151,41 @@ python3 -m http.server 8000 --directory --bind 0.0.0.0 hdc shell aa test -b com.xbt.project -m entry_test -s unittest OpenHarmonyTestRunner ``` -Gotchas learned: hypium's default per-spec timeout is 5 s (raise with -`Hypium.setTimeConfig(ms)` — `setTimeOut` does not exist in hypium 1.0.19); -`aa test` disconnects a previously running VPN extension of the same bundle; -the emulator's guest reaches the host at `10.0.2.2` and can also reach the -host's LAN address directly via slirp, so a successful fetch alone proves -nothing — the core's counters (or the server log) must be checked. +Gotchas learned: + +- hypium's default per-spec timeout is 5 s. Raise it with `-s timeout ` on + the `aa test` command line. **Not** with `Hypium.setTimeConfig(ms)`, despite + the name: that installs a *system-time provider* object, which hypium later + calls `.getRealTime()` on while reporting a finished spec. Handing it a + number makes that call throw inside the reporter, and the run hangs after the + spec body completes — the test process stays alive, logs nothing more, and + `aa test` sits there until its own `-w` deadline. +- `aa test` disconnects a previously running VPN extension of the same bundle. +- A freshly booted image is **locked**, and `aa test` will not launch the test + ability then ("The device screen is locked … cannot be unlocked + automatically", because the image is in developer mode). Wake and unlock it + first: `power-shell wakeup`, `power-shell timeout -o 2147483647` (so it does + not dim again mid-run) and a swipe, `uinput -T -m 660 2400 660 900 200`. +- Closing a `TCPSocket` whose `connect()` is still in flight kills the test + process outright — no JS error, no faultlog, just silence. +- The emulator's guest reaches the host at `10.0.2.2` and can also reach the + host's LAN address directly via slirp, so a successful fetch alone proves + nothing — the core's counters (or the server log) must be checked. The SOCKS + e2e sidesteps this by fetching `127.0.0.1:18800`, an address only the + host-side `ssserver` can resolve to the marker (see below). + +### The e2e that *does* run on the emulator + +Because §2a rules out any tun-based test here, the on-device test CI runs is +`entry/src/ohosTest/ets/test/SocksE2e.test.ets`: it starts the core in SOCKS +mode through the same NAPI entry point the app uses and pulls a marker page +through the tunnel, with a companion spec asserting the marker is unreachable +without it. `ci/hos-emulator-e2e.sh` drives the whole thing (build, sign, boot, +unlock, install, run) and is what `.github/workflows/harmonyos-e2e.yml` +invokes — on a self-hosted Apple-silicon +runner, since GitHub's hosted macOS runners cannot run this emulator (their +Apple-silicon machines have no nested virtualization, and their Intel ones +cannot execute an arm64 emulator binary at all). ## 3. Emulator image signature verification diff --git a/entry/src/ohosTest/ets/test/List.test.ets b/entry/src/ohosTest/ets/test/List.test.ets index 40b154e..f55dc29 100644 --- a/entry/src/ohosTest/ets/test/List.test.ets +++ b/entry/src/ohosTest/ets/test/List.test.ets @@ -1,7 +1,9 @@ import abilityTest from './Ability.test'; +import socksE2eTest from './SocksE2e.test'; import vpnE2eTest from './VpnE2e.test'; export default function testsuite() { abilityTest(); + socksE2eTest(); vpnE2eTest(); } diff --git a/entry/src/ohosTest/ets/test/SocksE2e.test.ets b/entry/src/ohosTest/ets/test/SocksE2e.test.ets new file mode 100644 index 0000000..1d65c20 --- /dev/null +++ b/entry/src/ohosTest/ets/test/SocksE2e.test.ets @@ -0,0 +1,197 @@ +/******************************************************************************* + * * + * Copyright (C) 2026 by Max Lv * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +import { afterAll, describe, expect, it } from '@ohos/hypium'; +import { socket } from '@kit.NetworkKit'; +import { hilog } from '@kit.PerformanceAnalysisKit'; +import sslocal from 'libsslocal.so'; + +const TAG = 'SocksE2eTest'; + +/** + * Host address as seen from the emulator's user-mode (slirp) network — QEMU + * maps it to the host's loopback, so a server bound to 127.0.0.1 on the host + * is reachable here (and only here). + */ +const HOST = '10.0.2.2'; +/** Host-side `ssserver` (see ci/hos-emulator-e2e.sh). */ +const SERVER_PORT = 18388; +const METHOD = 'aes-256-gcm'; +const PASSWORD = 'shadowsocks-ohos-e2e'; +/** SOCKS5 port the core listens on inside the guest. */ +const LOCAL_PORT = 11080; + +/** + * Marker HTTP server, bound to the **host's** loopback. Addressed as + * `127.0.0.1` on purpose: that literal is resolved on the far side of the + * tunnel, by `ssserver` on the host. From inside the guest, 127.0.0.1 is the + * guest's own loopback, where nothing listens — so a successful fetch can + * only mean the request travelled through the encrypted tunnel. (Addressing + * the host as 10.0.2.2 would prove nothing: slirp would carry it directly.) + */ +const MARKER_HOST = '127.0.0.1'; +const MARKER_PORT = 18800; +const MARKER_BODY = 'shadowsocks-ohos e2e OK'; + +const REQUEST = `GET /e2e.txt HTTP/1.0\r\nHost: ${MARKER_HOST}:${MARKER_PORT}\r\n\r\n`; + +/** JSON config for the core: one SOCKS5 local, pointing at the host ssserver. */ +function localConfig(): string { + const config: Record = { + 'locals': [Object({ + 'protocol': 'socks', + 'local_address': '127.0.0.1', + 'local_port': LOCAL_PORT, + })], + 'server': HOST, + 'server_port': SERVER_PORT, + 'password': PASSWORD, + 'method': METHOD, + }; + return JSON.stringify(config); +} + +/** Sleeps for `ms`. */ +function sleep(ms: number): Promise { + return new Promise((resolve: Function) => setTimeout(resolve, ms)); +} + +/** ASCII decode; the marker page and HTTP headers are plain ASCII. */ +function decode(data: ArrayBuffer): string { + const bytes = new Uint8Array(data); + let text = ''; + for (let i = 0; i < bytes.length; i++) { + text += String.fromCharCode(bytes[i]); + } + return text; +} + +/** + * Connects to the marker server, optionally through the core's SOCKS5 local, + * and returns what the server sent back. Rejects if the connection cannot be + * established. + * + * Closing a socket whose `connect()` is still in flight kills the test process + * (no JS error, no faultlog), so the socket is only ever closed once connected. + */ +async function fetchMarker(viaSocks: boolean, timeoutMs: number): Promise { + const tcp: socket.TCPSocket = socket.constructTCPSocketInstance(); + const connectOptions: socket.TCPConnectOptions = { + address: { address: MARKER_HOST, port: MARKER_PORT, family: 1 }, + timeout: timeoutMs, + }; + if (viaSocks) { + connectOptions.proxy = { + type: socket.ProxyTypes.SOCKS5, + address: { address: '127.0.0.1', port: LOCAL_PORT, family: 1 }, + }; + } + await tcp.connect(connectOptions); + try { + return await new Promise((resolve, reject) => { + let received = ''; + let settled = false; + const settle = (err: Error | null): void => { + if (settled) { + return; + } + settled = true; + if (err !== null) { + reject(err); + } else { + resolve(received); + } + }; + const timer = setTimeout((): void => { + settle(new Error(`no marker after ${timeoutMs}ms, got ${received.length} bytes`)); + }, timeoutMs); + tcp.on('message', (value: socket.SocketMessageInfo) => { + received += decode(value.message); + if (received.includes(MARKER_BODY)) { + clearTimeout(timer); + settle(null); + } + }); + tcp.send({ data: REQUEST }).catch((err: Error): void => { + clearTimeout(timer); + settle(err); + }); + }); + } finally { + await tcp.close(); + } +} + +/** + * On-device end-to-end test of the SOCKS path: starts the native core through + * the same NAPI entry point the app uses, then pulls a marker page through the + * encrypted tunnel to a host-side `ssserver`. + * + * Unlike `VpnE2e.test.ets` this needs no VPN consent and no tun routing, so it + * runs on the public HarmonyOS emulator image, where guest traffic never + * reaches `vpn-tun` (see docs/hos-emulator-vpn.md §2a). It is the on-device + * test CI runs; the VPN e2e stays a real-device exercise. + * + * Host side, provided by `ci/hos-emulator-e2e.sh`: + * ssserver -s 127.0.0.1:18388 -k -m aes-256-gcm + * python3 -m http.server 18800 --bind 127.0.0.1 # serving e2e.txt + */ +export default function socksE2eTest() { + describe('SocksE2eTest', () => { + afterAll(() => { + sslocal.stop(); + }); + + it('round-trips an HTTP fetch through the shadowsocks tunnel', 0, async () => { + const rc = sslocal.start(localConfig()); + if (rc !== 0) { + hilog.error(0x0000, TAG, 'sslocal.start failed: %{public}s', sslocal.lastError()); + } + expect(rc).assertEqual(0); + expect(sslocal.isRunning()).assertTrue(); + + // the core binds its SOCKS listener asynchronously + await sleep(1000); + + const body = await fetchMarker(true, 20000); + hilog.info(0x0000, TAG, 'tunnelled fetch: %{public}s', body); + expect(body.includes(MARKER_BODY)).assertTrue(); + + expect(sslocal.stop()).assertEqual(0); + expect(sslocal.isRunning()).assertFalse(); + }); + + it('cannot reach the marker without the tunnel', 0, async () => { + // Control for the test above: the marker listens on the *host's* + // loopback, so the guest must not be able to reach `127.0.0.1:18800` + // on its own. Without this, a fetch that slipped out through slirp + // would look exactly like a working tunnel. + let reached = false; + try { + const body = await fetchMarker(false, 5000); + hilog.warn(0x0000, TAG, 'direct fetch unexpectedly returned: %{public}s', body); + reached = body.includes(MARKER_BODY); + } catch (err) { + hilog.info(0x0000, TAG, 'direct fetch failed as expected: %{public}s', + JSON.stringify(err)); + } + expect(reached).assertFalse(); + }); + }); +} diff --git a/entry/src/ohosTest/ets/test/VpnE2e.test.ets b/entry/src/ohosTest/ets/test/VpnE2e.test.ets index 5b613f3..6c0113a 100644 --- a/entry/src/ohosTest/ets/test/VpnE2e.test.ets +++ b/entry/src/ohosTest/ets/test/VpnE2e.test.ets @@ -17,7 +17,7 @@ * * *******************************************************************************/ -import { describe, expect, it, Hypium } from '@ohos/hypium'; +import { describe, expect, it } from '@ohos/hypium'; import { http, vpnExtension } from '@kit.NetworkKit'; import { Want } from '@kit.AbilityKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; @@ -75,8 +75,6 @@ const MARKER_BODY = 'shadowsocks-ohos e2e OK'; export default function vpnE2eTest() { describe('VpnE2eTest', () => { it('routes an HTTP fetch through the shadowsocks tunnel', 0, async () => { - // starting the VPN + fetching + stats sampling exceeds hypium's 5s default - Hypium.setTimeConfig(20000); await vpnExtension.startVpnExtensionAbility(VPN_ABILITY); hilog.info(0x0000, TAG, 'vpn extension started'); try { diff --git a/native/tun-e2e-linux.sh b/native/tun-e2e-linux.sh index 2b77cc8..3f4cffd 100755 --- a/native/tun-e2e-linux.sh +++ b/native/tun-e2e-linux.sh @@ -24,6 +24,10 @@ MESSAGE="hello-through-the-tun-packet-router" [[ -x "$HELPER" ]] || { echo "helper binary not found/executable: $HELPER" >&2; exit 1; } PIDS=() +# Invoked by the EXIT trap below, which shellcheck cannot see: older versions +# call the body unreachable (SC2317), newer ones call the function unused +# (SC2329). +# shellcheck disable=SC2317,SC2329 cleanup() { for pid in "${PIDS[@]:-}"; do kill "$pid" 2>/dev/null || true; done ip netns pids ssns 2>/dev/null | xargs -r kill 2>/dev/null || true diff --git a/test-e2e-host.sh b/test-e2e-host.sh index d76b9f2..fcb6a35 100755 --- a/test-e2e-host.sh +++ b/test-e2e-host.sh @@ -1,79 +1,118 @@ #!/usr/bin/env bash # -# Host-side verification of the HarmonyOS NEXT subproject's native core: +# Host-side verification of the HarmonyOS NEXT subproject's native core. Three +# independent steps, run all together by default or one at a time by name: # -# 1. cargo test — includes tests/e2e.rs, a genuine end-to-end tunnel test: -# an in-process shadowsocks server, an sslocal instance driven through -# the same C ABI the NAPI layer uses, and a SOCKS5 round-trip through the -# encrypted tunnel to an echo server. -# 2. cargo check --target aarch64-unknown-linux-ohos — proves the whole core -# (shadowsocks-service + FFI) compiles for OpenHarmony. Uses the real OHOS -# SDK clang when OHOS_NDK_HOME is set, otherwise falls back to a zig-based -# compile-only shim for the C bits (blake3). +# tests cargo test — includes tests/e2e.rs, a genuine end-to-end tunnel +# test: an in-process shadowsocks server, an sslocal instance driven +# through the same C ABI the NAPI layer uses, and a SOCKS5 round-trip +# through the encrypted tunnel to an echo server. +# cross cargo check --target aarch64-unknown-linux-ohos — proves the whole +# core (shadowsocks-service + FFI) compiles for OpenHarmony. Uses the +# real OHOS SDK clang when OHOS_NDK_HOME is set, otherwise falls back +# to a zig-based compile-only shim for the C bits (blake3). +# tun a real TCP flow into a tun device, asserted to round-trip through +# the tunnel. Needs Linux, /dev/net/tun and CAP_NET_ADMIN. +# +# Usage: +# ./test-e2e-host.sh # all three +# ./test-e2e-host.sh tests # just the Rust suite +# ./test-e2e-host.sh cross tun # any subset, in the order given +# +# CI runs each step as its own workflow (.github/workflows/test-*.yml) so a +# failure names the surface that broke; this script stays the single place that +# defines how each one runs. # set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -cd "$SCRIPT_DIR/native/sslocal-ffi" +CRATE_DIR="$SCRIPT_DIR/native/sslocal-ffi" + +STEPS=("$@") +[[ ${#STEPS[@]} -gt 0 ]] || STEPS=(tests cross tun) -echo "=== 1/3 host tests (incl. SOCKS5 e2e round-trip) ===" -cargo test +step_tests() { + echo "=== host tests (incl. SOCKS5 e2e round-trip) ===" + cd "$CRATE_DIR" + cargo test +} -echo "=== 2/3 cross-compile check for aarch64-unknown-linux-ohos ===" -if ! rustup target list --installed | grep -q aarch64-unknown-linux-ohos; then - rustup target add aarch64-unknown-linux-ohos -fi -# Prefer the real OpenHarmony SDK clang; fall back to a zig cc shim for the C -# bits (blake3) when the SDK is absent. -if [[ -z "${OHOS_NDK_HOME:-}" ]]; then - for candidate in \ - "$HOME/Downloads/command-line-tools/sdk/default/openharmony/native" \ - "$HOME/command-line-tools/sdk/default/openharmony/native"; do - [[ -d "$candidate" ]] && OHOS_NDK_HOME="$candidate" && break - done -fi -if [[ -n "${OHOS_NDK_HOME:-}" ]]; then - export CC_aarch64_unknown_linux_ohos="$OHOS_NDK_HOME/llvm/bin/aarch64-unknown-linux-ohos-clang" -elif command -v zig >/dev/null; then - export CC_aarch64_unknown_linux_ohos="$SCRIPT_DIR/native/ohos-cc-wrapper.sh" -else - echo "Neither OHOS_NDK_HOME nor zig available; skipping C shim" >&2 -fi -cargo check --target aarch64-unknown-linux-ohos +step_cross() { + echo "=== cross-compile check for aarch64-unknown-linux-ohos ===" + cd "$CRATE_DIR" + if ! rustup target list --installed | grep -q aarch64-unknown-linux-ohos; then + rustup target add aarch64-unknown-linux-ohos + fi + # Prefer the real OpenHarmony SDK clang; fall back to a zig cc shim for the + # C bits (blake3) when the SDK is absent. + if [[ -z "${OHOS_NDK_HOME:-}" ]]; then + for candidate in \ + "$HOME/Downloads/command-line-tools/sdk/default/openharmony/native" \ + "$HOME/command-line-tools/sdk/default/openharmony/native"; do + [[ -d "$candidate" ]] && OHOS_NDK_HOME="$candidate" && break + done + fi + if [[ -n "${OHOS_NDK_HOME:-}" ]]; then + export CC_aarch64_unknown_linux_ohos="$OHOS_NDK_HOME/llvm/bin/aarch64-unknown-linux-ohos-clang" + elif command -v zig >/dev/null; then + export CC_aarch64_unknown_linux_ohos="$SCRIPT_DIR/native/ohos-cc-wrapper.sh" + else + echo "Neither OHOS_NDK_HOME nor zig available; skipping C shim" >&2 + fi + cargo check --target aarch64-unknown-linux-ohos +} -echo "=== 3/3 tun packet-routing e2e ===" -# Genuine IP-packet round-trip through the tun stack. Needs Linux, iproute2, -# /dev/net/tun and CAP_NET_ADMIN — root, or passwordless sudo (which is what -# CI runners and most Linux dev boxes have). On other hosts, run it in a -# container with harmony/native/run-tun-e2e-docker.sh instead. -AS_ROOT=() -if [[ "$(uname -s)" != "Linux" ]]; then - TUN_SKIP="not Linux" -elif ! command -v ip >/dev/null; then - TUN_SKIP="iproute2 (ip) not installed" -elif [[ ! -c /dev/net/tun ]]; then - TUN_SKIP="/dev/net/tun is missing" -elif [[ "$(id -u)" == "0" ]]; then - TUN_SKIP="" -elif command -v sudo >/dev/null && sudo -n true 2>/dev/null; then - TUN_SKIP="" - AS_ROOT=(sudo) -else - TUN_SKIP="needs root or passwordless sudo" -fi +step_tun() { + echo "=== tun packet-routing e2e ===" + # Genuine IP-packet round-trip through the tun stack. Needs Linux, + # iproute2, /dev/net/tun and CAP_NET_ADMIN — root, or passwordless sudo + # (which is what CI runners and most Linux dev boxes have). On other hosts, + # run it in a container with native/run-tun-e2e-docker.sh instead. + local as_root=() + local skip="" + if [[ "$(uname -s)" != "Linux" ]]; then + skip="not Linux" + elif ! command -v ip >/dev/null; then + skip="iproute2 (ip) not installed" + elif [[ ! -c /dev/net/tun ]]; then + skip="/dev/net/tun is missing" + elif [[ "$(id -u)" == "0" ]]; then + skip="" + elif command -v sudo >/dev/null && sudo -n true 2>/dev/null; then + as_root=(sudo) + else + skip="needs root or passwordless sudo" + fi + + # A skip must not look like a pass where the environment can support the + # test: CI sets TUN_E2E_REQUIRED=1 so a missing prerequisite fails the job. + if [[ -n "$skip" ]]; then + if [[ -n "${TUN_E2E_REQUIRED:-}" ]]; then + echo "tun e2e cannot run ($skip) but TUN_E2E_REQUIRED is set" >&2 + return 1 + fi + echo "SKIPPED ($skip); use run-tun-e2e-docker.sh on other hosts" + return 0 + fi -if [[ -z "${TUN_SKIP:-}" ]]; then # build the helper as the invoking user, run the namespace setup as root + cd "$CRATE_DIR" cargo build --release --example net_helper - "${AS_ROOT[@]+"${AS_ROOT[@]}"}" env \ - NET_HELPER="$SCRIPT_DIR/native/sslocal-ffi/target/release/examples/net_helper" \ + "${as_root[@]+"${as_root[@]}"}" env \ + NET_HELPER="$CRATE_DIR/target/release/examples/net_helper" \ RUST_LOG="${RUST_LOG:-info}" \ bash "$SCRIPT_DIR/native/tun-e2e-linux.sh" -else - echo "SKIPPED ($TUN_SKIP); use run-tun-e2e-docker.sh on other hosts" -fi +} + +for step in "${STEPS[@]}"; do + case "$step" in + tests|cross|tun) "step_$step" ;; + *) echo "unknown step '$step' (want: tests, cross, tun)" >&2; exit 2 ;; + esac + echo "" +done -echo "" echo "========================================" echo " HarmonyOS core verification PASSED" +echo " steps: ${STEPS[*]}" echo "========================================"