diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..ad503a61 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Stored Git patches contain source-context whitespace by design. +tool/patches/*.patch text eol=lf whitespace=-trailing-space,-space-before-tab diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 05af3f91..477a7fa4 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -28,6 +28,9 @@ jobs: - name: Install dependencies run: flutter pub get + - name: Apply Clash.Meta patches + run: dart tool/apply_clash_meta_patches.dart + - name: Setup Go uses: actions/setup-go@v5 with: @@ -46,6 +49,10 @@ jobs: CGO_ENABLED: '0' run: go test . + - name: Test Android local proxy UDP security + working-directory: core/Clash.Meta + run: go test ./listener -run '^TestShouldDisableDefaultUDP$' -count=1 + - name: Verify delay test concurrency alignment run: ruby tool/verify_delay_test_concurrency.rb diff --git a/.github/workflows/pull-request-validation.yaml b/.github/workflows/pull-request-validation.yaml index 3f988f85..76605f66 100644 --- a/.github/workflows/pull-request-validation.yaml +++ b/.github/workflows/pull-request-validation.yaml @@ -86,6 +86,16 @@ jobs: with: submodules: recursive + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + flutter-version: ${{ env.FLUTTER_VERSION }} + cache: true + + - name: Apply Clash.Meta patches + run: dart tool/apply_clash_meta_patches.dart + - name: Setup Go uses: actions/setup-go@v5 with: @@ -98,6 +108,10 @@ jobs: CGO_ENABLED: '0' run: go test . + - name: Test Android local proxy UDP security + working-directory: core/Clash.Meta + run: go test ./listener -run '^TestShouldDisableDefaultUDP$' -count=1 + go-vet: name: Go vet runs-on: ubuntu-latest @@ -529,6 +543,10 @@ jobs: script: tool/verify_android_tile_background_toggle.rb - id: android-vpn-stop-cleanup script: tool/verify_android_proxy_stop.rb + - id: android-local-proxy-security + script: tool/verify_android_local_proxy_security.rb + - id: clash-meta-patch-delivery + script: tool/verify_clash_meta_patch_delivery.rb - id: android-service-lifecycle script: tool/verify_android_service_lifecycle.rb - id: linux-x11-thread diff --git a/.gitignore b/.gitignore index c72a837e..66cc294c 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,7 @@ app.*.map.json # Documentation (generated/planning artifacts) docs/ +/.worktrees/ # Android Studio will place build artifacts here @@ -74,4 +75,4 @@ devtools_options.yaml # FVM Version Cache .fvm/ -.fvmrc \ No newline at end of file +.fvmrc diff --git a/android/service/src/main/java/com/follow/clash/service/VpnService.kt b/android/service/src/main/java/com/follow/clash/service/VpnService.kt index 0a33890f..5cc0042f 100644 --- a/android/service/src/main/java/com/follow/clash/service/VpnService.kt +++ b/android/service/src/main/java/com/follow/clash/service/VpnService.kt @@ -2,7 +2,6 @@ package com.follow.clash.service import android.content.Intent import android.net.ConnectivityManager -import android.net.ProxyInfo import android.os.Binder import android.os.Build import android.os.IBinder @@ -219,14 +218,6 @@ class VpnService : SystemVpnService(), IBaseService, if (options.allowBypass) { allowBypass() } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && options.systemProxy) { - GlobalState.log("Open http proxy") - setHttpProxy( - ProxyInfo.buildDirectProxy( - "127.0.0.1", options.port, options.bypassDomain - ) - ) - } establish()?.detachFd() ?: throw NullPointerException("Establish VPN rejected by system") } diff --git a/docs/superpowers/plans/2026-08-09-android-local-proxy-security.md b/docs/superpowers/plans/2026-08-09-android-local-proxy-security.md new file mode 100644 index 00000000..73f64d09 --- /dev/null +++ b/docs/superpowers/plans/2026-08-09-android-local-proxy-security.md @@ -0,0 +1,440 @@ +# Android 本機代理安全修復 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 修復上游 #2183,讓 Android 本機代理具備程序級隨機驗證,並移除無法攜帶帳密的預設 UDP 與系統 HTTP 代理入口。 + +**Architecture:** Flutter 端在產生最終核心 YAML 前注入當前程序的隨機 `authentication`,並讓 App 內部 HttpClient 只對目前 mixed-port 自動完成 Basic proxy challenge。核心端沿用既有 `authStore.Default` 驗證 TCP;Android 永遠跳過預設 mixed/socks UDP listener,VPN 端不再設定 `ProxyInfo` 系統代理。 + +**Tech Stack:** Dart/Flutter、Go/Clash.Meta、Kotlin/Android VpnService、Ruby 靜態驗證、GitHub Actions。 + +## Global Constraints + +- 每一個上游 issue 完成後,必須等完整跨平台 CI/CD 全部通過,才可以處理下一個 issue。 +- Android 實機不可用時,只報告程式碼、測試與 CI 證據,不宣稱已完成實機驗證。 +- 桌面版代理與桌面版系統代理行為不變。 +- 不將本次隨機帳密寫入偏好設定、profile、日誌或 issue comment。 +- 不移除 `VpnOptions.systemProxy` 欄位,保留分享檔與 IPC 相容性。 +- 先寫測試並觀察缺少實作的失敗,再加入最小實作。 + +--- + +### Task 1: 保存設計規格與計畫 + +**Files:** +- Create: `docs/superpowers/specs/2026-08-09-android-local-proxy-security-design.md` +- Create: `docs/superpowers/plans/2026-08-09-android-local-proxy-security.md` + +- [x] **Step 1: 固定問題、取捨與驗收標準** + + 已在設計規格記錄上游報告、核心現況、採用方案、桌面相容性與 Android 實機限制。 + +- [ ] **Step 2: 提交規格與計畫** + + Run: + + ```bash + git add -f docs/superpowers/specs/2026-08-09-android-local-proxy-security-design.md docs/superpowers/plans/2026-08-09-android-local-proxy-security.md + git commit -m "docs: define Android local proxy security fix" + ``` + + Expected: commit succeeds and only the two design documents are included. + +--- + +### Task 2: 先寫 Dart 本機代理安全測試 + +**Files:** +- Create: `test/common/local_proxy_test.dart` + +**Interfaces:** +- Consumes: planned `LocalProxyCredentials`, `applyAndroidLocalProxyAuthentication`, `shouldUseSystemProxy`, and `isLocalProxyEndpoint` from `lib/common/local_proxy.dart`. +- Produces: executable regression cases for later Flutter implementation. + +- [ ] **Step 1: Write the failing test** + + Add tests with these exact behaviors: + + ```dart + test('generated credentials are safe for core authentication', () { + final credentials = LocalProxyCredentials.generate(); + expect(credentials.username, 'flclash-android'); + expect(credentials.password.length, 32); + expect(credentials.password, matches(RegExp(r'^[A-Za-z0-9_-]+$'))); + expect(credentials.coreAuthentication, 'flclash-android:${credentials.password}'); + }); + + test('Android authentication replaces imported authentication without mutating input', () { + final credentials = const LocalProxyCredentials( + username: 'flclash-android', + password: 'test-password', + ); + final rawConfig = { + 'authentication': ['old-user:old-password'], + 'mixed-port': 7890, + }; + + final patched = applyAndroidLocalProxyAuthentication( + rawConfig: rawConfig, + credentials: credentials, + ); + + expect(patched['authentication'], ['flclash-android:test-password']); + expect(rawConfig['authentication'], ['old-user:old-password']); + expect(patched['mixed-port'], 7890); + }); + + test('only Android disables the unsupported system proxy path', () { + expect(shouldUseSystemProxy(isAndroid: true, requested: true), isFalse); + expect(shouldUseSystemProxy(isAndroid: true, requested: false), isFalse); + expect(shouldUseSystemProxy(isAndroid: false, requested: true), isTrue); + expect(shouldUseSystemProxy(isAndroid: false, requested: false), isFalse); + }); + + test('proxy credentials are limited to the current local endpoint', () { + expect(isLocalProxyEndpoint('localhost', 7890, 7890), isTrue); + expect(isLocalProxyEndpoint('127.0.0.1', 7890, 7890), isTrue); + expect(isLocalProxyEndpoint('10.0.0.2', 7890, 7890), isFalse); + expect(isLocalProxyEndpoint('localhost', 7891, 7890), isFalse); + }); + ``` + +- [ ] **Step 2: Run test to verify it fails** + + Run: + + ```bash + flutter test test/common/local_proxy_test.dart --reporter expanded + ``` + + Expected on a Flutter-equipped runner: FAIL because `lib/common/local_proxy.dart` and its functions do not exist yet. On this macOS workspace, if `flutter` is unavailable, record the exact command-not-found limitation and let GitHub CI perform the Flutter red test/green test gate. + +--- + +### Task 3: 先寫 Go Android UDP 決策測試 + +**Files:** +- Create: `core/Clash.Meta/listener/listener_security_test.go` + +**Interfaces:** +- Consumes: planned `shouldDisableDefaultUDP(androidBuild bool) bool` in package `listener`. +- Produces: a Go regression test covering desktop and Android builds. + +- [ ] **Step 1: Write the failing test** + + Add: + + ```go + func TestShouldDisableDefaultUDP(t *testing.T) { + tests := []struct { + name string + androidBuild bool + want bool + }{ + {name: "desktop", androidBuild: false, want: false}, + {name: "android", androidBuild: true, want: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := shouldDisableDefaultUDP(test.androidBuild); got != test.want { + t.Fatalf("shouldDisableDefaultUDP(%t) = %t, want %t", test.androidBuild, got, test.want) + } + }) + } + } + ``` + +- [ ] **Step 2: Run test to verify it fails** + + Run from `core/Clash.Meta`: + + ```bash + go test ./listener -run TestShouldDisableDefaultUDP -count=1 + ``` + + Expected: FAIL because `shouldDisableDefaultUDP` does not exist yet. + +--- + +### Task 4: Implement Dart credentials and inject them into Android profiles + +**Files:** +- Create: `lib/common/local_proxy.dart` +- Modify: `lib/common/common.dart` +- Modify: `lib/state.dart` +- Modify: `lib/providers/action.dart` + +**Interfaces:** +- Produces `LocalProxyCredentials.generate()`, `LocalProxyCredentials.coreAuthentication`, `applyAndroidLocalProxyAuthentication(...)`, `shouldUseSystemProxy(...)`, and `isLocalProxyEndpoint(...)`. + +- [ ] **Step 1: Add the smallest pure helper implementation** + + `LocalProxyCredentials.generate()` must choose 32 characters from `A-Za-z0-9_-` using `Random.secure()`. `applyAndroidLocalProxyAuthentication` must clone the top-level map and replace only `authentication` with a one-element list containing `coreAuthentication`. `shouldUseSystemProxy` must return `requested && !isAndroid`. `isLocalProxyEndpoint` must accept only `localhost` or `127.0.0.1` and an exact expected port. + +- [ ] **Step 2: Store credentials for the current process** + + Add a `final localProxyCredentials = LocalProxyCredentials.generate();` field to `GlobalState`. Do not persist it or print it. + +- [ ] **Step 3: Inject authentication after scripts have run** + + In `getProfile`, immediately after the optional `handleEvaluate` call and before `MakeRealProfileState`, use: + + ```dart + if (system.isAndroid) { + rawConfig = applyAndroidLocalProxyAuthentication( + rawConfig: rawConfig, + credentials: globalState.localProxyCredentials, + ); + } + ``` + +- [ ] **Step 4: Run the Dart targeted test** + + Run: + + ```bash + flutter test test/common/local_proxy_test.dart --reporter expanded + ``` + + Expected: all four tests pass. + +- [ ] **Step 5: Commit** + + ```bash + git add lib/common/local_proxy.dart lib/common/common.dart lib/state.dart lib/providers/action.dart test/common/local_proxy_test.dart + git commit -m "fix(android): add per-process local proxy authentication" + ``` + +--- + +### Task 5: Authenticate FlClash's own Dart proxy requests + +**Files:** +- Modify: `lib/common/http.dart` +- Modify: `lib/common/request.dart` + +**Interfaces:** +- Consumes: `globalState.localProxyCredentials`, `isLocalProxyEndpoint`, and the current `patchClashConfigProvider` mixed port. +- Produces: an `HttpClient.authenticateProxy` callback that only supplies the generated Basic credentials to the current local mixed-port. + +- [ ] **Step 1: Configure global HttpClient instances** + + Add a helper that, on Android only, sets `authenticateProxy`. It must reject a non-local host, a port different from the current mixed-port, or a non-Basic scheme. For an accepted challenge it must call `addProxyCredentials` with `HttpClientBasicCredentials` and return `true`. + +- [ ] **Step 2: Configure the custom Dio proxy client** + + Call the same helper in `Request._createProxiedDio` after creating its `HttpClient`. Do not call it for the direct Dio client. + +- [ ] **Step 3: Run targeted Dart tests and format check** + + Run: + + ```bash + flutter test test/common/local_proxy_test.dart --reporter expanded + dart format --output=none --set-exit-if-changed lib/common/local_proxy.dart lib/common/http.dart lib/common/request.dart lib/providers/action.dart lib/providers/state.dart lib/state.dart test/common/local_proxy_test.dart + ``` + + Expected: tests pass and formatter exits 0. If Flutter/Dart is absent locally, record it and rely on the matching CI jobs. + +- [ ] **Step 4: Commit** + + ```bash + git add lib/common/http.dart lib/common/request.dart + git commit -m "fix(android): authenticate internal proxy requests" + ``` + +--- + +### Task 6: Disable Android's unauthenticated system proxy path in Flutter and Kotlin + +**Files:** +- Modify: `lib/providers/state.dart` +- Modify: `lib/views/config/network.dart` +- Modify: `lib/views/dashboard/widgets/quick_options.dart` +- Modify: `android/service/src/main/java/com/follow/clash/service/VpnService.kt` + +**Interfaces:** +- Consumes: `shouldUseSystemProxy`. +- Produces: Android `VpnOptions.systemProxy == false`, no Android system proxy UI, and no `ProxyInfo`/`setHttpProxy` call in the service. + +- [ ] **Step 1: Force the effective Android option off** + + In `sharedState`, replace `systemProxy: vpnSetting.systemProxy` with: + + ```dart + systemProxy: shouldUseSystemProxy( + isAndroid: system.isAndroid, + requested: vpnSetting.systemProxy, + ), + ``` + +- [ ] **Step 2: Remove Android-only UI entries** + + Keep desktop `SystemProxyItem` unchanged. Do not include `VpnSystemProxyItem` in the Android network list or the Android VPN quick-options list; the stored legacy setting may remain for compatibility but cannot be activated. + +- [ ] **Step 3: Remove the unsafe Android service operation** + + Remove the `ProxyInfo` import and the `Build.VERSION...setHttpProxy(...)` block from `VpnService.kt`. Keep VPN route establishment and `Core.startTun` unchanged. + +- [ ] **Step 4: Run static checks** + + Run: + + ```bash + ruby tool/verify_android_local_proxy_security.rb + git diff --check + ``` + + Expected: static verifier passes after Task 8 adds it; until then, use source inspection to confirm the intended wiring. + +- [ ] **Step 5: Commit** + + ```bash + git add lib/providers/state.dart lib/views/config/network.dart lib/views/dashboard/widgets/quick_options.dart android/service/src/main/java/com/follow/clash/service/VpnService.kt + git commit -m "fix(android): remove unauthenticated system proxy" + ``` + +--- + +### Task 7: Implement core Android UDP protection + +**Files:** +- Modify: `core/Clash.Meta/listener/listener.go` +- Test: `core/Clash.Meta/listener/listener_security_test.go` + +**Interfaces:** +- Consumes: failing `shouldDisableDefaultUDP` test. +- Produces: Android-aware UDP listener lifecycle for `ReCreateSocks` and `ReCreateMixed`. + +- [ ] **Step 1: Add the decision helper** + + Import `constant/features` and `listener/auth`, then add: + + ```go + func shouldDisableDefaultUDP(androidBuild bool, authenticated bool) bool { + return androidBuild && authenticated + } + + func defaultUDPDisabled() bool { + return shouldDisableDefaultUDP(features.Android, authStore.Default.Authenticator() != nil) + } + ``` + +- [ ] **Step 2: Update `ReCreateSocks`** + + Calculate `disableUDP := defaultUDPDisabled()`. Close an existing `socksUDPListener` when `disableUDP` is true. Return early when the TCP listener is already correct and either UDP is already correct or UDP is disabled. Create the TCP listener only when missing, and create `socks.NewUDP` only when `disableUDP` is false and the UDP listener is missing. `defaultUDPDisabled()` must be true for every Android build, even if the current config has no `authentication` entry. + +- [ ] **Step 3: Update `ReCreateMixed`** + + Apply the same lifecycle rules to `mixedUDPLister` and `mixed.New`. The existing mixed TCP listener must continue using `authStore.Default`; the independent default UDP listener is skipped on every Android build. + +- [ ] **Step 4: Run Go targeted test and formatting** + + From `core/Clash.Meta` run: + + ```bash + gofmt -w listener/listener.go listener/listener_security_test.go + go test ./listener -run TestShouldDisableDefaultUDP -count=1 + ``` + + Expected: test passes. + +- [ ] **Step 5: Commit** + + ```bash + git add core/Clash.Meta/listener/listener.go core/Clash.Meta/listener/listener_security_test.go + git commit -m "fix(android): disable unauthenticated default UDP listeners" + ``` + +--- + +### Task 8: Add static security verifier and wire it into CI + +**Files:** +- Create: `tool/verify_android_local_proxy_security.rb` +- Modify: `.github/workflows/pull-request-validation.yaml` +- Modify: `tool/verify_ci_layout.rb` + +**Interfaces:** +- Consumes: the Dart, Kotlin, Go, and test files from Tasks 2–7. +- Produces: one independent required `static-source` job that fails if a security-critical wiring point disappears. + +- [ ] **Step 1: Write the verifier** + + The Ruby verifier must read the relevant files and abort unless all of these are present: Android profile authentication injection, proxy challenge handling with `addProxyCredentials` and `HttpClientBasicCredentials`, Android system-proxy gating, no `ProxyInfo`/`setHttpProxy` in `VpnService.kt`, `shouldDisableDefaultUDP` plus conditional `socks.NewUDP` in core, and the Dart/Go regression test files. + +- [ ] **Step 2: Add the verifier to the static-source matrix** + + Add: + + ```yaml + - id: android-local-proxy-security + script: tool/verify_android_local_proxy_security.rb + ``` + + to `.github/workflows/pull-request-validation.yaml`. + +- [ ] **Step 3: Make the CI layout verifier require it** + + Add `tool/verify_android_local_proxy_security.rb` to `expected_scripts` in `tool/verify_ci_layout.rb`. + +- [ ] **Step 4: Run local static checks** + + ```bash + ruby tool/verify_android_local_proxy_security.rb + ruby tool/verify_ci_layout.rb + git diff --check + ``` + + Expected: both Ruby verifiers pass and the diff has no whitespace errors. + +- [ ] **Step 5: Commit** + + ```bash + git add -f tool/verify_android_local_proxy_security.rb .github/workflows/pull-request-validation.yaml tool/verify_ci_layout.rb + git commit -m "ci: verify Android local proxy security wiring" + ``` + +--- + +### Task 9: Run the full issue gate and close downstream #85 + +**Files:** +- No source changes unless verification finds a failure. + +- [ ] **Step 1: Inspect the final diff and source wiring** + + ```bash + git diff main...HEAD --stat + git diff --check + git status --short --branch + ``` + + Expected: only the documented #2183 files are changed; no credentials appear in tracked files. + +- [ ] **Step 2: Push the branch** + + ```bash + git push origin fix/macos-upstream-2281-tray-performance + ``` + +- [ ] **Step 3: Wait for the exact pushed commit's full workflow** + + Use GitHub Actions for `pull-request-validation.yaml`. Verify the run `headSha` equals the pushed commit, every matrix job has conclusion `success`, and `ci-complete` is successful. Do not use an older green run. + +- [ ] **Step 4: If CI fails, debug and rerun before issue closure** + + Read the failing job log, apply the smallest fix, rerun the relevant local check, push a new commit, and repeat the full workflow verification. Do not close #85 on partial success. + +- [ ] **Step 5: Record exact evidence on downstream issue #85** + + Comment the exact commit SHA, exact Actions run URL, total successful job count, and the honest Android-device limitation. Do not include the generated username/password. + +- [ ] **Step 6: Close #85 only after evidence is verified** + + Close `https://github.com/SingLinkNetwork/FlClash/issues/85`, then read it back and verify `state == CLOSED` and the evidence comment is present. + +- [ ] **Step 7: Pause this round** + + Report the upstream issue, downstream issue, commit, full CI run, what was verified, and the Android real-device limitation. Do not start the next upstream issue in the same round. diff --git a/docs/superpowers/plans/2026-08-10-android-local-proxy-core-patch.md b/docs/superpowers/plans/2026-08-10-android-local-proxy-core-patch.md new file mode 100644 index 00000000..df00ad91 --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-android-local-proxy-core-patch.md @@ -0,0 +1,240 @@ +# Android Core Patch Delivery Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Deliver the existing Android local-proxy UDP security fix from the parent repository, without forking Clash.Meta, so local builds and both GitHub workflows compile and test the same patched core. + +**Architecture:** Keep `core/Clash.Meta` pinned to the public upstream commit `80362fc1895dcf60b79b562896653046e0687413`. Store the narrow core change as a checked-in patch, apply it idempotently before every Go-core build, and apply it explicitly before CI jobs that inspect or test the submodule directly. The patch itself disables default SOCKS and mixed UDP listeners only in Android builds; Dart-side credentials and Android system-proxy removal already live in commit `3dd64a5`. + +**Tech Stack:** Dart command-line utility, Git patches, Go/Clash.Meta, Ruby static verifier, Flutter/GitHub Actions. + +## Global Constraints + +- Do not fork or push to `chen08209/Clash.Meta`. +- Leave the parent Gitlink at `80362fc1895dcf60b79b562896653046e0687413`; the parent repository must contain all delivery artifacts. +- Patch application must be idempotent: a second run succeeds without changing source; an upstream conflict fails loudly. +- The core patch must run before every native Go-core build on Android, macOS, Linux, and Windows. +- PR CI and tag-release CI must explicitly apply the patch before direct submodule tests or static inspection. +- Do not claim Android device coverage; require successful Android CI build and retain device/cloud testing as release evidence. + +--- + +### Task 1: Red test for parent-owned patch delivery + +**Files:** +- Create: `tool/verify_clash_meta_patch_delivery.rb` +- Modify: `tool/verify_android_local_proxy_security.rb` +- Test: `tool/verify_clash_meta_patch_delivery.rb` + +**Interfaces:** +- Consumes `tool/patches/0001-android-disable-default-udp-listeners.patch`, `tool/apply_clash_meta_patches.dart`, the build-tool source, and both workflows. +- Produces a CI-safe assertion that fails if the core patch cannot be delivered from this repository. + +- [ ] **Step 1: Write the verifier before delivery files exist** + + The verifier must abort unless all of these are true: + + ```ruby + patch = read.call('tool', 'patches', '0001-android-disable-default-udp-listeners.patch') + applier = read.call('tool', 'apply_clash_meta_patches.dart') + build_tool = read.call('plugins', 'setup', 'buildkit', 'build_tool', 'lib', 'src', 'build_tool.dart') + pr_workflow = read.call('.github', 'workflows', 'pull-request-validation.yaml') + release_workflow = read.call('.github', 'workflows', 'build.yaml') + + abort 'core patch must pin listener/listener.go' unless patch.include?('diff --git a/listener/listener.go b/listener/listener.go') + abort 'core patch must include a listener regression test' unless patch.include?('listener_security_test.go') + abort 'core patch applier is missing' unless applier.include?('0001-android-disable-default-udp-listeners.patch') + abort 'native build tool does not apply core patches' unless build_tool.include?('applyClashMetaPatches') + abort 'PR CI does not apply core patch before direct core checks' unless pr_workflow.include?('Apply Clash.Meta patches') + abort 'release CI does not apply core patch before direct core checks' unless release_workflow.include?('Apply Clash.Meta patches') + ``` + +- [ ] **Step 2: Observe the red result** + + Run: + + ```bash + ruby tool/verify_clash_meta_patch_delivery.rb + ``` + + Expected: exit 1 because the patch file and Dart applier do not exist yet. + +- [ ] **Step 3: Add this verifier to the Android security verifier and PR static matrix** + + `tool/verify_android_local_proxy_security.rb` must require the delivery verifier source to exist. Add this exact matrix entry to `static-source`: + + ```yaml + - id: clash-meta-patch-delivery + script: tool/verify_clash_meta_patch_delivery.rb + ``` + + Add the script path to `tool/verify_ci_layout.rb`'s expected static scripts. + +### Task 2: Store and apply the core patch without a fork + +**Files:** +- Create: `tool/patches/0001-android-disable-default-udp-listeners.patch` +- Create: `tool/apply_clash_meta_patches.dart` +- Modify: `plugins/setup/buildkit/build_tool/lib/src/build_tool.dart` + +**Interfaces:** +- `Future applyClashMetaPatches(String rootDir)` applies all repository-owned patches to `core/Clash.Meta`. +- `Future runMain(List args)` invokes it once after resolving `_rootDir` and before a platform builder can call `GoBuilder.buildAll`. + +- [ ] **Step 1: Add the exact core patch** + + Create the patch from the known, reviewed difference between upstream `80362fc1895dcf60b79b562896653046e0687413` and local security commit `cdbbefab0568f73f6941e7a06c2f532f5eeeabf1`: + + ```bash + git -C core/Clash.Meta diff --binary --full-index \ + 80362fc1895dcf60b79b562896653046e0687413 \ + cdbbefab0568f73f6941e7a06c2f532f5eeeabf1 \ + -- listener/listener.go listener/listener_security_test.go + ``` + + The patch must import `constant/features`, add `shouldDisableDefaultUDP(bool)`, and skip/retire `socks.NewUDP` in both `ReCreateSocks` and `ReCreateMixed` when `features.Android` is true. It must include `TestShouldDisableDefaultUDP`. + +- [ ] **Step 2: Implement the portable idempotent applier** + + `tool/apply_clash_meta_patches.dart` uses only `dart:io` and must run these commands in `core/Clash.Meta` for each patch: + + ```dart + final alreadyApplied = await Process.run( + 'git', + ['apply', '--reverse', '--check', patch.path], + workingDirectory: coreDir.path, + ); + if (alreadyApplied.exitCode == 0) continue; + + final check = await Process.run( + 'git', + ['apply', '--check', '--whitespace=error-all', patch.path], + workingDirectory: coreDir.path, + ); + if (check.exitCode != 0) { + stderr.write(check.stderr); + throw ProcessException('git apply', ['--check', patch.path], check.stderr as String, check.exitCode); + } + + final applied = await Process.run( + 'git', + ['apply', '--whitespace=error-all', patch.path], + workingDirectory: coreDir.path, + ); + if (applied.exitCode != 0) { + stderr.write(applied.stderr); + throw ProcessException('git apply', [patch.path], applied.stderr as String, applied.exitCode); + } + ``` + + `main()` resolves the repository root from the script location, calls the function, and exits nonzero after printing the failure. Patch files are sorted by filename so the mechanism remains deterministic. + +- [ ] **Step 3: Call the applier before native Go builds** + + In `build_tool.dart`, import `../../../tool/apply_clash_meta_patches.dart` is not valid because that tool is outside the package. Instead create `lib/src/clash_meta_patches.dart` in the build-tool package with the same `applyClashMetaPatches` implementation, then call it in `runMain` immediately after `_rootDir` is assigned: + + ```dart + _rootDir = (topResults['root-dir'] as String?) ?? _findProjectRoot(); + await applyClashMetaPatches(_rootDir); + await runner.run(args); + ``` + + The command-line tool imports that package source through a relative import so CI can invoke the same implementation directly. + +- [ ] **Step 4: Prove apply and repeat apply** + + In a clean checkout of `80362fc1895dcf60b79b562896653046e0687413`, run: + + ```bash + dart tool/apply_clash_meta_patches.dart + dart tool/apply_clash_meta_patches.dart + git -C core/Clash.Meta diff --check + ``` + + Expected: both commands exit 0; the second reports the patch already applied. + +### Task 3: Validate the patched core directly + +**Files:** +- Test: `core/Clash.Meta/listener/listener_security_test.go` (from patch) +- Modify: `.github/workflows/pull-request-validation.yaml` +- Modify: `.github/workflows/build.yaml` + +**Interfaces:** +- Direct test command: `go test ./listener -run '^TestShouldDisableDefaultUDP$' -count=1` from `core/Clash.Meta`. + +- [ ] **Step 1: Run the Go regression test after patch application** + + ```bash + (cd core/Clash.Meta && go test ./listener -run '^TestShouldDisableDefaultUDP$' -count=1) + ``` + + Expected: both desktop and Android decision cases pass. + +- [ ] **Step 2: Make PR CI apply the patch before direct use** + + For `go-tests`, `go-vet`, and `core-cli-arguments`, add this Ubuntu step immediately after checkout: + + ```yaml + - name: Apply Clash.Meta patches + run: dart tool/apply_clash_meta_patches.dart + ``` + + Set up Flutter before this step, because it provides Dart. Add a direct listener test to `go-tests`. For `static-source`, check out `submodules: recursive`, set up Flutter, apply the patch, then run the Ruby matrix. The build matrix remains covered by the build-tool invocation in Task 2. + +- [ ] **Step 3: Make tag-release CI apply the patch before direct tests** + + In the `test` job in `.github/workflows/build.yaml`, after Flutter setup and before `go test .`, add the same `Apply Clash.Meta patches` step and run the listener regression test. The release build matrix is covered by the build-tool invocation. + +- [ ] **Step 4: Keep the CI structural checks accurate** + + Extend `tool/verify_ci_layout.rb` so it checks that the static-source checkout includes recursive submodules and that `go-tests` contains both the applier and the listener security test. Update `tool/verify_android_local_proxy_security.rb` to require the patch, applier, and the direct Go test command. + +### Task 4: Green verification and review gate + +**Files:** +- Test: `test/common/local_proxy_test.dart` +- Test: `tool/verify_android_local_proxy_security.rb` +- Test: `tool/verify_clash_meta_patch_delivery.rb` + +- [ ] **Step 1: Run the specific security checks** + + ```bash + ruby tool/verify_clash_meta_patch_delivery.rb + ruby tool/verify_android_local_proxy_security.rb + dart tool/apply_clash_meta_patches.dart + (cd core/Clash.Meta && go test ./listener -run '^TestShouldDisableDefaultUDP$' -count=1) + ``` + +- [ ] **Step 2: Run application-level checks using the pinned SDK** + + ```bash + /Volumes/SING_02/flclash/flutter-sdk-3.44.8/bin/flutter pub get + /Volumes/SING_02/flclash/flutter-sdk-3.44.8/bin/flutter test test/common/local_proxy_test.dart --reporter expanded + /Volumes/SING_02/flclash/flutter-sdk-3.44.8/bin/flutter analyze --no-fatal-infos + ruby tool/verify_ci_layout.rb + git diff --check + ``` + +- [ ] **Step 3: Commit only the P0 delivery fix** + + ```bash + git add tool/patches/0001-android-disable-default-udp-listeners.patch \ + tool/apply_clash_meta_patches.dart \ + plugins/setup/buildkit/build_tool/lib/src/clash_meta_patches.dart \ + plugins/setup/buildkit/build_tool/lib/src/build_tool.dart \ + tool/verify_clash_meta_patch_delivery.rb \ + tool/verify_android_local_proxy_security.rb \ + tool/verify_ci_layout.rb \ + .github/workflows/pull-request-validation.yaml \ + .github/workflows/build.yaml + git add -f docs/superpowers/plans/2026-08-10-android-local-proxy-core-patch.md + git commit -m "fix(android): deliver authenticated core patch in CI" + ``` + +### Plan self-review + +- Scope is limited to making the already-reviewed Android UDP protection reproducible; no desktop proxy behavior changes are introduced. +- The patch itself, native builder, PR workflow, release workflow, direct Go test, and static verifier are all covered. +- No fork, secret, or Android-device claim is required. +- The plan contains no unresolved implementation placeholders. diff --git a/docs/superpowers/specs/2026-08-09-android-local-proxy-security-design.md b/docs/superpowers/specs/2026-08-09-android-local-proxy-security-design.md new file mode 100644 index 00000000..dbcf6fd8 --- /dev/null +++ b/docs/superpowers/specs/2026-08-09-android-local-proxy-security-design.md @@ -0,0 +1,108 @@ +# Android 本機代理安全修復設計 + +## 背景 + +上游 issue [#2183](https://github.com/chen08209/FlClash/issues/2183) 回報 Android 版本的本機代理入口可能被其他 App 直接連線。外部安全分析指出,若本機 SOCKS5/HTTP 入口沒有驗證,其他 App 可以繞過 FlClash 的 VPN 分流規則,直接使用本機代理並取得代理出口 IP。這不是 macOS 本機可以完整重現的問題,必須用程式碼審計、跨平台編譯與 Android 實機測試共同驗收。 + +目前程式有兩個獨立風險: + +1. Android 產生的核心設定沒有固定加入 `authentication`,所以預設 mixed-port 的 TCP 入口可能不要求帳號密碼。 +2. 核心的 mixed-port 和 socks-port 會另外建立未驗證的 UDP SOCKS 入口;即使 TCP 加上驗證,UDP 仍可能被其他 App 使用。 + +另外,Android `VpnService` 以 `ProxyInfo.buildDirectProxy` 設定系統 HTTP 代理,但 Android 這個 API 沒有提供帳號密碼欄位,因此不能安全地把它指向需要驗證的本機代理。 + +## 目標與不變事項 + +### 目標 + +- Android 每次 App 啟動產生新的本機代理密碼,不把密碼寫入使用者設定或日誌。 +- Android 產生的核心設定只使用這組隨機帳密,覆蓋匯入設定或腳本遺留的本機代理帳密。 +- Android mixed-port 與 socks-port 的 TCP HTTP/SOCKS 入口要求帳密。 +- Android 預設 mixed-port 與 socks-port 不再開放 UDP 入口;這個核心層保護不依賴帳密是否剛好注入。 +- FlClash 自己的 Dart 網路請求自動回應本機代理的 HTTP Basic 驗證。 +- Android 不再設定無法攜帶帳密的系統 HTTP 代理;VPN TUN 仍是 Android 的主要流量路徑。 +- 桌面版現有本機代理行為保持不變。 +- CI 增加可重複的 Dart/Go 單元測試、Android 靜態安全檢查,以及既有的完整跨平台建置矩陣。 + +### 不在本次範圍 + +- 不修改外部控制器、使用者自訂 inbound、遠端代理伺服器或桌面版系統代理。 +- 不宣稱已完成 Android 實機安全測試;本機只有 macOS,Android 實機或雲端裝置測試另列為發佈前驗收項目。 +- 不移除設定模型中的 `systemProxy` 欄位,避免破壞已存在的 Android 分享檔與 IPC 格式;Android 端只將它視為不再啟用的舊相容欄位。 + +## 採用方案 + +採用「Android 隨機帳密 + 核心停用預設 UDP + 關閉 Android 無法驗證的系統代理」方案。 + +### 方案流程 + +```text +App 啟動 + -> GlobalState 產生本次程序專用帳密 + -> Android 取得 profile / 執行腳本後 + -> 覆蓋 rawConfig.authentication + -> 核心啟動 + -> TCP mixed/socks 使用 authStore.Default + -> Android 永不建立預設 UDP listener + -> App 內部 HttpClient 收到 407 + -> 僅對 localhost:本次 mixed-port 回應 Basic 帳密 + -> Android VPN 不設定 ProxyInfo 系統 HTTP 代理 +``` + +帳號固定為 `flclash-android`,密碼由 `Random.secure()` 產生 32 個 URL-safe 字元。固定帳號方便核心和 Dart 端配對,隨機密碼避免其他 App 依賴固定憑證。密碼只存在於當前 App 程序記憶體,App 重啟即輪換。 + +### 為什麼不只綁定 localhost + +本機 loopback 不是安全邊界:同一台 Android 裝置上的其他 App 通常仍能連線到 `127.0.0.1`。因此只綁定 loopback 不能解決 issue #2183,必須要求驗證。 + +### 為什麼 Android UDP 直接關閉 + +目前預設 UDP listener 是獨立的 SOCKS5 UDP 封包入口,不走同一套 TCP HTTP/SOCKS 帳密握手。為了避免「TCP 有驗證但 UDP 仍可繞過」的假安全,Android 核心永遠不建立這兩個預設 UDP listener;即使未來某條設定注入路徑漏掉帳密,UDP 也不會重新暴露。VPN TUN 的 UDP 流量不受此變更影響。 + +### 為什麼停用 Android 系統代理 + +`ProxyInfo` 可以指定本機位址與埠,但沒有可供 FlClash 寫入帳號密碼的欄位。若保留這條路徑,其他 App 會拿到一個無法安全驗證的系統代理入口。Android VPN 已經負責主要流量接管,因此本次直接移除 `VpnService` 的 `setHttpProxy` 路徑,並在 Dart 端不再把 `systemProxy` 傳成有效值;桌面版 `SystemProxyItem` 不變。 + +## 受影響檔案與責任 + +- `lib/common/local_proxy.dart`:純 Dart 的本機帳密、設定注入與 Android 系統代理判斷。 +- `lib/common/common.dart`:匯出本機代理安全 helper。 +- `lib/state.dart`:保存當前程序的隨機帳密。 +- `lib/providers/action.dart`:在腳本處理完成後,把 Android 帳密注入最終 profile。 +- `lib/providers/state.dart`:Android 的 `VpnOptions.systemProxy` 強制為 false。 +- `lib/common/http.dart`、`lib/common/request.dart`:只對本機 mixed-port 回應 HTTP Basic proxy challenge。 +- `lib/views/config/network.dart`、`lib/views/dashboard/widgets/quick_options.dart`:不再在 Android 顯示無法安全工作的系統代理開關。 +- `android/service/src/main/java/com/follow/clash/service/VpnService.kt`:移除無帳密 `ProxyInfo` 系統代理設定。 +- `core/Clash.Meta/listener/listener.go`:Android 且有核心驗證時,跳過預設 SOCKS UDP listener。 +- `test/common/local_proxy_test.dart`:Dart helper 的行為回歸測試。 +- `core/Clash.Meta/listener/listener_security_test.go`:Go 的 Android/驗證組合測試。 +- `tool/verify_android_local_proxy_security.rb`:靜態檢查安全關鍵 wiring。 +- `.github/workflows/pull-request-validation.yaml`、`tool/verify_ci_layout.rb`:將靜態檢查納入 required CI。 + +## 驗收標準 + +### 功能與安全 + +- [ ] Android 最終 YAML 含有唯一一組 `authentication`,值為本次程序產生的帳密。 +- [ ] Android 匯入設定原本的 `authentication` 和腳本改寫結果都不會覆蓋這組帳密。 +- [ ] Android mixed-port / socks-port 的 TCP HTTP 和 SOCKS 入口使用核心驗證。 +- [ ] Android 的 `ReCreateMixed` 和 `ReCreateSocks` 都不建立預設 UDP listener,且從既有版本升級或重新載入時會關閉舊 listener。 +- [ ] App 內部 HTTP client 只在 host 為 `localhost`/`127.0.0.1` 且埠等於目前 mixed-port、scheme 為 Basic 時加入帳密。 +- [ ] 非本機、非 Basic 或其他埠的 proxy challenge 不會收到 FlClash 帳密。 +- [ ] Android `VpnService` 不再呼叫 `setHttpProxy` 或建立 `ProxyInfo`。 +- [ ] 桌面版不注入 Android `authentication`,桌面版系統代理仍可正常工作。 + +### 驗證層級 + +- Dart targeted test:helper 的密碼格式、設定覆蓋、系統代理判斷與 endpoint 判斷。 +- Go targeted test:Android/驗證四種組合的 UDP 決策。 +- Ruby static verifier:檢查跨 Dart、Kotlin、Go 的安全 wiring 沒被漏接。 +- GitHub Actions:Dart format、analyze、Flutter tests、Go tests/vet、Android/Windows/macOS/Linux 建置與既有 CI gate 全部成功。 +- Android 實機或雲端裝置:發佈前由具備裝置的環境驗證「其他 App 無帳密不能使用本機 TCP 代理、UDP 埠不在 listen、VPN 仍可上網」。本次沒有把這項不可取得的實機證據冒充為 CI 已完成。 + +## 風險與回滾 + +- 使用者若依賴 Android 系統 HTTP 代理欄位,升級後該欄位不再生效;這是為了避免未驗證代理入口,VPN TUN 路徑不受影響。 +- 依賴本機代理的 App 若無法提供帳密,Android 端預設 mixed-port 連線會被拒絕;這正是安全邊界的預期結果。 +- 若核心版本在某個平台未正確帶入 Android build tag,CI 的 Android 建置與靜態檢查必須阻止合併;不以 macOS 本機編譯替代。 +- 回滾只需回滾本次 commit,不修改使用者既有 profile 資料;程序內密碼不落盤。 diff --git a/lib/common/common.dart b/lib/common/common.dart index 654f7f91..195fe5c8 100644 --- a/lib/common/common.dart +++ b/lib/common/common.dart @@ -19,6 +19,7 @@ export 'javascript.dart'; export 'keyboard.dart'; export 'launch.dart'; export 'link.dart'; +export 'local_proxy.dart'; export 'linux_clipboard.dart'; export 'lock.dart'; export 'measure.dart'; diff --git a/lib/common/http.dart b/lib/common/http.dart index 5bd71f63..9d9c1f50 100644 --- a/lib/common/http.dart +++ b/lib/common/http.dart @@ -5,6 +5,27 @@ import 'package:fl_clash/providers/providers.dart'; import 'package:fl_clash/state.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +void configureLocalProxyAuthentication(HttpClient client) { + if (!system.isAndroid) return; + client.authenticateProxy = (host, port, scheme, realm) async { + final mixedPort = globalState.container.read( + patchClashConfigProvider.select((state) => state.mixedPort), + ); + if (!isLocalProxyEndpoint(host, port, mixedPort) || + scheme.toLowerCase() != 'basic') { + return false; + } + final credentials = globalState.localProxyCredentials; + client.addProxyCredentials( + host, + port, + realm ?? '', + HttpClientBasicCredentials(credentials.username, credentials.password), + ); + return true; + }; +} + class FlClashHttpOverrides extends HttpOverrides { static String handleFindProxy(Uri url) { if ([localhost].contains(url.host)) { @@ -26,6 +47,7 @@ class FlClashHttpOverrides extends HttpOverrides { final client = super.createHttpClient(context); client.badCertificateCallback = (_, _, _) => true; client.findProxy = handleFindProxy; + configureLocalProxyAuthentication(client); return client; } } diff --git a/lib/common/local_proxy.dart b/lib/common/local_proxy.dart new file mode 100644 index 00000000..f7c64753 --- /dev/null +++ b/lib/common/local_proxy.dart @@ -0,0 +1,47 @@ +import 'dart:math'; + +const _localProxyUsername = 'flclash-android'; +const _localProxyPasswordAlphabet = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-'; +const _localProxyPasswordLength = 32; + +class LocalProxyCredentials { + final String username; + final String password; + + const LocalProxyCredentials({required this.username, required this.password}); + + factory LocalProxyCredentials.generate() { + final random = Random.secure(); + final password = List.generate( + _localProxyPasswordLength, + (_) => + _localProxyPasswordAlphabet[random.nextInt( + _localProxyPasswordAlphabet.length, + )], + ).join(); + return LocalProxyCredentials( + username: _localProxyUsername, + password: password, + ); + } + + String get coreAuthentication => '$username:$password'; +} + +Map applyAndroidLocalProxyAuthentication({ + required Map rawConfig, + required LocalProxyCredentials credentials, +}) { + final patchedConfig = Map.from(rawConfig); + patchedConfig['authentication'] = [credentials.coreAuthentication]; + return patchedConfig; +} + +bool shouldUseSystemProxy({required bool isAndroid, required bool requested}) { + return requested && !isAndroid; +} + +bool isLocalProxyEndpoint(String host, int port, int expectedPort) { + return (host == 'localhost' || host == '127.0.0.1') && port == expectedPort; +} diff --git a/lib/common/request.dart b/lib/common/request.dart index 9aff0091..8b66db2f 100644 --- a/lib/common/request.dart +++ b/lib/common/request.dart @@ -32,6 +32,7 @@ class Request { httpClient.userAgent = globalState.ua; return FlClashHttpOverrides.handleFindProxy(uri); }; + configureLocalProxyAuthentication(httpClient); return httpClient; }, ); diff --git a/lib/providers/action.dart b/lib/providers/action.dart index 5e645ce0..15f18e74 100644 --- a/lib/providers/action.dart +++ b/lib/providers/action.dart @@ -382,6 +382,12 @@ class SetupAction extends _$SetupAction { if (scriptContent?.isNotEmpty == true) { rawConfig = await handleEvaluate(scriptContent!, rawConfig); } + if (system.isAndroid) { + rawConfig = applyAndroidLocalProxyAuthentication( + rawConfig: rawConfig, + credentials: globalState.localProxyCredentials, + ); + } final directory = await appPath.profilesPath; final res = makeRealProfileTask( MakeRealProfileState( diff --git a/lib/providers/state.dart b/lib/providers/state.dart index e1bbe8fb..f106d57a 100644 --- a/lib/providers/state.dart +++ b/lib/providers/state.dart @@ -664,7 +664,10 @@ SharedState sharedState(Ref ref) { vpnOptions: VpnOptions( enable: vpnSetting.enable, stack: stack, - systemProxy: vpnSetting.systemProxy, + systemProxy: shouldUseSystemProxy( + isAndroid: system.isAndroid, + requested: vpnSetting.systemProxy, + ), port: port, ipv6: vpnSetting.ipv6, dnsHijacking: vpnSetting.dnsHijacking, diff --git a/lib/state.dart b/lib/state.dart index 76e626c3..14e9629e 100644 --- a/lib/state.dart +++ b/lib/state.dart @@ -32,6 +32,7 @@ class GlobalState { late CommonTheme theme; late Color accentColor; late ProviderContainer container; + final localProxyCredentials = LocalProxyCredentials.generate(); bool startProxyFromCommandLine = false; bool needInitStatus = true; diff --git a/lib/views/config/network.dart b/lib/views/config/network.dart index 8c81cdb2..5812912a 100644 --- a/lib/views/config/network.dart +++ b/lib/views/config/network.dart @@ -103,30 +103,6 @@ class AllowBypassItem extends ConsumerWidget { } } -class VpnSystemProxyItem extends ConsumerWidget { - const VpnSystemProxyItem({super.key}); - - @override - Widget build(BuildContext context, ref) { - final appLocalizations = context.appLocalizations; - final systemProxy = ref.watch( - vpnSettingProvider.select((state) => state.systemProxy), - ); - return ListItem.switchItem( - title: Text(appLocalizations.systemProxy), - subtitle: Text(appLocalizations.systemProxyDesc), - delegate: SwitchDelegate( - value: systemProxy, - onChanged: (bool value) async { - ref - .read(vpnSettingProvider.notifier) - .update((state) => state.copyWith(systemProxy: value)); - }, - ), - ); - } -} - class SystemProxyItem extends ConsumerWidget { const SystemProxyItem({super.key}); @@ -392,7 +368,6 @@ class NetworkListView extends StatelessWidget { ...generateSection( title: 'VPN', items: [ - const VpnSystemProxyItem(), const BypassDomainItem(), const AllowBypassItem(), const Ipv6Item(), diff --git a/lib/views/dashboard/widgets/quick_options.dart b/lib/views/dashboard/widgets/quick_options.dart index beb3287b..4c5df809 100644 --- a/lib/views/dashboard/widgets/quick_options.dart +++ b/lib/views/dashboard/widgets/quick_options.dart @@ -174,11 +174,7 @@ class VpnButton extends StatelessWidget { return AdaptiveSheetScaffold( body: generateListView( generateSection( - items: [ - const VPNItem(), - const VpnSystemProxyItem(), - const TunStackItem(), - ], + items: [const VPNItem(), const TunStackItem()], ), ), title: 'VPN', diff --git a/plugins/setup/buildkit/build_tool/lib/src/build_tool.dart b/plugins/setup/buildkit/build_tool/lib/src/build_tool.dart index 22b98976..1d50c95e 100644 --- a/plugins/setup/buildkit/build_tool/lib/src/build_tool.dart +++ b/plugins/setup/buildkit/build_tool/lib/src/build_tool.dart @@ -7,6 +7,7 @@ import 'package:path/path.dart' as p; import 'environment.dart'; import 'error.dart'; +import 'clash_meta_patches.dart'; import 'go_builder.dart'; import 'logging.dart'; import 'options.dart'; @@ -112,8 +113,9 @@ class BuildLinuxCommand extends BuildCommand { final config = BuildConfig.load(rootDir: _rootDir); final arch = archName ?? await _hostGoArch(); - final targets = - Target.forPlatform('linux').where((t) => t.goarch == arch).toList(); + final targets = Target.forPlatform( + 'linux', + ).where((t) => t.goarch == arch).toList(); if (targets.isEmpty) { throw BuildException('Invalid arch: $arch'); @@ -148,8 +150,9 @@ class BuildWindowsCommand extends BuildCommand { final config = BuildConfig.load(rootDir: _rootDir); final arch = archName ?? await _hostGoArch(); - final targets = - Target.forPlatform('windows').where((t) => t.goarch == arch).toList(); + final targets = Target.forPlatform( + 'windows', + ).where((t) => t.goarch == arch).toList(); if (targets.isEmpty) { throw BuildException('Invalid arch: $arch'); @@ -172,8 +175,9 @@ class BuildWindowsCommand extends BuildCommand { final coreSha256 = await calcSha256(corePaths.first); final rustBuilder = RustBuilder(rootDir: _rootDir, config: config); await rustBuilder.build(targets.first, coreSha256); - await File(p.join(_rootDir, 'core_sha256.json')) - .writeAsString(jsonEncode({'CORE_SHA256': coreSha256})); + await File( + p.join(_rootDir, 'core_sha256.json'), + ).writeAsString(jsonEncode({'CORE_SHA256': coreSha256})); } _log.info('Build complete: $corePaths'); @@ -201,8 +205,9 @@ class BuildMacosCommand extends BuildCommand { final config = BuildConfig.load(rootDir: _rootDir); final arch = archName ?? await _hostGoArch(); - final targets = - Target.forPlatform('darwin').where((t) => t.goarch == arch).toList(); + final targets = Target.forPlatform( + 'darwin', + ).where((t) => t.goarch == arch).toList(); if (targets.isEmpty) { throw BuildException('Invalid arch: $arch'); @@ -232,6 +237,7 @@ Future runMain(List args) async { final topResults = runner.parse(args); _rootDir = (topResults['root-dir'] as String?) ?? _findProjectRoot(); + await applyClashMetaPatches(_rootDir); await runner.run(args); } on BuildException catch (e) { _log.severe(e.toString()); diff --git a/plugins/setup/buildkit/build_tool/lib/src/clash_meta_patches.dart b/plugins/setup/buildkit/build_tool/lib/src/clash_meta_patches.dart new file mode 100644 index 00000000..de16257f --- /dev/null +++ b/plugins/setup/buildkit/build_tool/lib/src/clash_meta_patches.dart @@ -0,0 +1,72 @@ +import 'dart:io'; + +const _patchFileNames = [ + '0001-android-disable-default-udp-listeners.patch', +]; + +Future applyClashMetaPatches(String rootDir) async { + final coreDir = Directory('$rootDir/core/Clash.Meta'); + if (!coreDir.existsSync()) { + throw FileSystemException('Clash.Meta submodule is missing', coreDir.path); + } + + for (final patchName in _patchFileNames) { + final patch = File('$rootDir/tool/patches/$patchName'); + if (!patch.existsSync()) { + throw FileSystemException('Clash.Meta patch is missing', patch.path); + } + + final alreadyApplied = await _gitApply(coreDir.path, [ + '--reverse', + '--check', + patch.path, + ]); + if (alreadyApplied.exitCode == 0) { + stdout.writeln('Clash.Meta patch already applied: $patchName'); + continue; + } + + final check = await _gitApply(coreDir.path, [ + '--check', + '--whitespace=error-all', + patch.path, + ]); + if (check.exitCode != 0) { + throw ProcessException( + 'git apply', + ['--check', patch.path], + _outputOf(check), + check.exitCode, + ); + } + + final applied = await _gitApply(coreDir.path, [ + '--whitespace=error-all', + patch.path, + ]); + if (applied.exitCode != 0) { + throw ProcessException( + 'git apply', + [patch.path], + _outputOf(applied), + applied.exitCode, + ); + } + + stdout.writeln('Applied Clash.Meta patch: $patchName'); + } +} + +Future _gitApply( + String workingDirectory, + List arguments, +) { + return Process.run('git', [ + 'apply', + ...arguments, + ], workingDirectory: workingDirectory); +} + +String _outputOf(ProcessResult result) { + return '${result.stdout}${result.stderr}'.trim(); +} diff --git a/test/common/local_proxy_test.dart b/test/common/local_proxy_test.dart new file mode 100644 index 00000000..a7a6329b --- /dev/null +++ b/test/common/local_proxy_test.dart @@ -0,0 +1,53 @@ +import 'package:fl_clash/common/local_proxy.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('generated credentials are safe for core authentication', () { + final credentials = LocalProxyCredentials.generate(); + + expect(credentials.username, 'flclash-android'); + expect(credentials.password.length, 32); + expect(credentials.password, matches(RegExp(r'^[A-Za-z0-9_-]+$'))); + expect( + credentials.coreAuthentication, + 'flclash-android:${credentials.password}', + ); + }); + + test( + 'Android authentication replaces imported authentication without mutating input', + () { + const credentials = LocalProxyCredentials( + username: 'flclash-android', + password: 'test-password', + ); + const rawConfig = { + 'authentication': ['old-user:old-password'], + 'mixed-port': 7890, + }; + + final patched = applyAndroidLocalProxyAuthentication( + rawConfig: rawConfig, + credentials: credentials, + ); + + expect(patched['authentication'], ['flclash-android:test-password']); + expect(rawConfig['authentication'], ['old-user:old-password']); + expect(patched['mixed-port'], 7890); + }, + ); + + test('only Android disables the unsupported system proxy path', () { + expect(shouldUseSystemProxy(isAndroid: true, requested: true), isFalse); + expect(shouldUseSystemProxy(isAndroid: true, requested: false), isFalse); + expect(shouldUseSystemProxy(isAndroid: false, requested: true), isTrue); + expect(shouldUseSystemProxy(isAndroid: false, requested: false), isFalse); + }); + + test('proxy credentials are limited to the current local endpoint', () { + expect(isLocalProxyEndpoint('localhost', 7890, 7890), isTrue); + expect(isLocalProxyEndpoint('127.0.0.1', 7890, 7890), isTrue); + expect(isLocalProxyEndpoint('10.0.0.2', 7890, 7890), isFalse); + expect(isLocalProxyEndpoint('localhost', 7891, 7890), isFalse); + }); +} diff --git a/tool/apply_clash_meta_patches.dart b/tool/apply_clash_meta_patches.dart new file mode 100644 index 00000000..d8dabb13 --- /dev/null +++ b/tool/apply_clash_meta_patches.dart @@ -0,0 +1,17 @@ +import 'dart:io'; + +// The build tool is a separate local Dart package; this command intentionally +// delegates to its shared patch implementation rather than duplicating it. +// ignore: avoid_relative_lib_imports +import '../plugins/setup/buildkit/build_tool/lib/src/clash_meta_patches.dart'; + +Future main() async { + try { + final rootDir = File.fromUri(Platform.script).parent.parent.path; + await applyClashMetaPatches(rootDir); + } catch (error, stackTrace) { + stderr.writeln('Failed to apply Clash.Meta patches: $error'); + stderr.writeln(stackTrace); + exitCode = 1; + } +} diff --git a/tool/patches/0001-android-disable-default-udp-listeners.patch b/tool/patches/0001-android-disable-default-udp-listeners.patch new file mode 100644 index 00000000..1c92e793 --- /dev/null +++ b/tool/patches/0001-android-disable-default-udp-listeners.patch @@ -0,0 +1,183 @@ +diff --git a/listener/listener.go b/listener/listener.go +index 7d751c924e32935371878abb40f57d1f623ee96d..b86b90495b1d05360a69b10fb3a130cc5c9e6578 100644 +--- a/listener/listener.go ++++ b/listener/listener.go +@@ -8,6 +8,7 @@ import ( + "sync" + + C "github.com/metacubex/mihomo/constant" ++ "github.com/metacubex/mihomo/constant/features" + LC "github.com/metacubex/mihomo/listener/config" + "github.com/metacubex/mihomo/listener/http" + "github.com/metacubex/mihomo/listener/mixed" +@@ -73,6 +74,14 @@ type Ports struct { + VmessConfig string `json:"vmess-config"` + } + ++func shouldDisableDefaultUDP(androidBuild bool) bool { ++ return androidBuild ++} ++ ++func defaultUDPDisabled() bool { ++ return shouldDisableDefaultUDP(features.Android) ++} ++ + func GetTunConf() LC.Tun { + if tunLister == nil { + return LastTunConf +@@ -149,6 +158,7 @@ func ReCreateSocks(port int, tunnel C.Tunnel) { + }() + + addr := genAddr(bindAddress, port, allowLan) ++ disableUDP := defaultUDPDisabled() + + shouldTCPIgnore := false + shouldUDPIgnore := false +@@ -163,7 +173,7 @@ func ReCreateSocks(port int, tunnel C.Tunnel) { + } + + if socksUDPListener != nil { +- if socksUDPListener.RawAddress() != addr { ++ if disableUDP || socksUDPListener.RawAddress() != addr { + socksUDPListener.Close() + socksUDPListener = nil + } else { +@@ -171,7 +181,7 @@ func ReCreateSocks(port int, tunnel C.Tunnel) { + } + } + +- if shouldTCPIgnore && shouldUDPIgnore { ++ if shouldTCPIgnore && (shouldUDPIgnore || disableUDP) { + return + } + +@@ -179,20 +189,27 @@ func ReCreateSocks(port int, tunnel C.Tunnel) { + return + } + +- tcpListener, err := socks.New(addr, tunnel) +- if err != nil { ++ if !shouldTCPIgnore { ++ socksListener, err = socks.New(addr, tunnel) ++ if err != nil { ++ return ++ } ++ } ++ ++ if disableUDP || shouldUDPIgnore { ++ log.Infoln("SOCKS proxy listening at: %s", socksListener.Address()) + return + } + +- udpListener, err := socks.NewUDP(addr, tunnel) ++ socksUDPListener, err = socks.NewUDP(addr, tunnel) + if err != nil { +- tcpListener.Close() ++ if !shouldTCPIgnore { ++ socksListener.Close() ++ socksListener = nil ++ } + return + } + +- socksListener = tcpListener +- socksUDPListener = udpListener +- + log.Infoln("SOCKS proxy listening at: %s", socksListener.Address()) + } + +@@ -451,6 +468,7 @@ func ReCreateMixed(port int, tunnel C.Tunnel) { + }() + + addr := genAddr(bindAddress, port, allowLan) ++ disableUDP := defaultUDPDisabled() + + shouldTCPIgnore := false + shouldUDPIgnore := false +@@ -464,7 +482,7 @@ func ReCreateMixed(port int, tunnel C.Tunnel) { + } + } + if mixedUDPLister != nil { +- if mixedUDPLister.RawAddress() != addr { ++ if disableUDP || mixedUDPLister.RawAddress() != addr { + mixedUDPLister.Close() + mixedUDPLister = nil + } else { +@@ -472,7 +490,7 @@ func ReCreateMixed(port int, tunnel C.Tunnel) { + } + } + +- if shouldTCPIgnore && shouldUDPIgnore { ++ if shouldTCPIgnore && (shouldUDPIgnore || disableUDP) { + return + } + +@@ -480,14 +498,24 @@ func ReCreateMixed(port int, tunnel C.Tunnel) { + return + } + +- mixedListener, err = mixed.New(addr, tunnel) +- if err != nil { ++ if !shouldTCPIgnore { ++ mixedListener, err = mixed.New(addr, tunnel) ++ if err != nil { ++ return ++ } ++ } ++ ++ if disableUDP || shouldUDPIgnore { ++ log.Infoln("Mixed(http+socks) proxy listening at: %s", mixedListener.Address()) + return + } + + mixedUDPLister, err = socks.NewUDP(addr, tunnel) + if err != nil { +- mixedListener.Close() ++ if !shouldTCPIgnore { ++ mixedListener.Close() ++ mixedListener = nil ++ } + return + } + +diff --git a/listener/listener_security_test.go b/listener/listener_security_test.go +new file mode 100644 +index 0000000000000000000000000000000000000000..2e5109dd3bb7fbb5d95a8a98a7cf76e44b8b634e +--- /dev/null ++++ b/listener/listener_security_test.go +@@ -0,0 +1,35 @@ ++package listener ++ ++import "testing" ++ ++func TestShouldDisableDefaultUDP(t *testing.T) { ++ tests := []struct { ++ name string ++ androidBuild bool ++ want bool ++ }{ ++ { ++ name: "desktop", ++ androidBuild: false, ++ want: false, ++ }, ++ { ++ name: "android", ++ androidBuild: true, ++ want: true, ++ }, ++ } ++ ++ for _, test := range tests { ++ t.Run(test.name, func(t *testing.T) { ++ if got := shouldDisableDefaultUDP(test.androidBuild); got != test.want { ++ t.Fatalf( ++ "shouldDisableDefaultUDP(%t) = %t, want %t", ++ test.androidBuild, ++ got, ++ test.want, ++ ) ++ } ++ }) ++ } ++} diff --git a/tool/verify_android_local_proxy_security.rb b/tool/verify_android_local_proxy_security.rb new file mode 100644 index 00000000..1885b1b7 --- /dev/null +++ b/tool/verify_android_local_proxy_security.rb @@ -0,0 +1,77 @@ +#!/usr/bin/env ruby + +root = File.expand_path('..', __dir__) +read = ->(*parts) { File.read(File.join(root, *parts)) } + +local_proxy = read.call('lib', 'common', 'local_proxy.dart') +http = read.call('lib', 'common', 'http.dart') +action = read.call('lib', 'providers', 'action.dart') +state = read.call('lib', 'providers', 'state.dart') +network = read.call('lib', 'views', 'config', 'network.dart') +quick_options = read.call('lib', 'views', 'dashboard', 'widgets', 'quick_options.dart') +vpn_service = read.call( + 'android', + 'service', + 'src', + 'main', + 'java', + 'com', + 'follow', + 'clash', + 'service', + 'VpnService.kt', +) +core_patch = read.call( + 'tool', + 'patches', + '0001-android-disable-default-udp-listeners.patch', +) +patch_applier = read.call('tool', 'apply_clash_meta_patches.dart') +dart_test = File.join(root, 'test', 'common', 'local_proxy_test.dart') +workflow = read.call('.github', 'workflows', 'pull-request-validation.yaml') + +abort 'Android local proxy helper is missing secure random credentials' unless + local_proxy.include?('Random.secure()') && + local_proxy.include?('coreAuthentication') + +abort 'Android profile generation does not replace authentication' unless + action.match?(/if \(system\.isAndroid\).*?applyAndroidLocalProxyAuthentication/m) && + action.include?('globalState.localProxyCredentials') + +abort 'Dart proxy client does not handle authenticated local proxy challenges' unless + http.include?('authenticateProxy') && + http.include?('addProxyCredentials') && + http.include?('HttpClientBasicCredentials') && + http.include?('isLocalProxyEndpoint') + +abort 'Android shared VPN state can still request the system proxy' unless + state.include?('shouldUseSystemProxy') && + state.match?(/systemProxy: shouldUseSystemProxy\(/) + +abort 'Android UI still exposes the unsupported VPN system proxy switch' if + network.include?('VpnSystemProxyItem') || quick_options.include?('VpnSystemProxyItem') + +abort 'Android service still creates an unauthenticated system HTTP proxy' if + vpn_service.include?('ProxyInfo') || vpn_service.include?('setHttpProxy') + +abort 'Core patch does not gate default UDP on Android' unless + core_patch.include?('shouldDisableDefaultUDP') && + core_patch.include?('features.Android') && + core_patch.scan('disableUDP := defaultUDPDisabled()').length == 2 && + core_patch.include?('if disableUDP || socksUDPListener.RawAddress() != addr') && + core_patch.include?('if disableUDP || mixedUDPLister.RawAddress() != addr') && + core_patch.scan('if disableUDP || shouldUDPIgnore').length == 2 + +abort 'Dart local proxy regression test is missing' unless File.file?(dart_test) +abort 'Core patch does not contain the Go local proxy regression test' unless + core_patch.include?('listener_security_test.go') && + core_patch.include?('TestShouldDisableDefaultUDP') + +abort 'Core patch applier is missing' unless + patch_applier.include?('applyClashMetaPatches') + +abort 'Android local proxy security verifier is not wired into CI' unless + workflow.include?('script: tool/verify_android_local_proxy_security.rb') && + workflow.include?("go test ./listener -run '^TestShouldDisableDefaultUDP$' -count=1") + +puts 'Android local proxy security wiring verified' diff --git a/tool/verify_ci_layout.rb b/tool/verify_ci_layout.rb index 323bb228..5e3e5222 100644 --- a/tool/verify_ci_layout.rb +++ b/tool/verify_ci_layout.rb @@ -63,6 +63,8 @@ tool/verify_android_background_location_permission.rb tool/verify_android_tile_background_toggle.rb tool/verify_android_proxy_stop.rb + tool/verify_android_local_proxy_security.rb + tool/verify_clash_meta_patch_delivery.rb tool/verify_android_service_lifecycle.rb tool/verify_linux_x11_thread_init.rb tool/verify_windows_maximize_work_area.rb @@ -112,6 +114,20 @@ missing_build_needs = expected_build_needs.reject { |job| build_needs.include?(job) } abort "CI build job is missing prerequisites: #{missing_build_needs.join(', ')}" unless missing_build_needs.empty? +go_test_steps = jobs.fetch('go-tests').fetch('steps') +unless go_test_steps.any? do |step| + step['name'] == 'Apply Clash.Meta patches' && + step['run'] == 'dart tool/apply_clash_meta_patches.dart' +end + abort 'go-tests must apply the parent-owned Clash.Meta patch' +end +unless go_test_steps.any? do |step| + step['working-directory'] == 'core/Clash.Meta' && + step['run'] == "go test ./listener -run '^TestShouldDisableDefaultUDP$' -count=1" +end + abort 'go-tests must execute the Android UDP listener security regression test' +end + gate = jobs.fetch('ci-complete') abort 'CI complete gate must run with always()' unless gate['if'].to_s.strip == '${{ always() }}' abort 'CI complete gate must depend on the build matrix' unless Array(gate['needs']) == ['build'] diff --git a/tool/verify_clash_meta_patch_delivery.rb b/tool/verify_clash_meta_patch_delivery.rb new file mode 100644 index 00000000..7fb167da --- /dev/null +++ b/tool/verify_clash_meta_patch_delivery.rb @@ -0,0 +1,58 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +root = File.expand_path('..', __dir__) +read = ->(*parts) { File.read(File.join(root, *parts)) } + +patch = read.call( + 'tool', + 'patches', + '0001-android-disable-default-udp-listeners.patch', +) +attributes = read.call('.gitattributes') +applier = read.call('tool', 'apply_clash_meta_patches.dart') +patch_helper = read.call( + 'plugins', + 'setup', + 'buildkit', + 'build_tool', + 'lib', + 'src', + 'clash_meta_patches.dart', +) +build_tool = read.call( + 'plugins', + 'setup', + 'buildkit', + 'build_tool', + 'lib', + 'src', + 'build_tool.dart', +) +pr_workflow = read.call('.github', 'workflows', 'pull-request-validation.yaml') +release_workflow = read.call('.github', 'workflows', 'build.yaml') + +checks = { + 'core patch pins listener/listener.go' => + patch.include?('diff --git a/listener/listener.go b/listener/listener.go'), + 'core patch includes a listener regression test' => + patch.include?('listener_security_test.go'), + 'core patch is checked out with LF line endings on every platform' => + attributes.include?('tool/patches/*.patch text eol=lf'), + 'command-line patch applier uses the build-tool helper' => + applier.include?('// ignore: avoid_relative_lib_imports') && + applier.include?('clash_meta_patches.dart'), + 'core patch helper references the Android UDP patch' => + patch_helper.include?('0001-android-disable-default-udp-listeners.patch'), + 'native build tool applies Clash.Meta patches' => + build_tool.include?('applyClashMetaPatches'), + 'PR CI applies Clash.Meta patches before direct core checks' => + pr_workflow.include?('Apply Clash.Meta patches'), + 'release CI applies Clash.Meta patches before direct core checks' => + release_workflow.include?('Apply Clash.Meta patches'), +} + +failed = checks.reject { |_description, passed| passed }.keys +abort "Clash.Meta patch delivery verification failed: #{failed.join(', ')}" unless failed.empty? + +puts 'Clash.Meta patch delivery verified'