Files
EasyTier/.github/workflows/core.yml
T
KKRainbow 32dc7f1f40 feat: add shared virtual nic (#2352)
* Add shared virtual NIC core with per-member routing

Share a named TUN across instances while keeping unnamed devices
dedicated. Track member addresses, routes, and MTU, and reject
overlapping destinations except the common Magic DNS host route.

Dispatch packets between the physical TUN and member tunnels. Keep
IPv4 and ordinary IPv6 source translation for mobile wrong-source
traffic. Update platform configuration and runtime host integration,
with unit and network namespace coverage for routing and lifecycle.

Use smoltcp for packet parsing and checksum updates. Keep core CI
matrix jobs independent.

Reserve native inbound flows before source translations allocate their
wire tuples. Keep active reservations under table pressure and leave
forwarded subnet traffic outside the local flow table.

Complete interface claim updates on the shared runtime and retain member
registrations until the transaction commits. Keep address, route, and
MTU cleanup ordered after cancelled callers, including device
initialization.

Project mobile VPN addresses, routes, and DNS from admitted native
members. Prefer unchanged attached claims and report conflicting members
individually. Apply each member's complete initial claims before opening
the shared fd, so conflicts leave healthy members usable.

Cover inbound IPv4/IPv6 collisions, cancellation during native changes
and dispatcher acknowledgments, admission priority, and atomic mobile
claim rejection with regression tests.

Use scoped TestNetwork fixtures for shared TUN bridge setup and cleanup.
Resolve public IPv6 namespace aliases through the lab's actual names so
the new tests remain isolated under parallel upstream test runs.

* Configure one mobile VPN from running TUN instances

Use admitted native members to configure VpnService and attach its
single fd to the core shared TUN. Wait for attachment completion and
record the actual attached members before refreshing VPN state. Report
rejected members in the GUI and preserve the VPN for unchanged healthy
members.

Reconcile ordinary configuration notifications without invalidating an
arbitrary member. Invalidate attachments on instance lifecycle changes,
including restarts that reuse an instance ID. Forward public IPv6
changes alongside proxy route changes to refresh the platform VPN
configuration.

Exclude confirmed IPv4-less members from readiness gating while
retaining retries for unavailable status and pending addresses. Quick
Settings stops all running TUN members and restores that group on the
next start, skipping removed configurations and no_tun networks.

Keep the FFI set_tun_fd call scoped to one instance and report
attachment errors. Cover mobile configuration, member rejection and
replacement, attachment completion, collection notifications, IPv4-less
members, and tile stop/start restoration in GUI regression tests.

Validate with 36 mobile VPN tests, vue-tsc --noEmit, Android GUI cargo
check, workspace clippy, and shared TUN network namespace tests.

* fix(tun): preserve shared NIC ownership and failure invariants

Keep mobile address and route claims in dispatcher tables without
applying desktop interface configuration to synthetic TUN names.

Limit the Magic DNS overlap exemption to route claims and remove the
shared route retry that bypassed claim accounting after invalidation.

Invalidate and stop dispatchers on failed mobile TUN replacement or I/O.
Bound TUN writes so stalled devices release pending source updates and
member cleanup instead of retaining an unusable shared dispatcher.

Treat missing Darwin and Windows addresses and routes as successful
deletions while preserving permission and other configuration errors.

Add regressions for claim conflicts, mobile configuration, failed TUN
replacement, stalled writes, and platform deletion error handling.

* fix(tun): forward single-member shared NIC traffic unchanged

Deliver TUN packets directly to the sole attached member without
destination matching or creating source translations. Keep native flow
reservations so a later member cannot collide with existing connections,
and continue applying mappings created before other members leave.

Reuse flow preparation with the original source address. Preserve the
desktop subnet forwarding bypass, including when the flow table is full.
IPv4 fragments and IPv6 extension headers remain unchanged on flows that
need no translation.

Cover unknown sources and destinations, first and later IP fragments,
native flows established before another member joins, and retained
translations after returning to one member. Document the forwarding
behavior and retain rejection coverage for multiple attached members.

* fix(tun): preserve wildcard UDP replies on shared member ports

Mark flow reservations created by incoming traffic separately from
outgoing mappings. When a wildcard UDP socket replies with another
member's source address, reuse the incoming reservation after source
correction and preserve its service port and inbound destination.

Keep exact outgoing mappings and outgoing flow collision handling ahead
of this rule. Incoming UDP sessions take priority when packet tuples
cannot distinguish a reply from a new client using identical endpoints.

Cover IPv4 and IPv6 replies, checksum updates, repeated inbound traffic,
existing outgoing mappings, TCP collisions, and single-to-shared member
transitions with regression tests.

* test(tun): wait for IPv6 activation in mobile configuration test

Assert that the configured IPv6 address is registered before forwarding
starts, then wait up to five seconds for the kernel to make it usable.
Netlink acknowledges address creation before asynchronous IPv6
activation completes, so an immediate bind can fail under CI
parallelism.

Disable automatic link-local address generation in the test namespace to
keep startup IPv6 control traffic out of member-routing assertions.

Validation: the original test failed 40 of 320 runs at 32-way
parallelism. The updated test passed all 320 runs; workspace fmt and
clippy also pass.
2026-10-13 21:00:03 +08:00

442 lines
15 KiB
YAML

name: EasyTier Core
on:
workflow_dispatch:
inputs:
sign_windows:
description: Submit Windows artifacts for SignPath test signing
type: boolean
default: true
push:
branches: [ "develop", "main", "releases/**" ]
pull_request:
branches: [ "develop", "main" ]
types: [ opened, synchronize, reopened, ready_for_review ]
concurrency:
# A new PR or branch push must not cancel a full build.
group: ${{ github.workflow }}-${{ (github.event_name == 'workflow_dispatch' || startsWith(github.ref_name, 'releases/')) && 'full' || 'check' }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
defaults:
run:
# necessary for windows
shell: bash
jobs:
pre_job:
runs-on: ubuntu-latest
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
env:
RELEVANT_PATHS: >-
["Cargo.toml", "Cargo.lock", "rust-toolchain.toml", ".cargo/**",
"pnpm-lock.yaml", "pnpm-workspace.yaml", "package.json", "easytier/**",
"easytier-core/**", "easytier-proto/**", "easytier-rpc-build/**",
"easytier-web/**", "easytier-contrib/easytier-magisk/**",
".github/workflows/core.yml", ".github/actions/**"]
FRONTEND_PATHS: >-
["easytier-web/frontend/**", "easytier-web/frontend-lib/**",
"easytier-web/config-generator/**", "easytier-proto/proto/**",
"easytier-core/src/config/**", "easytier-core/src/lib.rs",
"pnpm-lock.yaml", "pnpm-workspace.yaml", "package.json",
"Cargo.toml", "Cargo.lock", "rust-toolchain.toml", ".cargo/**",
"easytier-core/Cargo.toml", "easytier-proto/Cargo.toml",
".github/workflows/core.yml", ".github/actions/prepare-pnpm/**"]
outputs:
full_build: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.ref_name, 'releases/') }}
frontend_changed: ${{ steps.path_filter.outputs.frontend }}
# Release builds must run even if they only change release metadata.
should_skip: >-
${{
(
steps.skip_check.outputs.should_skip == 'true' ||
(
github.event_name == 'pull_request' &&
steps.path_filter.outputs.relevant != 'true'
)
) &&
github.event_name != 'workflow_dispatch' &&
!startsWith(github.ref_name, 'releases/')
}}
steps:
- id: skip_check
if: github.event_name != 'workflow_dispatch' && !startsWith(github.ref_name, 'releases/')
uses: fkirc/skip-duplicate-actions@v5
with:
concurrent_skipping: 'same_content_newer'
skip_after_successful_duplicate: 'true'
paths: ${{ env.RELEVANT_PATHS }}
- id: path_filter
if: >-
github.event_name == 'pull_request' &&
steps.skip_check.outputs.should_skip != 'true'
uses: dorny/paths-filter@v4
with:
filters: |
relevant: ${{ env.RELEVANT_PATHS }}
frontend: ${{ env.FRONTEND_PATHS }}
check:
name: Check (${{ matrix.TARGET }})
needs: pre_job
if: needs.pre_job.outputs.should_skip != 'true' && needs.pre_job.outputs.full_build != 'true'
strategy:
fail-fast: true
matrix:
include:
- TARGET: x86_64-unknown-linux-musl
OS: ubuntu-24.04
FEATURES: jemalloc
- TARGET: aarch64-apple-darwin
OS: macos-latest
FEATURES: jemalloc
- TARGET: x86_64-pc-windows-msvc
OS: windows-latest
FEATURES: mimalloc
runs-on: ${{ matrix.OS }}
steps:
- uses: actions/checkout@v5
- uses: ./.github/actions/prepare-build
with:
target: ${{ matrix.TARGET }}
gui: false
pnpm: false
token: ${{ github.token }}
- uses: Swatinem/rust-cache@v2
with:
shared-key: core-check-${{ matrix.TARGET }}
save-if: ${{ github.event_name != 'pull_request' }}
- name: Check Core and Web
run: cargo check --locked --all-targets --target ${{ matrix.TARGET }} --features ${{ matrix.FEATURES }}
build_web:
runs-on: ubuntu-latest
needs: pre_job
if: >-
needs.pre_job.outputs.should_skip != 'true' &&
(
needs.pre_job.outputs.full_build == 'true' ||
github.event_name != 'pull_request' ||
needs.pre_job.outputs.frontend_changed == 'true'
)
steps:
- uses: actions/checkout@v5
- name: Setup Frontend Environment
uses: ./.github/actions/prepare-pnpm
with:
token: ${{ github.token }}
build-filter: './easytier-web/*'
- name: Archive artifact
if: needs.pre_job.outputs.full_build == 'true'
uses: actions/upload-artifact@v5
with:
name: easytier-web-dashboard
path: |
easytier-web/frontend/dist/*
build:
strategy:
fail-fast: false
matrix:
include:
- TARGET: x86_64-unknown-linux-musl
OS: ubuntu-24.04
ARTIFACT_NAME: linux-x86_64
- TARGET: aarch64-unknown-linux-musl
OS: ubuntu-24.04-arm
ARTIFACT_NAME: linux-aarch64
- TARGET: riscv64gc-unknown-linux-musl
OS: ubuntu-24.04
ARTIFACT_NAME: linux-riscv64
- TARGET: loongarch64-unknown-linux-musl
OS: ubuntu-24.04
ARTIFACT_NAME: linux-loongarch64
- TARGET: armv7-unknown-linux-musleabihf # raspberry pi 2-3-4, not tested
OS: ubuntu-24.04
ARTIFACT_NAME: linux-armv7hf
- TARGET: armv7-unknown-linux-musleabi # raspberry pi 2-3-4, not tested
OS: ubuntu-24.04
ARTIFACT_NAME: linux-armv7
- TARGET: arm-unknown-linux-musleabihf # raspberry pi 0-1, not tested
OS: ubuntu-24.04
ARTIFACT_NAME: linux-armhf
- TARGET: arm-unknown-linux-musleabi # raspberry pi 0-1, not tested
OS: ubuntu-24.04
ARTIFACT_NAME: linux-arm
- TARGET: mips-unknown-linux-musl
OS: ubuntu-24.04
ARTIFACT_NAME: linux-mips
- TARGET: mipsel-unknown-linux-musl
OS: ubuntu-24.04
ARTIFACT_NAME: linux-mipsel
- TARGET: x86_64-unknown-freebsd
OS: ubuntu-24.04
ARTIFACT_NAME: freebsd-13.2-x86_64
BSD_VERSION: 13.2
- TARGET: x86_64-apple-darwin
OS: macos-latest
ARTIFACT_NAME: macos-x86_64
- TARGET: aarch64-apple-darwin
OS: macos-latest
ARTIFACT_NAME: macos-aarch64
- TARGET: x86_64-pc-windows-msvc
OS: windows-latest
ARTIFACT_NAME: windows-x86_64
- TARGET: i686-pc-windows-msvc
OS: windows-latest
ARTIFACT_NAME: windows-i686
- TARGET: aarch64-pc-windows-msvc
OS: windows-11-arm
ARTIFACT_NAME: windows-arm64
runs-on: ${{ matrix.OS }}
env:
NAME: easytier
TARGET: ${{ matrix.TARGET }}
OS: ${{ matrix.OS }}
OSS_BUCKET: ${{ secrets.ALIYUN_OSS_BUCKET }}
needs:
- pre_job
- build_web
if: needs.pre_job.outputs.should_skip != 'true' && needs.pre_job.outputs.full_build == 'true'
steps:
- uses: actions/checkout@v5
- name: Set current ref as env variable
run: |
echo "GIT_DESC=$(git log -1 --format=%cd.%h --date=format:%Y-%m-%d_%H:%M:%S)" >> $GITHUB_ENV
- name: Download web artifact
uses: actions/download-artifact@v4
with:
name: easytier-web-dashboard
path: easytier-web/frontend/dist/
- name: Prepare build environment
uses: ./.github/actions/prepare-build
with:
target: ${{ matrix.TARGET }}
gui: false
pnpm: false
token: ${{ secrets.GITHUB_TOKEN }}
- uses: Swatinem/rust-cache@v2
with:
# The prefix cache key, this can be changed to start a new cache manually.
# default: "v0-rust"
prefix-key: ""
shared-key: core-release-${{ matrix.TARGET }}
cache-targets: "true"
- uses: mlugg/setup-zig@v2
if: ${{ contains(matrix.OS, 'ubuntu') }}
with:
version: 0.16.0
use-cache: true
- uses: taiki-e/install-action@v2
if: ${{ contains(matrix.OS, 'ubuntu') }}
with:
# v0.23.3 emits -mcpu=generic+v6+strict_align for
# arm-unknown-linux-musleabi, which zig 0.16.0 rejects;
# unpin only together with a zig bump.
tool: cargo-zigbuild@0.23.2
- name: Build
if: ${{ !contains(matrix.TARGET, 'mips') }}
run: |
if [[ "$TARGET" == *windows* ]]; then
SUFFIX=.exe
else
SUFFIX=""
fi
if [[ "$TARGET" =~ (x86_64-unknown-linux-musl|aarch64-unknown-linux-musl|windows|darwin) ]]; then
BUILD=build
else
BUILD=zigbuild
fi
if [[ "$TARGET" =~ ^(riscv64|loongarch64|aarch64).*$ || "$TARGET" =~ (freebsd|windows) ]]; then
FEATURES="mimalloc"
else
FEATURES="jemalloc"
fi
cargo $BUILD --release --target $TARGET --package=easytier-web --features=embed
mv ./target/$TARGET/release/easytier-web"$SUFFIX" ./target/$TARGET/release/easytier-web-embed"$SUFFIX"
cargo $BUILD --release --target $TARGET --features=$FEATURES
- name: Build (MIPS)
if: ${{ contains(matrix.TARGET, 'mips') }}
env:
RUSTC_BOOTSTRAP: 1
run: |
cargo build -r --target $TARGET -Z build-std=std,panic_abort --package=easytier --features=jemalloc
- name: Compress
run: |
mkdir -p ./artifacts/objects/
# windows is the only OS using a different convention for executable file name
if [[ $OS =~ ^windows.*$ ]]; then
SUFFIX=.exe
case $TARGET in
x86_64*) ARCH_DIR=x86_64 ;;
i686*) ARCH_DIR=i686 ;;
aarch64*) ARCH_DIR=arm64 ;;
esac
if [[ -n "$ARCH_DIR" ]]; then
find "easytier/third_party/${ARCH_DIR}" -maxdepth 1 -type f \( -name "*.dll" -o -name "*.sys" \) -exec cp {} ./artifacts/objects/ \;
fi
fi
if [[ $GITHUB_REF_TYPE =~ ^tag$ ]]; then
TAG=$GITHUB_REF_NAME
else
TAG=$GITHUB_SHA
fi
if [[ $OS =~ ^ubuntu.*$ && ! $TARGET =~ (loongarch|freebsd) ]]; then
HOST_ARCH=$(uname -m)
case $HOST_ARCH in
x86_64) UPX_ARCH="amd64" ;;
aarch64) UPX_ARCH="arm64" ;;
*) UPX_ARCH="amd64" ;;
esac
UPX_VERSION=4.2.4
UPX_PKG="upx-${UPX_VERSION}-${UPX_ARCH}_linux"
curl -L "https://github.com/upx/upx/releases/download/v${UPX_VERSION}/${UPX_PKG}.tar.xz" -s | tar xJvf -
cp "${UPX_PKG}/upx" .
UPX_BIN=./upx
fi
for BIN in ./target/$TARGET/release/easytier-{core,cli,web,web-embed}"$SUFFIX"; do
if [[ -f "$BIN" ]]; then
if [[ -n "$UPX_BIN" ]]; then
$UPX_BIN --lzma --best "$BIN" || true
fi
mv "$BIN" ./artifacts/objects/
fi
done
mv ./artifacts/objects/* ./artifacts/
rm -rf ./artifacts/objects/
- name: Archive artifact
uses: actions/upload-artifact@v5
with:
name: easytier-${{ matrix.ARTIFACT_NAME }}
path: |
./artifacts/*
- name: Prepare SignPath test artifact
if: ${{ github.event_name == 'workflow_dispatch' && inputs.sign_windows && contains(matrix.TARGET, 'windows') }}
run: |
mkdir -p ./signpath-artifacts
cp \
./artifacts/easytier-core.exe \
./artifacts/easytier-cli.exe \
./artifacts/easytier-web.exe \
./artifacts/easytier-web-embed.exe \
./signpath-artifacts/
- name: Upload unsigned SignPath test artifact
if: ${{ github.event_name == 'workflow_dispatch' && inputs.sign_windows && contains(matrix.TARGET, 'windows') }}
id: signpath-upload
uses: actions/upload-artifact@v5
with:
name: signpath-unsigned-${{ matrix.ARTIFACT_NAME }}
path: ./signpath-artifacts/*
if-no-files-found: error
- name: Submit SignPath test-signing request
if: ${{ github.event_name == 'workflow_dispatch' && inputs.sign_windows && contains(matrix.TARGET, 'windows') }}
uses: signpath/github-action-submit-signing-request@v2
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: 368cf38d-86d5-4bfe-a1fa-82a50dce7e9f
project-slug: EasyTier
signing-policy-slug: test-signing
artifact-configuration-slug: initial
github-artifact-id: ${{ steps.signpath-upload.outputs.artifact-id }}
wait-for-completion: true
output-artifact-directory: ./signpath-signed-artifacts
- name: Upload signed SignPath test artifact
if: ${{ github.event_name == 'workflow_dispatch' && inputs.sign_windows && contains(matrix.TARGET, 'windows') }}
uses: actions/upload-artifact@v5
with:
name: signpath-signed-${{ matrix.ARTIFACT_NAME }}
path: ./signpath-signed-artifacts/*
if-no-files-found: error
build_magisk:
runs-on: ubuntu-latest
needs: [ pre_job, build_web, build ]
if: needs.pre_job.result == 'success' && needs.pre_job.outputs.should_skip != 'true' && needs.pre_job.outputs.full_build == 'true' && !cancelled()
steps:
- name: Checkout Code
uses: actions/checkout@v5 # 必须先检出代码才能获取模块配置
# 下载二进制文件到独立目录
- name: Download Linux aarch64 binaries
uses: actions/download-artifact@v4
with:
name: easytier-linux-aarch64
path: ./downloaded-binaries/ # 独立目录避免冲突
- name: Download Magisk WebUI
uses: actions/download-artifact@v4
with:
name: easytier-web-dashboard
path: ./downloaded-webui/
# 将二进制文件和 WebUI 复制到 Magisk 模块目录
- name: Prepare Magisk module
run: |
module_dir=./easytier-contrib/easytier-magisk
mkdir -p "$module_dir/webroot"
cp ./downloaded-binaries/easytier-core "$module_dir/"
cp ./downloaded-binaries/easytier-cli "$module_dir/"
cp ./downloaded-binaries/easytier-web "$module_dir/"
cp -R ./downloaded-webui/config-generator/. "$module_dir/webroot/"
test -f "$module_dir/webroot/index.html"
# 上传生成的模块
- name: Upload Magisk Module
uses: actions/upload-artifact@v5
with:
name: Easytier-Magisk
path: |
./easytier-contrib/easytier-magisk
!./easytier-contrib/easytier-magisk/build.sh
!./easytier-contrib/easytier-magisk/magisk_update.json
if-no-files-found: error
core-result:
runs-on: ubuntu-latest
needs: [ pre_job, check, build_web, build, build_magisk ]
if: ${{ always() }}
steps:
- name: Mark result as failed
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
run: exit 1