diff --git a/.github/CRONET_GO_VERSION b/.github/CRONET_GO_VERSION index 53d53768ca..ea51fa11cc 100644 --- a/.github/CRONET_GO_VERSION +++ b/.github/CRONET_GO_VERSION @@ -1 +1 @@ -b3eec8134aec1387d850e0671dd8531e2e6140b0 +617d38f41f935b46a68f550d9add2e38abb3f168 diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 0d76b981b6..11a2c71bd2 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -31,6 +31,8 @@ body: - sing-box for macOS Graphical Client - sing-box for Apple tvOS Graphical Client - sing-box for Android Graphical Client + - sing-box for Windows Graphical Client + - sing-box for Linux Graphical Client - Third-party graphical clients that advertise themselves as using sing-box (Windows) - Third-party graphical clients that advertise themselves as using sing-box (Android) - Others diff --git a/.github/ISSUE_TEMPLATE/bug_report_zh.yml b/.github/ISSUE_TEMPLATE/bug_report_zh.yml index cea9ebf744..1faad734fb 100644 --- a/.github/ISSUE_TEMPLATE/bug_report_zh.yml +++ b/.github/ISSUE_TEMPLATE/bug_report_zh.yml @@ -31,6 +31,8 @@ body: - sing-box for macOS 图形客户端程序 - sing-box for Apple tvOS 图形客户端程序 - sing-box for Android 图形客户端程序 + - sing-box for Windows 图形客户端程序 + - sing-box for Linux 图形客户端程序 - 宣传使用 sing-box 的第三方图形客户端程序 (Windows) - 宣传使用 sing-box 的第三方图形客户端程序 (Android) - 其他 diff --git a/.github/setup_go_for_macos1013.sh b/.github/setup_go_for_macos1013.sh index 9e501ca949..ddcb3ea060 100755 --- a/.github/setup_go_for_macos1013.sh +++ b/.github/setup_go_for_macos1013.sh @@ -2,7 +2,7 @@ set -euo pipefail -VERSION="1.25.10" +VERSION="1.25.12" PATCH_COMMITS=( "afe69d3cec1c6dcf0f1797b20546795730850070" "1ed289b0cf87dc5aae9c6fe1aa5f200a83412938" diff --git a/.github/setup_go_for_windows7.sh b/.github/setup_go_for_windows7.sh index 01588bca0f..dfc2e0cb9f 100755 --- a/.github/setup_go_for_windows7.sh +++ b/.github/setup_go_for_windows7.sh @@ -2,14 +2,14 @@ set -euo pipefail -VERSION="1.25.10" +VERSION="1.25.12" PATCH_COMMITS=( - "466f6c7a29bc098b0d4c987b803c779222894a11" - "1bdabae205052afe1dadb2ad6f1ba612cdbc532a" - "a90777dcf692dd2168577853ba743b4338721b06" - "f6bddda4e8ff58a957462a1a09562924d5f3d05c" - "bed309eff415bcb3c77dd4bc3277b682b89a388d" - "34b899c2fb39b092db4fa67c4417e41dc046be4b" + "da4094da73b3b419e3f347594d805e2831f65667" + "824aa60e77f06dbae86c20a164c78df722eb7047" + "a3b6ba31c8cc67b6d899b978bba7b53e95afc46b" + "edfa8de63435a409a59f60731b66ab5940d6d3a4" + "284f9b24d6284984966a8431e30fdc2583938f96" + "9864798dee8dd47b55d1d5100d2f1b909a2a6e6c" ) CURL_ARGS=( -fL diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 42dde88591..ac6d9ef630 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,13 +16,20 @@ on: - All - Binary - Android + - Windows + - Linux - Apple - - app-store + - Apple-release - iOS - macOS - tvOS - macOS-standalone - # - publish-android + - iOS-jailbreak + - publish-android + test_message: + description: "TestFlight test message" + required: false + type: string push: branches: - main-next @@ -48,7 +55,20 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: ^1.25.6 + go-version: 1.25.12 + cache: false + - name: Cache Go modules + uses: actions/cache/restore@v4 + with: + path: ~/go/pkg/mod + key: go-mod-linux-${{ hashFiles('go.sum') }} + restore-keys: go-mod-linux- + - name: Cache Go tool builds + uses: actions/cache/restore@v4 + with: + path: ~/.cache/go-build + key: go-build-tools-linux-${{ hashFiles('go.sum') }} + restore-keys: go-build-tools-linux- - name: Check input version if: github.event_name == 'workflow_dispatch' run: |- @@ -65,11 +85,12 @@ jobs: build: name: Build binary if: github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Binary' - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 needs: - calculate_version strategy: fail-fast: false + max-parallel: 13 matrix: include: - { os: linux, arch: amd64, variant: purego, naive: true } @@ -119,7 +140,14 @@ jobs: if: ${{ ! (matrix.legacy_win7 || matrix.legacy_go124) }} uses: actions/setup-go@v5 with: - go-version: ^1.25.6 + go-version: 1.25.12 + cache: false + - name: Cache Go modules + uses: actions/cache/restore@v4 + with: + path: ~/go/pkg/mod + key: go-mod-linux-${{ hashFiles('go.sum') }} + restore-keys: go-mod-linux- - name: Setup Go 1.24 if: matrix.legacy_go124 uses: actions/setup-go@v5 @@ -132,7 +160,7 @@ jobs: with: path: | ~/go/go_win7 - key: go_win7_1255 + key: go_win7_12512 - name: Setup Go for Windows 7 if: matrix.legacy_win7 && steps.cache-go-for-windows7.outputs.cache-hit != 'true' run: |- @@ -147,7 +175,6 @@ jobs: uses: nttld/setup-ndk@v1 with: ndk-version: r28 - local-cache: true - name: Clone cronet-go if: matrix.naive run: | @@ -167,6 +194,13 @@ jobs: ~/cronet-go/naiveproxy/src/third_party/llvm-build/Release+Asserts ~/cronet-go/naiveproxy/src/out/sysroot-build key: chromium-toolchain-${{ matrix.arch }}-${{ matrix.variant }}-${{ hashFiles('.github/CRONET_GO_VERSION') }} + - name: Regenerate Debian keyring + if: matrix.naive + run: | + set -xeuo pipefail + rm -f ~/cronet-go/naiveproxy/src/build/linux/sysroot_scripts/keyring.gpg + cd ~/cronet-go + GPG_TTY=/dev/null ./naiveproxy/src/build/linux/sysroot_scripts/generate_keyring.sh - name: Download Chromium toolchain if: matrix.naive run: | @@ -315,7 +349,6 @@ jobs: run: | set -xeuo pipefail sudo gem install fpm - sudo apt-get update sudo apt-get install -y debsigs cp .fpm_systemd .fpm fpm -t deb \ @@ -323,8 +356,6 @@ jobs: -p "dist/sing-box_${{ needs.calculate_version.outputs.version }}_${{ matrix.os }}_${{ matrix.debian }}.deb" \ --architecture ${{ matrix.debian }} \ dist/sing-box=/usr/bin/sing-box - curl -Lo '/tmp/debsigs.diff' 'https://gitlab.com/debsigs/debsigs/-/commit/160138f5de1ec110376d3c807b60a37388bc7c90.diff' - sudo patch /usr/bin/debsigs < '/tmp/debsigs.diff' rm -rf $HOME/.gnupg gpg --pinentry-mode loopback --passphrase "${{ secrets.GPG_PASSPHRASE }}" --import <> "$GITHUB_ENV" - # git tag v${{ needs.calculate_version.outputs.version }} -f - # - name: Set build tags - # run: | - # set -xeuo pipefail - # TAGS='with_v2ray_api,with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,with_ocm,badlinkname,tfogo_checklinkname0' - # if [[ "${{ matrix.legacy_go124 }}" != "true" ]]; then - # TAGS="${TAGS},with_naive_outbound" - # fi - # echo "BUILD_TAGS=${TAGS}" >> "${GITHUB_ENV}" - # - name: Build - # run: | - # set -xeuo pipefail - # mkdir -p dist - # go build -v -trimpath -o dist/sing-box -tags "${BUILD_TAGS}" \ - # -ldflags '-s -buildid= -X github.com/sagernet/sing-box/constant.Version=${{ needs.calculate_version.outputs.version }} -X internal/godebug.defaultGODEBUG=multipathtcp=0 -checklinkname=0' \ - # ./cmd/sing-box - # env: - # CGO_ENABLED: "1" - # GOOS: darwin - # GOARCH: ${{ matrix.arch }} - # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # - name: Set name - # run: |- - # DIR_NAME="sing-box-${{ needs.calculate_version.outputs.version }}-darwin-${{ matrix.arch }}" - # if [[ -n "${{ matrix.legacy_name }}" ]]; then - # DIR_NAME="${DIR_NAME}-legacy-${{ matrix.legacy_name }}" - # fi - # echo "DIR_NAME=${DIR_NAME}" >> "${GITHUB_ENV}" - # - name: Archive - # run: | - # set -xeuo pipefail - # cd dist - # mkdir -p "${DIR_NAME}" - # cp ../LICENSE "${DIR_NAME}" - # cp sing-box "${DIR_NAME}" - # tar -czvf "${DIR_NAME}.tar.gz" "${DIR_NAME}" - # rm -r "${DIR_NAME}" - # - name: Cleanup - # run: rm dist/sing-box - # - name: Upload artifact - # uses: actions/upload-artifact@v4 - # with: - # name: binary-darwin_${{ matrix.arch }}${{ matrix.legacy_name && format('-legacy-{0}', matrix.legacy_name) }} - # path: "dist" - # retention-days: 1 - # build_windows: - # name: Build Windows binaries - # if: github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Binary' - # runs-on: windows-latest - # needs: - # - calculate_version - # strategy: - # matrix: - # include: - # - { arch: amd64, naive: true } - # - { arch: "386" } - # - { arch: arm64, naive: true } - # steps: - # - name: Checkout - # uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 - # with: - # fetch-depth: 0 - # submodules: 'recursive' - # - name: Setup Go - # uses: actions/setup-go@v5 - # with: - # go-version: ^1.25.4 - # - name: Set tag - # run: |- - # git ls-remote --exit-code --tags origin v${{ needs.calculate_version.outputs.version }} || echo "PUBLISHED=false" >> "$env:GITHUB_ENV" - # git tag v${{ needs.calculate_version.outputs.version }} -f - # - name: Build - # if: matrix.naive - # run: | - # mkdir -p dist - # go build -v -trimpath -o dist/sing-box.exe -tags "with_v2ray_api,with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,with_ocm,with_naive_outbound,with_purego,badlinkname,tfogo_checklinkname0" ` - # -ldflags "-s -buildid= -X github.com/sagernet/sing-box/constant.Version=${{ needs.calculate_version.outputs.version }} -X internal/godebug.defaultGODEBUG=multipathtcp=0 -checklinkname=0" ` - # ./cmd/sing-box - # env: - # CGO_ENABLED: "0" - # GOOS: windows - # GOARCH: ${{ matrix.arch }} - # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # - name: Build - # if: ${{ !matrix.naive }} - # run: | - # mkdir -p dist - # go build -v -trimpath -o dist/sing-box.exe -tags "with_v2ray_api,with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,with_ocm,badlinkname,tfogo_checklinkname0" ` - # -ldflags "-s -buildid= -X github.com/sagernet/sing-box/constant.Version=${{ needs.calculate_version.outputs.version }} -X internal/godebug.defaultGODEBUG=multipathtcp=0 -checklinkname=0" ` - # ./cmd/sing-box - # env: - # CGO_ENABLED: "0" - # GOOS: windows - # GOARCH: ${{ matrix.arch }} - # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # - name: Extract libcronet.dll - # if: matrix.naive - # run: | - # $CRONET_GO_VERSION = Get-Content .github/CRONET_GO_VERSION - # $env:CGO_ENABLED = "0" - # go run -v "github.com/sagernet/cronet-go/cmd/build-naive@$CRONET_GO_VERSION" extract-lib --target windows/${{ matrix.arch }} -o dist - # - name: Archive - # if: matrix.naive - # run: | - # $DIR_NAME = "sing-box-${{ needs.calculate_version.outputs.version }}-windows-${{ matrix.arch }}" - # mkdir "dist/$DIR_NAME" - # Copy-Item LICENSE "dist/$DIR_NAME" - # Copy-Item "dist/sing-box.exe" "dist/$DIR_NAME" - # Copy-Item "dist/libcronet.dll" "dist/$DIR_NAME" - # Compress-Archive -Path "dist/$DIR_NAME" -DestinationPath "dist/$DIR_NAME.zip" - # Remove-Item -Recurse "dist/$DIR_NAME" - # - name: Archive - # if: ${{ !matrix.naive }} - # run: | - # $DIR_NAME = "sing-box-${{ needs.calculate_version.outputs.version }}-windows-${{ matrix.arch }}" - # mkdir "dist/$DIR_NAME" - # Copy-Item LICENSE "dist/$DIR_NAME" - # Copy-Item "dist/sing-box.exe" "dist/$DIR_NAME" - # Compress-Archive -Path "dist/$DIR_NAME" -DestinationPath "dist/$DIR_NAME.zip" - # Remove-Item -Recurse "dist/$DIR_NAME" - # - name: Cleanup - # if: matrix.naive - # run: Remove-Item dist/sing-box.exe, dist/libcronet.dll - # - name: Cleanup - # if: ${{ !matrix.naive }} - # run: Remove-Item dist/sing-box.exe - # - name: Upload artifact - # uses: actions/upload-artifact@v4 - # with: - # name: binary-windows_${{ matrix.arch }} - # path: "dist" - # retention-days: 1 - # build_android: - # name: Build Android - # # if: github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Android' - # if: false - # runs-on: ubuntu-latest - # needs: - # - calculate_version - # steps: - # - name: Checkout - # uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 - # with: - # fetch-depth: 0 - # submodules: 'recursive' - # - name: Setup Go - # uses: actions/setup-go@v5 - # with: - # go-version: ^1.25.6 - # - name: Setup Android NDK - # id: setup-ndk - # uses: nttld/setup-ndk@v1 - # with: - # ndk-version: r28 - # - name: Setup OpenJDK - # run: |- - # sudo apt update && sudo apt install -y openjdk-17-jdk-headless - # /usr/lib/jvm/java-17-openjdk-amd64/bin/java --version - # - name: Set tag - # run: |- - # git ls-remote --exit-code --tags origin v${{ needs.calculate_version.outputs.version }} || echo "PUBLISHED=false" >> "$GITHUB_ENV" - # git tag v${{ needs.calculate_version.outputs.version }} -f - # - name: Build library - # run: |- - # make lib_install - # export PATH="$PATH:$(go env GOPATH)/bin" - # make lib_android - # env: - # JAVA_HOME: /usr/lib/jvm/java-17-openjdk-amd64 - # ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }} - # - name: Checkout main branch - # if: github.ref == 'refs/heads/main-next' && github.event_name != 'workflow_dispatch' - # run: |- - # cd clients/android - # git checkout main - # - name: Checkout dev branch - # if: github.ref == 'refs/heads/dev-next' - # run: |- - # cd clients/android - # git checkout dev - # - name: Gradle cache - # uses: actions/cache@v4 - # with: - # path: ~/.gradle - # key: gradle-${{ hashFiles('**/*.gradle') }} - # - name: Update version - # if: github.event_name == 'workflow_dispatch' - # run: |- - # go run -v ./cmd/internal/update_android_version --ci - # - name: Update nightly version - # if: github.event_name != 'workflow_dispatch' - # run: |- - # go run -v ./cmd/internal/update_android_version --ci --nightly - # - name: Build - # run: |- - # mkdir clients/android/app/libs - # cp *.aar clients/android/app/libs - # cd clients/android - # ./gradlew :app:assembleOtherRelease :app:assembleOtherLegacyRelease - # env: - # JAVA_HOME: /usr/lib/jvm/java-17-openjdk-amd64 - # ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }} - # LOCAL_PROPERTIES: ${{ secrets.LOCAL_PROPERTIES }} - # - name: Prepare upload - # run: |- - # mkdir -p dist - # #cp clients/android/app/build/outputs/apk/play/release/*.apk dist - # cp clients/android/app/build/outputs/apk/other/release/*.apk dist - # cp clients/android/app/build/outputs/apk/otherLegacy/release/*.apk dist - # VERSION_CODE=$(grep VERSION_CODE clients/android/version.properties | cut -d= -f2) - # VERSION_NAME=$(grep VERSION_NAME clients/android/version.properties | cut -d= -f2) - # cat > dist/SFA-version-metadata.json << EOF - # { - # "version_code": ${VERSION_CODE}, - # "version_name": "${VERSION_NAME}" - # } - # EOF - # cat dist/SFA-version-metadata.json - # - name: Upload artifact - # uses: actions/upload-artifact@v4 - # with: - # name: binary-android-apks - # path: 'dist' - # retention-days: 1 - # publish_android: - - # name: Publish Android - # # if: github.event_name == 'workflow_dispatch' && inputs.build == 'publish-android' - # if: false - # runs-on: ubuntu-latest - # needs: - # - calculate_version - # steps: - # - name: Checkout - # uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 - # with: - # fetch-depth: 0 - # submodules: 'recursive' - # - name: Setup Go - # uses: actions/setup-go@v5 - # with: - # go-version: ^1.25.6 - # - name: Setup Android NDK - # id: setup-ndk - # uses: nttld/setup-ndk@v1 - # with: - # ndk-version: r28 - # - name: Setup OpenJDK - # run: |- - # sudo apt update && sudo apt install -y openjdk-17-jdk-headless - # /usr/lib/jvm/java-17-openjdk-amd64/bin/java --version - # - name: Set tag - # run: |- - # git ls-remote --exit-code --tags origin v${{ needs.calculate_version.outputs.version }} || echo "PUBLISHED=false" >> "$GITHUB_ENV" - # git tag v${{ needs.calculate_version.outputs.version }} -f - # - name: Build library - # run: |- - # make lib_install - # export PATH="$PATH:$(go env GOPATH)/bin" - # make lib_android - # env: - # JAVA_HOME: /usr/lib/jvm/java-17-openjdk-amd64 - # ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }} - # - name: Checkout main branch - # if: github.ref == 'refs/heads/main-next' && github.event_name != 'workflow_dispatch' - # run: |- - # cd clients/android - # git checkout main - # - name: Checkout dev branch - # if: github.ref == 'refs/heads/dev-next' - # run: |- - # cd clients/android - # git checkout dev - # - name: Gradle cache - # uses: actions/cache@v4 - # with: - # path: ~/.gradle - # key: gradle-${{ hashFiles('**/*.gradle') }} - # - name: Build - # run: |- - # go run -v ./cmd/internal/update_android_version --ci - # mkdir clients/android/app/libs - # cp *.aar clients/android/app/libs - # cd clients/android - # echo -n "$SERVICE_ACCOUNT_CREDENTIALS" | base64 --decode > service-account-credentials.json - # ./gradlew :app:publishPlayReleaseBundle - # env: - # JAVA_HOME: /usr/lib/jvm/java-17-openjdk-amd64 - # ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }} - # LOCAL_PROPERTIES: ${{ secrets.LOCAL_PROPERTIES }} - # SERVICE_ACCOUNT_CREDENTIALS: ${{ secrets.SERVICE_ACCOUNT_CREDENTIALS }} - # build_apple: - # name: Build Apple clients - # runs-on: macos-26 - # if: false # github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'app-store' || inputs.build == 'iOS' || inputs.build == 'macOS' || inputs.build == 'tvOS' || inputs.build == 'macOS-standalone' - # needs: - # - calculate_version - # strategy: - # matrix: - # include: - # - name: iOS - # if: ${{ github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'app-store'|| inputs.build == 'iOS' }} - # platform: ios - # scheme: SFI - # destination: 'generic/platform=iOS' - # archive: build/SFI.xcarchive - # upload: SFI/Upload.plist - # - name: macOS - # if: ${{ github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'app-store'|| inputs.build == 'macOS' }} - # platform: macos - # scheme: SFM - # destination: 'generic/platform=macOS' - # archive: build/SFM.xcarchive - # upload: SFI/Upload.plist - # - name: tvOS - # if: ${{ github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'app-store'|| inputs.build == 'tvOS' }} - # platform: tvos - # scheme: SFT - # destination: 'generic/platform=tvOS' - # archive: build/SFT.xcarchive - # upload: SFI/Upload.plist - # - name: macOS-standalone - # if: ${{ github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'macOS-standalone' }} - # platform: macos - # scheme: SFM.System - # destination: 'generic/platform=macOS' - # archive: build/SFM.System.xcarchive - # export: SFM.System/Export.plist - # export_path: build/SFM.System - # steps: - # - name: Checkout - # if: matrix.if - # uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 - # with: - # fetch-depth: 0 - # submodules: 'recursive' - # - name: Setup Go - # if: matrix.if - # uses: actions/setup-go@v5 - # with: - # go-version: ^1.25.6 - # - name: Set tag - # if: matrix.if - # run: |- - # git ls-remote --exit-code --tags origin v${{ needs.calculate_version.outputs.version }} || echo "PUBLISHED=false" >> "$GITHUB_ENV" - # git tag v${{ needs.calculate_version.outputs.version }} -f - # echo "VERSION=${{ needs.calculate_version.outputs.version }}" >> "$GITHUB_ENV" - # - name: Checkout main branch - # if: matrix.if && github.ref == 'refs/heads/main-next' && github.event_name != 'workflow_dispatch' - # run: |- - # cd clients/apple - # git checkout main - # - name: Checkout dev branch - # if: matrix.if && github.ref == 'refs/heads/dev-next' - # run: |- - # cd clients/apple - # git checkout dev - # - name: Setup certificates - # if: matrix.if - # run: |- - # CERTIFICATE_PATH=$RUNNER_TEMP/Certificates.p12 - # KEYCHAIN_PATH=$RUNNER_TEMP/certificates.keychain-db - # echo -n "$CERTIFICATES_P12" | base64 --decode -o $CERTIFICATE_PATH - # security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH - # security set-keychain-settings -lut 21600 $KEYCHAIN_PATH - # security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH - # security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH - # security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH - # security list-keychain -d user -s $KEYCHAIN_PATH - - # PROFILES_ZIP_PATH=$RUNNER_TEMP/Profiles.zip - # echo -n "$PROVISIONING_PROFILES" | base64 --decode -o $PROFILES_ZIP_PATH - - # PROFILES_PATH="$HOME/Library/MobileDevice/Provisioning Profiles" - # mkdir -p "$PROFILES_PATH" - # unzip $PROFILES_ZIP_PATH -d "$PROFILES_PATH" - - # ASC_KEY_PATH=$RUNNER_TEMP/Key.p12 - # echo -n "$ASC_KEY" | base64 --decode -o $ASC_KEY_PATH - - # xcrun notarytool store-credentials "notarytool-password" \ - # --key $ASC_KEY_PATH \ - # --key-id $ASC_KEY_ID \ - # --issuer $ASC_KEY_ISSUER_ID - - # echo "ASC_KEY_PATH=$ASC_KEY_PATH" >> "$GITHUB_ENV" - # echo "ASC_KEY_ID=$ASC_KEY_ID" >> "$GITHUB_ENV" - # echo "ASC_KEY_ISSUER_ID=$ASC_KEY_ISSUER_ID" >> "$GITHUB_ENV" - # env: - # CERTIFICATES_P12: ${{ secrets.CERTIFICATES_P12 }} - # P12_PASSWORD: ${{ secrets.P12_PASSWORD }} - # KEYCHAIN_PASSWORD: ${{ secrets.P12_PASSWORD }} - # PROVISIONING_PROFILES: ${{ secrets.PROVISIONING_PROFILES }} - # ASC_KEY: ${{ secrets.ASC_KEY }} - # ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} - # ASC_KEY_ISSUER_ID: ${{ secrets.ASC_KEY_ISSUER_ID }} - # - name: Build library - # if: matrix.if - # run: |- - # make lib_install - # export PATH="$PATH:$(go env GOPATH)/bin" - # go run ./cmd/internal/build_libbox -target apple -platform ${{ matrix.platform }} - # mv Libbox.xcframework clients/apple - # - name: Update macOS version - # if: matrix.if && matrix.name == 'macOS' && github.event_name == 'workflow_dispatch' - # run: |- - # MACOS_PROJECT_VERSION=$(go run -v ./cmd/internal/app_store_connect next_macos_project_version) - # echo "MACOS_PROJECT_VERSION=$MACOS_PROJECT_VERSION" - # echo "MACOS_PROJECT_VERSION=$MACOS_PROJECT_VERSION" >> "$GITHUB_ENV" - # - name: Update version - # if: matrix.if && matrix.name != 'iOS' - # run: |- - # go run -v ./cmd/internal/update_apple_version --ci - # - name: Build - # if: matrix.if - # run: |- - # cd clients/apple - # xcodebuild archive \ - # -scheme "${{ matrix.scheme }}" \ - # -configuration Release \ - # -destination "${{ matrix.destination }}" \ - # -archivePath "${{ matrix.archive }}" \ - # -allowProvisioningUpdates \ - # -authenticationKeyPath $ASC_KEY_PATH \ - # -authenticationKeyID $ASC_KEY_ID \ - # -authenticationKeyIssuerID $ASC_KEY_ISSUER_ID - # - name: Upload to App Store Connect - # if: matrix.if && matrix.name != 'macOS-standalone' && github.event_name == 'workflow_dispatch' - # run: |- - # go run -v ./cmd/internal/app_store_connect cancel_app_store ${{ matrix.platform }} - # cd clients/apple - # xcodebuild -exportArchive \ - # -archivePath "${{ matrix.archive }}" \ - # -exportOptionsPlist ${{ matrix.upload }} \ - # -allowProvisioningUpdates \ - # -authenticationKeyPath $ASC_KEY_PATH \ - # -authenticationKeyID $ASC_KEY_ID \ - # -authenticationKeyIssuerID $ASC_KEY_ISSUER_ID - # - name: Publish to TestFlight - # if: matrix.if && matrix.name != 'macOS-standalone' && github.event_name == 'workflow_dispatch' && github.ref =='refs/heads/dev-next' - # run: |- - # go run -v ./cmd/internal/app_store_connect publish_testflight ${{ matrix.platform }} - # - name: Build image - # if: matrix.if && matrix.name == 'macOS-standalone' && github.event_name == 'workflow_dispatch' - # run: |- - # pushd clients/apple - # xcodebuild -exportArchive \ - # -archivePath "${{ matrix.archive }}" \ - # -exportOptionsPlist ${{ matrix.export }} \ - # -exportPath "${{ matrix.export_path }}" - # brew install create-dmg - # create-dmg \ - # --volname "sing-box" \ - # --volicon "${{ matrix.export_path }}/SFM.app/Contents/Resources/AppIcon.icns" \ - # --icon "SFM.app" 0 0 \ - # --hide-extension "SFM.app" \ - # --app-drop-link 0 0 \ - # --skip-jenkins \ - # SFM.dmg "${{ matrix.export_path }}/SFM.app" - # xcrun notarytool submit "SFM.dmg" --wait --keychain-profile "notarytool-password" - # cd "${{ matrix.archive }}" - # zip -r SFM.dSYMs.zip dSYMs - # popd + build_darwin: + name: Build Darwin binaries + if: ${{ !failure() && !cancelled() && (github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Binary') }} + runs-on: macos-latest + needs: + - calculate_version + - build_apple_library + strategy: + matrix: + include: + - { arch: amd64 } + - { arch: arm64 } + - { arch: amd64, legacy_osx: true, legacy_name: "macos-10.13" } + steps: + - name: Checkout + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + with: + fetch-depth: 1 + - name: Setup Go + if: ${{ ! matrix.legacy_osx }} + uses: actions/setup-go@v5 + with: + go-version: ^1.25.3 + cache: false + - name: Cache Go modules + uses: actions/cache/restore@v4 + with: + path: ~/go/pkg/mod + key: go-mod-macos-${{ hashFiles('go.sum') }} + restore-keys: go-mod-macos- + - name: Cache Go for macOS 10.13 + if: matrix.legacy_osx + id: cache-go-for-macos1013 + uses: actions/cache@v4 + with: + path: | + ~/go/go_osx + key: go_osx_1258 + - name: Setup Go for macOS 10.13 + if: matrix.legacy_osx && steps.cache-go-for-macos1013.outputs.cache-hit != 'true' + env: + GITHUB_TOKEN: ${{ github.token }} + run: |- + .github/setup_go_for_macos1013.sh + - name: Setup Go for macOS 10.13 + if: matrix.legacy_osx + run: |- + echo "PATH=$HOME/go/go_osx/bin:$PATH" >> $GITHUB_ENV + echo "GOROOT=$HOME/go/go_osx" >> $GITHUB_ENV + - name: Set tag + run: |- + git ls-remote --exit-code --tags origin v${{ needs.calculate_version.outputs.version }} || echo "PUBLISHED=false" >> "$GITHUB_ENV" + git tag v${{ needs.calculate_version.outputs.version }} -f + - name: Set build tags + run: | + set -xeuo pipefail + if [[ "${{ matrix.legacy_osx }}" != "true" ]]; then + TAGS=$(cat release/DEFAULT_BUILD_TAGS) + else + TAGS=$(cat release/DEFAULT_BUILD_TAGS_OTHERS) + fi + echo "BUILD_TAGS=${TAGS}" >> "${GITHUB_ENV}" + - name: Set shared ldflags + run: | + echo "LDFLAGS_SHARED=$(cat release/LDFLAGS)" >> "${GITHUB_ENV}" + - name: Build + run: | + set -xeuo pipefail + mkdir -p dist + go build -v -trimpath -o dist/sing-box -tags "${BUILD_TAGS}" \ + -ldflags "-X 'github.com/sagernet/sing-box/constant.Version=${{ needs.calculate_version.outputs.version }}' ${LDFLAGS_SHARED} -s -w -buildid=" \ + ./cmd/sing-box + env: + CGO_ENABLED: "1" + GOOS: darwin + GOARCH: ${{ matrix.arch }} + MACOSX_DEPLOYMENT_TARGET: ${{ matrix.legacy_osx && '10.13' || '' }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Set name + run: |- + DIR_NAME="sing-box-${{ needs.calculate_version.outputs.version }}-darwin-${{ matrix.arch }}" + if [[ -n "${{ matrix.legacy_name }}" ]]; then + DIR_NAME="${DIR_NAME}-legacy-${{ matrix.legacy_name }}" + fi + echo "DIR_NAME=${DIR_NAME}" >> "${GITHUB_ENV}" + - name: Archive + run: | + set -xeuo pipefail + cd dist + mkdir -p "${DIR_NAME}" + cp ../LICENSE "${DIR_NAME}" + cp sing-box "${DIR_NAME}" + tar -czvf "${DIR_NAME}.tar.gz" "${DIR_NAME}" + rm -r "${DIR_NAME}" + - name: Cleanup + run: rm dist/sing-box + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: binary-darwin_${{ matrix.arch }}${{ matrix.legacy_name && format('-legacy-{0}', matrix.legacy_name) }} + path: "dist" + build_windows: + name: Build Windows binaries + if: github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Binary' + runs-on: windows-latest + needs: + - calculate_version + strategy: + matrix: + include: + - { arch: amd64, naive: true } + - { arch: "386" } + - { arch: arm64, naive: true } + steps: + - name: Checkout + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + with: + fetch-depth: 1 + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: ^1.25.4 + cache: false + - name: Cache Go modules + uses: actions/cache@v4 + with: + path: ~/go/pkg/mod + key: go-mod-windows-${{ hashFiles('go.sum') }} + restore-keys: go-mod-windows- + - name: Set tag + run: |- + git ls-remote --exit-code --tags origin v${{ needs.calculate_version.outputs.version }} || echo "PUBLISHED=false" >> "$env:GITHUB_ENV" + git tag v${{ needs.calculate_version.outputs.version }} -f + - name: Build + if: matrix.naive + run: | + $TAGS = Get-Content release/DEFAULT_BUILD_TAGS_WINDOWS + $LDFLAGS_SHARED = Get-Content release/LDFLAGS + mkdir -p dist + go build -v -trimpath -o dist/sing-box.exe -tags "$TAGS" ` + -ldflags "-X 'github.com/sagernet/sing-box/constant.Version=${{ needs.calculate_version.outputs.version }}' $LDFLAGS_SHARED -s -w -buildid=" ` + ./cmd/sing-box + env: + CGO_ENABLED: "0" + GOOS: windows + GOARCH: ${{ matrix.arch }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Build + if: ${{ !matrix.naive }} + run: | + $TAGS = Get-Content release/DEFAULT_BUILD_TAGS_OTHERS + $LDFLAGS_SHARED = Get-Content release/LDFLAGS + mkdir -p dist + go build -v -trimpath -o dist/sing-box.exe -tags "$TAGS" ` + -ldflags "-X 'github.com/sagernet/sing-box/constant.Version=${{ needs.calculate_version.outputs.version }}' $LDFLAGS_SHARED -s -w -buildid=" ` + ./cmd/sing-box + env: + CGO_ENABLED: "0" + GOOS: windows + GOARCH: ${{ matrix.arch }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Extract libcronet.dll + if: matrix.naive + run: | + $CRONET_GO_VERSION = Get-Content .github/CRONET_GO_VERSION + git init "$env:RUNNER_TEMP/cronet-go" + git -C "$env:RUNNER_TEMP/cronet-go" remote add origin https://github.com/sagernet/cronet-go.git + git -C "$env:RUNNER_TEMP/cronet-go" fetch --depth=1 origin "$CRONET_GO_VERSION" + git -C "$env:RUNNER_TEMP/cronet-go" checkout FETCH_HEAD + $env:CGO_ENABLED = "0" + go -C "$env:RUNNER_TEMP/cronet-go" build -v -o "$env:RUNNER_TEMP/build-naive.exe" ./cmd/build-naive + $env:GOPROXY = "direct" + $env:GOSUMDB = "off" + & "$env:RUNNER_TEMP/build-naive.exe" extract-lib --target windows/${{ matrix.arch }} -o dist + - name: Archive + if: matrix.naive + run: | + $DIR_NAME = "sing-box-${{ needs.calculate_version.outputs.version }}-windows-${{ matrix.arch }}" + mkdir "dist/$DIR_NAME" + Copy-Item LICENSE "dist/$DIR_NAME" + Copy-Item "dist/sing-box.exe" "dist/$DIR_NAME" + Copy-Item "dist/libcronet.dll" "dist/$DIR_NAME" + Compress-Archive -Path "dist/$DIR_NAME" -DestinationPath "dist/$DIR_NAME.zip" + Remove-Item -Recurse "dist/$DIR_NAME" + - name: Archive + if: ${{ !matrix.naive }} + run: | + $DIR_NAME = "sing-box-${{ needs.calculate_version.outputs.version }}-windows-${{ matrix.arch }}" + mkdir "dist/$DIR_NAME" + Copy-Item LICENSE "dist/$DIR_NAME" + Copy-Item "dist/sing-box.exe" "dist/$DIR_NAME" + Compress-Archive -Path "dist/$DIR_NAME" -DestinationPath "dist/$DIR_NAME.zip" + Remove-Item -Recurse "dist/$DIR_NAME" + - name: Cleanup + if: matrix.naive + run: Remove-Item dist/sing-box.exe, dist/libcronet.dll + - name: Cleanup + if: ${{ !matrix.naive }} + run: Remove-Item dist/sing-box.exe + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: binary-windows_${{ matrix.arch }} + path: "dist" + build_android_library: + name: Build Android library (${{ matrix.go_arch }}) + if: (github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Android' || inputs.build == 'publish-android') && github.ref != 'refs/heads/oldstable' + runs-on: ubuntu-latest + needs: + - calculate_version + strategy: + fail-fast: false + matrix: + go_arch: + - "386" + - amd64 + - arm + - arm64 + steps: + - name: Checkout + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + with: + fetch-depth: 1 + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: 1.25.12 + cache: false + - name: Cache Go modules + uses: actions/cache@v4 + with: + path: ~/go/pkg/mod + key: go-mod-linux-${{ hashFiles('go.sum') }} + restore-keys: go-mod-linux- + - name: Setup Android NDK + id: setup-ndk + uses: nttld/setup-ndk@v1 + with: + ndk-version: r28 + - name: Setup OpenJDK + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 + with: + distribution: temurin + java-version: 17 + - name: Set tag + run: |- + git ls-remote --exit-code --tags origin v${{ needs.calculate_version.outputs.version }} || echo "PUBLISHED=false" >> "$GITHUB_ENV" + git tag v${{ needs.calculate_version.outputs.version }} -f + - name: Build library + run: |- + make lib_install + export PATH="$PATH:$(go env GOPATH)/bin" + go run ./cmd/internal/build_libbox -target android -platform android/${{ matrix.go_arch }} + mkdir -p dist/${{ matrix.go_arch }} + mv libbox.aar libbox-legacy.aar dist/${{ matrix.go_arch }} + env: + ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }} + - name: Upload library + uses: actions/upload-artifact@v4 + with: + name: libbox-android-${{ matrix.go_arch }} + path: dist + compression-level: 0 + build_android: + name: Build Android (${{ matrix.variant }}) + if: (github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Android') && github.ref != 'refs/heads/oldstable' + runs-on: ubuntu-latest + needs: + - calculate_version + - build_android_library + strategy: + fail-fast: false + matrix: + include: + - variant: other + task: :app:assembleOtherRelease + output: app/build/outputs/apk/other/release/*.apk + - variant: other-legacy + task: :app:assembleOtherLegacyRelease + output: app/build/outputs/apk/otherLegacy/release/*.apk + steps: + - name: Checkout + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + with: + fetch-depth: 1 + - name: Checkout Android client + run: git submodule update --init clients/android + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: 1.25.12 + cache: false + - name: Cache Go modules + uses: actions/cache/restore@v4 + with: + path: ~/go/pkg/mod + key: go-mod-linux-${{ hashFiles('go.sum') }} + restore-keys: go-mod-linux- + - name: Cache Go tool builds + uses: actions/cache@v4 + with: + path: ~/.cache/go-build + key: go-build-tools-linux-${{ hashFiles('go.sum') }} + restore-keys: go-build-tools-linux- + - name: Setup Android NDK + id: setup-ndk + uses: nttld/setup-ndk@v1 + with: + ndk-version: r28 + - name: Setup OpenJDK + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 + with: + distribution: temurin + java-version: 17 + - name: Set tag + run: |- + git ls-remote --exit-code --tags origin v${{ needs.calculate_version.outputs.version }} || echo "PUBLISHED=false" >> "$GITHUB_ENV" + git tag v${{ needs.calculate_version.outputs.version }} -f + - name: Download libraries + uses: actions/download-artifact@v4 + with: + pattern: libbox-android-* + path: libbox-inputs + merge-multiple: true + - name: Merge libraries + run: |- + go run ./cmd/internal/merge_aar -output libbox.aar libbox-inputs/*/libbox.aar + go run ./cmd/internal/merge_aar -output libbox-legacy.aar libbox-inputs/*/libbox-legacy.aar + - name: Checkout main branch + if: github.ref == 'refs/heads/stable' && github.event_name != 'workflow_dispatch' + run: |- + cd clients/android + git checkout main + - name: Checkout dev branch + if: github.ref == 'refs/heads/testing' + run: |- + cd clients/android + git checkout dev + - name: Checkout submodule recursive + run: |- + cd clients/android + git submodule update --init --recursive -v + - name: Setup Gradle + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + with: + cache-provider: basic + - name: Update version + if: github.event_name == 'workflow_dispatch' + run: |- + go run -v ./cmd/internal/update_android_version --ci + - name: Update nightly version + if: github.event_name != 'workflow_dispatch' + run: |- + go run -v ./cmd/internal/update_android_version --ci --nightly + - name: Build + run: |- + mkdir -p clients/android/app/libs + cp *.aar clients/android/app/libs + cd clients/android + ./gradlew --build-cache --parallel ${{ matrix.task }} + env: + ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }} + LOCAL_PROPERTIES: ${{ secrets.LOCAL_PROPERTIES }} + - name: Prepare upload + run: |- + mkdir -p dist + cp clients/android/${{ matrix.output }} dist + VERSION_CODE=$(grep VERSION_CODE clients/android/version.properties | cut -d= -f2) + VERSION_NAME=$(grep VERSION_NAME clients/android/version.properties | cut -d= -f2) + cat > dist/SFA-version-metadata.json << EOF + { + "version_code": ${VERSION_CODE}, + "version_name": "${VERSION_NAME}" + } + EOF + cat dist/SFA-version-metadata.json + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: binary-android-apks-${{ matrix.variant }} + path: 'dist' + publish_android: + name: Publish Android + if: github.event_name == 'workflow_dispatch' && inputs.build == 'publish-android' && (github.ref == 'refs/heads/stable' || github.ref == 'refs/heads/testing') + runs-on: ubuntu-latest + needs: + - calculate_version + - build_android_library + steps: + - name: Checkout + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + with: + fetch-depth: 1 + - name: Checkout Android client + run: git submodule update --init clients/android + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: 1.25.12 + cache: false + - name: Cache Go modules + uses: actions/cache/restore@v4 + with: + path: ~/go/pkg/mod + key: go-mod-linux-${{ hashFiles('go.sum') }} + restore-keys: go-mod-linux- + - name: Cache Go tool builds + uses: actions/cache@v4 + with: + path: ~/.cache/go-build + key: go-build-tools-linux-${{ hashFiles('go.sum') }} + restore-keys: go-build-tools-linux- + - name: Setup Android NDK + id: setup-ndk + uses: nttld/setup-ndk@v1 + with: + ndk-version: r28 + - name: Setup OpenJDK + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 + with: + distribution: temurin + java-version: 17 + - name: Set tag + run: |- + git ls-remote --exit-code --tags origin v${{ needs.calculate_version.outputs.version }} || echo "PUBLISHED=false" >> "$GITHUB_ENV" + git tag v${{ needs.calculate_version.outputs.version }} -f + - name: Download libraries + uses: actions/download-artifact@v4 + with: + pattern: libbox-android-* + path: libbox-inputs + merge-multiple: true + - name: Merge libraries + run: |- + go run ./cmd/internal/merge_aar -output libbox.aar libbox-inputs/*/libbox.aar + go run ./cmd/internal/merge_aar -output libbox-legacy.aar libbox-inputs/*/libbox-legacy.aar + - name: Checkout main branch + if: github.ref == 'refs/heads/stable' && github.event_name != 'workflow_dispatch' + run: |- + cd clients/android + git checkout main + - name: Checkout dev branch + if: github.ref == 'refs/heads/testing' + run: |- + cd clients/android + git checkout dev + - name: Checkout submodule recursive + run: |- + cd clients/android + git submodule update --init --recursive -v + - name: Setup Gradle + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + with: + cache-provider: basic + - name: Prepare build + run: |- + go run -v ./cmd/internal/update_android_version --ci + mkdir -p clients/android/app/libs + cp *.aar clients/android/app/libs + cd clients/android + echo -n "$SERVICE_ACCOUNT_CREDENTIALS" | base64 --decode > service-account-credentials.json + chmod 600 service-account-credentials.json + jq -e ' + .type == "service_account" and + (.project_id | type == "string" and length > 0) and + (.private_key | type == "string" and length > 0) and + (.client_email | type == "string" and length > 0) and + (.token_uri | type == "string" and length > 0) + ' service-account-credentials.json > /dev/null + jq -r '.private_key' service-account-credentials.json | openssl pkey -check -noout + env: + LOCAL_PROPERTIES: ${{ secrets.LOCAL_PROPERTIES }} + SERVICE_ACCOUNT_CREDENTIALS: ${{ secrets.SERVICE_ACCOUNT_CREDENTIALS }} + - name: Publish to Google Play + run: |- + cd clients/android + ./gradlew --build-cache --parallel :app:publishPlayReleaseBundle + env: + ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }} + LOCAL_PROPERTIES: ${{ secrets.LOCAL_PROPERTIES }} + build_windows_client: + name: Build Windows client (${{ matrix.desktop_arch }}) + if: (github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Windows') && github.ref != 'refs/heads/oldstable' + runs-on: windows-latest + needs: + - calculate_version + strategy: + fail-fast: false + matrix: + include: + - { desktop_arch: x64, rust_target: x86_64-pc-windows-msvc } + - { desktop_arch: x86, rust_target: i686-pc-windows-msvc } + - { desktop_arch: arm64, rust_target: aarch64-pc-windows-msvc } + steps: + - name: Checkout + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + with: + fetch-depth: 1 + - name: Checkout desktop client + run: git submodule update --init clients/desktop + - name: Checkout main branch + if: github.ref == 'refs/heads/testing' + run: git -C clients/desktop checkout main + - name: Checkout desktop submodules + run: git -C clients/desktop submodule update --init --recursive + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: 1.25.12 + cache: false + - name: Cache Go modules + uses: actions/cache@v4 + with: + path: ~/go/pkg/mod + key: go-mod-desktop-windows-${{ hashFiles('go.sum') }} + restore-keys: go-mod-desktop-windows- + - name: Setup Rust + uses: dtolnay/rust-toolchain@1.88.0 + with: + targets: ${{ matrix.rust_target }} + - name: Cache Windows sharing module + uses: actions/cache@v4 + with: + path: | + ~/.cargo/git + ~/.cargo/registry + clients/desktop/bin/windows-share-toolchain/cargo-target + key: windows-share-${{ matrix.rust_target }}-${{ hashFiles('clients/desktop/native/windows-share/Cargo.lock') }} + restore-keys: windows-share-${{ matrix.rust_target }}- + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + package_json_file: clients/desktop/package.json + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: clients/desktop/package.json + cache: pnpm + cache-dependency-path: | + clients/desktop/pnpm-lock.yaml + clients/desktop/dashboard/pnpm-lock.yaml + - name: Set tag + run: |- + git ls-remote --exit-code --tags origin v${{ needs.calculate_version.outputs.version }} + if ($LASTEXITCODE -ne 0) { + "PUBLISHED=false" >> $env:GITHUB_ENV + } + git tag v${{ needs.calculate_version.outputs.version }} -f + - name: Update version + if: github.event_name == 'workflow_dispatch' + run: |- + go run -v ./cmd/internal/update_desktop_version --ci + - name: Update nightly version + if: github.event_name != 'workflow_dispatch' + run: |- + go run -v ./cmd/internal/update_desktop_version --ci --nightly + - name: Setup signing + run: |- + $certificatePath = Join-Path $env:RUNNER_TEMP "windows-signing.p12" + [IO.File]::WriteAllBytes($certificatePath, [Convert]::FromBase64String($env:WINDOWS_CERTIFICATES_P12)) + @{ + windows = @{ + certificateFile = $certificatePath + certificatePassword = $env:WINDOWS_P12_PASSWORD + } + } | ConvertTo-Json -Depth 3 | Set-Content -Encoding utf8NoBOM clients/desktop/signing.local.json + env: + WINDOWS_CERTIFICATES_P12: ${{ secrets.WINDOWS_CERTIFICATES_P12 }} + WINDOWS_P12_PASSWORD: ${{ secrets.WINDOWS_P12_PASSWORD }} + - name: Install dependencies + run: |- + pnpm -C clients/desktop install + - name: Build + run: |- + pnpm -C clients/desktop package:win -- ${{ matrix.desktop_arch }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Prepare upload + run: |- + $artifacts = @(Get-ChildItem clients/desktop/release/SFW-*.exe -File) + if ($artifacts.Count -ne 1) { + throw "expected 1 Windows installer for ${{ matrix.desktop_arch }}, found $($artifacts.Count)" + } + New-Item -ItemType Directory -Force dist | Out-Null + Copy-Item $artifacts.FullName dist + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: binary-windows-client-${{ matrix.desktop_arch }} + path: 'dist' + build_linux_client: + name: Build Linux client (${{ matrix.desktop_arch }}) + if: (github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Linux') && github.ref != 'refs/heads/oldstable' + runs-on: ubuntu-26.04 + needs: + - calculate_version + strategy: + matrix: + include: + - { desktop_arch: x64, go_arch: amd64 } + - { desktop_arch: arm64, go_arch: arm64 } + - { desktop_arch: armv7l, go_arch: arm, go_arm: "7" } + steps: + - name: Checkout + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + with: + fetch-depth: 1 + - name: Checkout desktop client + run: git submodule update --init clients/desktop + - name: Checkout main branch + if: github.ref == 'refs/heads/testing' + run: git -C clients/desktop checkout main + - name: Checkout desktop submodules + run: git -C clients/desktop submodule update --init --recursive + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: 1.25.12 + cache: false + - name: Cache Go modules + uses: actions/cache@v4 + with: + path: ~/go/pkg/mod + key: go-mod-desktop-linux-${{ hashFiles('go.sum') }} + restore-keys: go-mod-desktop-linux- + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + package_json_file: clients/desktop/package.json + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: clients/desktop/package.json + cache: pnpm + cache-dependency-path: | + clients/desktop/pnpm-lock.yaml + clients/desktop/dashboard/pnpm-lock.yaml + - name: Set tag + run: |- + git ls-remote --exit-code --tags origin v${{ needs.calculate_version.outputs.version }} || echo "PUBLISHED=false" >> "$GITHUB_ENV" + git tag v${{ needs.calculate_version.outputs.version }} -f + - name: Update version + if: github.event_name == 'workflow_dispatch' + run: |- + go run -v ./cmd/internal/update_desktop_version --ci + - name: Update nightly version + if: github.event_name != 'workflow_dispatch' + run: |- + go run -v ./cmd/internal/update_desktop_version --ci --nightly + - name: Clone cronet-go + run: |- + set -xeuo pipefail + CRONET_GO_VERSION=$(cat .github/CRONET_GO_VERSION) + git init ~/cronet-go + git -C ~/cronet-go remote add origin https://github.com/sagernet/cronet-go.git + git -C ~/cronet-go fetch --depth=1 origin "$CRONET_GO_VERSION" + git -C ~/cronet-go checkout FETCH_HEAD + git -C ~/cronet-go submodule update --init --recursive --depth=1 + - name: Regenerate Debian keyring + run: |- + set -xeuo pipefail + rm -f ~/cronet-go/naiveproxy/src/build/linux/sysroot_scripts/keyring.gpg + cd ~/cronet-go + GPG_TTY=/dev/null ./naiveproxy/src/build/linux/sysroot_scripts/generate_keyring.sh + - name: Download Chromium toolchain + run: |- + set -xeuo pipefail + cd ~/cronet-go + go run ./cmd/build-naive --target=linux/${{ matrix.go_arch }} download-toolchain + - name: Set Chromium toolchain environment + run: |- + set -xeuo pipefail + cd ~/cronet-go + go run ./cmd/build-naive --target=linux/${{ matrix.go_arch }} env >> "$GITHUB_ENV" + - name: Install package tools + run: |- + set -euo pipefail + sudo apt-get update || true + sudo apt-get install -y debsigs libarchive-tools rpm zstd + - name: Install dependencies + run: |- + pnpm -C clients/desktop install + - name: Build + run: |- + pnpm -C clients/desktop package:linux -- deb rpm pacman ${{ matrix.desktop_arch }} + env: + CGO_ENABLED: "0" + GOARM: ${{ matrix.go_arm }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Setup package signing + run: |- + set -euo pipefail + SIGNING_GNUPG_HOME="$RUNNER_TEMP/package-signing-gnupg" + SIGNING_PASSPHRASE_FILE="$RUNNER_TEMP/package-signing-passphrase" + mkdir -m 700 "$SIGNING_GNUPG_HOME" + printf '%s' "$GPG_PASSPHRASE" > "$SIGNING_PASSPHRASE_FILE" + chmod 600 "$SIGNING_PASSPHRASE_FILE" + { + echo "GNUPGHOME=$SIGNING_GNUPG_HOME" + echo "SIGNING_PASSPHRASE_FILE=$SIGNING_PASSPHRASE_FILE" + } >> "$GITHUB_ENV" + printf '%s' "$GPG_KEY" | gpg \ + --homedir "$SIGNING_GNUPG_HOME" \ + --batch \ + --pinentry-mode loopback \ + --passphrase-file "$SIGNING_PASSPHRASE_FILE" \ + --import + cat > "$HOME/.rpmmacros" <&2 + exit 1 + fi + mkdir -p dist + cp "${artifacts[@]}" dist + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: binary-linux-client-${{ matrix.desktop_arch }} + path: 'dist' + build_apple_library: + name: Build Apple library (${{ matrix.artifact }}) + runs-on: ${{ matrix.build && 'macos-26' || 'ubuntu-latest' }} + if: github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'Apple-release' || inputs.build == 'iOS' || inputs.build == 'macOS' || inputs.build == 'tvOS' || inputs.build == 'macOS-standalone' || inputs.build == 'iOS-jailbreak' + needs: + - calculate_version + strategy: + fail-fast: false + matrix: + include: + - artifact: ios-arm64 + platform: ios/arm64 + build: ${{ github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'Apple-release' || inputs.build == 'iOS' || inputs.build == 'iOS-jailbreak' }} + - artifact: tvos-arm64 + platform: tvos/arm64 + build: ${{ github.event_name == 'workflow_dispatch' && (inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'tvOS') }} + - artifact: macos-arm64 + platform: macos/arm64 + build: ${{ github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'Apple-release' || inputs.build == 'macOS' || inputs.build == 'macOS-standalone' }} + - artifact: macos-amd64 + platform: macos/amd64 + build: ${{ github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'Apple-release' || inputs.build == 'macOS' || inputs.build == 'macOS-standalone' }} + steps: + - name: Checkout + if: matrix.build + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + with: + fetch-depth: 1 + - name: Setup Go + if: matrix.build + uses: actions/setup-go@v5 + with: + go-version: 1.25.12 + cache: false + - name: Cache Go modules + if: matrix.build + uses: actions/cache@v4 + with: + path: ~/go/pkg/mod + key: go-mod-macos-${{ hashFiles('go.sum') }} + restore-keys: go-mod-macos- + - name: Cache Go build + if: matrix.build + uses: actions/cache@v4 + with: + path: ~/Library/Caches/go-build + key: libbox-go-build-${{ matrix.artifact }}-1.25.12-${{ hashFiles('go.sum') }} + restore-keys: libbox-go-build-${{ matrix.artifact }}-1.25.12- + - name: Set tag + if: matrix.build + run: git tag v${{ needs.calculate_version.outputs.version }} -f + - name: Build library + if: matrix.build + run: |- + make lib_install + export PATH="$PATH:$(go env GOPATH)/bin" + go run ./cmd/internal/build_libbox -target apple -platform ${{ matrix.platform }} + mkdir -p dist + tar -cf dist/Libbox-${{ matrix.artifact }}.tar Libbox.xcframework + - name: Upload library + if: matrix.build + uses: actions/upload-artifact@v4 + with: + name: libbox-apple-${{ matrix.artifact }} + path: dist + compression-level: 1 + build_apple: + name: Build Apple release clients + runs-on: ${{ matrix.if && 'macos-26' || 'ubuntu-latest' }} + if: github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Apple-release' || inputs.build == 'macOS-standalone' || inputs.build == 'iOS-jailbreak' + needs: + - calculate_version + - build_apple_library + strategy: + fail-fast: false + matrix: + include: + - name: macOS-standalone + if: ${{ github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Apple-release' || inputs.build == 'macOS-standalone' }} + type: standalone + platform: macos + variant: all + library_arch: '*' + scheme: '' + destination: '' + archive: '' + upload: '' + - name: iOS-jailbreak + if: ${{ github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Apple-release' || inputs.build == 'iOS-jailbreak' }} + type: jailbreak + platform: ios + variant: '' + library_arch: '' + scheme: '' + destination: '' + archive: '' + upload: '' + steps: &build_apple_steps + - name: Checkout + if: matrix.if + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + with: + fetch-depth: 1 + - name: Checkout Apple client + if: matrix.if + run: git submodule update --init --recursive clients/apple + - name: Setup Go + if: matrix.if + uses: actions/setup-go@v5 + with: + go-version: 1.25.12 + cache: false + - name: Cache Go modules + if: matrix.if + uses: actions/cache/restore@v4 + with: + path: ~/go/pkg/mod + key: go-mod-macos-${{ hashFiles('go.sum') }} + restore-keys: go-mod-macos- + - name: Cache Go tool builds + if: matrix.if + uses: actions/cache@v4 + with: + path: ~/Library/Caches/go-build + key: go-build-tools-macos-${{ hashFiles('go.sum') }} + restore-keys: go-build-tools-macos- + - name: Set tag + if: matrix.if + run: |- + git ls-remote --exit-code --tags origin v${{ needs.calculate_version.outputs.version }} || echo "PUBLISHED=false" >> "$GITHUB_ENV" + git tag v${{ needs.calculate_version.outputs.version }} -f + echo "VERSION=${{ needs.calculate_version.outputs.version }}" >> "$GITHUB_ENV" + - name: Checkout main branch + if: matrix.if && github.ref == 'refs/heads/stable' && github.event_name != 'workflow_dispatch' + run: |- + cd clients/apple + git checkout main + - name: Checkout dev branch + if: matrix.if && github.ref == 'refs/heads/testing' + run: |- + cd clients/apple + git checkout dev + - name: Cache Swift packages + if: matrix.if + uses: actions/cache@v4 + with: + path: ${{ runner.temp }}/SourcePackages + key: apple-spm-${{ hashFiles('clients/apple/sing-box.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }} + restore-keys: apple-spm- + - name: Cache Xcode build data + if: matrix.if + uses: actions/cache@v4 + with: + path: ${{ matrix.type == 'standalone' && 'clients/apple/build/SFM.System-*.dd' || matrix.type == 'jailbreak' && 'clients/apple/build/jailbreak/DerivedData' || format('{0}/DerivedData', runner.temp) }} + key: apple-derived-data-${{ matrix.type }}-${{ matrix.platform }}-${{ matrix.variant || 'default' }}-${{ hashFiles('clients/apple/sing-box.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}-${{ github.sha }} + restore-keys: | + apple-derived-data-${{ matrix.type }}-${{ matrix.platform }}-${{ matrix.variant || 'default' }}-${{ hashFiles('clients/apple/sing-box.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}- + apple-derived-data-${{ matrix.type }}-${{ matrix.platform }}-${{ matrix.variant || 'default' }}- + - name: Setup App Store Connect key + if: matrix.if && matrix.type != 'jailbreak' && github.event_name == 'workflow_dispatch' + run: |- + ASC_KEY_PATH=$RUNNER_TEMP/AuthKey.p8 + echo -n "$ASC_KEY" | base64 --decode -o $ASC_KEY_PATH + echo "ASC_KEY_PATH=$ASC_KEY_PATH" >> "$GITHUB_ENV" + echo "ASC_KEY_ID=$ASC_KEY_ID" >> "$GITHUB_ENV" + echo "ASC_KEY_ISSUER_ID=$ASC_KEY_ISSUER_ID" >> "$GITHUB_ENV" + env: + ASC_KEY: ${{ secrets.ASC_KEY }} + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_KEY_ISSUER_ID: ${{ secrets.ASC_KEY_ISSUER_ID }} + - name: Install Apple provisioning profiles + if: matrix.if && matrix.type != 'jailbreak' + run: |- + PROFILES_ZIP_PATH=$RUNNER_TEMP/AppleProvisioningProfiles.zip + printf '%s%s%s%s%s%s%s%s' \ + "$PROVISIONING_PROFILES_1" \ + "$PROVISIONING_PROFILES_2" \ + "$PROVISIONING_PROFILES_3" \ + "$PROVISIONING_PROFILES_4" \ + "$PROVISIONING_PROFILES_5" \ + "$PROVISIONING_PROFILES_6" \ + "$PROVISIONING_PROFILES_7" \ + "$PROVISIONING_PROFILES_8" \ + | base64 --decode -o $PROFILES_ZIP_PATH + PROFILES_PATH="$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles" + mkdir -p "$PROFILES_PATH" + unzip -q $PROFILES_ZIP_PATH -d "$PROFILES_PATH" + env: + PROVISIONING_PROFILES_1: ${{ secrets.APPLE_PROVISIONING_PROFILES_1 }} + PROVISIONING_PROFILES_2: ${{ secrets.APPLE_PROVISIONING_PROFILES_2 }} + PROVISIONING_PROFILES_3: ${{ secrets.APPLE_PROVISIONING_PROFILES_3 }} + PROVISIONING_PROFILES_4: ${{ secrets.APPLE_PROVISIONING_PROFILES_4 }} + PROVISIONING_PROFILES_5: ${{ secrets.APPLE_PROVISIONING_PROFILES_5 }} + PROVISIONING_PROFILES_6: ${{ secrets.APPLE_PROVISIONING_PROFILES_6 }} + PROVISIONING_PROFILES_7: ${{ secrets.APPLE_PROVISIONING_PROFILES_7 }} + PROVISIONING_PROFILES_8: ${{ secrets.APPLE_PROVISIONING_PROFILES_8 }} + - name: Setup Apple Development certificate + if: matrix.if && matrix.type != 'jailbreak' + run: |- + CERTIFICATE_PATH=$RUNNER_TEMP/AppleDevelopment.p12 + KEYCHAIN_PATH=$RUNNER_TEMP/apple-development.keychain-db + echo -n "$APPLE_DEVELOPMENT_CERTIFICATES_P12" | base64 --decode -o $CERTIFICATE_PATH + security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH + security set-keychain-settings -lut 21600 $KEYCHAIN_PATH + security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH + security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH + security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH + security list-keychains -d user -s $KEYCHAIN_PATH + security default-keychain -d user -s $KEYCHAIN_PATH + security find-identity -v -p codesigning $KEYCHAIN_PATH + echo "APPLE_DEVELOPMENT_KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV" + env: + APPLE_DEVELOPMENT_CERTIFICATES_P12: ${{ secrets.APPLE_DEVELOPMENT_CERTIFICATES_P12 }} + P12_PASSWORD: ${{ secrets.APPLE_DEVELOPMENT_P12_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.APPLE_KEYCHAIN_PASSWORD }} + - name: Setup Developer ID certificates + if: matrix.if && matrix.type == 'standalone' + run: |- + CERTIFICATE_PATH=$RUNNER_TEMP/DeveloperIDCertificates.p12 + KEYCHAIN_PATH=$RUNNER_TEMP/developer-id.keychain-db + echo -n "$DEVELOPER_ID_CERTIFICATES_P12" | base64 --decode -o $CERTIFICATE_PATH + security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH + security set-keychain-settings -lut 21600 $KEYCHAIN_PATH + security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH + security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH + security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH + security list-keychains -d user -s $KEYCHAIN_PATH $APPLE_DEVELOPMENT_KEYCHAIN_PATH + security default-keychain -d user -s $KEYCHAIN_PATH + security find-identity -v -p codesigning $KEYCHAIN_PATH + echo "SIGNING_KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV" + env: + DEVELOPER_ID_CERTIFICATES_P12: ${{ secrets.DEVELOPER_ID_CERTIFICATES_P12 }} + P12_PASSWORD: ${{ secrets.DEVELOPER_ID_P12_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.APPLE_KEYCHAIN_PASSWORD }} + - name: Setup notarization credentials + if: matrix.if && matrix.type == 'standalone' && github.event_name == 'workflow_dispatch' + run: |- + security unlock-keychain -p "$KEYCHAIN_PASSWORD" $SIGNING_KEYCHAIN_PATH + xcrun notarytool store-credentials "notarytool-password" \ + --key $ASC_KEY_PATH \ + --key-id $ASC_KEY_ID \ + --issuer $ASC_KEY_ISSUER_ID \ + --keychain $SIGNING_KEYCHAIN_PATH + env: + KEYCHAIN_PASSWORD: ${{ secrets.APPLE_KEYCHAIN_PASSWORD }} + - name: Install jailbreak packaging tools + if: matrix.if && matrix.type == 'jailbreak' + run: |- + brew install dpkg ldid + - name: Trust build plugins + if: matrix.if && matrix.type != 'jailbreak' + run: |- + defaults write com.apple.dt.Xcode IDESkipPackagePluginFingerprintValidatation -bool YES + - name: Download library + if: matrix.if + uses: actions/download-artifact@v4 + with: + pattern: ${{ matrix.type == 'standalone' && format('libbox-apple-macos-{0}', matrix.library_arch) || format('libbox-apple-{0}-*', matrix.platform) }} + path: libbox-inputs + merge-multiple: true + - name: Prepare library + if: matrix.if + run: |- + mkdir -p libbox-slices + library_inputs=() + for library_archive in libbox-inputs/*.tar; do + slice_name=$(basename "$library_archive" .tar) + slice_path="libbox-slices/$slice_name" + mkdir -p "$slice_path" + tar -xf "$library_archive" -C "$slice_path" + library_inputs+=("$slice_path/Libbox.xcframework") + done + go run ./cmd/internal/merge_apple_xcframework \ + -output clients/apple/Libbox.xcframework \ + "${library_inputs[@]}" + - name: Update macOS version + if: matrix.if && matrix.name == 'macOS' && github.event_name == 'workflow_dispatch' + run: |- + MACOS_PROJECT_VERSION=$(go run -v ./cmd/internal/app_store_connect next_macos_project_version) + echo "MACOS_PROJECT_VERSION=$MACOS_PROJECT_VERSION" + echo "MACOS_PROJECT_VERSION=$MACOS_PROJECT_VERSION" >> "$GITHUB_ENV" + - name: Update version + if: matrix.if && matrix.name != 'iOS' + run: |- + go run -v ./cmd/internal/update_apple_version --ci + - name: Archive App Store build + if: matrix.if && matrix.type == 'app-store' + run: |- + cd clients/apple + xcodebuild archive \ + -clonedSourcePackagesDirPath "$RUNNER_TEMP/SourcePackages" \ + -derivedDataPath "$RUNNER_TEMP/DerivedData" \ + -scheme "${{ matrix.scheme }}" \ + -configuration Release \ + -destination "${{ matrix.destination }}" \ + -archivePath "${{ matrix.archive }}" + env: + DISABLE_SWIFTLINT: "1" + - name: Upload to App Store Connect + if: matrix.if && matrix.type == 'app-store' && github.event_name == 'workflow_dispatch' + run: |- + go run -v ./cmd/internal/app_store_connect cancel_app_store ${{ matrix.platform }} + cd clients/apple + xcodebuild -exportArchive \ + -archivePath "${{ matrix.archive }}" \ + -exportOptionsPlist ${{ matrix.upload }} \ + -authenticationKeyPath $ASC_KEY_PATH \ + -authenticationKeyID $ASC_KEY_ID \ + -authenticationKeyIssuerID $ASC_KEY_ISSUER_ID + - name: Publish to TestFlight + if: matrix.if && matrix.type == 'app-store' && github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/testing' + run: |- + arguments=(publish_testflight "${{ matrix.platform }}") + if [[ -n "$TEST_MESSAGE" ]]; then + arguments+=("$TEST_MESSAGE") + fi + go run -v ./cmd/internal/app_store_connect "${arguments[@]}" + env: + TEST_MESSAGE: ${{ inputs.test_message }} + - name: Build standalone packages + if: matrix.if && matrix.type == 'standalone' + run: |- + make -C clients/apple build_macos_pkg_${{ matrix.variant }} + env: + DISABLE_SWIFTLINT: "1" + XCODEBUILD_FLAGS: -clonedSourcePackagesDirPath ${{ runner.temp }}/SourcePackages + - name: Notarize standalone packages + if: matrix.if && matrix.type == 'standalone' && github.event_name == 'workflow_dispatch' + run: |- + make -C clients/apple notarize_macos_pkg_${{ matrix.variant }} + - name: Prepare standalone packages + if: matrix.if && matrix.type == 'standalone' + run: |- + mkdir -p dist + for label in Apple Intel Universal; do + cp "clients/apple/build/SFM-${label}.pkg" "dist/SFM-${VERSION}-${label}.pkg" + done + pushd clients/apple/build/SFM.System-universal.xcarchive + zip -r SFM.dSYMs.zip dSYMs + popd + cp clients/apple/build/SFM.System-universal.xcarchive/SFM.dSYMs.zip "dist/SFM-${VERSION}.dSYMs.zip" + - name: Build jailbreak package + if: matrix.if && matrix.type == 'jailbreak' + run: |- + make -C clients/apple build_ios_deb + mkdir -p dist + cp "clients/apple/build/jailbreak/SFI-${VERSION}-iphoneos-arm64.deb" dist + env: + XCODEBUILD_CLONED_SOURCE_PACKAGES_DIR_PATH: ${{ runner.temp }}/SourcePackages + - name: Report cache directory sizes + if: matrix.if + run: |- + du -sh "$RUNNER_TEMP/SourcePackages"/* 2>/dev/null || true + du -sh "$RUNNER_TEMP/DerivedData" 2>/dev/null || true + du -sh clients/apple/build/jailbreak/DerivedData 2>/dev/null || true + du -sh clients/apple/build/SFM.System-*.dd 2>/dev/null || true + - name: Prune Swift package repositories + if: matrix.if + run: rm -rf "$RUNNER_TEMP/SourcePackages/repositories" + - name: Upload release artifact + if: matrix.if && (matrix.type == 'standalone' || matrix.type == 'jailbreak') + uses: actions/upload-artifact@v4 + with: + name: binary-apple-${{ matrix.type }}${{ matrix.variant && format('-{0}', matrix.variant) || '' }} + path: 'dist' + build_apple_app_store: + name: Build Apple App Store clients + runs-on: ${{ matrix.if && 'macos-26' || 'ubuntu-latest' }} + if: github.event_name == 'workflow_dispatch' && (inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'iOS' || inputs.build == 'macOS' || inputs.build == 'tvOS') + needs: + - calculate_version + - build_apple_library + strategy: + fail-fast: false + matrix: + include: + - name: iOS + if: ${{ inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'iOS' }} + type: app-store + platform: ios + scheme: SFI + destination: 'generic/platform=iOS' + archive: build/SFI.xcarchive + upload: SFI/Upload.plist + variant: '' + library_arch: '' + - name: macOS + if: ${{ inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'macOS' }} + type: app-store + platform: macos + scheme: SFM + destination: 'generic/platform=macOS' + archive: build/SFM.xcarchive + upload: SFI/Upload.plist + variant: '' + library_arch: '' + - name: tvOS + if: ${{ inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'tvOS' }} + type: app-store + platform: tvos + scheme: SFT + destination: 'generic/platform=tvOS' + archive: build/SFT.xcarchive + upload: SFI/Upload.plist + variant: '' + library_arch: '' + steps: *build_apple_steps + upload: + name: Upload builds + if: "!failure() && github.event_name == 'workflow_dispatch' && (inputs.build == 'All' || inputs.build == 'Binary' || inputs.build == 'Android' || inputs.build == 'Windows' || inputs.build == 'Linux' || inputs.build == 'Apple-release' || inputs.build == 'macOS-standalone' || inputs.build == 'iOS-jailbreak')" + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + attestations: write + artifact-metadata: write + needs: + - calculate_version + - build + - build_darwin + - build_windows + - build_android_library + - build_android + - build_windows_client + - build_linux_client + - build_apple_library + - build_apple + steps: + - name: Checkout + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + with: + fetch-depth: 1 + - name: Cache ghr + uses: actions/cache@v4 + id: cache-ghr + with: + path: | + ~/go/bin/ghr + key: ghr + - name: Setup ghr + if: steps.cache-ghr.outputs.cache-hit != 'true' + run: |- + cd $HOME + git clone https://github.com/nekohasekai/ghr ghr + cd ghr + go install -v . + - name: Set tag + run: |- + git ls-remote --exit-code --tags origin v${{ needs.calculate_version.outputs.version }} || echo "PUBLISHED=false" >> "$GITHUB_ENV" + git tag v${{ needs.calculate_version.outputs.version }} -f + echo "VERSION=${{ needs.calculate_version.outputs.version }}" >> "$GITHUB_ENV" + - name: Download builds + uses: actions/download-artifact@v5 + with: + pattern: binary-* + path: dist + merge-multiple: true + - name: Attest build provenance + uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0 + with: + subject-path: 'dist/**' + - name: Upload builds + if: ${{ env.PUBLISHED == 'false' }} + run: |- + export PATH="$PATH:$HOME/go/bin" + ghr --replace --draft --prerelease -p 5 "v${VERSION}" dist + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Replace builds + if: ${{ env.PUBLISHED != 'false' }} + run: |- + export PATH="$PATH:$HOME/go/bin" + ghr --replace -p 5 "v${VERSION}" dist + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + cache_gc: + name: Cache GC + if: always() + runs-on: ubuntu-latest + permissions: + actions: write + needs: + - build + - build_darwin + - build_windows + - build_android_library + - build_android + - build_windows_client + - build_linux_client + - build_apple_library + - build_apple + - build_apple_app_store + - upload + steps: + - name: Delete superseded caches + env: + GH_TOKEN: ${{ github.token }} + run: |- + set -euo pipefail + gh api "repos/$GITHUB_REPOSITORY/actions/caches?per_page=100" --paginate \ + --jq '.actions_caches[] | [.id, .ref, .created_at, .last_accessed_at, .size_in_bytes, .key] | @tsv' > "$RUNNER_TEMP/caches.tsv" + python3 - "$GITHUB_REPOSITORY" "$RUNNER_TEMP/caches.tsv" <<'EOF' + import re + import subprocess + import sys - # mkdir -p dist - # cp clients/apple/SFM.dmg "dist/SFM-${VERSION}-universal.dmg" - # cp "clients/apple/${{ matrix.archive }}/SFM.dSYMs.zip" "dist/SFM-${VERSION}-universal.dSYMs.zip" - # - name: Upload image - # if: matrix.if && matrix.name == 'macOS-standalone' && github.event_name == 'workflow_dispatch' - # uses: actions/upload-artifact@v4 - # with: - # name: binary-macos-dmg - # path: 'dist' - # retention-days: 1 - # upload: - # name: Upload builds - # if: "!failure() && github.event_name == 'workflow_dispatch' && (inputs.build == 'All' || inputs.build == 'Binary' || inputs.build == 'Android' || inputs.build == 'Apple' || inputs.build == 'macOS-standalone')" - # runs-on: ubuntu-latest - # needs: - # - calculate_version - # - build - # - build_darwin - # - build_windows - # # - build_android - # # - build_apple - # steps: - # - name: Checkout - # uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 - # with: - # fetch-depth: 0 - # submodules: 'recursive' - # - name: Cache ghr - # uses: actions/cache@v4 - # id: cache-ghr - # with: - # path: | - # ~/go/bin/ghr - # key: ghr - # - name: Setup ghr - # if: steps.cache-ghr.outputs.cache-hit != 'true' - # run: |- - # cd $HOME - # git clone https://github.com/nekohasekai/ghr ghr - # cd ghr - # go install -v . - # - name: Set tag - # run: |- - # git ls-remote --exit-code --tags origin v${{ needs.calculate_version.outputs.version }} || echo "PUBLISHED=false" >> "$GITHUB_ENV" - # git tag v${{ needs.calculate_version.outputs.version }} -f - # echo "VERSION=${{ needs.calculate_version.outputs.version }}" >> "$GITHUB_ENV" - # - name: Download builds - # uses: actions/download-artifact@v5 - # with: - # path: dist - # merge-multiple: true - # - name: Upload builds - # if: ${{ env.PUBLISHED == 'false' }} - # run: |- - # export PATH="$PATH:$HOME/go/bin" - # ghr --replace --draft --prerelease -p 5 "v${VERSION}" dist - # env: - # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # - name: Replace builds - # if: ${{ env.PUBLISHED != 'false' }} - # run: |- - # export PATH="$PATH:$HOME/go/bin" - # ghr --replace -p 5 "v${VERSION}" dist - # env: - # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + repository, list_path = sys.argv[1], sys.argv[2] + budget = 9 * 1024 * 1024 * 1024 + rows = [] + with open(list_path) as list_file: + for line in list_file: + cache_id, ref, created_at, accessed_at, size, key = line.rstrip("\n").split("\t") + family = key + while True: + stripped = re.sub(r"-[0-9a-f]{16,}$", "", family) + if stripped == family: + break + family = stripped + rows.append({ + "id": cache_id, "ref": ref, "created": created_at, + "accessed": accessed_at, "size": int(size), "key": key, + "family": family, + }) + newest = {} + for row in rows: + group = (row["ref"], row["family"]) + if group not in newest or row["created"] > newest[group]["created"]: + newest[group] = row + doomed = [row for row in rows if newest[(row["ref"], row["family"])] is not row] + kept = [row for row in rows if newest[(row["ref"], row["family"])] is row] + total = sum(row["size"] for row in kept) + for row in sorted(kept, key=lambda entry: entry["accessed"]): + if total <= budget: + break + doomed.append(row) + total -= row["size"] + for row in doomed: + print(f"delete {row['size'] >> 20}MB {row['key']} ({row['ref']})") + subprocess.run( + ["gh", "api", "-X", "DELETE", f"repos/{repository}/actions/caches/{row['id']}"], + check=False, + ) + print(f"kept {len(rows) - len(doomed)} caches, {total >> 20}MB total") + EOF diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 0ef3fe969f..518af08d93 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -55,7 +55,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: ~1.25.10 + go-version: 1.25.12 - name: Clone cronet-go if: matrix.naive run: | diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index 768ea56800..61eb24f71e 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -29,7 +29,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: ~1.25.10 + go-version: 1.25.12 - name: Check input version if: github.event_name == 'workflow_dispatch' run: |- @@ -45,7 +45,7 @@ jobs: echo "version=$version" >> "$GITHUB_OUTPUT" build: name: Build binary - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 needs: - calculate_version strategy: @@ -72,7 +72,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: ~1.25.10 + go-version: 1.25.12 - name: Clone cronet-go if: matrix.naive run: | @@ -182,8 +182,6 @@ jobs: -p "dist/${NAME}_${{ needs.calculate_version.outputs.version }}_linux_${{ matrix.debian }}.deb" \ --architecture ${{ matrix.debian }} \ dist/sing-box=/usr/bin/sing-box - curl -Lo '/tmp/debsigs.diff' 'https://gitlab.com/debsigs/debsigs/-/commit/160138f5de1ec110376d3c807b60a37388bc7c90.diff' - sudo patch /usr/bin/debsigs < '/tmp/debsigs.diff' rm -rf $HOME/.gnupg gpg --pinentry-mode loopback --passphrase "${{ secrets.GPG_PASSPHRASE }}" --import < 0 { + for referenceName := range referenceEntries { + if !seenEntries[referenceName] { + return E.New("missing AAR entry: ", referenceName) + } + } + } + } + + absoluteOutputPath, err := filepath.Abs(outputPath) + if err != nil { + return E.Cause(err, "resolve output AAR path") + } + err = os.MkdirAll(filepath.Dir(absoluteOutputPath), 0o755) + if err != nil { + return E.Cause(err, "create output AAR directory") + } + temporaryFile, err := os.CreateTemp(filepath.Dir(absoluteOutputPath), ".merge-aar-*.aar") + if err != nil { + return E.Cause(err, "create temporary output AAR") + } + temporaryPath := temporaryFile.Name() + defer os.Remove(temporaryPath) + archiveWriter := zip.NewWriter(temporaryFile) + for _, archiveFile := range selectedEntries { + rawReader, openErr := archiveFile.OpenRaw() + if openErr != nil { + archiveWriter.Close() + temporaryFile.Close() + return E.Cause(openErr, "open raw AAR entry: ", archiveFile.Name) + } + header := archiveFile.FileHeader + rawWriter, createErr := archiveWriter.CreateRaw(&header) + if createErr != nil { + archiveWriter.Close() + temporaryFile.Close() + return E.Cause(createErr, "create output AAR entry: ", archiveFile.Name) + } + _, copyErr := io.Copy(rawWriter, rawReader) + if copyErr != nil { + archiveWriter.Close() + temporaryFile.Close() + return E.Cause(copyErr, "copy output AAR entry: ", archiveFile.Name) + } + } + err = archiveWriter.Close() + if err != nil { + temporaryFile.Close() + return E.Cause(err, "finalize output AAR") + } + err = temporaryFile.Close() + if err != nil { + return E.Cause(err, "close output AAR") + } + err = os.Rename(temporaryPath, absoluteOutputPath) + if err != nil { + return E.Cause(err, "replace output AAR") + } + return nil +} + +func digestEntry(archiveFile *zip.File) ([sha256.Size]byte, error) { + entryReader, err := archiveFile.Open() + if err != nil { + return [sha256.Size]byte{}, err + } + digest := sha256.New() + _, err = io.Copy(digest, entryReader) + closeErr := entryReader.Close() + if err != nil { + return [sha256.Size]byte{}, err + } + if closeErr != nil { + return [sha256.Size]byte{}, closeErr + } + var result [sha256.Size]byte + copy(result[:], digest.Sum(nil)) + return result, nil +} diff --git a/cmd/internal/merge_apple_xcframework/main.go b/cmd/internal/merge_apple_xcframework/main.go new file mode 100644 index 0000000000..f8f98bec56 --- /dev/null +++ b/cmd/internal/merge_apple_xcframework/main.go @@ -0,0 +1,172 @@ +package main + +import ( + "flag" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + + "github.com/sagernet/sing-box/log" + E "github.com/sagernet/sing/common/exceptions" + + "howett.net/plist" +) + +type xcFrameworkInfo struct { + AvailableLibraries []xcFrameworkLibrary `plist:"AvailableLibraries"` +} + +type xcFrameworkLibrary struct { + BinaryPath string `plist:"BinaryPath"` + LibraryIdentifier string `plist:"LibraryIdentifier"` + LibraryPath string `plist:"LibraryPath"` + SupportedArchitectures []string `plist:"SupportedArchitectures"` + SupportedPlatform string `plist:"SupportedPlatform"` + SupportedPlatformVariant string `plist:"SupportedPlatformVariant"` +} + +type frameworkSlice struct { + rootPath string + library xcFrameworkLibrary +} + +var outputPath string + +func init() { + flag.StringVar(&outputPath, "output", "", "output XCFramework path") +} + +func main() { + flag.Parse() + err := merge() + if err != nil { + log.Fatal(err) + } +} + +func merge() error { + inputPaths := flag.Args() + if outputPath == "" { + return E.New("missing output path") + } + if len(inputPaths) == 0 { + return E.New("missing input XCFramework paths") + } + frameworkGroups := make(map[string][]frameworkSlice) + for _, inputPath := range inputPaths { + infoFile, err := os.Open(filepath.Join(inputPath, "Info.plist")) + if err != nil { + return E.Cause(err, "open XCFramework metadata: ", inputPath) + } + var info xcFrameworkInfo + decoder := plist.NewDecoder(infoFile) + err = decoder.Decode(&info) + closeErr := infoFile.Close() + if err != nil { + return E.Cause(err, "decode XCFramework metadata: ", inputPath) + } + if closeErr != nil { + return E.Cause(closeErr, "close XCFramework metadata: ", inputPath) + } + for _, library := range info.AvailableLibraries { + groupName := library.SupportedPlatform + "|" + library.SupportedPlatformVariant + frameworkGroups[groupName] = append(frameworkGroups[groupName], frameworkSlice{ + rootPath: inputPath, + library: library, + }) + } + } + groupNames := make([]string, 0, len(frameworkGroups)) + for groupName := range frameworkGroups { + groupNames = append(groupNames, groupName) + } + sort.Strings(groupNames) + absoluteOutputPath, err := filepath.Abs(outputPath) + if err != nil { + return E.Cause(err, "resolve output XCFramework path") + } + err = os.MkdirAll(filepath.Dir(absoluteOutputPath), 0o755) + if err != nil { + return E.Cause(err, "create output XCFramework directory") + } + temporaryDirectory, err := os.MkdirTemp(filepath.Dir(absoluteOutputPath), ".merge-xcframework-*") + if err != nil { + return E.Cause(err, "create XCFramework merge directory") + } + defer os.RemoveAll(temporaryDirectory) + frameworkPaths := make([]string, 0, len(groupNames)) + for groupIndex, groupName := range groupNames { + frameworkSlices := frameworkGroups[groupName] + firstSlice := frameworkSlices[0] + firstFrameworkPath := filepath.Join(firstSlice.rootPath, firstSlice.library.LibraryIdentifier, firstSlice.library.LibraryPath) + if len(frameworkSlices) == 1 { + frameworkPaths = append(frameworkPaths, firstFrameworkPath) + continue + } + architectures := make(map[string]bool) + binaryPaths := make([]string, 0, len(frameworkSlices)) + for _, currentSlice := range frameworkSlices { + if currentSlice.library.LibraryPath != firstSlice.library.LibraryPath || currentSlice.library.BinaryPath != firstSlice.library.BinaryPath { + return E.New("incompatible XCFramework slices for platform: ", currentSlice.library.SupportedPlatform) + } + for _, architecture := range currentSlice.library.SupportedArchitectures { + if architectures[architecture] { + return E.New("duplicate XCFramework architecture: ", architecture) + } + architectures[architecture] = true + } + binaryPaths = append(binaryPaths, filepath.Join(currentSlice.rootPath, currentSlice.library.LibraryIdentifier, currentSlice.library.BinaryPath)) + } + mergedFrameworkPath := filepath.Join(temporaryDirectory, "framework-"+strconv.Itoa(groupIndex), filepath.Base(firstSlice.library.LibraryPath)) + copyCommand := exec.Command("ditto", firstFrameworkPath, mergedFrameworkPath) + copyCommand.Stdout = os.Stdout + copyCommand.Stderr = os.Stderr + err = copyCommand.Run() + if err != nil { + return E.Cause(err, "copy XCFramework slice") + } + binaryRelativePath, relativeErr := filepath.Rel(firstSlice.library.LibraryPath, firstSlice.library.BinaryPath) + if relativeErr != nil { + return E.Cause(relativeErr, "resolve XCFramework binary path") + } + if binaryRelativePath == "." || strings.HasPrefix(binaryRelativePath, ".."+string(filepath.Separator)) { + return E.New("invalid XCFramework binary path: ", firstSlice.library.BinaryPath) + } + mergedBinaryPath := filepath.Join(mergedFrameworkPath, binaryRelativePath) + temporaryBinaryPath := mergedBinaryPath + ".merged" + lipoArguments := append([]string{"lipo", "-create"}, binaryPaths...) + lipoArguments = append(lipoArguments, "-output", temporaryBinaryPath) + lipoCommand := exec.Command("xcrun", lipoArguments...) + lipoCommand.Stdout = os.Stdout + lipoCommand.Stderr = os.Stderr + err = lipoCommand.Run() + if err != nil { + return E.Cause(err, "merge XCFramework binaries") + } + err = os.Rename(temporaryBinaryPath, mergedBinaryPath) + if err != nil { + return E.Cause(err, "replace merged XCFramework binary") + } + frameworkPaths = append(frameworkPaths, mergedFrameworkPath) + } + err = os.RemoveAll(absoluteOutputPath) + if err != nil { + return E.Cause(err, "remove output XCFramework") + } + xcodebuildArguments := []string{"-create-xcframework"} + for _, frameworkPath := range frameworkPaths { + xcodebuildArguments = append(xcodebuildArguments, "-framework", frameworkPath) + } + xcodebuildArguments = append(xcodebuildArguments, "-output", absoluteOutputPath) + xcodebuildCommand := exec.Command("xcodebuild", xcodebuildArguments...) + xcodebuildCommand.Stdout = os.Stdout + xcodebuildCommand.Stderr = os.Stderr + err = xcodebuildCommand.Run() + if err != nil { + return E.Cause(err, "create XCFramework") + } + return nil +} diff --git a/cmd/internal/update_desktop_version/main.go b/cmd/internal/update_desktop_version/main.go new file mode 100644 index 0000000000..ee8375d797 --- /dev/null +++ b/cmd/internal/update_desktop_version/main.go @@ -0,0 +1,66 @@ +package main + +import ( + "encoding/json" + "flag" + "os" + "path/filepath" + "runtime" + + "github.com/sagernet/sing-box/cmd/internal/build_shared" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing/common" +) + +var ( + flagRunInCI bool + flagRunNightly bool +) + +type versionMetadata struct { + Version string `json:"version"` + GoVersion string `json:"go_version"` +} + +func init() { + flag.BoolVar(&flagRunInCI, "ci", false, "Run in CI") + flag.BoolVar(&flagRunNightly, "nightly", false, "Run nightly") +} + +func main() { + flag.Parse() + newVersion := common.Must1(build_shared.ReadTag()) + desktopPath := "../sing-box-for-desktop" + if flagRunInCI { + desktopPath = "clients/desktop" + } + desktopPath = common.Must1(filepath.Abs(desktopPath)) + versionPath := filepath.Join(desktopPath, "version.json") + versionFile := common.Must1(os.Open(versionPath)) + var metadata versionMetadata + common.Must(json.NewDecoder(versionFile).Decode(&metadata)) + common.Must(versionFile.Close()) + newGoVersion := runtime.Version() + versionUpdated := metadata.Version != newVersion + goVersionUpdated := metadata.GoVersion != newGoVersion + if !(versionUpdated || goVersionUpdated) { + log.Info("version not changed") + return + } + if versionUpdated { + log.Info("updated version from ", metadata.Version, " to ", newVersion) + } + if goVersionUpdated { + log.Info("updated Go version from ", metadata.GoVersion, " to ", newGoVersion) + } + if flagRunInCI && !flagRunNightly { + log.Fatal("version changed, commit changes first.") + } + metadata.Version = newVersion + metadata.GoVersion = newGoVersion + outputFile := common.Must1(os.Create(versionPath)) + encoder := json.NewEncoder(outputFile) + encoder.SetIndent("", " ") + common.Must(encoder.Encode(metadata)) + common.Must(outputFile.Close()) +} diff --git a/cmd/sing-box/cmd_schema.go b/cmd/sing-box/cmd_schema.go new file mode 100644 index 0000000000..73538125be --- /dev/null +++ b/cmd/sing-box/cmd_schema.go @@ -0,0 +1,45 @@ +package main + +import ( + "context" + "os" + "reflect" + + "github.com/sagernet/sing-box/include" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/schema" + + "github.com/spf13/cobra" +) + +var commandSchemaFlagOutput string + +var commandSchema = &cobra.Command{ + Use: "schema", + Short: "Generate configuration JSON schema", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + err := generateSchema() + if err != nil { + log.Fatal(err) + } + }, +} + +func init() { + commandSchema.Flags().StringVarP(&commandSchemaFlagOutput, "output", "o", "", "write schema to file instead of stdout") + mainCommand.AddCommand(commandSchema) +} + +func generateSchema() error { + content, err := schema.Generate(include.Context(context.Background()), reflect.TypeFor[option.Options]()) + if err != nil { + return err + } + if commandSchemaFlagOutput != "" { + return os.WriteFile(commandSchemaFlagOutput, content, 0o644) + } + _, err = os.Stdout.Write(content) + return err +} diff --git a/common/certificate/store.go b/common/certificate/store.go index f489267d34..aca544da52 100644 --- a/common/certificate/store.go +++ b/common/certificate/store.go @@ -17,11 +17,13 @@ import ( E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/logger" "github.com/sagernet/sing/service" + "github.com/sagernet/sing/service/filemanager" ) var _ adapter.CertificateStore = (*Store)(nil) type Store struct { + ctx context.Context access sync.RWMutex storeType string systemPool *x509.CertPool @@ -65,6 +67,7 @@ func NewStore(ctx context.Context, logger logger.Logger, options option.Certific return nil, E.New("unknown certificate store: ", options.Store) } store := &Store{ + ctx: ctx, storeType: storeType, systemPool: systemPool, certificate: strings.Join(options.Certificate, "\n"), @@ -171,7 +174,7 @@ func (s *Store) update() error { appendPEMBlock(pemBuffer, s.certificate) } for _, path := range s.certificatePaths { - pemContent, err := os.ReadFile(path) + pemContent, err := filemanager.ReadFile(s.ctx, path) if err != nil { return err } @@ -182,7 +185,7 @@ func (s *Store) update() error { } var firstErr error for _, directoryPath := range s.certificateDirectoryPaths { - directoryEntries, err := readUniqueDirectoryEntries(directoryPath) + directoryEntries, err := readUniqueDirectoryEntries(s.ctx, directoryPath) if err != nil { if firstErr == nil && !os.IsNotExist(err) { firstErr = E.Cause(err, "invalid certificate directory: ", directoryPath) @@ -190,7 +193,7 @@ func (s *Store) update() error { continue } for _, directoryEntry := range directoryEntries { - pemContent, err := os.ReadFile(filepath.Join(directoryPath, directoryEntry.Name())) + pemContent, err := filemanager.ReadFile(s.ctx, filepath.Join(directoryPath, directoryEntry.Name())) if err == nil && currentPool.AppendCertsFromPEM(pemContent) { appendPEMBlock(pemBuffer, string(pemContent)) } @@ -229,8 +232,8 @@ func (s *Store) newBasePool() (*x509.CertPool, error) { } } -func readUniqueDirectoryEntries(dir string) ([]fs.DirEntry, error) { - files, err := os.ReadDir(dir) +func readUniqueDirectoryEntries(ctx context.Context, dir string) ([]fs.DirEntry, error) { + files, err := filemanager.ReadDir(ctx, dir) if err != nil { return nil, err } diff --git a/common/dialer/default.go b/common/dialer/default.go index 2310fe97ee..3e7b695716 100644 --- a/common/dialer/default.go +++ b/common/dialer/default.go @@ -24,7 +24,7 @@ import ( var ( _ ParallelInterfaceDialer = (*DefaultDialer)(nil) - _ WireGuardListener = (*DefaultDialer)(nil) + _ UDPListener = (*DefaultDialer)(nil) ) type DefaultDialer struct { @@ -97,7 +97,7 @@ func NewDefault(ctx context.Context, options option.DialerOptions) (*DefaultDial dialer.Control = control.Append(dialer.Control, bindFunc) listener.Control = control.Append(listener.Control, bindFunc) } else if networkManager.AutoDetectInterface() && !disableDefaultBind { - if platformInterface != nil { + if platformInterface != nil && platformInterface.UsePlatformNetworkInterfaces() { networkStrategy = (*C.NetworkStrategy)(options.NetworkStrategy) networkType = common.Map(options.NetworkType, option.InterfaceType.Build) fallbackNetworkType = common.Map(options.FallbackNetworkType, option.InterfaceType.Build) @@ -155,6 +155,13 @@ func NewDefault(ctx context.Context, options option.DialerOptions) (*DefaultDial if options.DisableTCPKeepAlive { dialer.KeepAlive = -1 dialer.KeepAliveConfig.Enable = false + } else if options.TCPKeepAliveSystemDefaults { + dialer.KeepAliveConfig = net.KeepAliveConfig{ + Enable: true, + Idle: -1, + Interval: -1, + Count: -1, + } } else { keepIdle := time.Duration(options.TCPKeepAlive) if keepIdle == 0 { @@ -188,8 +195,11 @@ func NewDefault(ctx context.Context, options option.DialerOptions) (*DefaultDial if options.Inet4BindAddress != nil { bindAddr := options.Inet4BindAddress.Build(netip.IPv4Unspecified()) dialer4.LocalAddr = &net.TCPAddr{IP: bindAddr.AsSlice()} - udpDialer4.LocalAddr = &net.UDPAddr{IP: bindAddr.AsSlice()} - udpAddr4 = M.SocksaddrFrom(bindAddr, 0).String() + udpDialer4.LocalAddr = &net.UDPAddr{IP: bindAddr.AsSlice(), Port: int(options.UDPBindPort)} + udpAddr4 = M.SocksaddrFrom(bindAddr, options.UDPBindPort).String() + } else if options.UDPBindPort != 0 { + udpDialer4.LocalAddr = &net.UDPAddr{IP: net.IPv4zero, Port: int(options.UDPBindPort)} + udpAddr4 = M.SocksaddrFrom(netip.IPv4Unspecified(), options.UDPBindPort).String() } var ( dialer6 = dialer @@ -199,8 +209,11 @@ func NewDefault(ctx context.Context, options option.DialerOptions) (*DefaultDial if options.Inet6BindAddress != nil { bindAddr := options.Inet6BindAddress.Build(netip.IPv6Unspecified()) dialer6.LocalAddr = &net.TCPAddr{IP: bindAddr.AsSlice()} - udpDialer6.LocalAddr = &net.UDPAddr{IP: bindAddr.AsSlice()} - udpAddr6 = M.SocksaddrFrom(bindAddr, 0).String() + udpDialer6.LocalAddr = &net.UDPAddr{IP: bindAddr.AsSlice(), Port: int(options.UDPBindPort)} + udpAddr6 = M.SocksaddrFrom(bindAddr, options.UDPBindPort).String() + } else if options.UDPBindPort != 0 { + udpDialer6.LocalAddr = &net.UDPAddr{IP: net.IPv6unspecified, Port: int(options.UDPBindPort)} + udpAddr6 = M.SocksaddrFrom(netip.IPv6Unspecified(), options.UDPBindPort).String() } if options.TCPMultiPath { dialer4.SetMultipathTCP(true) @@ -381,8 +394,13 @@ func (d *DefaultDialer) ListenSerialInterfacePacket(ctx context.Context, destina return d.trackPacketConn(packetConn, nil) } -func (d *DefaultDialer) WireGuardControl() control.Func { - return d.udpListener.Control +func (d *DefaultDialer) UDPListenerControl() (control.Func, bool) { + egressEnabled := d.autoDetectBindFunc != nil && d.netns == "" + listenerControl := d.udpListener.Control + if egressEnabled && d.networkManager.AutoRedirectOutputMark() != 0 { + listenerControl = control.Append(listenerControl, control.UnbindFromInterface()) + } + return listenerControl, egressEnabled } func (d *DefaultDialer) trackConn(conn net.Conn, err error) (net.Conn, error) { diff --git a/common/dialer/wireguard.go b/common/dialer/udp_listener.go similarity index 50% rename from common/dialer/wireguard.go rename to common/dialer/udp_listener.go index 8a916a5916..416952c965 100644 --- a/common/dialer/wireguard.go +++ b/common/dialer/udp_listener.go @@ -4,6 +4,6 @@ import ( "github.com/sagernet/sing/common/control" ) -type WireGuardListener interface { - WireGuardControl() control.Func +type UDPListener interface { + UDPListenerControl() (control.Func, bool) } diff --git a/common/httpclient/apple_transport_darwin.go b/common/httpclient/apple_transport_darwin.go index 4619dc58de..36584f9cf5 100644 --- a/common/httpclient/apple_transport_darwin.go +++ b/common/httpclient/apple_transport_darwin.go @@ -96,6 +96,15 @@ type appleTransport struct { closed bool } +func validateAppleTransport(ctx context.Context, options option.HTTPClientOptions) error { + sessionConfig, err := newAppleSessionConfig(ctx, options) + if err != nil { + return err + } + sessionConfig.close() + return nil +} + func newAppleTransport(ctx context.Context, logger logger.ContextLogger, rawDialer N.Dialer, options option.HTTPClientOptions) (innerTransport, error) { sessionConfig, err := newAppleSessionConfig(ctx, options) if err != nil { @@ -111,6 +120,10 @@ func newAppleTransport(ctx context.Context, logger logger.ContextLogger, rawDial if err != nil { return nil, err } + err = bridge.Start() + if err != nil { + return nil, err + } shared := &appleTransportShared{ logger: logger, bridge: bridge, diff --git a/common/httpclient/apple_transport_darwin_test.go b/common/httpclient/apple_transport_darwin_test.go index 3055c25f25..272e8f1baf 100644 --- a/common/httpclient/apple_transport_darwin_test.go +++ b/common/httpclient/apple_transport_darwin_test.go @@ -94,7 +94,9 @@ func TestNewAppleSessionConfig(t *testing.T) { options: option.HTTPClientOptions{ Version: 2, DialerOptions: option.DialerOptions{ - ConnectTimeout: badoption.Duration(2 * time.Second), + AbstractDialerOptions: option.AbstractDialerOptions{ + ConnectTimeout: badoption.Duration(2 * time.Second), + }, }, OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{ TLS: &option.OutboundTLSOptions{ diff --git a/common/httpclient/apple_transport_stub.go b/common/httpclient/apple_transport_stub.go index 9735998f4e..5e6caa6c34 100644 --- a/common/httpclient/apple_transport_stub.go +++ b/common/httpclient/apple_transport_stub.go @@ -11,6 +11,10 @@ import ( N "github.com/sagernet/sing/common/network" ) +func validateAppleTransport(ctx context.Context, options option.HTTPClientOptions) error { + return E.New("Apple HTTP engine is not available on non-Apple platforms") +} + func newAppleTransport(ctx context.Context, logger logger.ContextLogger, rawDialer N.Dialer, options option.HTTPClientOptions) (innerTransport, error) { return nil, E.New("Apple HTTP engine is not available on non-Apple platforms") } diff --git a/common/httpclient/client.go b/common/httpclient/client.go index 9dbb8cc1d5..b310152a3b 100644 --- a/common/httpclient/client.go +++ b/common/httpclient/client.go @@ -35,11 +35,11 @@ func NewTransport(ctx context.Context, logger logger.ContextLogger, tag string, var cheapRebuild bool switch options.Engine { case C.TLSEngineApple: - inner, transportErr := newAppleTransport(ctx, logger, rawDialer, options) - if transportErr != nil { - return nil, transportErr + err = validateAppleTransport(ctx, options) + if err != nil { + return nil, err } - managedTransport := &ManagedTransport{ + return &ManagedTransport{ dialer: rawDialer, headers: headers, host: host, @@ -47,9 +47,7 @@ func NewTransport(ctx context.Context, logger logger.ContextLogger, tag string, factory: func() (innerTransport, error) { return newAppleTransport(ctx, logger, rawDialer, options) }, - } - managedTransport.epoch.Store(&transportEpoch{transport: inner}) - return managedTransport, nil + }, nil case "", C.TLSEngineGo: cheapRebuild = true default: diff --git a/common/listener/listener.go b/common/listener/listener.go index e04ae466a0..7b657c9858 100644 --- a/common/listener/listener.go +++ b/common/listener/listener.go @@ -107,7 +107,7 @@ func (l *Listener) Start() error { } else { listenAddrString = listenAddr.String() } - systemProxy, err := settings.NewSystemProxy(l.ctx, M.ParseSocksaddrHostPort(listenAddrString, listenPort), l.systemProxySOCKS) + systemProxy, err := settings.NewSystemProxy(l.ctx, M.ParseSocksaddrHostPort(listenAddrString, listenPort), l.systemProxySOCKS, nil) if err != nil { return E.Cause(err, "initialize system proxy") } diff --git a/common/listener/listener_udp.go b/common/listener/listener_udp.go index 407eaf5c3e..fab87cbc84 100644 --- a/common/listener/listener_udp.go +++ b/common/listener/listener_udp.go @@ -22,8 +22,11 @@ import ( const udpOutputBatchSize = 128 func (l *Listener) ListenUDP() (net.PacketConn, error) { + return l.ListenUDPWithConfig(net.ListenConfig{}) +} + +func (l *Listener) ListenUDPWithConfig(listenConfig net.ListenConfig) (net.PacketConn, error) { bindAddr := M.SocksaddrFrom(l.listenOptions.Listen.Build(netip.AddrFrom4([4]byte{127, 0, 0, 1})), l.listenOptions.ListenPort) - var listenConfig net.ListenConfig if l.listenOptions.BindInterface != "" { listenConfig.Control = control.Append(listenConfig.Control, control.BindToInterface(service.FromContext[adapter.NetworkManager](l.ctx).InterfaceFinder(), l.listenOptions.BindInterface, -1)) } diff --git a/common/process/searcher.go b/common/process/searcher.go index 64305237ae..7a882fc983 100644 --- a/common/process/searcher.go +++ b/common/process/searcher.go @@ -14,6 +14,7 @@ import ( type Searcher interface { FindProcessInfo(ctx context.Context, network string, source netip.AddrPort, destination netip.AddrPort) (*adapter.ConnectionOwner, error) + ResetCache() Close() error } diff --git a/common/process/searcher_android.go b/common/process/searcher_android.go index e634774488..6b845eb527 100644 --- a/common/process/searcher_android.go +++ b/common/process/searcher_android.go @@ -23,6 +23,9 @@ func NewSearcher(config Config) (Searcher, error) { return &androidSearcher{config.PackageManager}, nil } +func (s *androidSearcher) ResetCache() { +} + func (s *androidSearcher) Close() error { return nil } diff --git a/common/process/searcher_darwin.go b/common/process/searcher_darwin.go index 1b5c0dd6ca..0ef7c1c92f 100644 --- a/common/process/searcher_darwin.go +++ b/common/process/searcher_darwin.go @@ -20,6 +20,10 @@ func NewSearcher(_ Config) (Searcher, error) { return &darwinSearcher{}, nil } +func (d *darwinSearcher) ResetCache() { + sharedDarwinConnectionFinder.resetCache() +} + func (d *darwinSearcher) Close() error { return nil } diff --git a/common/process/searcher_darwin_shared.go b/common/process/searcher_darwin_shared.go index 0557ae676a..4cb3dde148 100644 --- a/common/process/searcher_darwin_shared.go +++ b/common/process/searcher_darwin_shared.go @@ -119,6 +119,12 @@ func (f *darwinConnectionFinder) find(network string, source netip.AddrPort, des return nil, ErrNotFound } +func (f *darwinConnectionFinder) resetCache() { + f.access.Lock() + defer f.access.Unlock() + clear(f.snapshots) +} + func (f *darwinConnectionFinder) loadSnapshot(network string, forceRefresh bool) (darwinSnapshot, bool, error) { f.access.Lock() defer f.access.Unlock() diff --git a/common/process/searcher_linux.go b/common/process/searcher_linux.go index 9b1a916038..1d94ea72d3 100644 --- a/common/process/searcher_linux.go +++ b/common/process/searcher_linux.go @@ -35,6 +35,10 @@ func NewSearcher(config Config) (Searcher, error) { return searcher, nil } +func (s *linuxSearcher) ResetCache() { + s.processPathCache.cache.Purge() +} + func (s *linuxSearcher) Close() error { var errs []error for _, conn := range s.diagConns { diff --git a/common/process/searcher_linux_shared.go b/common/process/searcher_linux_shared.go index 9e868f36ae..faf2bbe94c 100644 --- a/common/process/searcher_linux_shared.go +++ b/common/process/searcher_linux_shared.go @@ -38,7 +38,7 @@ type socketDiagConn struct { } type uidProcessPathCache struct { - cache freelru.Cache[uint32, *uidProcessPaths] + cache *freelru.Cache[uint32, *uidProcessPaths] } type uidProcessPaths struct { @@ -85,7 +85,7 @@ func socketDiagSettings(network string, source netip.AddrPort) (family, protocol } func newUIDProcessPathCache(ttl time.Duration) *uidProcessPathCache { - cache := common.Must1(freelru.NewSharded[uint32, *uidProcessPaths](64, maphash.NewHasher[uint32]().Hash32)) + cache := common.Must1(freelru.New[uint32, *uidProcessPaths](64, maphash.NewHasher[uint32]().Hash32, true)) cache.SetLifetime(ttl) return &uidProcessPathCache{cache: cache} } diff --git a/common/process/searcher_windows.go b/common/process/searcher_windows.go index 39695355b8..f011765705 100644 --- a/common/process/searcher_windows.go +++ b/common/process/searcher_windows.go @@ -28,6 +28,9 @@ func initWin32API() error { return winiphlpapi.LoadExtendedTable() } +func (s *windowsSearcher) ResetCache() { +} + func (s *windowsSearcher) Close() error { return nil } diff --git a/common/proxybridge/bridge.go b/common/proxybridge/bridge.go index 3380cae447..0db61bb901 100644 --- a/common/proxybridge/bridge.go +++ b/common/proxybridge/bridge.go @@ -34,23 +34,26 @@ type Bridge struct { func New(ctx context.Context, logger logger.ContextLogger, tag string, dialer N.Dialer) (*Bridge, error) { username := randomHex(16) password := randomHex(16) - tcpListener, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1)}) - if err != nil { - return nil, err - } - bridge := &Bridge{ + return &Bridge{ ctx: ctx, logger: logger, tag: tag, dialer: dialer, connection: service.FromContext[adapter.ConnectionManager](ctx), - tcpListener: tcpListener, username: username, password: password, authenticator: auth.NewAuthenticator([]auth.User{{Username: username, Password: password}}), + }, nil +} + +func (b *Bridge) Start() error { + tcpListener, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + return err } - go bridge.acceptLoop() - return bridge, nil + b.tcpListener = tcpListener + go b.acceptLoop() + return nil } func randomHex(size int) string { @@ -72,7 +75,7 @@ func (b *Bridge) Password() string { } func (b *Bridge) Close() error { - return common.Close(b.tcpListener) + return common.Close(common.PtrOrNil(b.tcpListener)) } func (b *Bridge) acceptLoop() { diff --git a/common/settings/proxy_android.go b/common/settings/proxy_android.go index d3768ceddc..df89c32e52 100644 --- a/common/settings/proxy_android.go +++ b/common/settings/proxy_android.go @@ -20,7 +20,7 @@ type AndroidSystemProxy struct { isEnabled bool } -func NewSystemProxy(ctx context.Context, serverAddr M.Socksaddr, supportSOCKS bool) (*AndroidSystemProxy, error) { +func NewSystemProxy(ctx context.Context, serverAddr M.Socksaddr, supportSOCKS bool, bypassDomain []string) (*AndroidSystemProxy, error) { userId := os.Getuid() var ( useRish bool diff --git a/common/settings/proxy_darwin.go b/common/settings/proxy_darwin.go index baaf6cedaa..51103d9ce1 100644 --- a/common/settings/proxy_darwin.go +++ b/common/settings/proxy_darwin.go @@ -24,7 +24,7 @@ type DarwinSystemProxy struct { isEnabled bool } -func NewSystemProxy(ctx context.Context, serverAddr M.Socksaddr, supportSOCKS bool) (*DarwinSystemProxy, error) { +func NewSystemProxy(ctx context.Context, serverAddr M.Socksaddr, supportSOCKS bool, bypassDomain []string) (*DarwinSystemProxy, error) { interfaceMonitor := service.FromContext[adapter.NetworkManager](ctx).InterfaceMonitor() if interfaceMonitor == nil { return nil, E.New("missing interface monitor") diff --git a/common/settings/proxy_linux.go b/common/settings/proxy_linux.go index 84e27b123b..5f594dcd88 100644 --- a/common/settings/proxy_linux.go +++ b/common/settings/proxy_linux.go @@ -16,15 +16,31 @@ import ( ) type LinuxSystemProxy struct { + execute func(name string, args ...string) error hasGSettings bool kWriteConfigCmd string - sudoUser string serverAddr M.Socksaddr supportSOCKS bool isEnabled bool } -func NewSystemProxy(ctx context.Context, serverAddr M.Socksaddr, supportSOCKS bool) (*LinuxSystemProxy, error) { +func NewSystemProxy(ctx context.Context, serverAddr M.Socksaddr, supportSOCKS bool, bypassDomain []string) (*LinuxSystemProxy, error) { + var sudoUser string + if os.Getuid() == 0 { + sudoUser = os.Getenv("SUDO_USER") + } + return NewLinuxSystemProxy(serverAddr, supportSOCKS, func(name string, args ...string) error { + if os.Getuid() != 0 { + return shell.Exec(name, args...).Attach().Run() + } else if sudoUser != "" { + return shell.Exec("su", "-", sudoUser, "-c", F.ToString(name, " ", strings.Join(args, " "))).Attach().Run() + } else { + return E.New("set system proxy: unable to set as root") + } + }) +} + +func NewLinuxSystemProxy(serverAddr M.Socksaddr, supportSOCKS bool, execute func(name string, args ...string) error) (*LinuxSystemProxy, error) { hasGSettings := common.Error(exec.LookPath("gsettings")) == nil kWriteConfigCmds := []string{ "kwriteconfig5", @@ -37,17 +53,13 @@ func NewSystemProxy(ctx context.Context, serverAddr M.Socksaddr, supportSOCKS bo break } } - var sudoUser string - if os.Getuid() == 0 { - sudoUser = os.Getenv("SUDO_USER") - } if !hasGSettings && kWriteConfigCmd == "" { return nil, E.New("unsupported desktop environment") } return &LinuxSystemProxy{ + execute: execute, hasGSettings: hasGSettings, kWriteConfigCmd: kWriteConfigCmd, - sudoUser: sudoUser, serverAddr: serverAddr, supportSOCKS: supportSOCKS, }, nil @@ -59,7 +71,7 @@ func (p *LinuxSystemProxy) IsEnabled() bool { func (p *LinuxSystemProxy) Enable() error { if p.hasGSettings { - err := p.runAsUser("gsettings", "set", "org.gnome.system.proxy.http", "enabled", "true") + err := p.execute("gsettings", "set", "org.gnome.system.proxy.http", "enabled", "true") if err != nil { return err } @@ -71,17 +83,17 @@ func (p *LinuxSystemProxy) Enable() error { if err != nil { return err } - err = p.runAsUser("gsettings", "set", "org.gnome.system.proxy", "use-same-proxy", F.ToString(p.supportSOCKS)) + err = p.execute("gsettings", "set", "org.gnome.system.proxy", "use-same-proxy", F.ToString(p.supportSOCKS)) if err != nil { return err } - err = p.runAsUser("gsettings", "set", "org.gnome.system.proxy", "mode", "manual") + err = p.execute("gsettings", "set", "org.gnome.system.proxy", "mode", "manual") if err != nil { return err } } if p.kWriteConfigCmd != "" { - err := p.runAsUser(p.kWriteConfigCmd, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "ProxyType", "1") + err := p.execute(p.kWriteConfigCmd, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "ProxyType", "1") if err != nil { return err } @@ -93,11 +105,11 @@ func (p *LinuxSystemProxy) Enable() error { if err != nil { return err } - err = p.runAsUser(p.kWriteConfigCmd, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "Authmode", "0") + err = p.execute(p.kWriteConfigCmd, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "Authmode", "0") if err != nil { return err } - err = p.runAsUser("dbus-send", "--type=signal", "/KIO/Scheduler", "org.kde.KIO.Scheduler.reparseSlaveConfiguration", "string:''") + err = p.execute("dbus-send", "--type=signal", "/KIO/Scheduler", "org.kde.KIO.Scheduler.reparseSlaveConfiguration", "string:''") if err != nil { return err } @@ -108,17 +120,17 @@ func (p *LinuxSystemProxy) Enable() error { func (p *LinuxSystemProxy) Disable() error { if p.hasGSettings { - err := p.runAsUser("gsettings", "set", "org.gnome.system.proxy", "mode", "none") + err := p.execute("gsettings", "set", "org.gnome.system.proxy", "mode", "none") if err != nil { return err } } if p.kWriteConfigCmd != "" { - err := p.runAsUser(p.kWriteConfigCmd, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "ProxyType", "0") + err := p.execute(p.kWriteConfigCmd, "--file", "kioslaverc", "--group", "Proxy Settings", "--key", "ProxyType", "0") if err != nil { return err } - err = p.runAsUser("dbus-send", "--type=signal", "/KIO/Scheduler", "org.kde.KIO.Scheduler.reparseSlaveConfiguration", "string:''") + err = p.execute("dbus-send", "--type=signal", "/KIO/Scheduler", "org.kde.KIO.Scheduler.reparseSlaveConfiguration", "string:''") if err != nil { return err } @@ -127,23 +139,13 @@ func (p *LinuxSystemProxy) Disable() error { return nil } -func (p *LinuxSystemProxy) runAsUser(name string, args ...string) error { - if os.Getuid() != 0 { - return shell.Exec(name, args...).Attach().Run() - } else if p.sudoUser != "" { - return shell.Exec("su", "-", p.sudoUser, "-c", F.ToString(name, " ", strings.Join(args, " "))).Attach().Run() - } else { - return E.New("set system proxy: unable to set as root") - } -} - func (p *LinuxSystemProxy) setGnomeProxy(proxyTypes ...string) error { for _, proxyType := range proxyTypes { - err := p.runAsUser("gsettings", "set", "org.gnome.system.proxy."+proxyType, "host", p.serverAddr.AddrString()) + err := p.execute("gsettings", "set", "org.gnome.system.proxy."+proxyType, "host", p.serverAddr.AddrString()) if err != nil { return err } - err = p.runAsUser("gsettings", "set", "org.gnome.system.proxy."+proxyType, "port", F.ToString(p.serverAddr.Port)) + err = p.execute("gsettings", "set", "org.gnome.system.proxy."+proxyType, "port", F.ToString(p.serverAddr.Port)) if err != nil { return err } @@ -159,7 +161,7 @@ func (p *LinuxSystemProxy) setKDEProxy(proxyTypes ...string) error { } else { proxyUrl = "http://" + p.serverAddr.String() } - err := p.runAsUser( + err := p.execute( p.kWriteConfigCmd, "--file", "kioslaverc", diff --git a/common/settings/proxy_stub.go b/common/settings/proxy_stub.go index 56eb4b6517..ffbf07221f 100644 --- a/common/settings/proxy_stub.go +++ b/common/settings/proxy_stub.go @@ -9,6 +9,6 @@ import ( M "github.com/sagernet/sing/common/metadata" ) -func NewSystemProxy(ctx context.Context, serverAddr M.Socksaddr, supportSOCKS bool) (SystemProxy, error) { +func NewSystemProxy(ctx context.Context, serverAddr M.Socksaddr, supportSOCKS bool, bypassDomain []string) (SystemProxy, error) { return nil, os.ErrInvalid } diff --git a/common/settings/proxy_windows.go b/common/settings/proxy_windows.go index 793ac1d1cf..bd6a5dd2cf 100644 --- a/common/settings/proxy_windows.go +++ b/common/settings/proxy_windows.go @@ -2,6 +2,7 @@ package settings import ( "context" + "strings" M "github.com/sagernet/sing/common/metadata" "github.com/sagernet/sing/common/wininet" @@ -10,13 +11,15 @@ import ( type WindowsSystemProxy struct { serverAddr M.Socksaddr supportSOCKS bool + bypassDomain []string isEnabled bool } -func NewSystemProxy(ctx context.Context, serverAddr M.Socksaddr, supportSOCKS bool) (*WindowsSystemProxy, error) { +func NewSystemProxy(ctx context.Context, serverAddr M.Socksaddr, supportSOCKS bool, bypassDomain []string) (*WindowsSystemProxy, error) { return &WindowsSystemProxy{ serverAddr: serverAddr, supportSOCKS: supportSOCKS, + bypassDomain: bypassDomain, }, nil } @@ -25,7 +28,7 @@ func (p *WindowsSystemProxy) IsEnabled() bool { } func (p *WindowsSystemProxy) Enable() error { - err := wininet.SetSystemProxy("http://"+p.serverAddr.String(), "") + err := wininet.SetSystemProxy("http://"+p.serverAddr.String(), strings.Join(p.bypassDomain, ";")) if err != nil { return err } diff --git a/common/tls/acme.go b/common/tls/acme.go index 93f293f6f2..c736d08f5c 100644 --- a/common/tls/acme.go +++ b/common/tls/acme.go @@ -5,6 +5,7 @@ package tls import ( "context" "crypto/tls" + "os" "slices" "strings" @@ -13,6 +14,7 @@ import ( "github.com/sagernet/sing-box/option" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/logger" + "github.com/sagernet/sing/service/filemanager" "github.com/caddyserver/certmagic" "github.com/libdns/acmedns" @@ -24,21 +26,45 @@ import ( ) type acmeWrapper struct { - ctx context.Context - cfg *certmagic.Config - cache *certmagic.Cache - domain []string + ctx context.Context + cfg *certmagic.Config + cache *certmagic.Cache + zapLogger *zap.Logger + dataDirectory string + domain []string } func (w *acmeWrapper) Start() error { + if w.dataDirectory != "" { + err := filemanager.MkdirAll(w.ctx, w.dataDirectory, 0o700) + if err != nil { + return E.Cause(err, "create ACME data directory") + } + } + config := w.cfg + cache := certmagic.NewCache(certmagic.CacheOptions{ + GetConfigForCert: func(certificate certmagic.Certificate) (*certmagic.Config, error) { + return config, nil + }, + Logger: w.zapLogger, + }) + config = certmagic.New(cache, *config) + w.cfg = config + w.cache = cache return w.cfg.ManageSync(w.ctx, w.domain) } func (w *acmeWrapper) Close() error { - w.cache.Stop() + if w.cache != nil { + w.cache.Stop() + } return nil } +func (w *acmeWrapper) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { + return w.cfg.GetCertificate(hello) +} + func startACME(ctx context.Context, logger logger.Logger, options option.InboundACMEOptions) (*tls.Config, adapter.SimpleLifecycle, error) { var acmeServer string switch options.Provider { @@ -52,10 +78,14 @@ func startACME(ctx context.Context, logger logger.Logger, options option.Inbound } acmeServer = options.Provider } - var storage certmagic.Storage + var ( + storage certmagic.Storage + dataDirectory string + ) if options.DataDirectory != "" { + dataDirectory = filemanager.BasePath(ctx, os.ExpandEnv(options.DataDirectory)) storage = &certmagic.FileStorage{ - Path: options.DataDirectory, + Path: dataDirectory, } } else { storage = certmagic.Default.Storage @@ -119,23 +149,23 @@ func startACME(ctx context.Context, logger logger.Logger, options option.Inbound acmeConfig.ExternalAccount = (*acme.EAB)(options.ExternalAccount) } config.Issuers = []certmagic.Issuer{certmagic.NewACMEIssuer(config, acmeConfig)} - cache := certmagic.NewCache(certmagic.CacheOptions{ - GetConfigForCert: func(certificate certmagic.Certificate) (*certmagic.Config, error) { - return config, nil - }, - Logger: zapLogger, - }) - config = certmagic.New(cache, *config) + wrapper := &acmeWrapper{ + ctx: ctx, + cfg: config, + zapLogger: zapLogger, + dataDirectory: dataDirectory, + domain: options.Domain, + } var tlsConfig *tls.Config if acmeConfig.DisableTLSALPNChallenge || acmeConfig.DNS01Solver != nil { tlsConfig = &tls.Config{ - GetCertificate: config.GetCertificate, + GetCertificate: wrapper.GetCertificate, } } else { tlsConfig = &tls.Config{ - GetCertificate: config.GetCertificate, + GetCertificate: wrapper.GetCertificate, NextProtos: []string{C.ACMETLS1Protocol}, } } - return tlsConfig, &acmeWrapper{ctx: ctx, cfg: config, cache: cache, domain: options.Domain}, nil + return tlsConfig, wrapper, nil } diff --git a/common/tls/ech.go b/common/tls/ech.go index 8c884cab61..46ab50a233 100644 --- a/common/tls/ech.go +++ b/common/tls/ech.go @@ -8,7 +8,6 @@ import ( "encoding/base64" "encoding/pem" "net" - "os" "strings" "sync" "time" @@ -19,6 +18,7 @@ import ( E "github.com/sagernet/sing/common/exceptions" aTLS "github.com/sagernet/sing/common/tls" "github.com/sagernet/sing/service" + "github.com/sagernet/sing/service/filemanager" mDNS "github.com/miekg/dns" "golang.org/x/crypto/cryptobyte" @@ -29,7 +29,7 @@ func parseECHClientConfig(ctx context.Context, clientConfig ECHCapableConfig, op if len(options.ECH.Config) > 0 { echConfig = []byte(strings.Join(options.ECH.Config, "\n")) } else if options.ECH.ConfigPath != "" { - content, err := os.ReadFile(options.ECH.ConfigPath) + content, err := filemanager.ReadFile(ctx, options.ECH.ConfigPath) if err != nil { return nil, E.Cause(err, "read ECH config") } @@ -60,7 +60,7 @@ func parseECHServerConfig(ctx context.Context, options option.InboundTLSOptions, if len(options.ECH.Key) > 0 { echKey = []byte(strings.Join(options.ECH.Key, "\n")) } else if options.ECH.KeyPath != "" { - content, err := os.ReadFile(options.ECH.KeyPath) + content, err := filemanager.ReadFile(ctx, options.ECH.KeyPath) if err != nil { return E.Cause(err, "read ECH keys") } diff --git a/common/tls/std_client.go b/common/tls/std_client.go index 6531039604..8091e1d69c 100644 --- a/common/tls/std_client.go +++ b/common/tls/std_client.go @@ -8,7 +8,6 @@ import ( "crypto/x509" "encoding/base64" "net" - "os" "strings" "time" @@ -20,6 +19,7 @@ import ( E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/logger" "github.com/sagernet/sing/common/ntp" + "github.com/sagernet/sing/service/filemanager" ) type STDClientConfig struct { @@ -179,7 +179,7 @@ func newSTDClient(ctx context.Context, logger logger.ContextLogger, serverAddres if len(options.Certificate) > 0 { certificate = []byte(strings.Join(options.Certificate, "\n")) } else if options.CertificatePath != "" { - content, err := os.ReadFile(options.CertificatePath) + content, err := filemanager.ReadFile(ctx, options.CertificatePath) if err != nil { return nil, E.Cause(err, "read certificate") } @@ -196,7 +196,7 @@ func newSTDClient(ctx context.Context, logger logger.ContextLogger, serverAddres if len(options.ClientCertificate) > 0 { clientCertificate = []byte(strings.Join(options.ClientCertificate, "\n")) } else if options.ClientCertificatePath != "" { - content, err := os.ReadFile(options.ClientCertificatePath) + content, err := filemanager.ReadFile(ctx, options.ClientCertificatePath) if err != nil { return nil, E.Cause(err, "read client certificate") } @@ -206,7 +206,7 @@ func newSTDClient(ctx context.Context, logger logger.ContextLogger, serverAddres if len(options.ClientKey) > 0 { clientKey = []byte(strings.Join(options.ClientKey, "\n")) } else if options.ClientKeyPath != "" { - content, err := os.ReadFile(options.ClientKeyPath) + content, err := filemanager.ReadFile(ctx, options.ClientKeyPath) if err != nil { return nil, E.Cause(err, "read client key") } diff --git a/common/tls/std_server.go b/common/tls/std_server.go index a99b327579..52e87b540d 100644 --- a/common/tls/std_server.go +++ b/common/tls/std_server.go @@ -5,7 +5,6 @@ import ( "crypto/tls" "crypto/x509" "net" - "os" "strings" "sync" "time" @@ -20,6 +19,7 @@ import ( E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/ntp" "github.com/sagernet/sing/service" + "github.com/sagernet/sing/service/filemanager" ) var errInsecureUnused = E.New("tls: insecure unused") @@ -90,6 +90,7 @@ func getACMENextProtos(provider adapter.CertificateProvider) []string { } type STDServerConfig struct { + ctx context.Context access sync.RWMutex config *tls.Config handshakeTimeout time.Duration @@ -260,13 +261,13 @@ func (c *STDServerConfig) certificateUpdated(path string) error { if path == c.certificatePath || path == c.keyPath { switch path { case c.certificatePath: - certificate, err := os.ReadFile(c.certificatePath) + certificate, err := filemanager.ReadFile(c.ctx, c.certificatePath) if err != nil { return E.Cause(err, "reload certificate from ", c.certificatePath) } c.certificate = certificate case c.keyPath: - key, err := os.ReadFile(c.keyPath) + key, err := filemanager.ReadFile(c.ctx, c.keyPath) if err != nil { return E.Cause(err, "reload key from ", c.keyPath) } @@ -286,7 +287,7 @@ func (c *STDServerConfig) certificateUpdated(path string) error { clientCertificateCA := x509.NewCertPool() var reloaded bool for _, certPath := range c.clientCertificatePath { - content, err := os.ReadFile(certPath) + content, err := filemanager.ReadFile(c.ctx, certPath) if err != nil { c.logger.Error(E.Cause(err, "reload certificate from ", c.clientCertificatePath)) continue @@ -307,7 +308,7 @@ func (c *STDServerConfig) certificateUpdated(path string) error { c.access.Unlock() c.logger.Info("reloaded client certificates") } else if path == c.echKeyPath { - echKey, err := os.ReadFile(c.echKeyPath) + echKey, err := filemanager.ReadFile(c.ctx, c.echKeyPath) if err != nil { return E.Cause(err, "reload ECH keys from ", c.echKeyPath) } @@ -405,7 +406,7 @@ func NewSTDServer(ctx context.Context, logger log.ContextLogger, options option. if len(options.Certificate) > 0 { certificate = []byte(strings.Join(options.Certificate, "\n")) } else if options.CertificatePath != "" { - content, err := os.ReadFile(options.CertificatePath) + content, err := filemanager.ReadFile(ctx, options.CertificatePath) if err != nil { return nil, E.Cause(err, "read certificate") } @@ -414,7 +415,7 @@ func NewSTDServer(ctx context.Context, logger log.ContextLogger, options option. if len(options.Key) > 0 { key = []byte(strings.Join(options.Key, "\n")) } else if options.KeyPath != "" { - content, err := os.ReadFile(options.KeyPath) + content, err := filemanager.ReadFile(ctx, options.KeyPath) if err != nil { return nil, E.Cause(err, "read key") } @@ -457,7 +458,7 @@ func NewSTDServer(ctx context.Context, logger log.ContextLogger, options option. } else if len(options.ClientCertificatePath) > 0 { clientCertificateCA := x509.NewCertPool() for _, path := range options.ClientCertificatePath { - content, err := os.ReadFile(path) + content, err := filemanager.ReadFile(ctx, path) if err != nil { return nil, E.Cause(err, "read client certificate from ", path) } @@ -494,6 +495,7 @@ func NewSTDServer(ctx context.Context, logger log.ContextLogger, options option. handshakeTimeout = C.TCPTimeout } serverConfig := &STDServerConfig{ + ctx: ctx, config: tlsConfig, handshakeTimeout: handshakeTimeout, logger: logger, diff --git a/common/tls/system_client.go b/common/tls/system_client.go index ade2bd3636..06f16352bd 100644 --- a/common/tls/system_client.go +++ b/common/tls/system_client.go @@ -3,7 +3,6 @@ package tls import ( "context" "crypto/x509" - "os" "strings" "time" @@ -11,6 +10,7 @@ import ( "github.com/sagernet/sing-box/option" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/service" + "github.com/sagernet/sing/service/filemanager" ) type SystemTLSValidated struct { @@ -89,7 +89,7 @@ func resolveSystemAnchors(ctx context.Context, options option.OutboundTLSOptions return []byte(strings.Join(options.Certificate, "\n")), true, nil, nil } if options.CertificatePath != "" { - content, err := os.ReadFile(options.CertificatePath) + content, err := filemanager.ReadFile(ctx, options.CertificatePath) if err != nil { return nil, false, nil, E.Cause(err, "read certificate") } diff --git a/common/tls/utls_client.go b/common/tls/utls_client.go index 1cc41554fa..7486652954 100644 --- a/common/tls/utls_client.go +++ b/common/tls/utls_client.go @@ -8,7 +8,6 @@ import ( "crypto/x509" "math/rand" "net" - "os" "strings" "time" @@ -21,6 +20,7 @@ import ( E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/logger" "github.com/sagernet/sing/common/ntp" + "github.com/sagernet/sing/service/filemanager" utls "github.com/metacubex/utls" "golang.org/x/net/http2" @@ -251,7 +251,7 @@ func newUTLSClient(ctx context.Context, logger logger.ContextLogger, serverAddre if len(options.Certificate) > 0 { certificate = []byte(strings.Join(options.Certificate, "\n")) } else if options.CertificatePath != "" { - content, err := os.ReadFile(options.CertificatePath) + content, err := filemanager.ReadFile(ctx, options.CertificatePath) if err != nil { return nil, E.Cause(err, "read certificate") } @@ -268,7 +268,7 @@ func newUTLSClient(ctx context.Context, logger logger.ContextLogger, serverAddre if len(options.ClientCertificate) > 0 { clientCertificate = []byte(strings.Join(options.ClientCertificate, "\n")) } else if options.ClientCertificatePath != "" { - content, err := os.ReadFile(options.ClientCertificatePath) + content, err := filemanager.ReadFile(ctx, options.ClientCertificatePath) if err != nil { return nil, E.Cause(err, "read client certificate") } @@ -278,7 +278,7 @@ func newUTLSClient(ctx context.Context, logger logger.ContextLogger, serverAddre if len(options.ClientKey) > 0 { clientKey = []byte(strings.Join(options.ClientKey, "\n")) } else if options.ClientKeyPath != "" { - content, err := os.ReadFile(options.ClientKeyPath) + content, err := filemanager.ReadFile(ctx, options.ClientKeyPath) if err != nil { return nil, E.Cause(err, "read client key") } diff --git a/common/tlsspoof/integration_windows_test.go b/common/tlsspoof/integration_windows_test.go index b0461a31b2..f2bb1ba3ec 100644 --- a/common/tlsspoof/integration_windows_test.go +++ b/common/tlsspoof/integration_windows_test.go @@ -5,13 +5,29 @@ package tlsspoof import ( "encoding/hex" "io" + "log" "net" + "os" "testing" "time" + "github.com/sagernet/sing-box/internal/winmutex" + E "github.com/sagernet/sing/common/exceptions" + "github.com/stretchr/testify/require" ) +func TestMain(m *testing.M) { + exitCode, err := winmutex.WithLock("SingBoxWinDivertIntegrationTests", 3*time.Minute, func() (int, error) { + return m.Run(), nil + }) + if err != nil { + log.Print(E.Cause(err, "run in exclusive WinDivert integration test environment")) + os.Exit(1) + } + os.Exit(exitCode) +} + func newSpoofer(t *testing.T, conn net.Conn, method Method) rawSpoofer { t.Helper() s, err := newRawSpoofer(conn, method) diff --git a/common/trafficcontrol/manager.go b/common/trafficcontrol/manager.go index 954263a605..c18a59f58e 100644 --- a/common/trafficcontrol/manager.go +++ b/common/trafficcontrol/manager.go @@ -50,13 +50,10 @@ type Manager struct { } func NewManager(outbound adapter.OutboundManager) *Manager { - manager := &Manager{ + return &Manager{ outbound: outbound, eventSubscriber: observable.NewSubscriber[ConnectionEvent](256), } - manager.eventObserver = observable.NewObserver(manager.eventSubscriber, 64) - manager.cleaner = cleanup.Add(manager.Clear) - return manager } func (m *Manager) Name() string { @@ -64,12 +61,21 @@ func (m *Manager) Name() string { } func (m *Manager) Start(stage adapter.StartStage) error { + if stage == adapter.StartStateInitialize { + m.eventObserver = observable.NewObserver(m.eventSubscriber, 64) + m.cleaner = cleanup.Add(m.Clear) + } return nil } func (m *Manager) Close() error { - m.cleaner.Close() - return m.eventObserver.Close() + if m.cleaner != nil { + m.cleaner.Close() + } + if m.eventObserver != nil { + return m.eventObserver.Close() + } + return nil } func (m *Manager) SubscribeEvents() (observable.Subscription[ConnectionEvent], <-chan struct{}, error) { diff --git a/common/urltest/urltest.go b/common/urltest/urltest.go index d5a26f2e30..55fd6a0b3f 100644 --- a/common/urltest/urltest.go +++ b/common/urltest/urltest.go @@ -12,6 +12,7 @@ import ( "github.com/sagernet/sing-box/adapter" C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing/common" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" "github.com/sagernet/sing/common/ntp" @@ -107,11 +108,21 @@ func (s *HistoryStorage) Close() error { return nil } -func URLTest(ctx context.Context, link string, detour N.Dialer) (t uint16, err error) { +func URLTest(ctx context.Context, link string, detour N.Dialer) (uint16, error) { if detour == nil { - err = fmt.Errorf("urltest dialer is nil") - return + return 0, fmt.Errorf("urltest dialer is nil") + } + multiplexOutbound, isMultiplexOutbound := common.Cast[adapter.OutboundWithMultiplex](detour) + if isMultiplexOutbound && multiplexOutbound.MultiplexEnabled() { + _, err := urlTest(ctx, link, detour) + if err != nil { + return 0, err + } } + return urlTest(ctx, link, detour) +} + +func urlTest(ctx context.Context, link string, detour N.Dialer) (t uint16, err error) { if link == "" { link = "https://www.gstatic.com/generate_204" } diff --git a/common/windivert/assets_386.go b/common/windivert/assets_386.go index 0cbf35ed5c..d1dac4cda5 100644 --- a/common/windivert/assets_386.go +++ b/common/windivert/assets_386.go @@ -7,8 +7,4 @@ import _ "embed" //go:embed assets/WinDivert32.sys var sysBytes []byte -func assetFiles() []assetFile { - return []assetFile{{"WinDivert32.sys", sysBytes}} -} - func driverSysName() string { return "WinDivert32.sys" } diff --git a/common/windivert/assets_amd64.go b/common/windivert/assets_amd64.go index 2c9fb6c6ad..3ff6c1435d 100644 --- a/common/windivert/assets_amd64.go +++ b/common/windivert/assets_amd64.go @@ -7,8 +7,4 @@ import _ "embed" //go:embed assets/WinDivert64.sys var sysBytes []byte -func assetFiles() []assetFile { - return []assetFile{{"WinDivert64.sys", sysBytes}} -} - func driverSysName() string { return "WinDivert64.sys" } diff --git a/common/windivert/assets_unsupported.go b/common/windivert/assets_unsupported.go index 04698953fa..189de58e27 100644 --- a/common/windivert/assets_unsupported.go +++ b/common/windivert/assets_unsupported.go @@ -2,6 +2,6 @@ package windivert -func assetFiles() []assetFile { return nil } +var sysBytes []byte func driverSysName() string { return "" } diff --git a/common/windivert/driver_asset_windows.go b/common/windivert/driver_asset_windows.go new file mode 100644 index 0000000000..d1b5de70f6 --- /dev/null +++ b/common/windivert/driver_asset_windows.go @@ -0,0 +1,100 @@ +//go:build windows + +package windivert + +import ( + "bytes" + "io" + "os" + "path/filepath" + "runtime" + "strconv" + + E "github.com/sagernet/sing/common/exceptions" + + "golang.org/x/sys/windows" +) + +func extractVerified() (string, *os.File, error) { + if len(sysBytes) == 0 { + return "", nil, E.New("windivert: unsupported architecture ", runtime.GOARCH) + } + + base, err := os.UserCacheDir() + if err != nil { + return "", nil, E.Cause(err, "windivert: locate user cache dir") + } + dir := filepath.Join(base, "sing-box", "windivert", "v"+AssetVersion) + err = os.MkdirAll(dir, 0o755) + if err != nil { + return "", nil, E.Cause(err, "windivert: mkdir ", dir) + } + target := filepath.Join(dir, driverSysName()) + + for attempt := 0; ; attempt++ { + sysFile, err := openDriverFile(target) + if err != nil { + if !os.IsNotExist(err) { + return "", nil, E.Cause(err, "windivert: open ", target) + } + err = writeDriverFile(target) + if err != nil { + return "", nil, err + } + sysFile, err = openDriverFile(target) + if err != nil { + return "", nil, E.Cause(err, "windivert: open ", target) + } + } + content, err := io.ReadAll(sysFile) + if err != nil { + sysFile.Close() + return "", nil, E.Cause(err, "windivert: read ", target) + } + if bytes.Equal(content, sysBytes) { + return target, sysFile, nil + } + sysFile.Close() + if attempt > 0 { + return "", nil, E.New("windivert: driver file ", target, " is being concurrently modified") + } + err = writeDriverFile(target) + if err != nil { + return "", nil, err + } + } +} + +func openDriverFile(path string) (*os.File, error) { + pathW, err := windows.UTF16PtrFromString(path) + if err != nil { + return nil, err + } + handle, err := windows.CreateFile( + pathW, + windows.GENERIC_READ, + windows.FILE_SHARE_READ, + nil, + windows.OPEN_EXISTING, + windows.FILE_ATTRIBUTE_NORMAL, + 0, + ) + if err != nil { + return nil, err + } + return os.NewFile(uintptr(handle), path), nil +} + +func writeDriverFile(target string) error { + temporaryPath := target + ".tmp-" + strconv.Itoa(os.Getpid()) + err := os.WriteFile(temporaryPath, sysBytes, 0o644) + if err != nil { + return E.Cause(err, "windivert: write ", filepath.Base(target)) + } + err = os.Rename(temporaryPath, target) + if err != nil { + os.Remove(temporaryPath) + return E.Cause(err, "windivert: rename ", filepath.Base(target)) + } + return nil +} diff --git a/common/windivert/driver_windows.go b/common/windivert/driver_windows.go index d6bc59f893..0925d26083 100644 --- a/common/windivert/driver_windows.go +++ b/common/windivert/driver_windows.go @@ -4,120 +4,131 @@ package windivert import ( "errors" - "os" - "path/filepath" "runtime" - "strconv" - "sync" + "time" + "github.com/sagernet/sing-box/internal/winmutex" E "github.com/sagernet/sing/common/exceptions" "golang.org/x/sys/windows" ) const ( - driverServiceName = "WinDivert" - driverDeviceName = `\\.\WinDivert` + driverServiceName = "WinDivert" + driverDeviceName = `\\.\WinDivert` + driverInstallMutexName = "WinDivertDriverInstallMutex" + driverInstallMutexTimeout = 90 * time.Second + driverReadyTimeout = 60 * time.Second + driverStateRefreshInterval = 50 * time.Millisecond ) -var ( - driverOnce sync.Once - driverErr error - // driverDevName is ASCII-safe and must be available before ensureDriver - // so Open can try CreateFile first and only install on FILE_NOT_FOUND. - driverDevName, _ = windows.UTF16PtrFromString(driverDeviceName) -) - -// Requires SeLoadDriverPrivilege (Administrator). Running the 386 build -// under WOW64 on a 64-bit kernel is rejected — use the amd64 build. -func ensureDriver() error { - driverOnce.Do(func() { - driverErr = installDriver() - }) - return driverErr -} +var driverDevName, _ = windows.UTF16PtrFromString(driverDeviceName) -func installDriver() error { +func acquireDevice() (windows.Handle, error) { + device, err := openDevice() + if err == nil { + return device, nil + } + fatalErr := driverOpenFatal(err) + if fatalErr != nil { + return 0, fatalErr + } if runtime.GOARCH == "386" { var isWow64 bool - err := windows.IsWow64Process(windows.CurrentProcess(), &isWow64) + err = windows.IsWow64Process(windows.CurrentProcess(), &isWow64) if err == nil && isWow64 { - return E.New("windivert: 386 build detected running under WOW64 on a 64-bit kernel; use the amd64 build") + return 0, E.New("windivert: 386 build detected running under WOW64 on a 64-bit kernel; use the amd64 build") + } + } + device, err = winmutex.WithLock(driverInstallMutexName, driverInstallMutexTimeout, installAndOpenDevice) + if err != nil && device != 0 { + closeErr := windows.CloseHandle(device) + if closeErr != nil { + closeErr = E.Cause(closeErr, "windivert: close device after install lock failure") } + return 0, E.Errors(err, closeErr) } + return device, err +} - dir, err := ensureExtracted() - if err != nil { - return err +func driverOpenFatal(err error) error { + if errors.Is(err, windows.ERROR_ACCESS_DENIED) { + return E.Cause(err, "windivert: open device (administrator required)") } - sysPath := filepath.Join(dir, driverSysName()) - sysPathW, err := windows.UTF16PtrFromString(sysPath) - if err != nil { - return E.Cause(err, "windivert: utf16 driver path") + if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) || + errors.Is(err, windows.ERROR_PATH_NOT_FOUND) || + errors.Is(err, windows.ERROR_NO_SUCH_DEVICE) { + return nil + } + return E.Cause(err, "windivert: open device") +} + +func installAndOpenDevice() (windows.Handle, error) { + device, err := openDevice() + if err == nil { + return device, nil + } + fatalErr := driverOpenFatal(err) + if fatalErr != nil { + return 0, fatalErr } - // Serialize driver install across concurrent processes. - mutexName, _ := windows.UTF16PtrFromString("WinDivertDriverInstallMutex") - mutex, err := windows.CreateMutex(nil, false, mutexName) + sysPath, sysFile, err := extractVerified() if err != nil { - return E.Cause(err, "windivert: create install mutex") + return 0, err } - defer windows.CloseHandle(mutex) - _, err = windows.WaitForSingleObject(mutex, windows.INFINITE) + defer sysFile.Close() + sysPathW, err := windows.UTF16PtrFromString(sysPath) if err != nil { - return E.Cause(err, "windivert: wait install mutex") + return 0, E.Cause(err, "windivert: utf16 driver path") } - defer windows.ReleaseMutex(mutex) manager, err := windows.OpenSCManager(nil, nil, windows.SC_MANAGER_ALL_ACCESS) if err != nil { - return E.Cause(err, "windivert: open SCM") + return 0, E.Cause(err, "windivert: open SCM") } defer windows.CloseServiceHandle(manager) serviceNameW, _ := windows.UTF16PtrFromString(driverServiceName) - service, err := windows.OpenService(manager, serviceNameW, windows.SERVICE_ALL_ACCESS) - if err != nil { - service, err = windows.CreateService( - manager, - serviceNameW, - serviceNameW, - windows.SERVICE_ALL_ACCESS, - windows.SERVICE_KERNEL_DRIVER, - windows.SERVICE_DEMAND_START, - windows.SERVICE_ERROR_NORMAL, - sysPathW, - nil, nil, nil, nil, nil, - ) - if err != nil { - if errors.Is(err, windows.ERROR_SERVICE_EXISTS) { - service, err = windows.OpenService(manager, serviceNameW, windows.SERVICE_ALL_ACCESS) - } - if err != nil { - return wrapDriverInstallError(err) + deadline := time.Now().Add(driverReadyTimeout) + for { + serviceErr := tryInstallService(manager, serviceNameW, sysPathW) + if serviceErr != nil && !driverServiceTransient(serviceErr) { + return 0, serviceErr + } + device, err = openDevice() + if err == nil { + return device, nil + } + fatalErr = driverOpenFatal(err) + if fatalErr != nil { + return 0, fatalErr + } + if time.Now().After(deadline) { + openErr := E.Cause(err, "windivert: open device after driver readiness timeout") + if serviceErr != nil { + return 0, E.Errors(serviceErr, openErr) } + return 0, openErr } + time.Sleep(driverStateRefreshInterval) + } +} + +func driverServiceTransient(err error) bool { + return errors.Is(err, windows.ERROR_SERVICE_MARKED_FOR_DELETE) || + errors.Is(err, windows.ERROR_SERVICE_DISABLED) || + errors.Is(err, windows.ERROR_OBJECT_ALREADY_EXISTS) +} + +func tryInstallService(manager windows.Handle, serviceNameW, sysPathW *uint16) error { + service, err := openOrCreateService(manager, serviceNameW, sysPathW) + if err != nil { + return err } defer windows.CloseServiceHandle(service) err = windows.StartService(service, 0, nil) - if err != nil && errors.Is(err, windows.ERROR_SERVICE_DISABLED) { - // A prior process called DeleteService on a still-running kernel - // driver: SCM marks the record for deletion and flips START_TYPE - // to DISABLED until the last handle closes. Re-enable so we can - // start it instead of waiting for a reboot. - err = windows.ChangeServiceConfig( - service, - windows.SERVICE_NO_CHANGE, - windows.SERVICE_DEMAND_START, - windows.SERVICE_NO_CHANGE, - nil, nil, nil, nil, nil, nil, nil, - ) - if err != nil { - return E.Cause(err, "windivert: re-enable disabled service") - } - err = windows.StartService(service, 0, nil) - } if err == nil { // Mark for deletion so the driver unregisters when the last handle // closes or on next reboot. Matches the upstream DLL's behavior: @@ -125,88 +136,55 @@ func installDriver() error { // cleanup responsibility. If another process already started it, // we leave DeleteService to them. _ = windows.DeleteService(service) - } else if !errors.Is(err, windows.ERROR_SERVICE_ALREADY_RUNNING) { - return E.Cause(err, "windivert: start service") - } - return nil -} - -func wrapDriverInstallError(err error) error { - if errors.Is(err, windows.ERROR_ACCESS_DENIED) { - return E.Cause(err, "windivert: installing the kernel driver requires Administrator privileges") - } - return E.Cause(err, "windivert: create service") -} - -type assetFile struct { - name string - data []byte -} - -var ( - extractOnce sync.Once - extractErr error - extractDir string -) - -// The on-disk copy is protected by Windows Authenticode signature -// enforcement, which rejects any tampered .sys at StartService time. -func ensureExtracted() (string, error) { - extractOnce.Do(func() { - extractDir, extractErr = extractImpl() - }) - return extractDir, extractErr -} - -func extractImpl() (string, error) { - files := assetFiles() - if len(files) == 0 { - return "", E.New("windivert: unsupported architecture ", runtime.GOARCH) - } - - base, err := os.UserCacheDir() - if err != nil { - return "", E.Cause(err, "windivert: locate user cache dir") + return nil } - dir := filepath.Join(base, "sing-box", "windivert", "v"+AssetVersion) - err = os.MkdirAll(dir, 0o755) - if err != nil { - return "", E.Cause(err, "windivert: mkdir ", dir) + if errors.Is(err, windows.ERROR_SERVICE_ALREADY_RUNNING) { + return nil } - - for _, asset := range files { - err = ensureAsset(dir, asset) - if err != nil { - return "", err + if errors.Is(err, windows.ERROR_SERVICE_DISABLED) { + // The disabled check precedes the running check: a running service + // marked for deletion reports ERROR_SERVICE_DISABLED instead of + // ERROR_SERVICE_ALREADY_RUNNING. The device is nonetheless up. + var status windows.SERVICE_STATUS + queryErr := windows.QueryServiceStatus(service, &status) + if queryErr == nil && status.CurrentState == windows.SERVICE_RUNNING { + return nil } } - return dir, nil + return E.Cause(err, "windivert: start service") } -// Concurrent sing-box processes race on os.Rename (atomic on NTFS); -// whichever wins creates the final file. Writers that lose the race -// silently discard their temp copy. -func ensureAsset(dir string, asset assetFile) error { - target := filepath.Join(dir, asset.name) - _, err := os.Stat(target) +func openOrCreateService(manager windows.Handle, serviceNameW, sysPathW *uint16) (windows.Handle, error) { + service, err := windows.OpenService(manager, serviceNameW, windows.SERVICE_ALL_ACCESS) if err == nil { - return nil - } - if !os.IsNotExist(err) { - return E.Cause(err, "windivert: stat ", asset.name) - } - tmp := target + ".tmp-" + strconv.Itoa(os.Getpid()) - err = os.WriteFile(tmp, asset.data, 0o644) - if err != nil { - return E.Cause(err, "windivert: write ", asset.name) + return service, nil + } + service, err = windows.CreateService( + manager, + serviceNameW, + serviceNameW, + windows.SERVICE_ALL_ACCESS, + windows.SERVICE_KERNEL_DRIVER, + windows.SERVICE_DEMAND_START, + windows.SERVICE_ERROR_NORMAL, + sysPathW, + nil, nil, nil, nil, nil, + ) + if err == nil { + return service, nil } - err = os.Rename(tmp, target) - if err != nil { - os.Remove(tmp) - if _, statErr := os.Stat(target); statErr == nil { - return nil + if errors.Is(err, windows.ERROR_SERVICE_EXISTS) { + service, err = windows.OpenService(manager, serviceNameW, windows.SERVICE_ALL_ACCESS) + if err == nil { + return service, nil } - return E.Cause(err, "windivert: rename ", asset.name) } - return nil + return 0, wrapDriverInstallError(err) +} + +func wrapDriverInstallError(err error) error { + if errors.Is(err, windows.ERROR_ACCESS_DENIED) { + return E.Cause(err, "windivert: installing the kernel driver requires Administrator privileges") + } + return E.Cause(err, "windivert: create service") } diff --git a/common/windivert/handle_windows.go b/common/windivert/handle_windows.go index 1d7aebfdef..31bf0d6cdd 100644 --- a/common/windivert/handle_windows.go +++ b/common/windivert/handle_windows.go @@ -48,29 +48,9 @@ func Open(filter *Filter, layer Layer, priority int16, flags Flag) (*Handle, err if err != nil { return nil, err } - device, err := openDevice() + device, err := acquireDevice() if err != nil { - if !errors.Is(err, windows.ERROR_FILE_NOT_FOUND) && - !errors.Is(err, windows.ERROR_PATH_NOT_FOUND) { - if errors.Is(err, windows.ERROR_ACCESS_DENIED) { - return nil, E.Cause(err, "windivert: open device (administrator required)") - } - return nil, E.Cause(err, "windivert: open device") - } - // Device node missing: kernel driver not loaded. Install + retry. - // Matches WinDivertOpen's lazy-install path; avoids racing StartService - // against a still-loaded driver whose SCM record is marked for deletion. - err = ensureDriver() - if err != nil { - return nil, err - } - device, err = openDevice() - if err != nil { - if errors.Is(err, windows.ERROR_ACCESS_DENIED) { - return nil, E.Cause(err, "windivert: open device (administrator required)") - } - return nil, E.Cause(err, "windivert: open device") - } + return nil, err } event, err := windows.CreateEvent(nil, 1, 0, nil) // manual reset, unsignaled if err != nil { diff --git a/common/windivert/integration_windows_test.go b/common/windivert/integration_windows_test.go index 1b4ce958cc..eb9a0e5bc1 100644 --- a/common/windivert/integration_windows_test.go +++ b/common/windivert/integration_windows_test.go @@ -3,15 +3,33 @@ package windivert import ( + "bytes" "errors" + "log" "net/netip" + "os" + "path/filepath" "testing" "time" + "github.com/sagernet/sing-box/internal/winmutex" + E "github.com/sagernet/sing/common/exceptions" + "github.com/stretchr/testify/require" "golang.org/x/sys/windows" ) +func TestMain(m *testing.M) { + exitCode, err := winmutex.WithLock("SingBoxWinDivertIntegrationTests", 3*time.Minute, func() (int, error) { + return m.Run(), nil + }) + if err != nil { + log.Print(E.Cause(err, "run in exclusive WinDivert integration test environment")) + os.Exit(1) + } + os.Exit(exitCode) +} + func openHandle(t *testing.T, filter *Filter, flags Flag) *Handle { t.Helper() h, err := Open(filter, LayerNetwork, 0, flags) @@ -67,18 +85,132 @@ func TestIntegrationRecvAbortsOnClose(t *testing.T) { } } +func cachedDriverPath(t *testing.T) string { + t.Helper() + base, err := os.UserCacheDir() + require.NoError(t, err) + return filepath.Join(base, "sing-box", "windivert", "v"+AssetVersion, driverSysName()) +} + +// The driver does not unload when the last handle closes: it stays running +// (and the memory manager keeps its backing image write-locked) until +// explicitly stopped, like `sc stop WinDivert`. The install-time +// DeleteService mark then removes the record once the last SCM handle +// closes. +func stopDriver(t *testing.T) { + t.Helper() + manager, err := windows.OpenSCManager(nil, nil, windows.SC_MANAGER_CONNECT) + require.NoError(t, err) + defer windows.CloseServiceHandle(manager) + serviceNameW, err := windows.UTF16PtrFromString(driverServiceName) + require.NoError(t, err) + service, err := windows.OpenService(manager, serviceNameW, windows.SERVICE_STOP|windows.SERVICE_QUERY_STATUS) + if err == nil { + defer windows.CloseServiceHandle(service) + var status windows.SERVICE_STATUS + err = windows.ControlService(service, windows.SERVICE_CONTROL_STOP, &status) + if err != nil && + !errors.Is(err, windows.ERROR_SERVICE_NOT_ACTIVE) && + !errors.Is(err, windows.ERROR_SERVICE_CANNOT_ACCEPT_CTRL) { + require.NoError(t, err) + } + require.Eventually(t, func() bool { + queryErr := windows.QueryServiceStatus(service, &status) + return queryErr == nil && status.CurrentState == windows.SERVICE_STOPPED + }, 60*time.Second, 200*time.Millisecond, "driver did not reach SERVICE_STOPPED") + } else { + require.True(t, errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST), "open driver service: %v", err) + } + // SCM can report SERVICE_STOPPED before the driver finishes deleting its + // device object. Wait for the absence acquireDevice uses to trigger install. + require.Eventually(t, func() bool { + device, openErr := openDevice() + if openErr == nil { + _ = windows.CloseHandle(device) + return false + } + return errors.Is(openErr, windows.ERROR_FILE_NOT_FOUND) || + errors.Is(openErr, windows.ERROR_PATH_NOT_FOUND) || + errors.Is(openErr, windows.ERROR_NO_SUCH_DEVICE) + }, 60*time.Second, 200*time.Millisecond, "driver device remained openable after stop") +} + +// The image lock on the cached .sys can outlive SERVICE_STOPPED by tens of +// seconds (observed on GitHub-hosted runners), and on current runner images +// it blocks renames as well as writes and deletes. Tests that need to tamper +// with the cache therefore redirect it to a directory the kernel has never +// loaded a driver from. Not t.TempDir: once StartService maps a .sys from +// the directory, the image lock makes the cleanup RemoveAll fail the test. +func setTempDriverCache(t *testing.T) { + t.Helper() + dir, err := os.MkdirTemp("", "sing-box-windivert-test-") + require.NoError(t, err) + t.Cleanup(func() { os.RemoveAll(dir) }) + t.Setenv("LocalAppData", dir) +} + +// A foreign .sys planted in the user-writable cache must never reach +// StartService: the install path has to detect the mismatch against the +// embedded asset and repair the file before handing it to SCM. +func TestIntegrationTamperedCacheRepaired(t *testing.T) { + setTempDriverCache(t) + // The driver left running by earlier tests would satisfy Open without + // touching the cache; stop it so the install path runs. + stopDriver(t) + + target := cachedDriverPath(t) + require.NoError(t, os.MkdirAll(filepath.Dir(target), 0o755)) + require.NoError(t, os.WriteFile(target, []byte("planted payload, not the WinDivert driver"), 0o644)) + + h := openHandle(t, nil, FlagSendOnly) + require.NoError(t, h.Close()) + + content, err := os.ReadFile(target) + require.NoError(t, err) + require.True(t, bytes.Equal(content, sysBytes), "cached driver was not repaired to the embedded asset") +} + +// The verified handle must lock the file against writers and renames until +// install completes; without this, the file could be swapped between +// verification and the kernel mapping it. +func TestIntegrationDriverFileLockedWhileHeld(t *testing.T) { + // A fresh cache directory guarantees the failures asserted below can + // only come from the handle extractVerified holds, not a kernel image + // lock left by earlier tests. + setTempDriverCache(t) + + sysPath, sysFile, err := extractVerified() + require.NoError(t, err) + defer sysFile.Close() + + writeErr := os.WriteFile(sysPath, []byte("overwrite attempt"), 0o644) + require.Error(t, writeErr) + require.True(t, errors.Is(writeErr, windows.ERROR_SHARING_VIOLATION), + "expected sharing violation, got %v", writeErr) + + evil := sysPath + ".evil" + require.NoError(t, os.WriteFile(evil, []byte("replacement attempt"), 0o644)) + defer os.Remove(evil) + renameErr := os.Rename(evil, sysPath) + require.Error(t, renameErr) +} + // Two concurrent Open calls must both succeed: the first wins the driver // install race, the second reuses the already-running service. func TestIntegrationConcurrentOpen(t *testing.T) { + stopDriver(t) + start := make(chan struct{}) errCh := make(chan error, 2) handles := make(chan *Handle, 2) for range 2 { go func() { + <-start h, err := Open(nil, LayerNetwork, 0, FlagSendOnly) handles <- h errCh <- err }() } + close(start) for range 2 { err := <-errCh h := <-handles diff --git a/common/windivert/windivert.go b/common/windivert/windivert.go index 619e41b611..80f6e4eb72 100644 --- a/common/windivert/windivert.go +++ b/common/windivert/windivert.go @@ -1,8 +1,9 @@ // Package windivert provides a pure-Go binding to the WinDivert kernel // driver on Windows (amd64 and 386). User-mode WinDivert calls are // reimplemented in Go; only the signed kernel driver is embedded as an -// asset, since SCM-installed drivers must live on disk and their -// Authenticode signature forbids modification. +// asset, since SCM-installed drivers must live on disk. The on-disk copy +// is verified byte-for-byte against the embedded asset on every install +// and held open deny-write while the kernel loads it. // // Administrator is required for the first Open in a process so SCM can // load the driver. Upstream: https://github.com/basil00/WinDivert v2.2.2, diff --git a/constant/dns.go b/constant/dns.go index 5189805a3e..4a09ce3527 100644 --- a/constant/dns.go +++ b/constant/dns.go @@ -15,21 +15,23 @@ const ( ) const ( - DNSTypeLegacy = "legacy" - DNSTypeUDP = "udp" - DNSTypeTCP = "tcp" - DNSTypeTLS = "tls" - DNSTypeHTTPS = "https" - DNSTypeQUIC = "quic" - DNSTypeHTTP3 = "h3" - DNSTypeLocal = "local" - DNSTypeHosts = "hosts" - DNSTypeFakeIP = "fakeip" - DNSTypeDHCP = "dhcp" - DNSTypeMDNS = "mdns" - DNSTypeTailscale = "tailscale" - DNSTypeMulti = "multi" //H - DNSTypeSDNS = "sdns" //H + DNSTypeLegacy = "legacy" + DNSTypeUDP = "udp" + DNSTypeTCP = "tcp" + DNSTypeTLS = "tls" + DNSTypeHTTPS = "https" + DNSTypeQUIC = "quic" + DNSTypeHTTP3 = "h3" + DNSTypeLocal = "local" + DNSTypeHosts = "hosts" + DNSTypeFakeIP = "fakeip" + DNSTypeDHCP = "dhcp" + DNSTypeMDNS = "mdns" + DNSTypeTailscale = "tailscale" + DNSTypeOpenConnect = "openconnect" + DNSTypeOpenVPN = "openvpn" + DNSTypeMulti = "multi" //H + DNSTypeSDNS = "sdns" //H ) const ( diff --git a/constant/proxy.go b/constant/proxy.go index 65c2654e49..706e8682db 100644 --- a/constant/proxy.go +++ b/constant/proxy.go @@ -27,6 +27,9 @@ const ( TypeVLESS = "vless" TypeTUIC = "tuic" TypeHysteria2 = "hysteria2" + TypeOpenConnect = "openconnect" + TypeOpenVPNClient = "openvpn-client" + TypeOpenVPNServer = "openvpn-server" TypeTailscale = "tailscale" TypeCloudflared = "cloudflared" TypeDERP = "derp" @@ -56,6 +59,7 @@ const ( TypeDNSTT = "dnstt" //H TypeGooseRelay = "gooserelay" //H TypeSmartDNSPool = "smart_dns_pool" //H — local recursive-resolver pool with AIMD throttling + recovery probing (github.com/hiddify/hmrd_multi_resolver_dns) + TypeTrustTunnel = "trusttunnel" ) const ( @@ -115,6 +119,12 @@ func ProxyDisplayName(proxyType string) string { return "Hysteria2" case TypeAnyTLS: return "AnyTLS" + case TypeOpenConnect: + return "OpenConnect" + case TypeOpenVPNClient: + return "OpenVPN Client" + case TypeOpenVPNServer: + return "OpenVPN Server" case TypeTailscale: return "Tailscale" case TypeCloudflared: @@ -145,6 +155,8 @@ func ProxyDisplayName(proxyType string) string { return "DNSTT" case TypeGooseRelay: return "GooseRelay" + case TypeTrustTunnel: + return "TrustTunnel" case TypeSelector: return "Selector" case TypeURLTest: diff --git a/constant/rule.go b/constant/rule.go index efd4a2d32d..251ed4e9a4 100644 --- a/constant/rule.go +++ b/constant/rule.go @@ -18,6 +18,8 @@ const ( RuleSetFormatBinary = "binary" ) +const RuleSetTagPlaceholder = "{tag}" + const ( RuleSetVersion1 = 1 + iota RuleSetVersion2 diff --git a/daemon/client.go b/daemon/client.go index c585a818b9..c7313b2570 100644 --- a/daemon/client.go +++ b/daemon/client.go @@ -61,8 +61,8 @@ func NewRemoteClient(options RemoteClientOptions) (*grpc.ClientConn, error) { } return grpc.NewClient(target, grpc.WithTransportCredentials(transportCredentials), - grpc.WithUnaryInterceptor(NewClientAuthUnaryInterceptor(options.Secret)), - grpc.WithStreamInterceptor(NewClientAuthStreamInterceptor(options.Secret)), + grpc.WithChainUnaryInterceptor(UnaryClientLocaleInterceptor, NewClientAuthUnaryInterceptor(options.Secret)), + grpc.WithChainStreamInterceptor(StreamClientLocaleInterceptor, NewClientAuthStreamInterceptor(options.Secret)), ) } diff --git a/daemon/errors.go b/daemon/errors.go deleted file mode 100644 index 17a4e80066..0000000000 --- a/daemon/errors.go +++ /dev/null @@ -1,42 +0,0 @@ -package daemon - -import ( - "context" - "errors" - "os" - - "google.golang.org/grpc" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" -) - -func UnaryErrorInterceptor(ctx context.Context, request any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { - response, err := handler(ctx, request) - if err != nil { - return nil, mapStatusError(err) - } - return response, nil -} - -func StreamErrorInterceptor(server any, stream grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { - err := handler(server, stream) - if err != nil { - return mapStatusError(err) - } - return nil -} - -func mapStatusError(err error) error { - if _, loaded := status.FromError(err); loaded { - return err - } - switch { - case errors.Is(err, os.ErrInvalid): - return status.Error(codes.FailedPrecondition, "service not started") - case errors.Is(err, os.ErrClosed): - return status.Error(codes.Unavailable, "service is closing") - case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded): - return status.FromContextError(err).Err() - } - return err -} diff --git a/daemon/instance.go b/daemon/instance.go index fef86e9770..44736e250b 100644 --- a/daemon/instance.go +++ b/daemon/instance.go @@ -10,6 +10,7 @@ import ( "github.com/sagernet/sing-box/common/urltest" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/experimental/deprecated" + "github.com/sagernet/sing-box/experimental/locale" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" "github.com/sagernet/sing/common" @@ -34,12 +35,14 @@ type Instance struct { logFactory log.Factory } -func (s *StartedService) CheckConfig(configContent string) error { - options, err := parseConfig(s.ctx, configContent) +func (s *StartedService) CheckConfig(ctx context.Context, configContent string) error { + selectedLocale := locale.FromContext(ctx) + ctx, _ = locale.ContextWithLocale(s.ctx, selectedLocale.Locale) + options, err := parseConfig(ctx, configContent) if err != nil { return err } - ctx, cancel := context.WithCancel(s.ctx) + ctx, cancel := context.WithCancel(ctx) defer cancel() instance, err := box.New(box.Options{ Context: ctx, @@ -51,8 +54,10 @@ func (s *StartedService) CheckConfig(configContent string) error { return err } -func (s *StartedService) FormatConfig(configContent string) (string, error) { - options, err := parseConfig(s.ctx, configContent) +func (s *StartedService) FormatConfig(ctx context.Context, configContent string) (string, error) { + selectedLocale := locale.FromContext(ctx) + ctx, _ = locale.ContextWithLocale(s.ctx, selectedLocale.Locale) + options, err := parseConfig(ctx, configContent) if err != nil { return "", err } @@ -72,8 +77,10 @@ type OverrideOptions struct { ExcludePackage []string } -func (s *StartedService) newInstance(profileContent string, overrideOptions *OverrideOptions) (*Instance, error) { - ctx := service.ExtendContext(s.ctx) +func (s *StartedService) newInstance(ctx context.Context, profileContent string, overrideOptions *OverrideOptions) (*Instance, error) { + selectedLocale := locale.FromContext(ctx) + ctx, _ = locale.ContextWithLocale(s.ctx, selectedLocale.Locale) + ctx = service.ExtendContext(ctx) service.MustRegister[deprecated.Manager](ctx, new(deprecatedManager)) ctx, cancel := context.WithCancel(ctx) options, err := parseConfig(ctx, profileContent) diff --git a/daemon/locale.go b/daemon/locale.go new file mode 100644 index 0000000000..58e9bd8f38 --- /dev/null +++ b/daemon/locale.go @@ -0,0 +1,54 @@ +package daemon + +import ( + "context" + + "github.com/sagernet/sing-box/experimental/locale" + + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" +) + +func contextWithLocale(ctx context.Context) context.Context { + requestMetadata, loaded := metadata.FromIncomingContext(ctx) + if !loaded { + return ctx + } + for _, localeID := range requestMetadata.Get("accept-language") { + localizedContext, matched := locale.ContextWithLocale(ctx, localeID) + if matched { + return localizedContext + } + } + return ctx +} + +func UnaryLocaleInterceptor(ctx context.Context, request any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { + return handler(contextWithLocale(ctx), request) +} + +func StreamLocaleInterceptor(server any, stream grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { + return handler(server, &localeServerStream{ + ServerStream: stream, + ctx: contextWithLocale(stream.Context()), + }) +} + +func UnaryClientLocaleInterceptor(ctx context.Context, method string, request, reply any, connection *grpc.ClientConn, invoker grpc.UnaryInvoker, options ...grpc.CallOption) error { + ctx = metadata.AppendToOutgoingContext(ctx, "accept-language", locale.FromContext(ctx).Locale) + return invoker(ctx, method, request, reply, connection, options...) +} + +func StreamClientLocaleInterceptor(ctx context.Context, description *grpc.StreamDesc, connection *grpc.ClientConn, method string, streamer grpc.Streamer, options ...grpc.CallOption) (grpc.ClientStream, error) { + ctx = metadata.AppendToOutgoingContext(ctx, "accept-language", locale.FromContext(ctx).Locale) + return streamer(ctx, description, connection, method, options...) +} + +type localeServerStream struct { + grpc.ServerStream + ctx context.Context +} + +func (s *localeServerStream) Context() context.Context { + return s.ctx +} diff --git a/daemon/managed_service.go b/daemon/managed_service.go index eb14d7bf11..14c2bd5797 100644 --- a/daemon/managed_service.go +++ b/daemon/managed_service.go @@ -44,7 +44,7 @@ func (s *ManagedService) StopService(ctx context.Context, empty *emptypb.Empty) } func (s *ManagedService) ReloadService(ctx context.Context, empty *emptypb.Empty) (*emptypb.Empty, error) { - err := s.handler.ServiceReload() + err := s.handler.ServiceReload(ctx) if err != nil { return nil, err } diff --git a/daemon/platform.go b/daemon/platform.go index 2e0d0e6aae..5f96ec59d2 100644 --- a/daemon/platform.go +++ b/daemon/platform.go @@ -1,5 +1,7 @@ package daemon +import "context" + type PlatformHandler interface { WriteDebugMessage(message string) ConnectSSHAgent() (int32, error) @@ -7,7 +9,7 @@ type PlatformHandler interface { type ManagedHandler interface { ServiceStop() error - ServiceReload() error + ServiceReload(ctx context.Context) error SystemProxyStatus() (*SystemProxyStatus, error) SetSystemProxyEnabled(enabled bool) error TriggerNativeCrash() error diff --git a/daemon/server.go b/daemon/server.go index b5e401621e..539a3b2ba3 100644 --- a/daemon/server.go +++ b/daemon/server.go @@ -15,8 +15,8 @@ import ( func NewServer(startedService *StartedService, secret string) *grpc.Server { server := grpc.NewServer( - grpc.ChainUnaryInterceptor(newUnaryAuthInterceptor(secret), UnaryErrorInterceptor), - grpc.ChainStreamInterceptor(newStreamAuthInterceptor(secret), StreamErrorInterceptor), + grpc.ChainUnaryInterceptor(newUnaryAuthInterceptor(secret), UnaryLocaleInterceptor), + grpc.ChainStreamInterceptor(newStreamAuthInterceptor(secret), StreamLocaleInterceptor), ) healthServer := health.NewServer() RegisterStartedServiceServer(server, startedService) diff --git a/daemon/started_service.go b/daemon/started_service.go index 0c0630e311..7d895620a0 100644 --- a/daemon/started_service.go +++ b/daemon/started_service.go @@ -2,6 +2,7 @@ package daemon import ( "context" + "net/netip" "os" "runtime" runtimeDebug "runtime/debug" @@ -16,6 +17,7 @@ import ( "github.com/sagernet/sing-box/common/urltest" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/experimental/deprecated" + "github.com/sagernet/sing-box/experimental/locale" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/protocol/group" "github.com/sagernet/sing/common" @@ -32,7 +34,7 @@ import ( "google.golang.org/protobuf/types/known/emptypb" ) -const APIVersion = 2 +const APIVersion = 3 var _ StartedServiceServer = (*StartedService)(nil) @@ -110,6 +112,14 @@ func NewStartedService(options ServiceOptions) *StartedService { return s } +func (s *StartedService) SetOOMKillerOptions(enabled bool, killerDisabled bool, memoryLimit uint64) { + s.serviceAccess.Lock() + defer s.serviceAccess.Unlock() + s.oomKillerEnabled = enabled + s.oomKillerDisabled = killerDisabled + s.oomMemoryLimit = memoryLimit +} + func (s *StartedService) GetVersion(ctx context.Context, empty *emptypb.Empty) (*Version, error) { return &Version{ Version: C.Version, @@ -178,7 +188,7 @@ func (s *StartedService) waitForStarted(ctx context.Context) error { } } -func (s *StartedService) StartOrReloadService(profileContent string, options *OverrideOptions) error { +func (s *StartedService) StartOrReloadService(ctx context.Context, profileContent string, options *OverrideOptions) error { s.serviceAccess.Lock() switch s.serviceStatus.Status { case ServiceStatus_IDLE, ServiceStatus_STARTED, ServiceStatus_STARTING, ServiceStatus_FATAL: @@ -188,6 +198,7 @@ func (s *StartedService) StartOrReloadService(profileContent string, options *Ov } oldInstance := s.instance if oldInstance != nil { + s.instance = nil s.updateStatus(ServiceStatus_STOPPING) s.serviceAccess.Unlock() _ = oldInstance.Close() @@ -196,7 +207,7 @@ func (s *StartedService) StartOrReloadService(profileContent string, options *Ov } s.updateStatus(ServiceStatus_STARTING) s.resetLogs() - instance, err := s.newInstance(profileContent, options) + instance, err := s.newInstance(ctx, profileContent, options) if err != nil { return s.updateStatusError(err) } @@ -213,6 +224,8 @@ func (s *StartedService) StartOrReloadService(profileContent string, options *Ov return nil } if err != nil { + s.instance = nil + _ = instance.Close() return s.updateStatusError(err) } s.startedAt = time.Now() @@ -235,16 +248,13 @@ func (s *StartedService) CloseService() error { case ServiceStatus_STARTING, ServiceStatus_STARTED: default: s.serviceAccess.Unlock() - return os.ErrInvalid + return nil } s.updateStatus(ServiceStatus_STOPPING) instance := s.instance s.instance = nil if instance != nil { - err := instance.Close() - if err != nil { - return s.updateStatusError(err) - } + _ = instance.Close() } s.startedAt = time.Time{} s.updateStatus(ServiceStatus_IDLE) @@ -432,13 +442,19 @@ func (s *StartedService) SubscribeGroups(empty *emptypb.Empty, server grpc.Serve return err } defer s.urlTestObserver.UnSubscribe(subscription) + statusSubscription, statusDone, err := s.serviceStatusObserver.Subscribe() + if err != nil { + return err + } + defer s.serviceStatusObserver.UnSubscribe(statusSubscription) for { s.serviceAccess.RLock() - if s.serviceStatus.Status != ServiceStatus_STARTED { - s.serviceAccess.RUnlock() - return os.ErrInvalid + var groups *Groups + if s.serviceStatus.Status == ServiceStatus_STARTED { + groups = s.readGroups() + } else { + groups = &Groups{} } - groups := s.readGroups() s.serviceAccess.RUnlock() err = server.Send(groups) if err != nil { @@ -446,12 +462,15 @@ func (s *StartedService) SubscribeGroups(empty *emptypb.Empty, server grpc.Serve } select { case <-subscription: + case <-statusSubscription: case <-s.ctx.Done(): return s.ctx.Err() case <-server.Context().Done(): return server.Context().Err() case <-done: return nil + case <-statusDone: + return nil } } } @@ -529,18 +548,24 @@ func (s *StartedService) SubscribeClashMode(empty *emptypb.Empty, server grpc.Se return err } defer s.clashModeObserver.UnSubscribe(subscription) + statusSubscription, statusDone, err := s.serviceStatusObserver.Subscribe() + if err != nil { + return err + } + defer s.serviceStatusObserver.UnSubscribe(statusSubscription) for { s.serviceAccess.RLock() - if s.serviceStatus.Status != ServiceStatus_STARTED { - s.serviceAccess.RUnlock() - return os.ErrInvalid - } - clashServer := s.instance.clashServer - if clashServer == nil { - s.serviceAccess.RUnlock() - return status.Error(codes.NotFound, "clash mode not available") + var message *ClashMode + if s.serviceStatus.Status == ServiceStatus_STARTED { + clashServer := s.instance.clashServer + if clashServer == nil { + s.serviceAccess.RUnlock() + return status.Error(codes.NotFound, "clash mode not available") + } + message = &ClashMode{Mode: clashServer.Mode()} + } else { + message = &ClashMode{} } - message := &ClashMode{Mode: clashServer.Mode()} s.serviceAccess.RUnlock() err = server.Send(message) if err != nil { @@ -548,12 +573,15 @@ func (s *StartedService) SubscribeClashMode(empty *emptypb.Empty, server grpc.Se } select { case <-subscription: + case <-statusSubscription: case <-s.ctx.Done(): return s.ctx.Err() case <-server.Context().Done(): return server.Context().Err() case <-done: return nil + case <-statusDone: + return nil } } } @@ -581,21 +609,17 @@ func (s *StartedService) URLTest(ctx context.Context, request *URLTestRequest) ( } boxService := s.instance s.serviceAccess.RUnlock() - groupTag := request.OutboundTag - abstractOutboundGroup, isLoaded := boxService.outboundManager.Outbound(groupTag) + outboundTag := request.OutboundTag + outbound, isLoaded := boxService.outboundManager.Outbound(outboundTag) if !isLoaded { - return nil, status.Error(codes.NotFound, "outbound group not found: "+groupTag) + return nil, status.Error(codes.NotFound, "outbound not found: "+outboundTag) } - outboundGroup, isOutboundGroup := abstractOutboundGroup.(adapter.OutboundGroup) - if !isOutboundGroup { - return nil, status.Error(codes.InvalidArgument, "outbound is not a group: "+groupTag) - } - urlTest, isURLTest := abstractOutboundGroup.(*group.URLTest) + historyStorage := boxService.urlTestHistoryStorage + urlTest, isURLTest := outbound.(*group.URLTest) + outboundGroup, isOutboundGroup := outbound.(adapter.OutboundGroup) if isURLTest { go urlTest.CheckOutbounds() - } else { - historyStorage := boxService.urlTestHistoryStorage - + } else if isOutboundGroup { outbounds := common.Filter(common.Map(outboundGroup.All(), func(it string) adapter.Outbound { itOutbound, _ := boxService.outboundManager.Outbound(it) return itOutbound @@ -609,13 +633,13 @@ func (s *StartedService) URLTest(ctx context.Context, request *URLTestRequest) ( b, _ := batch.New(boxService.ctx, batch.WithConcurrencyNum[any](10)) for _, detour := range outbounds { outboundToTest := detour - outboundTag := outboundToTest.Tag() - b.Go(outboundTag, func() (any, error) { + itemTag := outboundToTest.Tag() + b.Go(itemTag, func() (any, error) { t, err := urltest.URLTest(boxService.ctx, "", outboundToTest) if err != nil { - historyStorage.DeleteURLTestHistory(outboundTag) + historyStorage.DeleteURLTestHistory(itemTag) } else { - historyStorage.StoreURLTestHistory(outboundTag, &adapter.URLTestHistory{ + historyStorage.StoreURLTestHistory(itemTag, &adapter.URLTestHistory{ Time: time.Now(), Delay: t, }) @@ -623,6 +647,18 @@ func (s *StartedService) URLTest(ctx context.Context, request *URLTestRequest) ( return nil, nil }) } + } else { + go func() { + t, err := urltest.URLTest(boxService.ctx, "", outbound) + if err != nil { + historyStorage.DeleteURLTestHistory(outboundTag) + } else { + historyStorage.StoreURLTestHistory(outboundTag, &adapter.URLTestHistory{ + Time: time.Now(), + Delay: t, + }) + } + }() } return &emptypb.Empty{}, nil } @@ -653,14 +689,11 @@ func (s *StartedService) SelectOutbound(ctx context.Context, request *SelectOutb func (s *StartedService) SetGroupExpand(ctx context.Context, request *SetGroupExpandRequest) (*emptypb.Empty, error) { s.serviceAccess.RLock() - switch s.serviceStatus.Status { - case ServiceStatus_STARTING, ServiceStatus_STARTED: - default: - s.serviceAccess.RUnlock() + defer s.serviceAccess.RUnlock() + if s.serviceStatus.Status != ServiceStatus_STARTED { return nil, os.ErrInvalid } boxService := s.instance - s.serviceAccess.RUnlock() if boxService.cacheFile != nil { err := boxService.cacheFile.StoreGroupExpand(request.GroupTag, request.IsExpand) if err != nil { @@ -1012,10 +1045,11 @@ func (s *StartedService) GetDeprecatedWarnings(ctx context.Context, empty *empty return &DeprecatedWarnings{}, nil } notes := manager.Get() + selectedLocale := locale.FromContext(ctx) return &DeprecatedWarnings{ Warnings: common.Map(notes, func(it deprecated.Note) *DeprecatedWarning { return &DeprecatedWarning{ - Message: it.Message(), + Message: it.MessageForLocale(selectedLocale), Impending: it.Impending(), MigrationLink: it.MigrationLink, Description: it.Description, @@ -1042,37 +1076,41 @@ func (s *StartedService) SubscribeOutbounds(_ *emptypb.Empty, server grpc.Server return err } defer s.urlTestObserver.UnSubscribe(subscription) + statusSubscription, statusDone, err := s.serviceStatusObserver.Subscribe() + if err != nil { + return err + } + defer s.serviceStatusObserver.UnSubscribe(statusSubscription) for { s.serviceAccess.RLock() - if s.serviceStatus.Status != ServiceStatus_STARTED { - s.serviceAccess.RUnlock() - return os.ErrInvalid - } boxService := s.instance + started := s.serviceStatus.Status == ServiceStatus_STARTED s.serviceAccess.RUnlock() - historyStorage := boxService.urlTestHistoryStorage var list OutboundList - for _, ob := range boxService.outboundManager.Outbounds() { - item := &GroupItem{ - Tag: ob.Tag(), - Type: ob.Type(), - } - if history := historyStorage.LoadURLTestHistory(adapter.OutboundTag(ob)); history != nil { - item.UrlTestTime = history.Time.Unix() - item.UrlTestDelay = int32(history.Delay) - } - list.Outbounds = append(list.Outbounds, item) - } - for _, ep := range boxService.endpointManager.Endpoints() { - item := &GroupItem{ - Tag: ep.Tag(), - Type: ep.Type(), + if started { + historyStorage := boxService.urlTestHistoryStorage + for _, ob := range boxService.outboundManager.Outbounds() { + item := &GroupItem{ + Tag: ob.Tag(), + Type: ob.Type(), + } + if history := historyStorage.LoadURLTestHistory(adapter.OutboundTag(ob)); history != nil { + item.UrlTestTime = history.Time.Unix() + item.UrlTestDelay = int32(history.Delay) + } + list.Outbounds = append(list.Outbounds, item) } - if history := historyStorage.LoadURLTestHistory(adapter.OutboundTag(ep)); history != nil { - item.UrlTestTime = history.Time.Unix() - item.UrlTestDelay = int32(history.Delay) + for _, ep := range boxService.endpointManager.Endpoints() { + item := &GroupItem{ + Tag: ep.Tag(), + Type: ep.Type(), + } + if history := historyStorage.LoadURLTestHistory(adapter.OutboundTag(ep)); history != nil { + item.UrlTestTime = history.Time.Unix() + item.UrlTestDelay = int32(history.Delay) + } + list.Outbounds = append(list.Outbounds, item) } - list.Outbounds = append(list.Outbounds, item) } err = server.Send(&list) if err != nil { @@ -1080,12 +1118,15 @@ func (s *StartedService) SubscribeOutbounds(_ *emptypb.Empty, server grpc.Server } select { case <-subscription: + case <-statusSubscription: case <-s.ctx.Done(): return s.ctx.Err() case <-server.Context().Done(): return server.Context().Err() case <-done: return nil + case <-statusDone: + return nil } } } @@ -1113,6 +1154,122 @@ func resolveTailscaleEndpoint(instance *Instance, tag string) (adapter.Endpoint, return endpoint, nil } +func NewNetworkQualityTestProgress(progress networkquality.Progress) *NetworkQualityTestProgress { + return &NetworkQualityTestProgress{ + Phase: int32(progress.Phase), + DownloadCapacity: progress.DownloadCapacity, + UploadCapacity: progress.UploadCapacity, + DownloadRPM: progress.DownloadRPM, + UploadRPM: progress.UploadRPM, + IdleLatencyMs: progress.IdleLatencyMs, + ElapsedMs: progress.ElapsedMs, + DownloadCapacityAccuracy: int32(progress.DownloadCapacityAccuracy), + UploadCapacityAccuracy: int32(progress.UploadCapacityAccuracy), + DownloadRPMAccuracy: int32(progress.DownloadRPMAccuracy), + UploadRPMAccuracy: int32(progress.UploadRPMAccuracy), + } +} + +func NewNetworkQualityTestResult(result *networkquality.Result) *NetworkQualityTestProgress { + return &NetworkQualityTestProgress{ + Phase: int32(networkquality.PhaseDone), + DownloadCapacity: result.DownloadCapacity, + UploadCapacity: result.UploadCapacity, + DownloadRPM: result.DownloadRPM, + UploadRPM: result.UploadRPM, + IdleLatencyMs: result.IdleLatencyMs, + IsFinal: true, + DownloadCapacityAccuracy: int32(result.DownloadCapacityAccuracy), + UploadCapacityAccuracy: int32(result.UploadCapacityAccuracy), + DownloadRPMAccuracy: int32(result.DownloadRPMAccuracy), + UploadRPMAccuracy: int32(result.UploadRPMAccuracy), + } +} + +func NewSTUNTestProgress(progress stun.Progress) *STUNTestProgress { + return &STUNTestProgress{ + Phase: int32(progress.Phase), + ExternalAddr: progress.ExternalAddr, + LatencyMs: progress.LatencyMs, + NatMapping: int32(progress.NATMapping), + NatFiltering: int32(progress.NATFiltering), + } +} + +func NewSTUNTestResult(result *stun.Result) *STUNTestProgress { + return &STUNTestProgress{ + Phase: int32(stun.PhaseDone), + ExternalAddr: result.ExternalAddr, + LatencyMs: result.LatencyMs, + NatMapping: int32(result.NATMapping), + NatFiltering: int32(result.NATFiltering), + IsFinal: true, + NatTypeSupported: result.NATTypeSupported, + } +} + +func resolveEndpoint[T adapter.Endpoint](instance *Instance, tag string, endpointType string, endpointName string) (T, error) { + var zero T + endpointManager := service.FromContext[adapter.EndpointManager](instance.ctx) + endpoint, loaded := endpointManager.Get(tag) + if !loaded { + return zero, status.Error(codes.NotFound, "endpoint not found: "+tag) + } + if endpoint.Type() != endpointType { + return zero, status.Error(codes.InvalidArgument, "endpoint is not "+endpointName+": "+tag) + } + return endpoint.(T), nil +} + +type endpointStatusProvider interface { + adapter.Endpoint + StatusUpdated() <-chan struct{} +} + +func subscribeEndpointStatus[T endpointStatusProvider](ctx context.Context, endpointManager adapter.EndpointManager, endpointType string, endpointName string, send func([]T) error) error { + var endpoints []T + for _, endpoint := range endpointManager.Endpoints() { + if endpoint.Type() == endpointType { + endpoints = append(endpoints, endpoint.(T)) + } + } + if len(endpoints) == 0 { + return status.Error(codes.NotFound, "no "+endpointName+" endpoint found") + } + + ctx, cancel := context.WithCancel(ctx) + defer cancel() + updated := make(chan struct{}, 1) + for _, endpoint := range endpoints { + go func(provider T) { + for { + statusUpdated := provider.StatusUpdated() + select { + case updated <- struct{}{}: + default: + } + select { + case <-statusUpdated: + case <-ctx.Done(): + return + } + } + }(endpoint) + } + + for { + select { + case <-updated: + case <-ctx.Done(): + return nil + } + err := send(endpoints) + if err != nil { + return err + } + } +} + func (s *StartedService) StartNetworkQualityTest( request *NetworkQualityTestRequest, server grpc.ServerStreamingServer[NetworkQualityTestProgress], @@ -1147,19 +1304,7 @@ func (s *StartedService) StartNetworkQualityTest( MaxRuntime: time.Duration(request.MaxRuntimeSeconds) * time.Second, Context: server.Context(), OnProgress: func(p networkquality.Progress) { - _ = server.Send(&NetworkQualityTestProgress{ - Phase: int32(p.Phase), - DownloadCapacity: p.DownloadCapacity, - UploadCapacity: p.UploadCapacity, - DownloadRPM: p.DownloadRPM, - UploadRPM: p.UploadRPM, - IdleLatencyMs: p.IdleLatencyMs, - ElapsedMs: p.ElapsedMs, - DownloadCapacityAccuracy: int32(p.DownloadCapacityAccuracy), - UploadCapacityAccuracy: int32(p.UploadCapacityAccuracy), - DownloadRPMAccuracy: int32(p.DownloadRPMAccuracy), - UploadRPMAccuracy: int32(p.UploadRPMAccuracy), - }) + _ = server.Send(NewNetworkQualityTestProgress(p)) }, }) if nqErr != nil { @@ -1168,19 +1313,7 @@ func (s *StartedService) StartNetworkQualityTest( Error: nqErr.Error(), }) } - return server.Send(&NetworkQualityTestProgress{ - Phase: int32(networkquality.PhaseDone), - DownloadCapacity: result.DownloadCapacity, - UploadCapacity: result.UploadCapacity, - DownloadRPM: result.DownloadRPM, - UploadRPM: result.UploadRPM, - IdleLatencyMs: result.IdleLatencyMs, - IsFinal: true, - DownloadCapacityAccuracy: int32(result.DownloadCapacityAccuracy), - UploadCapacityAccuracy: int32(result.UploadCapacityAccuracy), - DownloadRPMAccuracy: int32(result.DownloadRPMAccuracy), - UploadRPMAccuracy: int32(result.UploadRPMAccuracy), - }) + return server.Send(NewNetworkQualityTestResult(result)) } func (s *StartedService) StartSTUNTest( @@ -1207,13 +1340,7 @@ func (s *StartedService) StartSTUNTest( Dialer: resolvedDialer, Context: server.Context(), OnProgress: func(p stun.Progress) { - _ = server.Send(&STUNTestProgress{ - Phase: int32(p.Phase), - ExternalAddr: p.ExternalAddr, - LatencyMs: p.LatencyMs, - NatMapping: int32(p.NATMapping), - NatFiltering: int32(p.NATFiltering), - }) + _ = server.Send(NewSTUNTestProgress(p)) }, }) if stunErr != nil { @@ -1222,15 +1349,7 @@ func (s *StartedService) StartSTUNTest( Error: stunErr.Error(), }) } - return server.Send(&STUNTestProgress{ - Phase: int32(stun.PhaseDone), - ExternalAddr: result.ExternalAddr, - LatencyMs: result.LatencyMs, - NatMapping: int32(result.NATMapping), - NatFiltering: int32(result.NATFiltering), - IsFinal: true, - NatTypeSupported: result.NATTypeSupported, - }) + return server.Send(NewSTUNTestResult(result)) } func (s *StartedService) SubscribeTailscaleStatus( @@ -1301,6 +1420,7 @@ func (s *StartedService) SubscribeTailscaleStatus( var tags []string statuses := make(map[string]*adapter.TailscaleEndpointStatus, len(endpoints)) + selectedLocale := locale.FromContext(server.Context()) for update := range updates { if _, exists := statuses[update.tag]; !exists { tags = append(tags, update.tag) @@ -1308,7 +1428,7 @@ func (s *StartedService) SubscribeTailscaleStatus( statuses[update.tag] = update.status protoEndpoints := make([]*TailscaleEndpointStatus, 0, len(statuses)) for _, tag := range tags { - protoEndpoints = append(protoEndpoints, tailscaleEndpointStatusToProto(tag, statuses[tag])) + protoEndpoints = append(protoEndpoints, tailscaleEndpointStatusToProto(tag, statuses[tag], selectedLocale)) } sendErr := server.Send(&TailscaleStatusUpdate{ Endpoints: protoEndpoints, @@ -1320,7 +1440,7 @@ func (s *StartedService) SubscribeTailscaleStatus( return nil } -func tailscaleEndpointStatusToProto(tag string, s *adapter.TailscaleEndpointStatus) *TailscaleEndpointStatus { +func tailscaleEndpointStatusToProto(tag string, s *adapter.TailscaleEndpointStatus, selectedLocale *locale.Locale) *TailscaleEndpointStatus { userGroups := make([]*TailscaleUserGroup, len(s.UserGroups)) for i, group := range s.UserGroups { peers := make([]*TailscalePeer, len(group.Peers)) @@ -1338,6 +1458,7 @@ func tailscaleEndpointStatusToProto(tag string, s *adapter.TailscaleEndpointStat result := &TailscaleEndpointStatus{ EndpointTag: tag, BackendState: s.BackendState, + StateText: selectedLocale.TailscaleStateText(s.BackendState), AuthURL: s.AuthURL, NetworkName: s.NetworkName, MagicDNSSuffix: s.MagicDNSSuffix, @@ -1477,6 +1598,268 @@ func (s *StartedService) TailscaleLogout(ctx context.Context, request *Tailscale return &emptypb.Empty{}, nil } +func (s *StartedService) SubscribeOpenConnectStatus( + _ *emptypb.Empty, + server grpc.ServerStreamingServer[OpenConnectStatusUpdate], +) error { + err := s.waitForStarted(server.Context()) + if err != nil { + return err + } + s.serviceAccess.RLock() + boxService := s.instance + s.serviceAccess.RUnlock() + + endpointManager := service.FromContext[adapter.EndpointManager](boxService.ctx) + selectedLocale := locale.FromContext(server.Context()) + return subscribeEndpointStatus(server.Context(), endpointManager, C.TypeOpenConnect, "OpenConnect client", func(endpoints []adapter.OpenConnectEndpoint) error { + return server.Send(&OpenConnectStatusUpdate{ + Endpoints: common.Map(endpoints, func(endpoint adapter.OpenConnectEndpoint) *OpenConnectEndpointStatus { + return openConnectEndpointStatusToProto(endpoint.Tag(), endpoint.OpenConnectStatus(), selectedLocale) + }), + }) + }) +} + +func openConnectEndpointStatusToProto(tag string, endpointStatus adapter.OpenConnectStatus, selectedLocale *locale.Locale) *OpenConnectEndpointStatus { + result := &OpenConnectEndpointStatus{ + EndpointTag: tag, + State: endpointStatus.State, + StateText: selectedLocale.VPNStateText(endpointStatus.State), + Error: endpointStatus.Error, + TunnelInfo: openConnectTunnelInfoToProto(endpointStatus.TunnelInfo), + } + if endpointStatus.AuthChallenge != nil { + challenge := &OpenConnectAuthChallenge{ + Id: endpointStatus.AuthChallenge.ID, + Banner: endpointStatus.AuthChallenge.Banner, + Message: endpointStatus.AuthChallenge.Message, + Error: endpointStatus.AuthChallenge.Error, + } + if endpointStatus.AuthChallenge.Form != nil { + challenge.Challenge = &OpenConnectAuthChallenge_Form{Form: &OpenConnectAuthForm{ + Fields: common.Map(endpointStatus.AuthChallenge.Form.Fields, func(field adapter.OpenConnectAuthFormField) *OpenConnectAuthFormField { + return &OpenConnectAuthFormField{ + SubmissionKey: field.SubmissionKey, + Name: field.Name, + Label: field.Label, + Kind: field.Kind, + Value: field.Value, + Options: common.Map(field.Options, func(option adapter.OpenConnectAuthFormChoice) *OpenConnectAuthFormChoice { + return &OpenConnectAuthFormChoice{ + Value: option.Value, + Label: option.Label, + } + }), + } + }), + }} + } + if endpointStatus.AuthChallenge.Browser != nil { + challenge.Challenge = &OpenConnectAuthChallenge_Browser{Browser: &OpenConnectBrowserRequest{ + Url: endpointStatus.AuthChallenge.Browser.URL, + FinalURL: endpointStatus.AuthChallenge.Browser.FinalURL, + CookieNames: endpointStatus.AuthChallenge.Browser.CookieNames, + EarlyCookieNames: endpointStatus.AuthChallenge.Browser.EarlyCookieNames, + HeaderNames: endpointStatus.AuthChallenge.Browser.HeaderNames, + CallbackURLPrefixes: endpointStatus.AuthChallenge.Browser.CallbackURLPrefixes, + CacheID: endpointStatus.AuthChallenge.Browser.CacheID, + }} + } + result.AuthChallenge = challenge + } + return result +} + +func (s *StartedService) SubmitOpenConnectAuthResponse(ctx context.Context, request *OpenConnectAuthResponseSubmission) (*emptypb.Empty, error) { + err := s.waitForStarted(ctx) + if err != nil { + return nil, err + } + s.serviceAccess.RLock() + boxService := s.instance + s.serviceAccess.RUnlock() + + endpoint, err := resolveEndpoint[adapter.OpenConnectEndpoint](boxService, request.EndpointTag, C.TypeOpenConnect, "OpenConnect client") + if err != nil { + return nil, err + } + var authResponse adapter.OpenConnectAuthResponse + form := request.GetForm() + if form != nil { + authResponse.Form = &adapter.OpenConnectAuthFormResponse{Values: form.Values} + } + browser := request.GetBrowser() + if browser != nil { + authResponse.Browser = &adapter.OpenConnectBrowserResult{ + FinalURL: browser.FinalURL, + Cookies: common.Map(browser.Cookies, func(cookie *OpenConnectBrowserCookie) adapter.OpenConnectBrowserCookie { + return adapter.OpenConnectBrowserCookie{Name: cookie.Name, Value: cookie.Value} + }), + Headers: common.Map(browser.Headers, func(header *OpenConnectBrowserHeader) adapter.OpenConnectBrowserHeader { + return adapter.OpenConnectBrowserHeader{Name: header.Name, Values: header.Values} + }), + } + } + err = endpoint.CompleteAuthChallenge(request.ChallengeID, authResponse) + if err != nil { + return nil, err + } + return &emptypb.Empty{}, nil +} + +func (s *StartedService) CancelOpenConnectAuthChallenge(ctx context.Context, request *OpenConnectAuthChallengeCancel) (*emptypb.Empty, error) { + err := s.waitForStarted(ctx) + if err != nil { + return nil, err + } + s.serviceAccess.RLock() + boxService := s.instance + s.serviceAccess.RUnlock() + + endpoint, err := resolveEndpoint[adapter.OpenConnectEndpoint](boxService, request.EndpointTag, C.TypeOpenConnect, "OpenConnect client") + if err != nil { + return nil, err + } + err = endpoint.CancelAuthChallenge(request.ChallengeID) + if err != nil { + return nil, err + } + return &emptypb.Empty{}, nil +} + +func (s *StartedService) SubscribeOpenVPNStatus( + _ *emptypb.Empty, + server grpc.ServerStreamingServer[OpenVPNStatusUpdate], +) error { + err := s.waitForStarted(server.Context()) + if err != nil { + return err + } + s.serviceAccess.RLock() + boxService := s.instance + s.serviceAccess.RUnlock() + + endpointManager := service.FromContext[adapter.EndpointManager](boxService.ctx) + selectedLocale := locale.FromContext(server.Context()) + return subscribeEndpointStatus(server.Context(), endpointManager, C.TypeOpenVPNClient, "OpenVPN client", func(endpoints []adapter.OpenVPNEndpoint) error { + return server.Send(&OpenVPNStatusUpdate{ + Endpoints: common.Map(endpoints, func(endpoint adapter.OpenVPNEndpoint) *OpenVPNEndpointStatus { + return openVPNEndpointStatusToProto(endpoint.Tag(), endpoint.OpenVPNStatus(), selectedLocale) + }), + }) + }) +} + +func openVPNEndpointStatusToProto(tag string, endpointStatus adapter.OpenVPNStatus, selectedLocale *locale.Locale) *OpenVPNEndpointStatus { + result := &OpenVPNEndpointStatus{ + EndpointTag: tag, + State: endpointStatus.State, + StateText: selectedLocale.VPNStateText(endpointStatus.State), + Error: endpointStatus.Error, + TunnelInfo: openVPNTunnelInfoToProto(endpointStatus.TunnelInfo), + } + if endpointStatus.Challenge != nil { + challenge := &OpenVPNChallenge{ + Id: endpointStatus.Challenge.ID, + Kind: endpointStatus.Challenge.Kind, + Username: endpointStatus.Challenge.Username, + Message: endpointStatus.Challenge.Message, + Url: endpointStatus.Challenge.URL, + SecretMessage: endpointStatus.Challenge.SecretMessage, + Echo: endpointStatus.Challenge.Echo, + PreviousError: endpointStatus.Challenge.PreviousError, + } + if !endpointStatus.Challenge.Deadline.IsZero() { + challenge.Deadline = endpointStatus.Challenge.Deadline.Unix() + } + result.Challenge = challenge + } + return result +} + +func openConnectTunnelInfoToProto(tunnelInfo *adapter.OpenConnectTunnelInfo) *OpenConnectTunnelInfo { + if tunnelInfo == nil { + return nil + } + result := &OpenConnectTunnelInfo{ + Server: tunnelInfo.Server, + Flavor: tunnelInfo.Flavor, + Transport: tunnelInfo.Transport, + Mtu: tunnelInfo.MTU, + } + if !tunnelInfo.ConnectedSince.IsZero() { + result.ConnectedSince = tunnelInfo.ConnectedSince.Unix() + } + result.Ipv4 = common.Map(tunnelInfo.IPv4, netip.Prefix.String) + result.Ipv6 = common.Map(tunnelInfo.IPv6, netip.Prefix.String) + result.Dns = common.Map(tunnelInfo.DNS, netip.Addr.String) + return result +} + +func openVPNTunnelInfoToProto(tunnelInfo *adapter.OpenVPNTunnelInfo) *OpenVPNTunnelInfo { + if tunnelInfo == nil { + return nil + } + result := &OpenVPNTunnelInfo{ + Server: tunnelInfo.Server, + Network: tunnelInfo.Network, + Cipher: tunnelInfo.Cipher, + Mtu: tunnelInfo.MTU, + } + if !tunnelInfo.ConnectedSince.IsZero() { + result.ConnectedSince = tunnelInfo.ConnectedSince.Unix() + } + result.Ipv4 = common.Map(tunnelInfo.IPv4, netip.Prefix.String) + result.Ipv6 = common.Map(tunnelInfo.IPv6, netip.Prefix.String) + result.Dns = common.Map(tunnelInfo.DNS, netip.Addr.String) + return result +} + +func (s *StartedService) SubmitOpenVPNChallengeResponse(ctx context.Context, request *OpenVPNChallengeSubmission) (*emptypb.Empty, error) { + err := s.waitForStarted(ctx) + if err != nil { + return nil, err + } + s.serviceAccess.RLock() + boxService := s.instance + s.serviceAccess.RUnlock() + + endpoint, err := resolveEndpoint[adapter.OpenVPNEndpoint](boxService, request.EndpointTag, C.TypeOpenVPNClient, "OpenVPN client") + if err != nil { + return nil, err + } + err = endpoint.CompleteChallenge(request.ChallengeID, adapter.OpenVPNChallengeResponse{ + Username: request.Username, + Password: request.Password, + Secret: request.Secret, + }) + if err != nil { + return nil, err + } + return &emptypb.Empty{}, nil +} + +func (s *StartedService) CancelOpenVPNChallenge(ctx context.Context, request *OpenVPNChallengeCancel) (*emptypb.Empty, error) { + err := s.waitForStarted(ctx) + if err != nil { + return nil, err + } + s.serviceAccess.RLock() + boxService := s.instance + s.serviceAccess.RUnlock() + + endpoint, err := resolveEndpoint[adapter.OpenVPNEndpoint](boxService, request.EndpointTag, C.TypeOpenVPNClient, "OpenVPN client") + if err != nil { + return nil, err + } + err = endpoint.CancelChallenge(request.ChallengeID) + if err != nil { + return nil, err + } + return &emptypb.Empty{}, nil +} + func (s *StartedService) mustEmbedUnimplementedStartedServiceServer() { } diff --git a/daemon/started_service.pb.go b/daemon/started_service.pb.go index 8b0782a156..f3fb9192f8 100644 --- a/daemon/started_service.pb.go +++ b/daemon/started_service.pb.go @@ -2222,13 +2222,14 @@ type TailscaleEndpointStatus struct { state protoimpl.MessageState `protogen:"open.v1"` EndpointTag string `protobuf:"bytes,1,opt,name=endpointTag,proto3" json:"endpointTag,omitempty"` BackendState string `protobuf:"bytes,2,opt,name=backendState,proto3" json:"backendState,omitempty"` - AuthURL string `protobuf:"bytes,3,opt,name=authURL,proto3" json:"authURL,omitempty"` - NetworkName string `protobuf:"bytes,4,opt,name=networkName,proto3" json:"networkName,omitempty"` - MagicDNSSuffix string `protobuf:"bytes,5,opt,name=magicDNSSuffix,proto3" json:"magicDNSSuffix,omitempty"` - Self *TailscalePeer `protobuf:"bytes,6,opt,name=self,proto3" json:"self,omitempty"` - UserGroups []*TailscaleUserGroup `protobuf:"bytes,7,rep,name=userGroups,proto3" json:"userGroups,omitempty"` - ExitNode *TailscalePeer `protobuf:"bytes,8,opt,name=exitNode,proto3" json:"exitNode,omitempty"` - KeyAuth bool `protobuf:"varint,9,opt,name=keyAuth,proto3" json:"keyAuth,omitempty"` + StateText string `protobuf:"bytes,3,opt,name=stateText,proto3" json:"stateText,omitempty"` + AuthURL string `protobuf:"bytes,4,opt,name=authURL,proto3" json:"authURL,omitempty"` + NetworkName string `protobuf:"bytes,5,opt,name=networkName,proto3" json:"networkName,omitempty"` + MagicDNSSuffix string `protobuf:"bytes,6,opt,name=magicDNSSuffix,proto3" json:"magicDNSSuffix,omitempty"` + Self *TailscalePeer `protobuf:"bytes,7,opt,name=self,proto3" json:"self,omitempty"` + UserGroups []*TailscaleUserGroup `protobuf:"bytes,8,rep,name=userGroups,proto3" json:"userGroups,omitempty"` + ExitNode *TailscalePeer `protobuf:"bytes,9,opt,name=exitNode,proto3" json:"exitNode,omitempty"` + KeyAuth bool `protobuf:"varint,10,opt,name=keyAuth,proto3" json:"keyAuth,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -2277,6 +2278,13 @@ func (x *TailscaleEndpointStatus) GetBackendState() string { return "" } +func (x *TailscaleEndpointStatus) GetStateText() string { + if x != nil { + return x.StateText + } + return "" +} + func (x *TailscaleEndpointStatus) GetAuthURL() string { if x != nil { return x.AuthURL @@ -4618,28 +4626,27 @@ func (x *USBSharedDevice) GetState() USBDeviceState { return USBDeviceState_USB_DEVICE_STATE_IDLE } -type Log_Message struct { - state protoimpl.MessageState `protogen:"open.v1"` - Level LogLevel `protobuf:"varint,1,opt,name=level,proto3,enum=daemon.LogLevel" json:"level,omitempty"` - Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` +type OpenConnectStatusUpdate struct { + state protoimpl.MessageState `protogen:"open.v1"` + Endpoints []*OpenConnectEndpointStatus `protobuf:"bytes,1,rep,name=endpoints,proto3" json:"endpoints,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *Log_Message) Reset() { - *x = Log_Message{} +func (x *OpenConnectStatusUpdate) Reset() { + *x = OpenConnectStatusUpdate{} mi := &file_daemon_started_service_proto_msgTypes[61] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *Log_Message) String() string { +func (x *OpenConnectStatusUpdate) String() string { return protoimpl.X.MessageStringOf(x) } -func (*Log_Message) ProtoMessage() {} +func (*OpenConnectStatusUpdate) ProtoMessage() {} -func (x *Log_Message) ProtoReflect() protoreflect.Message { +func (x *OpenConnectStatusUpdate) ProtoReflect() protoreflect.Message { mi := &file_daemon_started_service_proto_msgTypes[61] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -4651,95 +4658,1532 @@ func (x *Log_Message) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use Log_Message.ProtoReflect.Descriptor instead. -func (*Log_Message) Descriptor() ([]byte, []int) { - return file_daemon_started_service_proto_rawDescGZIP(), []int{3, 0} +// Deprecated: Use OpenConnectStatusUpdate.ProtoReflect.Descriptor instead. +func (*OpenConnectStatusUpdate) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{61} } -func (x *Log_Message) GetLevel() LogLevel { +func (x *OpenConnectStatusUpdate) GetEndpoints() []*OpenConnectEndpointStatus { if x != nil { - return x.Level + return x.Endpoints } - return LogLevel_PANIC + return nil } -func (x *Log_Message) GetMessage() string { +type OpenConnectEndpointStatus struct { + state protoimpl.MessageState `protogen:"open.v1"` + EndpointTag string `protobuf:"bytes,1,opt,name=endpointTag,proto3" json:"endpointTag,omitempty"` + State string `protobuf:"bytes,2,opt,name=state,proto3" json:"state,omitempty"` + StateText string `protobuf:"bytes,3,opt,name=stateText,proto3" json:"stateText,omitempty"` + AuthChallenge *OpenConnectAuthChallenge `protobuf:"bytes,4,opt,name=authChallenge,proto3" json:"authChallenge,omitempty"` + Error string `protobuf:"bytes,5,opt,name=error,proto3" json:"error,omitempty"` + TunnelInfo *OpenConnectTunnelInfo `protobuf:"bytes,6,opt,name=tunnelInfo,proto3" json:"tunnelInfo,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OpenConnectEndpointStatus) Reset() { + *x = OpenConnectEndpointStatus{} + mi := &file_daemon_started_service_proto_msgTypes[62] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OpenConnectEndpointStatus) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpenConnectEndpointStatus) ProtoMessage() {} + +func (x *OpenConnectEndpointStatus) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[62] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OpenConnectEndpointStatus.ProtoReflect.Descriptor instead. +func (*OpenConnectEndpointStatus) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{62} +} + +func (x *OpenConnectEndpointStatus) GetEndpointTag() string { + if x != nil { + return x.EndpointTag + } + return "" +} + +func (x *OpenConnectEndpointStatus) GetState() string { + if x != nil { + return x.State + } + return "" +} + +func (x *OpenConnectEndpointStatus) GetStateText() string { + if x != nil { + return x.StateText + } + return "" +} + +func (x *OpenConnectEndpointStatus) GetAuthChallenge() *OpenConnectAuthChallenge { + if x != nil { + return x.AuthChallenge + } + return nil +} + +func (x *OpenConnectEndpointStatus) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +func (x *OpenConnectEndpointStatus) GetTunnelInfo() *OpenConnectTunnelInfo { + if x != nil { + return x.TunnelInfo + } + return nil +} + +type OpenConnectTunnelInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + Server string `protobuf:"bytes,1,opt,name=server,proto3" json:"server,omitempty"` + Flavor string `protobuf:"bytes,2,opt,name=flavor,proto3" json:"flavor,omitempty"` + Transport string `protobuf:"bytes,3,opt,name=transport,proto3" json:"transport,omitempty"` + Ipv4 []string `protobuf:"bytes,4,rep,name=ipv4,proto3" json:"ipv4,omitempty"` + Ipv6 []string `protobuf:"bytes,5,rep,name=ipv6,proto3" json:"ipv6,omitempty"` + Dns []string `protobuf:"bytes,6,rep,name=dns,proto3" json:"dns,omitempty"` + Mtu uint32 `protobuf:"varint,7,opt,name=mtu,proto3" json:"mtu,omitempty"` + ConnectedSince int64 `protobuf:"varint,8,opt,name=connectedSince,proto3" json:"connectedSince,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OpenConnectTunnelInfo) Reset() { + *x = OpenConnectTunnelInfo{} + mi := &file_daemon_started_service_proto_msgTypes[63] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OpenConnectTunnelInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpenConnectTunnelInfo) ProtoMessage() {} + +func (x *OpenConnectTunnelInfo) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[63] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OpenConnectTunnelInfo.ProtoReflect.Descriptor instead. +func (*OpenConnectTunnelInfo) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{63} +} + +func (x *OpenConnectTunnelInfo) GetServer() string { + if x != nil { + return x.Server + } + return "" +} + +func (x *OpenConnectTunnelInfo) GetFlavor() string { + if x != nil { + return x.Flavor + } + return "" +} + +func (x *OpenConnectTunnelInfo) GetTransport() string { + if x != nil { + return x.Transport + } + return "" +} + +func (x *OpenConnectTunnelInfo) GetIpv4() []string { + if x != nil { + return x.Ipv4 + } + return nil +} + +func (x *OpenConnectTunnelInfo) GetIpv6() []string { + if x != nil { + return x.Ipv6 + } + return nil +} + +func (x *OpenConnectTunnelInfo) GetDns() []string { + if x != nil { + return x.Dns + } + return nil +} + +func (x *OpenConnectTunnelInfo) GetMtu() uint32 { + if x != nil { + return x.Mtu + } + return 0 +} + +func (x *OpenConnectTunnelInfo) GetConnectedSince() int64 { + if x != nil { + return x.ConnectedSince + } + return 0 +} + +type OpenConnectAuthChallenge struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Banner string `protobuf:"bytes,2,opt,name=banner,proto3" json:"banner,omitempty"` + Message string `protobuf:"bytes,3,opt,name=message,proto3" json:"message,omitempty"` + Error string `protobuf:"bytes,4,opt,name=error,proto3" json:"error,omitempty"` + // Types that are valid to be assigned to Challenge: + // + // *OpenConnectAuthChallenge_Form + // *OpenConnectAuthChallenge_Browser + Challenge isOpenConnectAuthChallenge_Challenge `protobuf_oneof:"challenge"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OpenConnectAuthChallenge) Reset() { + *x = OpenConnectAuthChallenge{} + mi := &file_daemon_started_service_proto_msgTypes[64] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OpenConnectAuthChallenge) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpenConnectAuthChallenge) ProtoMessage() {} + +func (x *OpenConnectAuthChallenge) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[64] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OpenConnectAuthChallenge.ProtoReflect.Descriptor instead. +func (*OpenConnectAuthChallenge) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{64} +} + +func (x *OpenConnectAuthChallenge) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *OpenConnectAuthChallenge) GetBanner() string { + if x != nil { + return x.Banner + } + return "" +} + +func (x *OpenConnectAuthChallenge) GetMessage() string { if x != nil { return x.Message } return "" } -var File_daemon_started_service_proto protoreflect.FileDescriptor +func (x *OpenConnectAuthChallenge) GetError() string { + if x != nil { + return x.Error + } + return "" +} -const file_daemon_started_service_proto_rawDesc = "" + - "\n" + - "\x1cdaemon/started_service.proto\x12\x06daemon\x1a\x1bgoogle/protobuf/empty.proto\"C\n" + - "\aVersion\x12\x18\n" + - "\aversion\x18\x01 \x01(\tR\aversion\x12\x1e\n" + - "\n" + - "apiVersion\x18\x02 \x01(\x05R\n" + - "apiVersion\"\xad\x01\n" + - "\rServiceStatus\x122\n" + - "\x06status\x18\x01 \x01(\x0e2\x1a.daemon.ServiceStatus.TypeR\x06status\x12\"\n" + - "\ferrorMessage\x18\x02 \x01(\tR\ferrorMessage\"D\n" + - "\x04Type\x12\b\n" + - "\x04IDLE\x10\x00\x12\f\n" + - "\bSTARTING\x10\x01\x12\v\n" + - "\aSTARTED\x10\x02\x12\f\n" + - "\bSTOPPING\x10\x03\x12\t\n" + - "\x05FATAL\x10\x04\"4\n" + - "\x16SubscribeStatusRequest\x12\x1a\n" + - "\binterval\x18\x01 \x01(\x03R\binterval\"\x99\x01\n" + - "\x03Log\x12/\n" + - "\bmessages\x18\x01 \x03(\v2\x13.daemon.Log.MessageR\bmessages\x12\x14\n" + - "\x05reset\x18\x02 \x01(\bR\x05reset\x1aK\n" + - "\aMessage\x12&\n" + - "\x05level\x18\x01 \x01(\x0e2\x10.daemon.LogLevelR\x05level\x12\x18\n" + - "\amessage\x18\x02 \x01(\tR\amessage\"9\n" + - "\x0fDefaultLogLevel\x12&\n" + - "\x05level\x18\x01 \x01(\x0e2\x10.daemon.LogLevelR\x05level\"\xb6\x02\n" + - "\x06Status\x12\x16\n" + - "\x06memory\x18\x01 \x01(\x04R\x06memory\x12\x1e\n" + - "\n" + - "goroutines\x18\x02 \x01(\x05R\n" + - "goroutines\x12$\n" + - "\rconnectionsIn\x18\x03 \x01(\x05R\rconnectionsIn\x12&\n" + - "\x0econnectionsOut\x18\x04 \x01(\x05R\x0econnectionsOut\x12*\n" + - "\x10trafficAvailable\x18\x05 \x01(\bR\x10trafficAvailable\x12\x16\n" + - "\x06uplink\x18\x06 \x01(\x03R\x06uplink\x12\x1a\n" + - "\bdownlink\x18\a \x01(\x03R\bdownlink\x12 \n" + - "\vuplinkTotal\x18\b \x01(\x03R\vuplinkTotal\x12$\n" + - "\rdownlinkTotal\x18\t \x01(\x03R\rdownlinkTotal\"-\n" + - "\x06Groups\x12#\n" + - "\x05group\x18\x01 \x03(\v2\r.daemon.GroupR\x05group\"\xae\x01\n" + - "\x05Group\x12\x10\n" + - "\x03tag\x18\x01 \x01(\tR\x03tag\x12\x12\n" + - "\x04type\x18\x02 \x01(\tR\x04type\x12\x1e\n" + - "\n" + - "selectable\x18\x03 \x01(\bR\n" + - "selectable\x12\x1a\n" + - "\bselected\x18\x04 \x01(\tR\bselected\x12\x1a\n" + - "\bisExpand\x18\x05 \x01(\bR\bisExpand\x12'\n" + - "\x05items\x18\x06 \x03(\v2\x11.daemon.GroupItemR\x05items\"w\n" + - "\tGroupItem\x12\x10\n" + - "\x03tag\x18\x01 \x01(\tR\x03tag\x12\x12\n" + - "\x04type\x18\x02 \x01(\tR\x04type\x12 \n" + - "\vurlTestTime\x18\x03 \x01(\x03R\vurlTestTime\x12\"\n" + - "\furlTestDelay\x18\x04 \x01(\x05R\furlTestDelay\"2\n" + - "\x0eURLTestRequest\x12 \n" + - "\voutboundTag\x18\x01 \x01(\tR\voutboundTag\"U\n" + - "\x15SelectOutboundRequest\x12\x1a\n" + - "\bgroupTag\x18\x01 \x01(\tR\bgroupTag\x12 \n" + - "\voutboundTag\x18\x02 \x01(\tR\voutboundTag\"O\n" + - "\x15SetGroupExpandRequest\x12\x1a\n" + - "\bgroupTag\x18\x01 \x01(\tR\bgroupTag\x12\x1a\n" + - "\bisExpand\x18\x02 \x01(\bR\bisExpand\"\x1f\n" + - "\tClashMode\x12\x12\n" + - "\x04mode\x18\x03 \x01(\tR\x04mode\"O\n" + - "\x0fClashModeStatus\x12\x1a\n" + - "\bmodeList\x18\x01 \x03(\tR\bmodeList\x12 \n" + - "\vcurrentMode\x18\x02 \x01(\tR\vcurrentMode\"9\n" + +func (x *OpenConnectAuthChallenge) GetChallenge() isOpenConnectAuthChallenge_Challenge { + if x != nil { + return x.Challenge + } + return nil +} + +func (x *OpenConnectAuthChallenge) GetForm() *OpenConnectAuthForm { + if x != nil { + if x, ok := x.Challenge.(*OpenConnectAuthChallenge_Form); ok { + return x.Form + } + } + return nil +} + +func (x *OpenConnectAuthChallenge) GetBrowser() *OpenConnectBrowserRequest { + if x != nil { + if x, ok := x.Challenge.(*OpenConnectAuthChallenge_Browser); ok { + return x.Browser + } + } + return nil +} + +type isOpenConnectAuthChallenge_Challenge interface { + isOpenConnectAuthChallenge_Challenge() +} + +type OpenConnectAuthChallenge_Form struct { + Form *OpenConnectAuthForm `protobuf:"bytes,5,opt,name=form,proto3,oneof"` +} + +type OpenConnectAuthChallenge_Browser struct { + Browser *OpenConnectBrowserRequest `protobuf:"bytes,6,opt,name=browser,proto3,oneof"` +} + +func (*OpenConnectAuthChallenge_Form) isOpenConnectAuthChallenge_Challenge() {} + +func (*OpenConnectAuthChallenge_Browser) isOpenConnectAuthChallenge_Challenge() {} + +type OpenConnectAuthForm struct { + state protoimpl.MessageState `protogen:"open.v1"` + Fields []*OpenConnectAuthFormField `protobuf:"bytes,1,rep,name=fields,proto3" json:"fields,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OpenConnectAuthForm) Reset() { + *x = OpenConnectAuthForm{} + mi := &file_daemon_started_service_proto_msgTypes[65] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OpenConnectAuthForm) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpenConnectAuthForm) ProtoMessage() {} + +func (x *OpenConnectAuthForm) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[65] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OpenConnectAuthForm.ProtoReflect.Descriptor instead. +func (*OpenConnectAuthForm) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{65} +} + +func (x *OpenConnectAuthForm) GetFields() []*OpenConnectAuthFormField { + if x != nil { + return x.Fields + } + return nil +} + +type OpenConnectAuthFormField struct { + state protoimpl.MessageState `protogen:"open.v1"` + SubmissionKey string `protobuf:"bytes,1,opt,name=submissionKey,proto3" json:"submissionKey,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Label string `protobuf:"bytes,3,opt,name=label,proto3" json:"label,omitempty"` + Kind string `protobuf:"bytes,4,opt,name=kind,proto3" json:"kind,omitempty"` + Value string `protobuf:"bytes,5,opt,name=value,proto3" json:"value,omitempty"` + Options []*OpenConnectAuthFormChoice `protobuf:"bytes,6,rep,name=options,proto3" json:"options,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OpenConnectAuthFormField) Reset() { + *x = OpenConnectAuthFormField{} + mi := &file_daemon_started_service_proto_msgTypes[66] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OpenConnectAuthFormField) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpenConnectAuthFormField) ProtoMessage() {} + +func (x *OpenConnectAuthFormField) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[66] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OpenConnectAuthFormField.ProtoReflect.Descriptor instead. +func (*OpenConnectAuthFormField) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{66} +} + +func (x *OpenConnectAuthFormField) GetSubmissionKey() string { + if x != nil { + return x.SubmissionKey + } + return "" +} + +func (x *OpenConnectAuthFormField) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *OpenConnectAuthFormField) GetLabel() string { + if x != nil { + return x.Label + } + return "" +} + +func (x *OpenConnectAuthFormField) GetKind() string { + if x != nil { + return x.Kind + } + return "" +} + +func (x *OpenConnectAuthFormField) GetValue() string { + if x != nil { + return x.Value + } + return "" +} + +func (x *OpenConnectAuthFormField) GetOptions() []*OpenConnectAuthFormChoice { + if x != nil { + return x.Options + } + return nil +} + +type OpenConnectAuthFormChoice struct { + state protoimpl.MessageState `protogen:"open.v1"` + Value string `protobuf:"bytes,1,opt,name=value,proto3" json:"value,omitempty"` + Label string `protobuf:"bytes,2,opt,name=label,proto3" json:"label,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OpenConnectAuthFormChoice) Reset() { + *x = OpenConnectAuthFormChoice{} + mi := &file_daemon_started_service_proto_msgTypes[67] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OpenConnectAuthFormChoice) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpenConnectAuthFormChoice) ProtoMessage() {} + +func (x *OpenConnectAuthFormChoice) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[67] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OpenConnectAuthFormChoice.ProtoReflect.Descriptor instead. +func (*OpenConnectAuthFormChoice) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{67} +} + +func (x *OpenConnectAuthFormChoice) GetValue() string { + if x != nil { + return x.Value + } + return "" +} + +func (x *OpenConnectAuthFormChoice) GetLabel() string { + if x != nil { + return x.Label + } + return "" +} + +type OpenConnectBrowserRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"` + FinalURL string `protobuf:"bytes,2,opt,name=finalURL,proto3" json:"finalURL,omitempty"` + CookieNames []string `protobuf:"bytes,3,rep,name=cookieNames,proto3" json:"cookieNames,omitempty"` + HeaderNames []string `protobuf:"bytes,4,rep,name=headerNames,proto3" json:"headerNames,omitempty"` + CallbackURLPrefixes []string `protobuf:"bytes,5,rep,name=callbackURLPrefixes,proto3" json:"callbackURLPrefixes,omitempty"` + EarlyCookieNames []string `protobuf:"bytes,6,rep,name=earlyCookieNames,proto3" json:"earlyCookieNames,omitempty"` + CacheID string `protobuf:"bytes,7,opt,name=cacheID,proto3" json:"cacheID,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OpenConnectBrowserRequest) Reset() { + *x = OpenConnectBrowserRequest{} + mi := &file_daemon_started_service_proto_msgTypes[68] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OpenConnectBrowserRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpenConnectBrowserRequest) ProtoMessage() {} + +func (x *OpenConnectBrowserRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[68] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OpenConnectBrowserRequest.ProtoReflect.Descriptor instead. +func (*OpenConnectBrowserRequest) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{68} +} + +func (x *OpenConnectBrowserRequest) GetUrl() string { + if x != nil { + return x.Url + } + return "" +} + +func (x *OpenConnectBrowserRequest) GetFinalURL() string { + if x != nil { + return x.FinalURL + } + return "" +} + +func (x *OpenConnectBrowserRequest) GetCookieNames() []string { + if x != nil { + return x.CookieNames + } + return nil +} + +func (x *OpenConnectBrowserRequest) GetHeaderNames() []string { + if x != nil { + return x.HeaderNames + } + return nil +} + +func (x *OpenConnectBrowserRequest) GetCallbackURLPrefixes() []string { + if x != nil { + return x.CallbackURLPrefixes + } + return nil +} + +func (x *OpenConnectBrowserRequest) GetEarlyCookieNames() []string { + if x != nil { + return x.EarlyCookieNames + } + return nil +} + +func (x *OpenConnectBrowserRequest) GetCacheID() string { + if x != nil { + return x.CacheID + } + return "" +} + +type OpenConnectBrowserCookie struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OpenConnectBrowserCookie) Reset() { + *x = OpenConnectBrowserCookie{} + mi := &file_daemon_started_service_proto_msgTypes[69] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OpenConnectBrowserCookie) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpenConnectBrowserCookie) ProtoMessage() {} + +func (x *OpenConnectBrowserCookie) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[69] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OpenConnectBrowserCookie.ProtoReflect.Descriptor instead. +func (*OpenConnectBrowserCookie) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{69} +} + +func (x *OpenConnectBrowserCookie) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *OpenConnectBrowserCookie) GetValue() string { + if x != nil { + return x.Value + } + return "" +} + +type OpenConnectBrowserHeader struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Values []string `protobuf:"bytes,2,rep,name=values,proto3" json:"values,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OpenConnectBrowserHeader) Reset() { + *x = OpenConnectBrowserHeader{} + mi := &file_daemon_started_service_proto_msgTypes[70] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OpenConnectBrowserHeader) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpenConnectBrowserHeader) ProtoMessage() {} + +func (x *OpenConnectBrowserHeader) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[70] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OpenConnectBrowserHeader.ProtoReflect.Descriptor instead. +func (*OpenConnectBrowserHeader) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{70} +} + +func (x *OpenConnectBrowserHeader) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *OpenConnectBrowserHeader) GetValues() []string { + if x != nil { + return x.Values + } + return nil +} + +type OpenConnectAuthFormResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Values map[string]string `protobuf:"bytes,1,rep,name=values,proto3" json:"values,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OpenConnectAuthFormResponse) Reset() { + *x = OpenConnectAuthFormResponse{} + mi := &file_daemon_started_service_proto_msgTypes[71] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OpenConnectAuthFormResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpenConnectAuthFormResponse) ProtoMessage() {} + +func (x *OpenConnectAuthFormResponse) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[71] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OpenConnectAuthFormResponse.ProtoReflect.Descriptor instead. +func (*OpenConnectAuthFormResponse) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{71} +} + +func (x *OpenConnectAuthFormResponse) GetValues() map[string]string { + if x != nil { + return x.Values + } + return nil +} + +type OpenConnectBrowserResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + FinalURL string `protobuf:"bytes,1,opt,name=finalURL,proto3" json:"finalURL,omitempty"` + Cookies []*OpenConnectBrowserCookie `protobuf:"bytes,2,rep,name=cookies,proto3" json:"cookies,omitempty"` + Headers []*OpenConnectBrowserHeader `protobuf:"bytes,3,rep,name=headers,proto3" json:"headers,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OpenConnectBrowserResult) Reset() { + *x = OpenConnectBrowserResult{} + mi := &file_daemon_started_service_proto_msgTypes[72] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OpenConnectBrowserResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpenConnectBrowserResult) ProtoMessage() {} + +func (x *OpenConnectBrowserResult) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[72] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OpenConnectBrowserResult.ProtoReflect.Descriptor instead. +func (*OpenConnectBrowserResult) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{72} +} + +func (x *OpenConnectBrowserResult) GetFinalURL() string { + if x != nil { + return x.FinalURL + } + return "" +} + +func (x *OpenConnectBrowserResult) GetCookies() []*OpenConnectBrowserCookie { + if x != nil { + return x.Cookies + } + return nil +} + +func (x *OpenConnectBrowserResult) GetHeaders() []*OpenConnectBrowserHeader { + if x != nil { + return x.Headers + } + return nil +} + +type OpenConnectAuthResponseSubmission struct { + state protoimpl.MessageState `protogen:"open.v1"` + EndpointTag string `protobuf:"bytes,1,opt,name=endpointTag,proto3" json:"endpointTag,omitempty"` + ChallengeID string `protobuf:"bytes,2,opt,name=challengeID,proto3" json:"challengeID,omitempty"` + // Types that are valid to be assigned to Response: + // + // *OpenConnectAuthResponseSubmission_Form + // *OpenConnectAuthResponseSubmission_Browser + Response isOpenConnectAuthResponseSubmission_Response `protobuf_oneof:"response"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OpenConnectAuthResponseSubmission) Reset() { + *x = OpenConnectAuthResponseSubmission{} + mi := &file_daemon_started_service_proto_msgTypes[73] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OpenConnectAuthResponseSubmission) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpenConnectAuthResponseSubmission) ProtoMessage() {} + +func (x *OpenConnectAuthResponseSubmission) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[73] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OpenConnectAuthResponseSubmission.ProtoReflect.Descriptor instead. +func (*OpenConnectAuthResponseSubmission) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{73} +} + +func (x *OpenConnectAuthResponseSubmission) GetEndpointTag() string { + if x != nil { + return x.EndpointTag + } + return "" +} + +func (x *OpenConnectAuthResponseSubmission) GetChallengeID() string { + if x != nil { + return x.ChallengeID + } + return "" +} + +func (x *OpenConnectAuthResponseSubmission) GetResponse() isOpenConnectAuthResponseSubmission_Response { + if x != nil { + return x.Response + } + return nil +} + +func (x *OpenConnectAuthResponseSubmission) GetForm() *OpenConnectAuthFormResponse { + if x != nil { + if x, ok := x.Response.(*OpenConnectAuthResponseSubmission_Form); ok { + return x.Form + } + } + return nil +} + +func (x *OpenConnectAuthResponseSubmission) GetBrowser() *OpenConnectBrowserResult { + if x != nil { + if x, ok := x.Response.(*OpenConnectAuthResponseSubmission_Browser); ok { + return x.Browser + } + } + return nil +} + +type isOpenConnectAuthResponseSubmission_Response interface { + isOpenConnectAuthResponseSubmission_Response() +} + +type OpenConnectAuthResponseSubmission_Form struct { + Form *OpenConnectAuthFormResponse `protobuf:"bytes,3,opt,name=form,proto3,oneof"` +} + +type OpenConnectAuthResponseSubmission_Browser struct { + Browser *OpenConnectBrowserResult `protobuf:"bytes,4,opt,name=browser,proto3,oneof"` +} + +func (*OpenConnectAuthResponseSubmission_Form) isOpenConnectAuthResponseSubmission_Response() {} + +func (*OpenConnectAuthResponseSubmission_Browser) isOpenConnectAuthResponseSubmission_Response() {} + +type OpenConnectAuthChallengeCancel struct { + state protoimpl.MessageState `protogen:"open.v1"` + EndpointTag string `protobuf:"bytes,1,opt,name=endpointTag,proto3" json:"endpointTag,omitempty"` + ChallengeID string `protobuf:"bytes,2,opt,name=challengeID,proto3" json:"challengeID,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OpenConnectAuthChallengeCancel) Reset() { + *x = OpenConnectAuthChallengeCancel{} + mi := &file_daemon_started_service_proto_msgTypes[74] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OpenConnectAuthChallengeCancel) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpenConnectAuthChallengeCancel) ProtoMessage() {} + +func (x *OpenConnectAuthChallengeCancel) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[74] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OpenConnectAuthChallengeCancel.ProtoReflect.Descriptor instead. +func (*OpenConnectAuthChallengeCancel) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{74} +} + +func (x *OpenConnectAuthChallengeCancel) GetEndpointTag() string { + if x != nil { + return x.EndpointTag + } + return "" +} + +func (x *OpenConnectAuthChallengeCancel) GetChallengeID() string { + if x != nil { + return x.ChallengeID + } + return "" +} + +type OpenVPNStatusUpdate struct { + state protoimpl.MessageState `protogen:"open.v1"` + Endpoints []*OpenVPNEndpointStatus `protobuf:"bytes,1,rep,name=endpoints,proto3" json:"endpoints,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OpenVPNStatusUpdate) Reset() { + *x = OpenVPNStatusUpdate{} + mi := &file_daemon_started_service_proto_msgTypes[75] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OpenVPNStatusUpdate) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpenVPNStatusUpdate) ProtoMessage() {} + +func (x *OpenVPNStatusUpdate) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[75] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OpenVPNStatusUpdate.ProtoReflect.Descriptor instead. +func (*OpenVPNStatusUpdate) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{75} +} + +func (x *OpenVPNStatusUpdate) GetEndpoints() []*OpenVPNEndpointStatus { + if x != nil { + return x.Endpoints + } + return nil +} + +type OpenVPNEndpointStatus struct { + state protoimpl.MessageState `protogen:"open.v1"` + EndpointTag string `protobuf:"bytes,1,opt,name=endpointTag,proto3" json:"endpointTag,omitempty"` + State string `protobuf:"bytes,2,opt,name=state,proto3" json:"state,omitempty"` + StateText string `protobuf:"bytes,3,opt,name=stateText,proto3" json:"stateText,omitempty"` + Challenge *OpenVPNChallenge `protobuf:"bytes,4,opt,name=challenge,proto3" json:"challenge,omitempty"` + Error string `protobuf:"bytes,5,opt,name=error,proto3" json:"error,omitempty"` + TunnelInfo *OpenVPNTunnelInfo `protobuf:"bytes,6,opt,name=tunnelInfo,proto3" json:"tunnelInfo,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OpenVPNEndpointStatus) Reset() { + *x = OpenVPNEndpointStatus{} + mi := &file_daemon_started_service_proto_msgTypes[76] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OpenVPNEndpointStatus) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpenVPNEndpointStatus) ProtoMessage() {} + +func (x *OpenVPNEndpointStatus) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[76] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OpenVPNEndpointStatus.ProtoReflect.Descriptor instead. +func (*OpenVPNEndpointStatus) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{76} +} + +func (x *OpenVPNEndpointStatus) GetEndpointTag() string { + if x != nil { + return x.EndpointTag + } + return "" +} + +func (x *OpenVPNEndpointStatus) GetState() string { + if x != nil { + return x.State + } + return "" +} + +func (x *OpenVPNEndpointStatus) GetStateText() string { + if x != nil { + return x.StateText + } + return "" +} + +func (x *OpenVPNEndpointStatus) GetChallenge() *OpenVPNChallenge { + if x != nil { + return x.Challenge + } + return nil +} + +func (x *OpenVPNEndpointStatus) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +func (x *OpenVPNEndpointStatus) GetTunnelInfo() *OpenVPNTunnelInfo { + if x != nil { + return x.TunnelInfo + } + return nil +} + +type OpenVPNTunnelInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + Server string `protobuf:"bytes,1,opt,name=server,proto3" json:"server,omitempty"` + Network string `protobuf:"bytes,3,opt,name=network,proto3" json:"network,omitempty"` + Ipv4 []string `protobuf:"bytes,4,rep,name=ipv4,proto3" json:"ipv4,omitempty"` + Ipv6 []string `protobuf:"bytes,5,rep,name=ipv6,proto3" json:"ipv6,omitempty"` + Dns []string `protobuf:"bytes,6,rep,name=dns,proto3" json:"dns,omitempty"` + Mtu uint32 `protobuf:"varint,7,opt,name=mtu,proto3" json:"mtu,omitempty"` + ConnectedSince int64 `protobuf:"varint,8,opt,name=connectedSince,proto3" json:"connectedSince,omitempty"` + Cipher string `protobuf:"bytes,9,opt,name=cipher,proto3" json:"cipher,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OpenVPNTunnelInfo) Reset() { + *x = OpenVPNTunnelInfo{} + mi := &file_daemon_started_service_proto_msgTypes[77] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OpenVPNTunnelInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpenVPNTunnelInfo) ProtoMessage() {} + +func (x *OpenVPNTunnelInfo) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[77] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OpenVPNTunnelInfo.ProtoReflect.Descriptor instead. +func (*OpenVPNTunnelInfo) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{77} +} + +func (x *OpenVPNTunnelInfo) GetServer() string { + if x != nil { + return x.Server + } + return "" +} + +func (x *OpenVPNTunnelInfo) GetNetwork() string { + if x != nil { + return x.Network + } + return "" +} + +func (x *OpenVPNTunnelInfo) GetIpv4() []string { + if x != nil { + return x.Ipv4 + } + return nil +} + +func (x *OpenVPNTunnelInfo) GetIpv6() []string { + if x != nil { + return x.Ipv6 + } + return nil +} + +func (x *OpenVPNTunnelInfo) GetDns() []string { + if x != nil { + return x.Dns + } + return nil +} + +func (x *OpenVPNTunnelInfo) GetMtu() uint32 { + if x != nil { + return x.Mtu + } + return 0 +} + +func (x *OpenVPNTunnelInfo) GetConnectedSince() int64 { + if x != nil { + return x.ConnectedSince + } + return 0 +} + +func (x *OpenVPNTunnelInfo) GetCipher() string { + if x != nil { + return x.Cipher + } + return "" +} + +type OpenVPNChallenge struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Kind string `protobuf:"bytes,2,opt,name=kind,proto3" json:"kind,omitempty"` + Username string `protobuf:"bytes,3,opt,name=username,proto3" json:"username,omitempty"` + Message string `protobuf:"bytes,4,opt,name=message,proto3" json:"message,omitempty"` + Url string `protobuf:"bytes,5,opt,name=url,proto3" json:"url,omitempty"` + SecretMessage string `protobuf:"bytes,6,opt,name=secretMessage,proto3" json:"secretMessage,omitempty"` + Echo bool `protobuf:"varint,7,opt,name=echo,proto3" json:"echo,omitempty"` + PreviousError string `protobuf:"bytes,8,opt,name=previousError,proto3" json:"previousError,omitempty"` + Deadline int64 `protobuf:"varint,9,opt,name=deadline,proto3" json:"deadline,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OpenVPNChallenge) Reset() { + *x = OpenVPNChallenge{} + mi := &file_daemon_started_service_proto_msgTypes[78] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OpenVPNChallenge) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpenVPNChallenge) ProtoMessage() {} + +func (x *OpenVPNChallenge) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[78] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OpenVPNChallenge.ProtoReflect.Descriptor instead. +func (*OpenVPNChallenge) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{78} +} + +func (x *OpenVPNChallenge) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *OpenVPNChallenge) GetKind() string { + if x != nil { + return x.Kind + } + return "" +} + +func (x *OpenVPNChallenge) GetUsername() string { + if x != nil { + return x.Username + } + return "" +} + +func (x *OpenVPNChallenge) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *OpenVPNChallenge) GetUrl() string { + if x != nil { + return x.Url + } + return "" +} + +func (x *OpenVPNChallenge) GetSecretMessage() string { + if x != nil { + return x.SecretMessage + } + return "" +} + +func (x *OpenVPNChallenge) GetEcho() bool { + if x != nil { + return x.Echo + } + return false +} + +func (x *OpenVPNChallenge) GetPreviousError() string { + if x != nil { + return x.PreviousError + } + return "" +} + +func (x *OpenVPNChallenge) GetDeadline() int64 { + if x != nil { + return x.Deadline + } + return 0 +} + +type OpenVPNChallengeSubmission struct { + state protoimpl.MessageState `protogen:"open.v1"` + EndpointTag string `protobuf:"bytes,1,opt,name=endpointTag,proto3" json:"endpointTag,omitempty"` + ChallengeID string `protobuf:"bytes,2,opt,name=challengeID,proto3" json:"challengeID,omitempty"` + Username string `protobuf:"bytes,3,opt,name=username,proto3" json:"username,omitempty"` + Password string `protobuf:"bytes,4,opt,name=password,proto3" json:"password,omitempty"` + Secret string `protobuf:"bytes,5,opt,name=secret,proto3" json:"secret,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OpenVPNChallengeSubmission) Reset() { + *x = OpenVPNChallengeSubmission{} + mi := &file_daemon_started_service_proto_msgTypes[79] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OpenVPNChallengeSubmission) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpenVPNChallengeSubmission) ProtoMessage() {} + +func (x *OpenVPNChallengeSubmission) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[79] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OpenVPNChallengeSubmission.ProtoReflect.Descriptor instead. +func (*OpenVPNChallengeSubmission) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{79} +} + +func (x *OpenVPNChallengeSubmission) GetEndpointTag() string { + if x != nil { + return x.EndpointTag + } + return "" +} + +func (x *OpenVPNChallengeSubmission) GetChallengeID() string { + if x != nil { + return x.ChallengeID + } + return "" +} + +func (x *OpenVPNChallengeSubmission) GetUsername() string { + if x != nil { + return x.Username + } + return "" +} + +func (x *OpenVPNChallengeSubmission) GetPassword() string { + if x != nil { + return x.Password + } + return "" +} + +func (x *OpenVPNChallengeSubmission) GetSecret() string { + if x != nil { + return x.Secret + } + return "" +} + +type OpenVPNChallengeCancel struct { + state protoimpl.MessageState `protogen:"open.v1"` + EndpointTag string `protobuf:"bytes,1,opt,name=endpointTag,proto3" json:"endpointTag,omitempty"` + ChallengeID string `protobuf:"bytes,2,opt,name=challengeID,proto3" json:"challengeID,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OpenVPNChallengeCancel) Reset() { + *x = OpenVPNChallengeCancel{} + mi := &file_daemon_started_service_proto_msgTypes[80] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OpenVPNChallengeCancel) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpenVPNChallengeCancel) ProtoMessage() {} + +func (x *OpenVPNChallengeCancel) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[80] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OpenVPNChallengeCancel.ProtoReflect.Descriptor instead. +func (*OpenVPNChallengeCancel) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{80} +} + +func (x *OpenVPNChallengeCancel) GetEndpointTag() string { + if x != nil { + return x.EndpointTag + } + return "" +} + +func (x *OpenVPNChallengeCancel) GetChallengeID() string { + if x != nil { + return x.ChallengeID + } + return "" +} + +type Log_Message struct { + state protoimpl.MessageState `protogen:"open.v1"` + Level LogLevel `protobuf:"varint,1,opt,name=level,proto3,enum=daemon.LogLevel" json:"level,omitempty"` + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Log_Message) Reset() { + *x = Log_Message{} + mi := &file_daemon_started_service_proto_msgTypes[81] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Log_Message) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Log_Message) ProtoMessage() {} + +func (x *Log_Message) ProtoReflect() protoreflect.Message { + mi := &file_daemon_started_service_proto_msgTypes[81] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Log_Message.ProtoReflect.Descriptor instead. +func (*Log_Message) Descriptor() ([]byte, []int) { + return file_daemon_started_service_proto_rawDescGZIP(), []int{3, 0} +} + +func (x *Log_Message) GetLevel() LogLevel { + if x != nil { + return x.Level + } + return LogLevel_PANIC +} + +func (x *Log_Message) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +var File_daemon_started_service_proto protoreflect.FileDescriptor + +const file_daemon_started_service_proto_rawDesc = "" + + "\n" + + "\x1cdaemon/started_service.proto\x12\x06daemon\x1a\x1bgoogle/protobuf/empty.proto\"C\n" + + "\aVersion\x12\x18\n" + + "\aversion\x18\x01 \x01(\tR\aversion\x12\x1e\n" + + "\n" + + "apiVersion\x18\x02 \x01(\x05R\n" + + "apiVersion\"\xad\x01\n" + + "\rServiceStatus\x122\n" + + "\x06status\x18\x01 \x01(\x0e2\x1a.daemon.ServiceStatus.TypeR\x06status\x12\"\n" + + "\ferrorMessage\x18\x02 \x01(\tR\ferrorMessage\"D\n" + + "\x04Type\x12\b\n" + + "\x04IDLE\x10\x00\x12\f\n" + + "\bSTARTING\x10\x01\x12\v\n" + + "\aSTARTED\x10\x02\x12\f\n" + + "\bSTOPPING\x10\x03\x12\t\n" + + "\x05FATAL\x10\x04\"4\n" + + "\x16SubscribeStatusRequest\x12\x1a\n" + + "\binterval\x18\x01 \x01(\x03R\binterval\"\x99\x01\n" + + "\x03Log\x12/\n" + + "\bmessages\x18\x01 \x03(\v2\x13.daemon.Log.MessageR\bmessages\x12\x14\n" + + "\x05reset\x18\x02 \x01(\bR\x05reset\x1aK\n" + + "\aMessage\x12&\n" + + "\x05level\x18\x01 \x01(\x0e2\x10.daemon.LogLevelR\x05level\x12\x18\n" + + "\amessage\x18\x02 \x01(\tR\amessage\"9\n" + + "\x0fDefaultLogLevel\x12&\n" + + "\x05level\x18\x01 \x01(\x0e2\x10.daemon.LogLevelR\x05level\"\xb6\x02\n" + + "\x06Status\x12\x16\n" + + "\x06memory\x18\x01 \x01(\x04R\x06memory\x12\x1e\n" + + "\n" + + "goroutines\x18\x02 \x01(\x05R\n" + + "goroutines\x12$\n" + + "\rconnectionsIn\x18\x03 \x01(\x05R\rconnectionsIn\x12&\n" + + "\x0econnectionsOut\x18\x04 \x01(\x05R\x0econnectionsOut\x12*\n" + + "\x10trafficAvailable\x18\x05 \x01(\bR\x10trafficAvailable\x12\x16\n" + + "\x06uplink\x18\x06 \x01(\x03R\x06uplink\x12\x1a\n" + + "\bdownlink\x18\a \x01(\x03R\bdownlink\x12 \n" + + "\vuplinkTotal\x18\b \x01(\x03R\vuplinkTotal\x12$\n" + + "\rdownlinkTotal\x18\t \x01(\x03R\rdownlinkTotal\"-\n" + + "\x06Groups\x12#\n" + + "\x05group\x18\x01 \x03(\v2\r.daemon.GroupR\x05group\"\xae\x01\n" + + "\x05Group\x12\x10\n" + + "\x03tag\x18\x01 \x01(\tR\x03tag\x12\x12\n" + + "\x04type\x18\x02 \x01(\tR\x04type\x12\x1e\n" + + "\n" + + "selectable\x18\x03 \x01(\bR\n" + + "selectable\x12\x1a\n" + + "\bselected\x18\x04 \x01(\tR\bselected\x12\x1a\n" + + "\bisExpand\x18\x05 \x01(\bR\bisExpand\x12'\n" + + "\x05items\x18\x06 \x03(\v2\x11.daemon.GroupItemR\x05items\"w\n" + + "\tGroupItem\x12\x10\n" + + "\x03tag\x18\x01 \x01(\tR\x03tag\x12\x12\n" + + "\x04type\x18\x02 \x01(\tR\x04type\x12 \n" + + "\vurlTestTime\x18\x03 \x01(\x03R\vurlTestTime\x12\"\n" + + "\furlTestDelay\x18\x04 \x01(\x05R\furlTestDelay\"2\n" + + "\x0eURLTestRequest\x12 \n" + + "\voutboundTag\x18\x01 \x01(\tR\voutboundTag\"U\n" + + "\x15SelectOutboundRequest\x12\x1a\n" + + "\bgroupTag\x18\x01 \x01(\tR\bgroupTag\x12 \n" + + "\voutboundTag\x18\x02 \x01(\tR\voutboundTag\"O\n" + + "\x15SetGroupExpandRequest\x12\x1a\n" + + "\bgroupTag\x18\x01 \x01(\tR\bgroupTag\x12\x1a\n" + + "\bisExpand\x18\x02 \x01(\bR\bisExpand\"\x1f\n" + + "\tClashMode\x12\x12\n" + + "\x04mode\x18\x03 \x01(\tR\x04mode\"O\n" + + "\x0fClashModeStatus\x12\x1a\n" + + "\bmodeList\x18\x01 \x03(\tR\bmodeList\x12 \n" + + "\vcurrentMode\x18\x02 \x01(\tR\vcurrentMode\"9\n" + "\x1bSubscribeConnectionsRequest\x12\x1a\n" + "\binterval\x18\x01 \x01(\x03R\binterval\"\xea\x01\n" + "\x0fConnectionEvent\x12/\n" + @@ -4836,19 +6280,21 @@ const file_daemon_started_service_proto_rawDesc = "" + "\x05error\x18\a \x01(\tR\x05error\x12*\n" + "\x10natTypeSupported\x18\b \x01(\bR\x10natTypeSupported\"V\n" + "\x15TailscaleStatusUpdate\x12=\n" + - "\tendpoints\x18\x01 \x03(\v2\x1f.daemon.TailscaleEndpointStatusR\tendpoints\"\xf7\x02\n" + + "\tendpoints\x18\x01 \x03(\v2\x1f.daemon.TailscaleEndpointStatusR\tendpoints\"\x95\x03\n" + "\x17TailscaleEndpointStatus\x12 \n" + "\vendpointTag\x18\x01 \x01(\tR\vendpointTag\x12\"\n" + - "\fbackendState\x18\x02 \x01(\tR\fbackendState\x12\x18\n" + - "\aauthURL\x18\x03 \x01(\tR\aauthURL\x12 \n" + - "\vnetworkName\x18\x04 \x01(\tR\vnetworkName\x12&\n" + - "\x0emagicDNSSuffix\x18\x05 \x01(\tR\x0emagicDNSSuffix\x12)\n" + - "\x04self\x18\x06 \x01(\v2\x15.daemon.TailscalePeerR\x04self\x12:\n" + + "\fbackendState\x18\x02 \x01(\tR\fbackendState\x12\x1c\n" + + "\tstateText\x18\x03 \x01(\tR\tstateText\x12\x18\n" + + "\aauthURL\x18\x04 \x01(\tR\aauthURL\x12 \n" + + "\vnetworkName\x18\x05 \x01(\tR\vnetworkName\x12&\n" + + "\x0emagicDNSSuffix\x18\x06 \x01(\tR\x0emagicDNSSuffix\x12)\n" + + "\x04self\x18\a \x01(\v2\x15.daemon.TailscalePeerR\x04self\x12:\n" + "\n" + - "userGroups\x18\a \x03(\v2\x1a.daemon.TailscaleUserGroupR\n" + + "userGroups\x18\b \x03(\v2\x1a.daemon.TailscaleUserGroupR\n" + "userGroups\x121\n" + - "\bexitNode\x18\b \x01(\v2\x15.daemon.TailscalePeerR\bexitNode\x12\x18\n" + - "\akeyAuth\x18\t \x01(\bR\akeyAuth\"\xbf\x01\n" + + "\bexitNode\x18\t \x01(\v2\x15.daemon.TailscalePeerR\bexitNode\x12\x18\n" + + "\akeyAuth\x18\n" + + " \x01(\bR\akeyAuth\"\xbf\x01\n" + "\x12TailscaleUserGroup\x12\x16\n" + "\x06userID\x18\x01 \x01(\x03R\x06userID\x12\x1c\n" + "\tloginName\x18\x02 \x01(\tR\tloginName\x12 \n" + @@ -5030,7 +6476,119 @@ const file_daemon_started_service_proto_rawDesc = "" + "\x05busId\x18\x02 \x01(\tR\x05busId\x12\x1a\n" + "\bstableId\x18\x03 \x01(\tR\bstableId\x12,\n" + "\abackend\x18\x04 \x01(\x0e2\x12.daemon.USBBackendR\abackend\x12,\n" + - "\x05state\x18\x05 \x01(\x0e2\x16.daemon.USBDeviceStateR\x05state*U\n" + + "\x05state\x18\x05 \x01(\x0e2\x16.daemon.USBDeviceStateR\x05state\"Z\n" + + "\x17OpenConnectStatusUpdate\x12?\n" + + "\tendpoints\x18\x01 \x03(\v2!.daemon.OpenConnectEndpointStatusR\tendpoints\"\x8e\x02\n" + + "\x19OpenConnectEndpointStatus\x12 \n" + + "\vendpointTag\x18\x01 \x01(\tR\vendpointTag\x12\x14\n" + + "\x05state\x18\x02 \x01(\tR\x05state\x12\x1c\n" + + "\tstateText\x18\x03 \x01(\tR\tstateText\x12F\n" + + "\rauthChallenge\x18\x04 \x01(\v2 .daemon.OpenConnectAuthChallengeR\rauthChallenge\x12\x14\n" + + "\x05error\x18\x05 \x01(\tR\x05error\x12=\n" + + "\n" + + "tunnelInfo\x18\x06 \x01(\v2\x1d.daemon.OpenConnectTunnelInfoR\n" + + "tunnelInfo\"\xd9\x01\n" + + "\x15OpenConnectTunnelInfo\x12\x16\n" + + "\x06server\x18\x01 \x01(\tR\x06server\x12\x16\n" + + "\x06flavor\x18\x02 \x01(\tR\x06flavor\x12\x1c\n" + + "\ttransport\x18\x03 \x01(\tR\ttransport\x12\x12\n" + + "\x04ipv4\x18\x04 \x03(\tR\x04ipv4\x12\x12\n" + + "\x04ipv6\x18\x05 \x03(\tR\x04ipv6\x12\x10\n" + + "\x03dns\x18\x06 \x03(\tR\x03dns\x12\x10\n" + + "\x03mtu\x18\a \x01(\rR\x03mtu\x12&\n" + + "\x0econnectedSince\x18\b \x01(\x03R\x0econnectedSince\"\xf1\x01\n" + + "\x18OpenConnectAuthChallenge\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x16\n" + + "\x06banner\x18\x02 \x01(\tR\x06banner\x12\x18\n" + + "\amessage\x18\x03 \x01(\tR\amessage\x12\x14\n" + + "\x05error\x18\x04 \x01(\tR\x05error\x121\n" + + "\x04form\x18\x05 \x01(\v2\x1b.daemon.OpenConnectAuthFormH\x00R\x04form\x12=\n" + + "\abrowser\x18\x06 \x01(\v2!.daemon.OpenConnectBrowserRequestH\x00R\abrowserB\v\n" + + "\tchallenge\"O\n" + + "\x13OpenConnectAuthForm\x128\n" + + "\x06fields\x18\x01 \x03(\v2 .daemon.OpenConnectAuthFormFieldR\x06fields\"\xd1\x01\n" + + "\x18OpenConnectAuthFormField\x12$\n" + + "\rsubmissionKey\x18\x01 \x01(\tR\rsubmissionKey\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\x12\x14\n" + + "\x05label\x18\x03 \x01(\tR\x05label\x12\x12\n" + + "\x04kind\x18\x04 \x01(\tR\x04kind\x12\x14\n" + + "\x05value\x18\x05 \x01(\tR\x05value\x12;\n" + + "\aoptions\x18\x06 \x03(\v2!.daemon.OpenConnectAuthFormChoiceR\aoptions\"G\n" + + "\x19OpenConnectAuthFormChoice\x12\x14\n" + + "\x05value\x18\x01 \x01(\tR\x05value\x12\x14\n" + + "\x05label\x18\x02 \x01(\tR\x05label\"\x85\x02\n" + + "\x19OpenConnectBrowserRequest\x12\x10\n" + + "\x03url\x18\x01 \x01(\tR\x03url\x12\x1a\n" + + "\bfinalURL\x18\x02 \x01(\tR\bfinalURL\x12 \n" + + "\vcookieNames\x18\x03 \x03(\tR\vcookieNames\x12 \n" + + "\vheaderNames\x18\x04 \x03(\tR\vheaderNames\x120\n" + + "\x13callbackURLPrefixes\x18\x05 \x03(\tR\x13callbackURLPrefixes\x12*\n" + + "\x10earlyCookieNames\x18\x06 \x03(\tR\x10earlyCookieNames\x12\x18\n" + + "\acacheID\x18\a \x01(\tR\acacheID\"D\n" + + "\x18OpenConnectBrowserCookie\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value\"F\n" + + "\x18OpenConnectBrowserHeader\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x16\n" + + "\x06values\x18\x02 \x03(\tR\x06values\"\xa1\x01\n" + + "\x1bOpenConnectAuthFormResponse\x12G\n" + + "\x06values\x18\x01 \x03(\v2/.daemon.OpenConnectAuthFormResponse.ValuesEntryR\x06values\x1a9\n" + + "\vValuesEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xae\x01\n" + + "\x18OpenConnectBrowserResult\x12\x1a\n" + + "\bfinalURL\x18\x01 \x01(\tR\bfinalURL\x12:\n" + + "\acookies\x18\x02 \x03(\v2 .daemon.OpenConnectBrowserCookieR\acookies\x12:\n" + + "\aheaders\x18\x03 \x03(\v2 .daemon.OpenConnectBrowserHeaderR\aheaders\"\xec\x01\n" + + "!OpenConnectAuthResponseSubmission\x12 \n" + + "\vendpointTag\x18\x01 \x01(\tR\vendpointTag\x12 \n" + + "\vchallengeID\x18\x02 \x01(\tR\vchallengeID\x129\n" + + "\x04form\x18\x03 \x01(\v2#.daemon.OpenConnectAuthFormResponseH\x00R\x04form\x12<\n" + + "\abrowser\x18\x04 \x01(\v2 .daemon.OpenConnectBrowserResultH\x00R\abrowserB\n" + + "\n" + + "\bresponse\"d\n" + + "\x1eOpenConnectAuthChallengeCancel\x12 \n" + + "\vendpointTag\x18\x01 \x01(\tR\vendpointTag\x12 \n" + + "\vchallengeID\x18\x02 \x01(\tR\vchallengeID\"R\n" + + "\x13OpenVPNStatusUpdate\x12;\n" + + "\tendpoints\x18\x01 \x03(\v2\x1d.daemon.OpenVPNEndpointStatusR\tendpoints\"\xf6\x01\n" + + "\x15OpenVPNEndpointStatus\x12 \n" + + "\vendpointTag\x18\x01 \x01(\tR\vendpointTag\x12\x14\n" + + "\x05state\x18\x02 \x01(\tR\x05state\x12\x1c\n" + + "\tstateText\x18\x03 \x01(\tR\tstateText\x126\n" + + "\tchallenge\x18\x04 \x01(\v2\x18.daemon.OpenVPNChallengeR\tchallenge\x12\x14\n" + + "\x05error\x18\x05 \x01(\tR\x05error\x129\n" + + "\n" + + "tunnelInfo\x18\x06 \x01(\v2\x19.daemon.OpenVPNTunnelInfoR\n" + + "tunnelInfo\"\xd7\x01\n" + + "\x11OpenVPNTunnelInfo\x12\x16\n" + + "\x06server\x18\x01 \x01(\tR\x06server\x12\x18\n" + + "\anetwork\x18\x03 \x01(\tR\anetwork\x12\x12\n" + + "\x04ipv4\x18\x04 \x03(\tR\x04ipv4\x12\x12\n" + + "\x04ipv6\x18\x05 \x03(\tR\x04ipv6\x12\x10\n" + + "\x03dns\x18\x06 \x03(\tR\x03dns\x12\x10\n" + + "\x03mtu\x18\a \x01(\rR\x03mtu\x12&\n" + + "\x0econnectedSince\x18\b \x01(\x03R\x0econnectedSince\x12\x16\n" + + "\x06cipher\x18\t \x01(\tR\x06cipherJ\x04\b\x02\x10\x03\"\xfa\x01\n" + + "\x10OpenVPNChallenge\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + + "\x04kind\x18\x02 \x01(\tR\x04kind\x12\x1a\n" + + "\busername\x18\x03 \x01(\tR\busername\x12\x18\n" + + "\amessage\x18\x04 \x01(\tR\amessage\x12\x10\n" + + "\x03url\x18\x05 \x01(\tR\x03url\x12$\n" + + "\rsecretMessage\x18\x06 \x01(\tR\rsecretMessage\x12\x12\n" + + "\x04echo\x18\a \x01(\bR\x04echo\x12$\n" + + "\rpreviousError\x18\b \x01(\tR\rpreviousError\x12\x1a\n" + + "\bdeadline\x18\t \x01(\x03R\bdeadline\"\xb0\x01\n" + + "\x1aOpenVPNChallengeSubmission\x12 \n" + + "\vendpointTag\x18\x01 \x01(\tR\vendpointTag\x12 \n" + + "\vchallengeID\x18\x02 \x01(\tR\vchallengeID\x12\x1a\n" + + "\busername\x18\x03 \x01(\tR\busername\x12\x1a\n" + + "\bpassword\x18\x04 \x01(\tR\bpassword\x12\x16\n" + + "\x06secret\x18\x05 \x01(\tR\x06secret\"\\\n" + + "\x16OpenVPNChallengeCancel\x12 \n" + + "\vendpointTag\x18\x01 \x01(\tR\vendpointTag\x12 \n" + + "\vchallengeID\x18\x02 \x01(\tR\vchallengeID*U\n" + "\bLogLevel\x12\t\n" + "\x05PANIC\x10\x00\x12\t\n" + "\x05FATAL\x10\x01\x12\t\n" + @@ -5053,7 +6611,7 @@ const file_daemon_started_service_proto_rawDesc = "" + "\x17USB_BACKEND_LINUX_SYSFS\x10\x01\x12\x17\n" + "\x13USB_BACKEND_DYNAMIC\x10\x02\x12\x1c\n" + "\x18USB_BACKEND_DARWIN_IOKIT\x10\x03\x12\x1f\n" + - "\x1bUSB_BACKEND_WINDOWS_VBOXUSB\x10\x042\xd4\x10\n" + + "\x1bUSB_BACKEND_WINDOWS_VBOXUSB\x10\x042\x80\x15\n" + "\x0eStartedService\x127\n" + "\n" + "GetVersion\x12\x16.google.protobuf.Empty\x1a\x0f.daemon.Version\"\x00\x12K\n" + @@ -5083,7 +6641,13 @@ const file_daemon_started_service_proto_rawDesc = "" + "\x0fTailscaleLogout\x12\x1e.daemon.TailscaleLogoutRequest\x1a\x16.google.protobuf.Empty\"\x00\x12f\n" + "\x18StartTailscaleSSHSession\x12!.daemon.TailscaleSSHClientMessage\x1a!.daemon.TailscaleSSHServerMessage\"\x00(\x010\x01\x12O\n" + "\x11ProvideUSBDevices\x12\x1a.daemon.USBProviderMessage\x1a\x18.daemon.USBServerMessage\"\x00(\x010\x01\x12Y\n" + - "\x1aSubscribeUSBIPServerStatus\x12\x16.google.protobuf.Empty\x1a\x1f.daemon.USBIPServerStatusUpdate\"\x000\x01B%Z#github.com/sagernet/sing-box/daemonb\x06proto3" + "\x1aSubscribeUSBIPServerStatus\x12\x16.google.protobuf.Empty\x1a\x1f.daemon.USBIPServerStatusUpdate\"\x000\x01\x12Y\n" + + "\x1aSubscribeOpenConnectStatus\x12\x16.google.protobuf.Empty\x1a\x1f.daemon.OpenConnectStatusUpdate\"\x000\x01\x12d\n" + + "\x1dSubmitOpenConnectAuthResponse\x12).daemon.OpenConnectAuthResponseSubmission\x1a\x16.google.protobuf.Empty\"\x00\x12b\n" + + "\x1eCancelOpenConnectAuthChallenge\x12&.daemon.OpenConnectAuthChallengeCancel\x1a\x16.google.protobuf.Empty\"\x00\x12Q\n" + + "\x16SubscribeOpenVPNStatus\x12\x16.google.protobuf.Empty\x1a\x1b.daemon.OpenVPNStatusUpdate\"\x000\x01\x12^\n" + + "\x1eSubmitOpenVPNChallengeResponse\x12\".daemon.OpenVPNChallengeSubmission\x1a\x16.google.protobuf.Empty\"\x00\x12R\n" + + "\x16CancelOpenVPNChallenge\x12\x1e.daemon.OpenVPNChallengeCancel\x1a\x16.google.protobuf.Empty\"\x00B%Z#github.com/sagernet/sing-box/daemonb\x06proto3" var ( file_daemon_started_service_proto_rawDescOnce sync.Once @@ -5099,82 +6663,103 @@ func file_daemon_started_service_proto_rawDescGZIP() []byte { var ( file_daemon_started_service_proto_enumTypes = make([]protoimpl.EnumInfo, 5) - file_daemon_started_service_proto_msgTypes = make([]protoimpl.MessageInfo, 62) + file_daemon_started_service_proto_msgTypes = make([]protoimpl.MessageInfo, 83) file_daemon_started_service_proto_goTypes = []any{ - (LogLevel)(0), // 0: daemon.LogLevel - (ConnectionEventType)(0), // 1: daemon.ConnectionEventType - (USBDeviceState)(0), // 2: daemon.USBDeviceState - (USBBackend)(0), // 3: daemon.USBBackend - (ServiceStatus_Type)(0), // 4: daemon.ServiceStatus.Type - (*Version)(nil), // 5: daemon.Version - (*ServiceStatus)(nil), // 6: daemon.ServiceStatus - (*SubscribeStatusRequest)(nil), // 7: daemon.SubscribeStatusRequest - (*Log)(nil), // 8: daemon.Log - (*DefaultLogLevel)(nil), // 9: daemon.DefaultLogLevel - (*Status)(nil), // 10: daemon.Status - (*Groups)(nil), // 11: daemon.Groups - (*Group)(nil), // 12: daemon.Group - (*GroupItem)(nil), // 13: daemon.GroupItem - (*URLTestRequest)(nil), // 14: daemon.URLTestRequest - (*SelectOutboundRequest)(nil), // 15: daemon.SelectOutboundRequest - (*SetGroupExpandRequest)(nil), // 16: daemon.SetGroupExpandRequest - (*ClashMode)(nil), // 17: daemon.ClashMode - (*ClashModeStatus)(nil), // 18: daemon.ClashModeStatus - (*SubscribeConnectionsRequest)(nil), // 19: daemon.SubscribeConnectionsRequest - (*ConnectionEvent)(nil), // 20: daemon.ConnectionEvent - (*ConnectionEvents)(nil), // 21: daemon.ConnectionEvents - (*Connection)(nil), // 22: daemon.Connection - (*ProcessInfo)(nil), // 23: daemon.ProcessInfo - (*CloseConnectionRequest)(nil), // 24: daemon.CloseConnectionRequest - (*DeprecatedWarnings)(nil), // 25: daemon.DeprecatedWarnings - (*DeprecatedWarning)(nil), // 26: daemon.DeprecatedWarning - (*StartedAt)(nil), // 27: daemon.StartedAt - (*OutboundList)(nil), // 28: daemon.OutboundList - (*NetworkQualityTestRequest)(nil), // 29: daemon.NetworkQualityTestRequest - (*NetworkQualityTestProgress)(nil), // 30: daemon.NetworkQualityTestProgress - (*STUNTestRequest)(nil), // 31: daemon.STUNTestRequest - (*STUNTestProgress)(nil), // 32: daemon.STUNTestProgress - (*TailscaleStatusUpdate)(nil), // 33: daemon.TailscaleStatusUpdate - (*TailscaleEndpointStatus)(nil), // 34: daemon.TailscaleEndpointStatus - (*TailscaleUserGroup)(nil), // 35: daemon.TailscaleUserGroup - (*TailscalePeer)(nil), // 36: daemon.TailscalePeer - (*TailscalePingRequest)(nil), // 37: daemon.TailscalePingRequest - (*TailscalePingResponse)(nil), // 38: daemon.TailscalePingResponse - (*SetTailscaleExitNodeRequest)(nil), // 39: daemon.SetTailscaleExitNodeRequest - (*TailscaleLogoutRequest)(nil), // 40: daemon.TailscaleLogoutRequest - (*TailscaleSSHClientMessage)(nil), // 41: daemon.TailscaleSSHClientMessage - (*TailscaleSSHStart)(nil), // 42: daemon.TailscaleSSHStart - (*TailscaleSSHInput)(nil), // 43: daemon.TailscaleSSHInput - (*TailscaleSSHResize)(nil), // 44: daemon.TailscaleSSHResize - (*TailscaleSSHServerMessage)(nil), // 45: daemon.TailscaleSSHServerMessage - (*TailscaleSSHAuthBanner)(nil), // 46: daemon.TailscaleSSHAuthBanner - (*TailscaleSSHReady)(nil), // 47: daemon.TailscaleSSHReady - (*TailscaleSSHOutput)(nil), // 48: daemon.TailscaleSSHOutput - (*TailscaleSSHExit)(nil), // 49: daemon.TailscaleSSHExit - (*TailscaleSSHError)(nil), // 50: daemon.TailscaleSSHError - (*USBProviderMessage)(nil), // 51: daemon.USBProviderMessage - (*USBServerMessage)(nil), // 52: daemon.USBServerMessage - (*USBDeviceDescriptor)(nil), // 53: daemon.USBDeviceDescriptor - (*USBDeviceAttach)(nil), // 54: daemon.USBDeviceAttach - (*USBInterface)(nil), // 55: daemon.USBInterface - (*USBDeviceDetach)(nil), // 56: daemon.USBDeviceDetach - (*USBDeviceReady)(nil), // 57: daemon.USBDeviceReady - (*USBURBRequest)(nil), // 58: daemon.USBURBRequest - (*USBURBResponse)(nil), // 59: daemon.USBURBResponse - (*USBIsoPacket)(nil), // 60: daemon.USBIsoPacket - (*USBEndpointAbort)(nil), // 61: daemon.USBEndpointAbort - (*USBError)(nil), // 62: daemon.USBError - (*USBIPServerStatusUpdate)(nil), // 63: daemon.USBIPServerStatusUpdate - (*USBIPServerStatus)(nil), // 64: daemon.USBIPServerStatus - (*USBSharedDevice)(nil), // 65: daemon.USBSharedDevice - (*Log_Message)(nil), // 66: daemon.Log.Message - (*emptypb.Empty)(nil), // 67: google.protobuf.Empty + (LogLevel)(0), // 0: daemon.LogLevel + (ConnectionEventType)(0), // 1: daemon.ConnectionEventType + (USBDeviceState)(0), // 2: daemon.USBDeviceState + (USBBackend)(0), // 3: daemon.USBBackend + (ServiceStatus_Type)(0), // 4: daemon.ServiceStatus.Type + (*Version)(nil), // 5: daemon.Version + (*ServiceStatus)(nil), // 6: daemon.ServiceStatus + (*SubscribeStatusRequest)(nil), // 7: daemon.SubscribeStatusRequest + (*Log)(nil), // 8: daemon.Log + (*DefaultLogLevel)(nil), // 9: daemon.DefaultLogLevel + (*Status)(nil), // 10: daemon.Status + (*Groups)(nil), // 11: daemon.Groups + (*Group)(nil), // 12: daemon.Group + (*GroupItem)(nil), // 13: daemon.GroupItem + (*URLTestRequest)(nil), // 14: daemon.URLTestRequest + (*SelectOutboundRequest)(nil), // 15: daemon.SelectOutboundRequest + (*SetGroupExpandRequest)(nil), // 16: daemon.SetGroupExpandRequest + (*ClashMode)(nil), // 17: daemon.ClashMode + (*ClashModeStatus)(nil), // 18: daemon.ClashModeStatus + (*SubscribeConnectionsRequest)(nil), // 19: daemon.SubscribeConnectionsRequest + (*ConnectionEvent)(nil), // 20: daemon.ConnectionEvent + (*ConnectionEvents)(nil), // 21: daemon.ConnectionEvents + (*Connection)(nil), // 22: daemon.Connection + (*ProcessInfo)(nil), // 23: daemon.ProcessInfo + (*CloseConnectionRequest)(nil), // 24: daemon.CloseConnectionRequest + (*DeprecatedWarnings)(nil), // 25: daemon.DeprecatedWarnings + (*DeprecatedWarning)(nil), // 26: daemon.DeprecatedWarning + (*StartedAt)(nil), // 27: daemon.StartedAt + (*OutboundList)(nil), // 28: daemon.OutboundList + (*NetworkQualityTestRequest)(nil), // 29: daemon.NetworkQualityTestRequest + (*NetworkQualityTestProgress)(nil), // 30: daemon.NetworkQualityTestProgress + (*STUNTestRequest)(nil), // 31: daemon.STUNTestRequest + (*STUNTestProgress)(nil), // 32: daemon.STUNTestProgress + (*TailscaleStatusUpdate)(nil), // 33: daemon.TailscaleStatusUpdate + (*TailscaleEndpointStatus)(nil), // 34: daemon.TailscaleEndpointStatus + (*TailscaleUserGroup)(nil), // 35: daemon.TailscaleUserGroup + (*TailscalePeer)(nil), // 36: daemon.TailscalePeer + (*TailscalePingRequest)(nil), // 37: daemon.TailscalePingRequest + (*TailscalePingResponse)(nil), // 38: daemon.TailscalePingResponse + (*SetTailscaleExitNodeRequest)(nil), // 39: daemon.SetTailscaleExitNodeRequest + (*TailscaleLogoutRequest)(nil), // 40: daemon.TailscaleLogoutRequest + (*TailscaleSSHClientMessage)(nil), // 41: daemon.TailscaleSSHClientMessage + (*TailscaleSSHStart)(nil), // 42: daemon.TailscaleSSHStart + (*TailscaleSSHInput)(nil), // 43: daemon.TailscaleSSHInput + (*TailscaleSSHResize)(nil), // 44: daemon.TailscaleSSHResize + (*TailscaleSSHServerMessage)(nil), // 45: daemon.TailscaleSSHServerMessage + (*TailscaleSSHAuthBanner)(nil), // 46: daemon.TailscaleSSHAuthBanner + (*TailscaleSSHReady)(nil), // 47: daemon.TailscaleSSHReady + (*TailscaleSSHOutput)(nil), // 48: daemon.TailscaleSSHOutput + (*TailscaleSSHExit)(nil), // 49: daemon.TailscaleSSHExit + (*TailscaleSSHError)(nil), // 50: daemon.TailscaleSSHError + (*USBProviderMessage)(nil), // 51: daemon.USBProviderMessage + (*USBServerMessage)(nil), // 52: daemon.USBServerMessage + (*USBDeviceDescriptor)(nil), // 53: daemon.USBDeviceDescriptor + (*USBDeviceAttach)(nil), // 54: daemon.USBDeviceAttach + (*USBInterface)(nil), // 55: daemon.USBInterface + (*USBDeviceDetach)(nil), // 56: daemon.USBDeviceDetach + (*USBDeviceReady)(nil), // 57: daemon.USBDeviceReady + (*USBURBRequest)(nil), // 58: daemon.USBURBRequest + (*USBURBResponse)(nil), // 59: daemon.USBURBResponse + (*USBIsoPacket)(nil), // 60: daemon.USBIsoPacket + (*USBEndpointAbort)(nil), // 61: daemon.USBEndpointAbort + (*USBError)(nil), // 62: daemon.USBError + (*USBIPServerStatusUpdate)(nil), // 63: daemon.USBIPServerStatusUpdate + (*USBIPServerStatus)(nil), // 64: daemon.USBIPServerStatus + (*USBSharedDevice)(nil), // 65: daemon.USBSharedDevice + (*OpenConnectStatusUpdate)(nil), // 66: daemon.OpenConnectStatusUpdate + (*OpenConnectEndpointStatus)(nil), // 67: daemon.OpenConnectEndpointStatus + (*OpenConnectTunnelInfo)(nil), // 68: daemon.OpenConnectTunnelInfo + (*OpenConnectAuthChallenge)(nil), // 69: daemon.OpenConnectAuthChallenge + (*OpenConnectAuthForm)(nil), // 70: daemon.OpenConnectAuthForm + (*OpenConnectAuthFormField)(nil), // 71: daemon.OpenConnectAuthFormField + (*OpenConnectAuthFormChoice)(nil), // 72: daemon.OpenConnectAuthFormChoice + (*OpenConnectBrowserRequest)(nil), // 73: daemon.OpenConnectBrowserRequest + (*OpenConnectBrowserCookie)(nil), // 74: daemon.OpenConnectBrowserCookie + (*OpenConnectBrowserHeader)(nil), // 75: daemon.OpenConnectBrowserHeader + (*OpenConnectAuthFormResponse)(nil), // 76: daemon.OpenConnectAuthFormResponse + (*OpenConnectBrowserResult)(nil), // 77: daemon.OpenConnectBrowserResult + (*OpenConnectAuthResponseSubmission)(nil), // 78: daemon.OpenConnectAuthResponseSubmission + (*OpenConnectAuthChallengeCancel)(nil), // 79: daemon.OpenConnectAuthChallengeCancel + (*OpenVPNStatusUpdate)(nil), // 80: daemon.OpenVPNStatusUpdate + (*OpenVPNEndpointStatus)(nil), // 81: daemon.OpenVPNEndpointStatus + (*OpenVPNTunnelInfo)(nil), // 82: daemon.OpenVPNTunnelInfo + (*OpenVPNChallenge)(nil), // 83: daemon.OpenVPNChallenge + (*OpenVPNChallengeSubmission)(nil), // 84: daemon.OpenVPNChallengeSubmission + (*OpenVPNChallengeCancel)(nil), // 85: daemon.OpenVPNChallengeCancel + (*Log_Message)(nil), // 86: daemon.Log.Message + nil, // 87: daemon.OpenConnectAuthFormResponse.ValuesEntry + (*emptypb.Empty)(nil), // 88: google.protobuf.Empty } ) var file_daemon_started_service_proto_depIdxs = []int32{ 4, // 0: daemon.ServiceStatus.status:type_name -> daemon.ServiceStatus.Type - 66, // 1: daemon.Log.messages:type_name -> daemon.Log.Message + 86, // 1: daemon.Log.messages:type_name -> daemon.Log.Message 0, // 2: daemon.DefaultLogLevel.level:type_name -> daemon.LogLevel 12, // 3: daemon.Groups.group:type_name -> daemon.Group 13, // 4: daemon.Group.items:type_name -> daemon.GroupItem @@ -5213,68 +6798,95 @@ var file_daemon_started_service_proto_depIdxs = []int32{ 53, // 37: daemon.USBSharedDevice.descriptor:type_name -> daemon.USBDeviceDescriptor 3, // 38: daemon.USBSharedDevice.backend:type_name -> daemon.USBBackend 2, // 39: daemon.USBSharedDevice.state:type_name -> daemon.USBDeviceState - 0, // 40: daemon.Log.Message.level:type_name -> daemon.LogLevel - 67, // 41: daemon.StartedService.GetVersion:input_type -> google.protobuf.Empty - 67, // 42: daemon.StartedService.SubscribeServiceStatus:input_type -> google.protobuf.Empty - 67, // 43: daemon.StartedService.SubscribeLog:input_type -> google.protobuf.Empty - 67, // 44: daemon.StartedService.GetDefaultLogLevel:input_type -> google.protobuf.Empty - 67, // 45: daemon.StartedService.ClearLogs:input_type -> google.protobuf.Empty - 7, // 46: daemon.StartedService.SubscribeStatus:input_type -> daemon.SubscribeStatusRequest - 67, // 47: daemon.StartedService.SubscribeGroups:input_type -> google.protobuf.Empty - 67, // 48: daemon.StartedService.GetClashModeStatus:input_type -> google.protobuf.Empty - 67, // 49: daemon.StartedService.SubscribeClashMode:input_type -> google.protobuf.Empty - 17, // 50: daemon.StartedService.SetClashMode:input_type -> daemon.ClashMode - 14, // 51: daemon.StartedService.URLTest:input_type -> daemon.URLTestRequest - 15, // 52: daemon.StartedService.SelectOutbound:input_type -> daemon.SelectOutboundRequest - 16, // 53: daemon.StartedService.SetGroupExpand:input_type -> daemon.SetGroupExpandRequest - 19, // 54: daemon.StartedService.SubscribeConnections:input_type -> daemon.SubscribeConnectionsRequest - 24, // 55: daemon.StartedService.CloseConnection:input_type -> daemon.CloseConnectionRequest - 67, // 56: daemon.StartedService.CloseAllConnections:input_type -> google.protobuf.Empty - 67, // 57: daemon.StartedService.GetDeprecatedWarnings:input_type -> google.protobuf.Empty - 67, // 58: daemon.StartedService.GetStartedAt:input_type -> google.protobuf.Empty - 67, // 59: daemon.StartedService.SubscribeOutbounds:input_type -> google.protobuf.Empty - 29, // 60: daemon.StartedService.StartNetworkQualityTest:input_type -> daemon.NetworkQualityTestRequest - 31, // 61: daemon.StartedService.StartSTUNTest:input_type -> daemon.STUNTestRequest - 67, // 62: daemon.StartedService.SubscribeTailscaleStatus:input_type -> google.protobuf.Empty - 37, // 63: daemon.StartedService.StartTailscalePing:input_type -> daemon.TailscalePingRequest - 39, // 64: daemon.StartedService.SetTailscaleExitNode:input_type -> daemon.SetTailscaleExitNodeRequest - 40, // 65: daemon.StartedService.TailscaleLogout:input_type -> daemon.TailscaleLogoutRequest - 41, // 66: daemon.StartedService.StartTailscaleSSHSession:input_type -> daemon.TailscaleSSHClientMessage - 51, // 67: daemon.StartedService.ProvideUSBDevices:input_type -> daemon.USBProviderMessage - 67, // 68: daemon.StartedService.SubscribeUSBIPServerStatus:input_type -> google.protobuf.Empty - 5, // 69: daemon.StartedService.GetVersion:output_type -> daemon.Version - 6, // 70: daemon.StartedService.SubscribeServiceStatus:output_type -> daemon.ServiceStatus - 8, // 71: daemon.StartedService.SubscribeLog:output_type -> daemon.Log - 9, // 72: daemon.StartedService.GetDefaultLogLevel:output_type -> daemon.DefaultLogLevel - 67, // 73: daemon.StartedService.ClearLogs:output_type -> google.protobuf.Empty - 10, // 74: daemon.StartedService.SubscribeStatus:output_type -> daemon.Status - 11, // 75: daemon.StartedService.SubscribeGroups:output_type -> daemon.Groups - 18, // 76: daemon.StartedService.GetClashModeStatus:output_type -> daemon.ClashModeStatus - 17, // 77: daemon.StartedService.SubscribeClashMode:output_type -> daemon.ClashMode - 67, // 78: daemon.StartedService.SetClashMode:output_type -> google.protobuf.Empty - 67, // 79: daemon.StartedService.URLTest:output_type -> google.protobuf.Empty - 67, // 80: daemon.StartedService.SelectOutbound:output_type -> google.protobuf.Empty - 67, // 81: daemon.StartedService.SetGroupExpand:output_type -> google.protobuf.Empty - 21, // 82: daemon.StartedService.SubscribeConnections:output_type -> daemon.ConnectionEvents - 67, // 83: daemon.StartedService.CloseConnection:output_type -> google.protobuf.Empty - 67, // 84: daemon.StartedService.CloseAllConnections:output_type -> google.protobuf.Empty - 25, // 85: daemon.StartedService.GetDeprecatedWarnings:output_type -> daemon.DeprecatedWarnings - 27, // 86: daemon.StartedService.GetStartedAt:output_type -> daemon.StartedAt - 28, // 87: daemon.StartedService.SubscribeOutbounds:output_type -> daemon.OutboundList - 30, // 88: daemon.StartedService.StartNetworkQualityTest:output_type -> daemon.NetworkQualityTestProgress - 32, // 89: daemon.StartedService.StartSTUNTest:output_type -> daemon.STUNTestProgress - 33, // 90: daemon.StartedService.SubscribeTailscaleStatus:output_type -> daemon.TailscaleStatusUpdate - 38, // 91: daemon.StartedService.StartTailscalePing:output_type -> daemon.TailscalePingResponse - 67, // 92: daemon.StartedService.SetTailscaleExitNode:output_type -> google.protobuf.Empty - 67, // 93: daemon.StartedService.TailscaleLogout:output_type -> google.protobuf.Empty - 45, // 94: daemon.StartedService.StartTailscaleSSHSession:output_type -> daemon.TailscaleSSHServerMessage - 52, // 95: daemon.StartedService.ProvideUSBDevices:output_type -> daemon.USBServerMessage - 63, // 96: daemon.StartedService.SubscribeUSBIPServerStatus:output_type -> daemon.USBIPServerStatusUpdate - 69, // [69:97] is the sub-list for method output_type - 41, // [41:69] is the sub-list for method input_type - 41, // [41:41] is the sub-list for extension type_name - 41, // [41:41] is the sub-list for extension extendee - 0, // [0:41] is the sub-list for field type_name + 67, // 40: daemon.OpenConnectStatusUpdate.endpoints:type_name -> daemon.OpenConnectEndpointStatus + 69, // 41: daemon.OpenConnectEndpointStatus.authChallenge:type_name -> daemon.OpenConnectAuthChallenge + 68, // 42: daemon.OpenConnectEndpointStatus.tunnelInfo:type_name -> daemon.OpenConnectTunnelInfo + 70, // 43: daemon.OpenConnectAuthChallenge.form:type_name -> daemon.OpenConnectAuthForm + 73, // 44: daemon.OpenConnectAuthChallenge.browser:type_name -> daemon.OpenConnectBrowserRequest + 71, // 45: daemon.OpenConnectAuthForm.fields:type_name -> daemon.OpenConnectAuthFormField + 72, // 46: daemon.OpenConnectAuthFormField.options:type_name -> daemon.OpenConnectAuthFormChoice + 87, // 47: daemon.OpenConnectAuthFormResponse.values:type_name -> daemon.OpenConnectAuthFormResponse.ValuesEntry + 74, // 48: daemon.OpenConnectBrowserResult.cookies:type_name -> daemon.OpenConnectBrowserCookie + 75, // 49: daemon.OpenConnectBrowserResult.headers:type_name -> daemon.OpenConnectBrowserHeader + 76, // 50: daemon.OpenConnectAuthResponseSubmission.form:type_name -> daemon.OpenConnectAuthFormResponse + 77, // 51: daemon.OpenConnectAuthResponseSubmission.browser:type_name -> daemon.OpenConnectBrowserResult + 81, // 52: daemon.OpenVPNStatusUpdate.endpoints:type_name -> daemon.OpenVPNEndpointStatus + 83, // 53: daemon.OpenVPNEndpointStatus.challenge:type_name -> daemon.OpenVPNChallenge + 82, // 54: daemon.OpenVPNEndpointStatus.tunnelInfo:type_name -> daemon.OpenVPNTunnelInfo + 0, // 55: daemon.Log.Message.level:type_name -> daemon.LogLevel + 88, // 56: daemon.StartedService.GetVersion:input_type -> google.protobuf.Empty + 88, // 57: daemon.StartedService.SubscribeServiceStatus:input_type -> google.protobuf.Empty + 88, // 58: daemon.StartedService.SubscribeLog:input_type -> google.protobuf.Empty + 88, // 59: daemon.StartedService.GetDefaultLogLevel:input_type -> google.protobuf.Empty + 88, // 60: daemon.StartedService.ClearLogs:input_type -> google.protobuf.Empty + 7, // 61: daemon.StartedService.SubscribeStatus:input_type -> daemon.SubscribeStatusRequest + 88, // 62: daemon.StartedService.SubscribeGroups:input_type -> google.protobuf.Empty + 88, // 63: daemon.StartedService.GetClashModeStatus:input_type -> google.protobuf.Empty + 88, // 64: daemon.StartedService.SubscribeClashMode:input_type -> google.protobuf.Empty + 17, // 65: daemon.StartedService.SetClashMode:input_type -> daemon.ClashMode + 14, // 66: daemon.StartedService.URLTest:input_type -> daemon.URLTestRequest + 15, // 67: daemon.StartedService.SelectOutbound:input_type -> daemon.SelectOutboundRequest + 16, // 68: daemon.StartedService.SetGroupExpand:input_type -> daemon.SetGroupExpandRequest + 19, // 69: daemon.StartedService.SubscribeConnections:input_type -> daemon.SubscribeConnectionsRequest + 24, // 70: daemon.StartedService.CloseConnection:input_type -> daemon.CloseConnectionRequest + 88, // 71: daemon.StartedService.CloseAllConnections:input_type -> google.protobuf.Empty + 88, // 72: daemon.StartedService.GetDeprecatedWarnings:input_type -> google.protobuf.Empty + 88, // 73: daemon.StartedService.GetStartedAt:input_type -> google.protobuf.Empty + 88, // 74: daemon.StartedService.SubscribeOutbounds:input_type -> google.protobuf.Empty + 29, // 75: daemon.StartedService.StartNetworkQualityTest:input_type -> daemon.NetworkQualityTestRequest + 31, // 76: daemon.StartedService.StartSTUNTest:input_type -> daemon.STUNTestRequest + 88, // 77: daemon.StartedService.SubscribeTailscaleStatus:input_type -> google.protobuf.Empty + 37, // 78: daemon.StartedService.StartTailscalePing:input_type -> daemon.TailscalePingRequest + 39, // 79: daemon.StartedService.SetTailscaleExitNode:input_type -> daemon.SetTailscaleExitNodeRequest + 40, // 80: daemon.StartedService.TailscaleLogout:input_type -> daemon.TailscaleLogoutRequest + 41, // 81: daemon.StartedService.StartTailscaleSSHSession:input_type -> daemon.TailscaleSSHClientMessage + 51, // 82: daemon.StartedService.ProvideUSBDevices:input_type -> daemon.USBProviderMessage + 88, // 83: daemon.StartedService.SubscribeUSBIPServerStatus:input_type -> google.protobuf.Empty + 88, // 84: daemon.StartedService.SubscribeOpenConnectStatus:input_type -> google.protobuf.Empty + 78, // 85: daemon.StartedService.SubmitOpenConnectAuthResponse:input_type -> daemon.OpenConnectAuthResponseSubmission + 79, // 86: daemon.StartedService.CancelOpenConnectAuthChallenge:input_type -> daemon.OpenConnectAuthChallengeCancel + 88, // 87: daemon.StartedService.SubscribeOpenVPNStatus:input_type -> google.protobuf.Empty + 84, // 88: daemon.StartedService.SubmitOpenVPNChallengeResponse:input_type -> daemon.OpenVPNChallengeSubmission + 85, // 89: daemon.StartedService.CancelOpenVPNChallenge:input_type -> daemon.OpenVPNChallengeCancel + 5, // 90: daemon.StartedService.GetVersion:output_type -> daemon.Version + 6, // 91: daemon.StartedService.SubscribeServiceStatus:output_type -> daemon.ServiceStatus + 8, // 92: daemon.StartedService.SubscribeLog:output_type -> daemon.Log + 9, // 93: daemon.StartedService.GetDefaultLogLevel:output_type -> daemon.DefaultLogLevel + 88, // 94: daemon.StartedService.ClearLogs:output_type -> google.protobuf.Empty + 10, // 95: daemon.StartedService.SubscribeStatus:output_type -> daemon.Status + 11, // 96: daemon.StartedService.SubscribeGroups:output_type -> daemon.Groups + 18, // 97: daemon.StartedService.GetClashModeStatus:output_type -> daemon.ClashModeStatus + 17, // 98: daemon.StartedService.SubscribeClashMode:output_type -> daemon.ClashMode + 88, // 99: daemon.StartedService.SetClashMode:output_type -> google.protobuf.Empty + 88, // 100: daemon.StartedService.URLTest:output_type -> google.protobuf.Empty + 88, // 101: daemon.StartedService.SelectOutbound:output_type -> google.protobuf.Empty + 88, // 102: daemon.StartedService.SetGroupExpand:output_type -> google.protobuf.Empty + 21, // 103: daemon.StartedService.SubscribeConnections:output_type -> daemon.ConnectionEvents + 88, // 104: daemon.StartedService.CloseConnection:output_type -> google.protobuf.Empty + 88, // 105: daemon.StartedService.CloseAllConnections:output_type -> google.protobuf.Empty + 25, // 106: daemon.StartedService.GetDeprecatedWarnings:output_type -> daemon.DeprecatedWarnings + 27, // 107: daemon.StartedService.GetStartedAt:output_type -> daemon.StartedAt + 28, // 108: daemon.StartedService.SubscribeOutbounds:output_type -> daemon.OutboundList + 30, // 109: daemon.StartedService.StartNetworkQualityTest:output_type -> daemon.NetworkQualityTestProgress + 32, // 110: daemon.StartedService.StartSTUNTest:output_type -> daemon.STUNTestProgress + 33, // 111: daemon.StartedService.SubscribeTailscaleStatus:output_type -> daemon.TailscaleStatusUpdate + 38, // 112: daemon.StartedService.StartTailscalePing:output_type -> daemon.TailscalePingResponse + 88, // 113: daemon.StartedService.SetTailscaleExitNode:output_type -> google.protobuf.Empty + 88, // 114: daemon.StartedService.TailscaleLogout:output_type -> google.protobuf.Empty + 45, // 115: daemon.StartedService.StartTailscaleSSHSession:output_type -> daemon.TailscaleSSHServerMessage + 52, // 116: daemon.StartedService.ProvideUSBDevices:output_type -> daemon.USBServerMessage + 63, // 117: daemon.StartedService.SubscribeUSBIPServerStatus:output_type -> daemon.USBIPServerStatusUpdate + 66, // 118: daemon.StartedService.SubscribeOpenConnectStatus:output_type -> daemon.OpenConnectStatusUpdate + 88, // 119: daemon.StartedService.SubmitOpenConnectAuthResponse:output_type -> google.protobuf.Empty + 88, // 120: daemon.StartedService.CancelOpenConnectAuthChallenge:output_type -> google.protobuf.Empty + 80, // 121: daemon.StartedService.SubscribeOpenVPNStatus:output_type -> daemon.OpenVPNStatusUpdate + 88, // 122: daemon.StartedService.SubmitOpenVPNChallengeResponse:output_type -> google.protobuf.Empty + 88, // 123: daemon.StartedService.CancelOpenVPNChallenge:output_type -> google.protobuf.Empty + 90, // [90:124] is the sub-list for method output_type + 56, // [56:90] is the sub-list for method input_type + 56, // [56:56] is the sub-list for extension type_name + 56, // [56:56] is the sub-list for extension extendee + 0, // [0:56] is the sub-list for field type_name } func init() { file_daemon_started_service_proto_init() } @@ -5305,13 +6917,21 @@ func file_daemon_started_service_proto_init() { (*USBServerMessage_Abort)(nil), (*USBServerMessage_Error)(nil), } + file_daemon_started_service_proto_msgTypes[64].OneofWrappers = []any{ + (*OpenConnectAuthChallenge_Form)(nil), + (*OpenConnectAuthChallenge_Browser)(nil), + } + file_daemon_started_service_proto_msgTypes[73].OneofWrappers = []any{ + (*OpenConnectAuthResponseSubmission_Form)(nil), + (*OpenConnectAuthResponseSubmission_Browser)(nil), + } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_daemon_started_service_proto_rawDesc), len(file_daemon_started_service_proto_rawDesc)), NumEnums: 5, - NumMessages: 62, + NumMessages: 83, NumExtensions: 0, NumServices: 1, }, diff --git a/daemon/started_service.proto b/daemon/started_service.proto index 97af9f8c4a..a03796f46d 100644 --- a/daemon/started_service.proto +++ b/daemon/started_service.proto @@ -38,6 +38,12 @@ service StartedService { rpc StartTailscaleSSHSession(stream TailscaleSSHClientMessage) returns (stream TailscaleSSHServerMessage) {} rpc ProvideUSBDevices(stream USBProviderMessage) returns (stream USBServerMessage) {} rpc SubscribeUSBIPServerStatus(google.protobuf.Empty) returns (stream USBIPServerStatusUpdate) {} + rpc SubscribeOpenConnectStatus(google.protobuf.Empty) returns (stream OpenConnectStatusUpdate) {} + rpc SubmitOpenConnectAuthResponse(OpenConnectAuthResponseSubmission) returns (google.protobuf.Empty) {} + rpc CancelOpenConnectAuthChallenge(OpenConnectAuthChallengeCancel) returns (google.protobuf.Empty) {} + rpc SubscribeOpenVPNStatus(google.protobuf.Empty) returns (stream OpenVPNStatusUpdate) {} + rpc SubmitOpenVPNChallengeResponse(OpenVPNChallengeSubmission) returns (google.protobuf.Empty) {} + rpc CancelOpenVPNChallenge(OpenVPNChallengeCancel) returns (google.protobuf.Empty) {} } message Version { @@ -268,13 +274,14 @@ message TailscaleStatusUpdate { message TailscaleEndpointStatus { string endpointTag = 1; string backendState = 2; - string authURL = 3; - string networkName = 4; - string magicDNSSuffix = 5; - TailscalePeer self = 6; - repeated TailscaleUserGroup userGroups = 7; - TailscalePeer exitNode = 8; - bool keyAuth = 9; + string stateText = 3; + string authURL = 4; + string networkName = 5; + string magicDNSSuffix = 6; + TailscalePeer self = 7; + repeated TailscaleUserGroup userGroups = 8; + TailscalePeer exitNode = 9; + bool keyAuth = 10; } message TailscaleUserGroup { @@ -516,3 +523,150 @@ enum USBBackend { USB_BACKEND_DARWIN_IOKIT = 3; USB_BACKEND_WINDOWS_VBOXUSB = 4; } + +message OpenConnectStatusUpdate { + repeated OpenConnectEndpointStatus endpoints = 1; +} + +message OpenConnectEndpointStatus { + string endpointTag = 1; + string state = 2; + string stateText = 3; + OpenConnectAuthChallenge authChallenge = 4; + string error = 5; + OpenConnectTunnelInfo tunnelInfo = 6; +} + +message OpenConnectTunnelInfo { + string server = 1; + string flavor = 2; + string transport = 3; + repeated string ipv4 = 4; + repeated string ipv6 = 5; + repeated string dns = 6; + uint32 mtu = 7; + int64 connectedSince = 8; +} + +message OpenConnectAuthChallenge { + string id = 1; + string banner = 2; + string message = 3; + string error = 4; + oneof challenge { + OpenConnectAuthForm form = 5; + OpenConnectBrowserRequest browser = 6; + } +} + +message OpenConnectAuthForm { + repeated OpenConnectAuthFormField fields = 1; +} + +message OpenConnectAuthFormField { + string submissionKey = 1; + string name = 2; + string label = 3; + string kind = 4; + string value = 5; + repeated OpenConnectAuthFormChoice options = 6; +} + +message OpenConnectAuthFormChoice { + string value = 1; + string label = 2; +} + +message OpenConnectBrowserRequest { + string url = 1; + string finalURL = 2; + repeated string cookieNames = 3; + repeated string headerNames = 4; + repeated string callbackURLPrefixes = 5; + repeated string earlyCookieNames = 6; + string cacheID = 7; +} + +message OpenConnectBrowserCookie { + string name = 1; + string value = 2; +} + +message OpenConnectBrowserHeader { + string name = 1; + repeated string values = 2; +} + +message OpenConnectAuthFormResponse { + map values = 1; +} + +message OpenConnectBrowserResult { + string finalURL = 1; + repeated OpenConnectBrowserCookie cookies = 2; + repeated OpenConnectBrowserHeader headers = 3; +} + +message OpenConnectAuthResponseSubmission { + string endpointTag = 1; + string challengeID = 2; + oneof response { + OpenConnectAuthFormResponse form = 3; + OpenConnectBrowserResult browser = 4; + } +} + +message OpenConnectAuthChallengeCancel { + string endpointTag = 1; + string challengeID = 2; +} + +message OpenVPNStatusUpdate { + repeated OpenVPNEndpointStatus endpoints = 1; +} + +message OpenVPNEndpointStatus { + string endpointTag = 1; + string state = 2; + string stateText = 3; + OpenVPNChallenge challenge = 4; + string error = 5; + OpenVPNTunnelInfo tunnelInfo = 6; +} + +message OpenVPNTunnelInfo { + string server = 1; + reserved 2; + string network = 3; + repeated string ipv4 = 4; + repeated string ipv6 = 5; + repeated string dns = 6; + uint32 mtu = 7; + int64 connectedSince = 8; + string cipher = 9; +} + +message OpenVPNChallenge { + string id = 1; + string kind = 2; + string username = 3; + string message = 4; + string url = 5; + string secretMessage = 6; + bool echo = 7; + string previousError = 8; + int64 deadline = 9; +} + +message OpenVPNChallengeSubmission { + string endpointTag = 1; + string challengeID = 2; + string username = 3; + string password = 4; + string secret = 5; +} + +message OpenVPNChallengeCancel { + string endpointTag = 1; + string challengeID = 2; +} diff --git a/daemon/started_service_grpc.pb.go b/daemon/started_service_grpc.pb.go index a707fdb383..405c8368cb 100644 --- a/daemon/started_service_grpc.pb.go +++ b/daemon/started_service_grpc.pb.go @@ -15,34 +15,40 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - StartedService_GetVersion_FullMethodName = "/daemon.StartedService/GetVersion" - StartedService_SubscribeServiceStatus_FullMethodName = "/daemon.StartedService/SubscribeServiceStatus" - StartedService_SubscribeLog_FullMethodName = "/daemon.StartedService/SubscribeLog" - StartedService_GetDefaultLogLevel_FullMethodName = "/daemon.StartedService/GetDefaultLogLevel" - StartedService_ClearLogs_FullMethodName = "/daemon.StartedService/ClearLogs" - StartedService_SubscribeStatus_FullMethodName = "/daemon.StartedService/SubscribeStatus" - StartedService_SubscribeGroups_FullMethodName = "/daemon.StartedService/SubscribeGroups" - StartedService_GetClashModeStatus_FullMethodName = "/daemon.StartedService/GetClashModeStatus" - StartedService_SubscribeClashMode_FullMethodName = "/daemon.StartedService/SubscribeClashMode" - StartedService_SetClashMode_FullMethodName = "/daemon.StartedService/SetClashMode" - StartedService_URLTest_FullMethodName = "/daemon.StartedService/URLTest" - StartedService_SelectOutbound_FullMethodName = "/daemon.StartedService/SelectOutbound" - StartedService_SetGroupExpand_FullMethodName = "/daemon.StartedService/SetGroupExpand" - StartedService_SubscribeConnections_FullMethodName = "/daemon.StartedService/SubscribeConnections" - StartedService_CloseConnection_FullMethodName = "/daemon.StartedService/CloseConnection" - StartedService_CloseAllConnections_FullMethodName = "/daemon.StartedService/CloseAllConnections" - StartedService_GetDeprecatedWarnings_FullMethodName = "/daemon.StartedService/GetDeprecatedWarnings" - StartedService_GetStartedAt_FullMethodName = "/daemon.StartedService/GetStartedAt" - StartedService_SubscribeOutbounds_FullMethodName = "/daemon.StartedService/SubscribeOutbounds" - StartedService_StartNetworkQualityTest_FullMethodName = "/daemon.StartedService/StartNetworkQualityTest" - StartedService_StartSTUNTest_FullMethodName = "/daemon.StartedService/StartSTUNTest" - StartedService_SubscribeTailscaleStatus_FullMethodName = "/daemon.StartedService/SubscribeTailscaleStatus" - StartedService_StartTailscalePing_FullMethodName = "/daemon.StartedService/StartTailscalePing" - StartedService_SetTailscaleExitNode_FullMethodName = "/daemon.StartedService/SetTailscaleExitNode" - StartedService_TailscaleLogout_FullMethodName = "/daemon.StartedService/TailscaleLogout" - StartedService_StartTailscaleSSHSession_FullMethodName = "/daemon.StartedService/StartTailscaleSSHSession" - StartedService_ProvideUSBDevices_FullMethodName = "/daemon.StartedService/ProvideUSBDevices" - StartedService_SubscribeUSBIPServerStatus_FullMethodName = "/daemon.StartedService/SubscribeUSBIPServerStatus" + StartedService_GetVersion_FullMethodName = "/daemon.StartedService/GetVersion" + StartedService_SubscribeServiceStatus_FullMethodName = "/daemon.StartedService/SubscribeServiceStatus" + StartedService_SubscribeLog_FullMethodName = "/daemon.StartedService/SubscribeLog" + StartedService_GetDefaultLogLevel_FullMethodName = "/daemon.StartedService/GetDefaultLogLevel" + StartedService_ClearLogs_FullMethodName = "/daemon.StartedService/ClearLogs" + StartedService_SubscribeStatus_FullMethodName = "/daemon.StartedService/SubscribeStatus" + StartedService_SubscribeGroups_FullMethodName = "/daemon.StartedService/SubscribeGroups" + StartedService_GetClashModeStatus_FullMethodName = "/daemon.StartedService/GetClashModeStatus" + StartedService_SubscribeClashMode_FullMethodName = "/daemon.StartedService/SubscribeClashMode" + StartedService_SetClashMode_FullMethodName = "/daemon.StartedService/SetClashMode" + StartedService_URLTest_FullMethodName = "/daemon.StartedService/URLTest" + StartedService_SelectOutbound_FullMethodName = "/daemon.StartedService/SelectOutbound" + StartedService_SetGroupExpand_FullMethodName = "/daemon.StartedService/SetGroupExpand" + StartedService_SubscribeConnections_FullMethodName = "/daemon.StartedService/SubscribeConnections" + StartedService_CloseConnection_FullMethodName = "/daemon.StartedService/CloseConnection" + StartedService_CloseAllConnections_FullMethodName = "/daemon.StartedService/CloseAllConnections" + StartedService_GetDeprecatedWarnings_FullMethodName = "/daemon.StartedService/GetDeprecatedWarnings" + StartedService_GetStartedAt_FullMethodName = "/daemon.StartedService/GetStartedAt" + StartedService_SubscribeOutbounds_FullMethodName = "/daemon.StartedService/SubscribeOutbounds" + StartedService_StartNetworkQualityTest_FullMethodName = "/daemon.StartedService/StartNetworkQualityTest" + StartedService_StartSTUNTest_FullMethodName = "/daemon.StartedService/StartSTUNTest" + StartedService_SubscribeTailscaleStatus_FullMethodName = "/daemon.StartedService/SubscribeTailscaleStatus" + StartedService_StartTailscalePing_FullMethodName = "/daemon.StartedService/StartTailscalePing" + StartedService_SetTailscaleExitNode_FullMethodName = "/daemon.StartedService/SetTailscaleExitNode" + StartedService_TailscaleLogout_FullMethodName = "/daemon.StartedService/TailscaleLogout" + StartedService_StartTailscaleSSHSession_FullMethodName = "/daemon.StartedService/StartTailscaleSSHSession" + StartedService_ProvideUSBDevices_FullMethodName = "/daemon.StartedService/ProvideUSBDevices" + StartedService_SubscribeUSBIPServerStatus_FullMethodName = "/daemon.StartedService/SubscribeUSBIPServerStatus" + StartedService_SubscribeOpenConnectStatus_FullMethodName = "/daemon.StartedService/SubscribeOpenConnectStatus" + StartedService_SubmitOpenConnectAuthResponse_FullMethodName = "/daemon.StartedService/SubmitOpenConnectAuthResponse" + StartedService_CancelOpenConnectAuthChallenge_FullMethodName = "/daemon.StartedService/CancelOpenConnectAuthChallenge" + StartedService_SubscribeOpenVPNStatus_FullMethodName = "/daemon.StartedService/SubscribeOpenVPNStatus" + StartedService_SubmitOpenVPNChallengeResponse_FullMethodName = "/daemon.StartedService/SubmitOpenVPNChallengeResponse" + StartedService_CancelOpenVPNChallenge_FullMethodName = "/daemon.StartedService/CancelOpenVPNChallenge" ) // StartedServiceClient is the client API for StartedService service. @@ -77,6 +83,12 @@ type StartedServiceClient interface { StartTailscaleSSHSession(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[TailscaleSSHClientMessage, TailscaleSSHServerMessage], error) ProvideUSBDevices(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[USBProviderMessage, USBServerMessage], error) SubscribeUSBIPServerStatus(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (grpc.ServerStreamingClient[USBIPServerStatusUpdate], error) + SubscribeOpenConnectStatus(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (grpc.ServerStreamingClient[OpenConnectStatusUpdate], error) + SubmitOpenConnectAuthResponse(ctx context.Context, in *OpenConnectAuthResponseSubmission, opts ...grpc.CallOption) (*emptypb.Empty, error) + CancelOpenConnectAuthChallenge(ctx context.Context, in *OpenConnectAuthChallengeCancel, opts ...grpc.CallOption) (*emptypb.Empty, error) + SubscribeOpenVPNStatus(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (grpc.ServerStreamingClient[OpenVPNStatusUpdate], error) + SubmitOpenVPNChallengeResponse(ctx context.Context, in *OpenVPNChallengeSubmission, opts ...grpc.CallOption) (*emptypb.Empty, error) + CancelOpenVPNChallenge(ctx context.Context, in *OpenVPNChallengeCancel, opts ...grpc.CallOption) (*emptypb.Empty, error) } type startedServiceClient struct { @@ -481,6 +493,84 @@ func (c *startedServiceClient) SubscribeUSBIPServerStatus(ctx context.Context, i // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. type StartedService_SubscribeUSBIPServerStatusClient = grpc.ServerStreamingClient[USBIPServerStatusUpdate] +func (c *startedServiceClient) SubscribeOpenConnectStatus(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (grpc.ServerStreamingClient[OpenConnectStatusUpdate], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &StartedService_ServiceDesc.Streams[14], StartedService_SubscribeOpenConnectStatus_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[emptypb.Empty, OpenConnectStatusUpdate]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type StartedService_SubscribeOpenConnectStatusClient = grpc.ServerStreamingClient[OpenConnectStatusUpdate] + +func (c *startedServiceClient) SubmitOpenConnectAuthResponse(ctx context.Context, in *OpenConnectAuthResponseSubmission, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, StartedService_SubmitOpenConnectAuthResponse_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *startedServiceClient) CancelOpenConnectAuthChallenge(ctx context.Context, in *OpenConnectAuthChallengeCancel, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, StartedService_CancelOpenConnectAuthChallenge_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *startedServiceClient) SubscribeOpenVPNStatus(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (grpc.ServerStreamingClient[OpenVPNStatusUpdate], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &StartedService_ServiceDesc.Streams[15], StartedService_SubscribeOpenVPNStatus_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[emptypb.Empty, OpenVPNStatusUpdate]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type StartedService_SubscribeOpenVPNStatusClient = grpc.ServerStreamingClient[OpenVPNStatusUpdate] + +func (c *startedServiceClient) SubmitOpenVPNChallengeResponse(ctx context.Context, in *OpenVPNChallengeSubmission, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, StartedService_SubmitOpenVPNChallengeResponse_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *startedServiceClient) CancelOpenVPNChallenge(ctx context.Context, in *OpenVPNChallengeCancel, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, StartedService_CancelOpenVPNChallenge_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // StartedServiceServer is the server API for StartedService service. // All implementations must embed UnimplementedStartedServiceServer // for forward compatibility. @@ -513,6 +603,12 @@ type StartedServiceServer interface { StartTailscaleSSHSession(grpc.BidiStreamingServer[TailscaleSSHClientMessage, TailscaleSSHServerMessage]) error ProvideUSBDevices(grpc.BidiStreamingServer[USBProviderMessage, USBServerMessage]) error SubscribeUSBIPServerStatus(*emptypb.Empty, grpc.ServerStreamingServer[USBIPServerStatusUpdate]) error + SubscribeOpenConnectStatus(*emptypb.Empty, grpc.ServerStreamingServer[OpenConnectStatusUpdate]) error + SubmitOpenConnectAuthResponse(context.Context, *OpenConnectAuthResponseSubmission) (*emptypb.Empty, error) + CancelOpenConnectAuthChallenge(context.Context, *OpenConnectAuthChallengeCancel) (*emptypb.Empty, error) + SubscribeOpenVPNStatus(*emptypb.Empty, grpc.ServerStreamingServer[OpenVPNStatusUpdate]) error + SubmitOpenVPNChallengeResponse(context.Context, *OpenVPNChallengeSubmission) (*emptypb.Empty, error) + CancelOpenVPNChallenge(context.Context, *OpenVPNChallengeCancel) (*emptypb.Empty, error) mustEmbedUnimplementedStartedServiceServer() } @@ -634,6 +730,30 @@ func (UnimplementedStartedServiceServer) ProvideUSBDevices(grpc.BidiStreamingSer func (UnimplementedStartedServiceServer) SubscribeUSBIPServerStatus(*emptypb.Empty, grpc.ServerStreamingServer[USBIPServerStatusUpdate]) error { return status.Error(codes.Unimplemented, "method SubscribeUSBIPServerStatus not implemented") } + +func (UnimplementedStartedServiceServer) SubscribeOpenConnectStatus(*emptypb.Empty, grpc.ServerStreamingServer[OpenConnectStatusUpdate]) error { + return status.Error(codes.Unimplemented, "method SubscribeOpenConnectStatus not implemented") +} + +func (UnimplementedStartedServiceServer) SubmitOpenConnectAuthResponse(context.Context, *OpenConnectAuthResponseSubmission) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method SubmitOpenConnectAuthResponse not implemented") +} + +func (UnimplementedStartedServiceServer) CancelOpenConnectAuthChallenge(context.Context, *OpenConnectAuthChallengeCancel) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method CancelOpenConnectAuthChallenge not implemented") +} + +func (UnimplementedStartedServiceServer) SubscribeOpenVPNStatus(*emptypb.Empty, grpc.ServerStreamingServer[OpenVPNStatusUpdate]) error { + return status.Error(codes.Unimplemented, "method SubscribeOpenVPNStatus not implemented") +} + +func (UnimplementedStartedServiceServer) SubmitOpenVPNChallengeResponse(context.Context, *OpenVPNChallengeSubmission) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method SubmitOpenVPNChallengeResponse not implemented") +} + +func (UnimplementedStartedServiceServer) CancelOpenVPNChallenge(context.Context, *OpenVPNChallengeCancel) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method CancelOpenVPNChallenge not implemented") +} func (UnimplementedStartedServiceServer) mustEmbedUnimplementedStartedServiceServer() {} func (UnimplementedStartedServiceServer) testEmbeddedByValue() {} @@ -1053,6 +1173,100 @@ func _StartedService_SubscribeUSBIPServerStatus_Handler(srv interface{}, stream // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. type StartedService_SubscribeUSBIPServerStatusServer = grpc.ServerStreamingServer[USBIPServerStatusUpdate] +func _StartedService_SubscribeOpenConnectStatus_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(emptypb.Empty) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(StartedServiceServer).SubscribeOpenConnectStatus(m, &grpc.GenericServerStream[emptypb.Empty, OpenConnectStatusUpdate]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type StartedService_SubscribeOpenConnectStatusServer = grpc.ServerStreamingServer[OpenConnectStatusUpdate] + +func _StartedService_SubmitOpenConnectAuthResponse_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(OpenConnectAuthResponseSubmission) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StartedServiceServer).SubmitOpenConnectAuthResponse(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StartedService_SubmitOpenConnectAuthResponse_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StartedServiceServer).SubmitOpenConnectAuthResponse(ctx, req.(*OpenConnectAuthResponseSubmission)) + } + return interceptor(ctx, in, info, handler) +} + +func _StartedService_CancelOpenConnectAuthChallenge_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(OpenConnectAuthChallengeCancel) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StartedServiceServer).CancelOpenConnectAuthChallenge(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StartedService_CancelOpenConnectAuthChallenge_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StartedServiceServer).CancelOpenConnectAuthChallenge(ctx, req.(*OpenConnectAuthChallengeCancel)) + } + return interceptor(ctx, in, info, handler) +} + +func _StartedService_SubscribeOpenVPNStatus_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(emptypb.Empty) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(StartedServiceServer).SubscribeOpenVPNStatus(m, &grpc.GenericServerStream[emptypb.Empty, OpenVPNStatusUpdate]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type StartedService_SubscribeOpenVPNStatusServer = grpc.ServerStreamingServer[OpenVPNStatusUpdate] + +func _StartedService_SubmitOpenVPNChallengeResponse_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(OpenVPNChallengeSubmission) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StartedServiceServer).SubmitOpenVPNChallengeResponse(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StartedService_SubmitOpenVPNChallengeResponse_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StartedServiceServer).SubmitOpenVPNChallengeResponse(ctx, req.(*OpenVPNChallengeSubmission)) + } + return interceptor(ctx, in, info, handler) +} + +func _StartedService_CancelOpenVPNChallenge_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(OpenVPNChallengeCancel) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(StartedServiceServer).CancelOpenVPNChallenge(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: StartedService_CancelOpenVPNChallenge_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(StartedServiceServer).CancelOpenVPNChallenge(ctx, req.(*OpenVPNChallengeCancel)) + } + return interceptor(ctx, in, info, handler) +} + // StartedService_ServiceDesc is the grpc.ServiceDesc for StartedService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -1116,6 +1330,22 @@ var StartedService_ServiceDesc = grpc.ServiceDesc{ MethodName: "TailscaleLogout", Handler: _StartedService_TailscaleLogout_Handler, }, + { + MethodName: "SubmitOpenConnectAuthResponse", + Handler: _StartedService_SubmitOpenConnectAuthResponse_Handler, + }, + { + MethodName: "CancelOpenConnectAuthChallenge", + Handler: _StartedService_CancelOpenConnectAuthChallenge_Handler, + }, + { + MethodName: "SubmitOpenVPNChallengeResponse", + Handler: _StartedService_SubmitOpenVPNChallengeResponse_Handler, + }, + { + MethodName: "CancelOpenVPNChallenge", + Handler: _StartedService_CancelOpenVPNChallenge_Handler, + }, }, Streams: []grpc.StreamDesc{ { @@ -1190,6 +1420,16 @@ var StartedService_ServiceDesc = grpc.ServiceDesc{ Handler: _StartedService_SubscribeUSBIPServerStatus_Handler, ServerStreams: true, }, + { + StreamName: "SubscribeOpenConnectStatus", + Handler: _StartedService_SubscribeOpenConnectStatus_Handler, + ServerStreams: true, + }, + { + StreamName: "SubscribeOpenVPNStatus", + Handler: _StartedService_SubscribeOpenVPNStatus_Handler, + ServerStreams: true, + }, }, Metadata: "daemon/started_service.proto", } diff --git a/daemon/started_service_usbip.go b/daemon/started_service_usbip.go index 3e771451bf..132336fcb0 100644 --- a/daemon/started_service_usbip.go +++ b/daemon/started_service_usbip.go @@ -29,7 +29,7 @@ func (s *StartedService) ProvideUSBDevices(server grpc.BidiStreamingServer[USBPr instance := s.instance s.serviceAccess.RUnlock() if instance == nil { - return E.New("service not started") + return nil } serviceManager := service.FromContext[adapter.ServiceManager](instance.ctx) if serviceManager == nil { @@ -122,7 +122,7 @@ func (s *StartedService) SubscribeUSBIPServerStatus( instance := s.instance s.serviceAccess.RUnlock() if instance == nil { - return E.New("service not started") + return nil } serviceManager := service.FromContext[adapter.ServiceManager](instance.ctx) if serviceManager == nil { diff --git a/debug.go b/debug.go index f620172be4..96e74c13b1 100644 --- a/debug.go +++ b/debug.go @@ -7,8 +7,14 @@ import ( E "github.com/sagernet/sing/common/exceptions" ) -func applyDebugOptions(options option.DebugOptions) error { - applyDebugListenOption(options) +func checkDebugOptions(options option.DebugOptions) error { + if options.OOMKiller != nil { + return E.New("legacy oom_killer in debug options is removed, use oom-killer service instead") + } + return nil +} + +func applyDebugOptions(options option.DebugOptions) { if options.GCPercent != nil { debug.SetGCPercent(*options.GCPercent) } @@ -27,8 +33,4 @@ func applyDebugOptions(options option.DebugOptions) error { if options.MemoryLimit.Value() != 0 { debug.SetMemoryLimit(int64(float64(options.MemoryLimit.Value()) / 1.5)) } - if options.OOMKiller != nil { - return E.New("legacy oom_killer in debug options is removed, use oom-killer service instead") - } - return nil } diff --git a/debug_http.go b/debug_http.go index e51a073143..ddafe8f2ce 100644 --- a/debug_http.go +++ b/debug_http.go @@ -1,6 +1,7 @@ package box import ( + "net" "net/http" "net/http/pprof" "runtime" @@ -17,15 +18,9 @@ import ( "github.com/go-chi/chi/v5" ) -var debugHTTPServer *http.Server - -func applyDebugListenOption(options option.DebugOptions) { - if debugHTTPServer != nil { - debugHTTPServer.Close() - debugHTTPServer = nil - } +func startDebugHTTPServer(options option.DebugOptions) (*http.Server, error) { if options.Listen == "" { - return + return nil, nil } r := chi.NewMux() r.Route("/debug", func(r chi.Router) { @@ -63,14 +58,19 @@ func applyDebugListenOption(options option.DebugOptions) { r.HandleFunc("/trace", pprof.Trace) }) }) - debugHTTPServer = &http.Server{ + server := &http.Server{ Addr: options.Listen, Handler: r, } + listener, err := net.Listen("tcp", options.Listen) + if err != nil { + return nil, E.Cause(err, "listen debug HTTP server") + } go func() { - err := debugHTTPServer.ListenAndServe() + err := server.Serve(listener) if err != nil && !E.IsClosed(err) { log.Error(E.Cause(err, "serve debug HTTP server")) } }() + return server, nil } diff --git a/dns/client.go b/dns/client.go index ece3e0bdf4..bb88549add 100644 --- a/dns/client.go +++ b/dns/client.go @@ -42,7 +42,7 @@ type Client struct { dnsCache adapter.DNSCacheStore initDNSCacheFunc func() adapter.DNSCacheStore logger logger.ContextLogger - cache freelru.Cache[dnsCacheKey, *dns.Msg] + cache *freelru.Cache[dnsCacheKey, *dns.Msg] cacheLock compatible.Map[dnsCacheKey, chan struct{}] backgroundRefresh compatible.Map[dnsCacheKey, struct{}] } @@ -89,6 +89,24 @@ func NewClient(options ClientOptions) *Client { type dnsCacheKey struct { dns.Question transportTag string + clientSubnet netip.Prefix +} + +func (k dnsCacheKey) persistentName() string { + if !k.clientSubnet.IsValid() { + return k.transportTag + } + return k.transportTag + "\x00" + k.clientSubnet.String() +} + +func (c *Client) effectiveClientSubnet(message *dns.Msg, options adapter.DNSQueryOptions) netip.Prefix { + if options.ClientSubnet.IsValid() { + return options.ClientSubnet + } + if c.clientSubnet.IsValid() { + return c.clientSubnet + } + return clientSubnetFromMessage(message) } func (c *Client) Start() { @@ -107,7 +125,7 @@ func (c *Client) initializeMemoryCache() { if c.disableCache || c.cache != nil { return } - c.cache = common.Must1(freelru.NewSharded[dnsCacheKey, *dns.Msg](c.cacheCapacity, maphash.NewHasher[dnsCacheKey]().Hash32)) + c.cache = common.Must1(freelru.New[dnsCacheKey, *dns.Msg](c.cacheCapacity, maphash.NewHasher[dnsCacheKey]().Hash32, true)) } func extractNegativeTTL(response *dns.Msg) (uint32, bool) { @@ -155,86 +173,130 @@ func normalizeTTL(response *dns.Msg, timeToLive uint32) { } } -func (c *Client) Exchange(ctx context.Context, transport adapter.DNSTransport, message *dns.Msg, options adapter.DNSQueryOptions, responseChecker func(response *dns.Msg) bool) (*dns.Msg, error) { +type exchangeStatus int + +const ( + exchangeReady exchangeStatus = iota + exchangeDone + exchangeWait +) + +type exchangeOperation struct { + ctx context.Context + message *dns.Msg + question dns.Question + messageId uint16 + options adapter.DNSQueryOptions + responseChecker func(response *dns.Msg) bool + disableCache bool + cacheKey dnsCacheKey + releaseCond func() +} + +func (o *exchangeOperation) release() { + if o.releaseCond != nil { + o.releaseCond() + o.releaseCond = nil + } +} + +func (c *Client) beginExchange(ctx context.Context, transport adapter.DNSTransport, message *dns.Msg, options adapter.DNSQueryOptions, responseChecker func(response *dns.Msg) bool, allowWait bool) (*exchangeOperation, *dns.Msg, exchangeStatus, error) { if len(message.Question) == 0 { if c.logger != nil { c.logger.WarnContext(ctx, "bad question size: ", len(message.Question)) } - return FixedResponseStatus(message, dns.RcodeFormatError), nil + return nil, FixedResponseStatus(message, dns.RcodeFormatError), exchangeDone, nil } question := message.Question[0] if question.Qtype == dns.TypeA && options.Strategy == C.DomainStrategyIPv6Only || question.Qtype == dns.TypeAAAA && options.Strategy == C.DomainStrategyIPv4Only { if c.logger != nil { c.logger.DebugContext(ctx, "strategy rejected") } - return FixedResponseStatus(message, dns.RcodeSuccess), nil + return nil, FixedResponseStatus(message, dns.RcodeSuccess), exchangeDone, nil } - message = c.prepareExchangeMessage(message, options) - isSimpleRequest := len(message.Question) == 1 && len(message.Ns) == 0 && (len(message.Extra) == 0 || len(message.Extra) == 1 && message.Extra[0].Header().Rrtype == dns.TypeOPT && message.Extra[0].Header().Class > 0 && message.Extra[0].Header().Ttl == 0 && - len(message.Extra[0].(*dns.OPT).Option) == 0) && - !options.ClientSubnet.IsValid() + common.All(message.Extra[0].(*dns.OPT).Option, func(it dns.EDNS0) bool { + return it.Option() == dns.EDNS0SUBNET + })) + message = c.prepareExchangeMessage(message, options) disableCache := !isSimpleRequest || c.disableCache || options.DisableCache + operation := &exchangeOperation{ + message: message, + question: question, + messageId: message.Id, + options: options, + responseChecker: responseChecker, + disableCache: disableCache, + } if !disableCache { - cacheKey := dnsCacheKey{Question: question, transportTag: transport.Tag()} + cacheKey := dnsCacheKey{Question: question, transportTag: transport.Tag(), clientSubnet: c.effectiveClientSubnet(message, options)} + operation.cacheKey = cacheKey cond, loaded := c.cacheLock.LoadOrStore(cacheKey, make(chan struct{})) if loaded { + if !allowWait { + return nil, nil, exchangeWait, nil + } select { case <-cond: case <-time.After(c.timeout): - return nil, E.New("cache wait timeout") + return nil, nil, exchangeDone, E.New("cache wait timeout") case <-ctx.Done(): - return nil, ctx.Err() + return nil, nil, exchangeDone, ctx.Err() } } else { - defer func() { + operation.releaseCond = func() { c.cacheLock.Delete(cacheKey) close(cond) - }() + } } - response, ttl, isStale := c.loadResponse(question, transport) + response, ttl, isStale := c.loadResponse(cacheKey) if response != nil { if isStale && !options.DisableOptimisticCache { - c.backgroundRefreshDNS(transport, question, message.Copy(), options, responseChecker) + c.backgroundRefreshDNS(transport, cacheKey, message.Copy(), options, responseChecker) logOptimisticResponse(c.logger, ctx, response) response.Id = message.Id - return response, nil + operation.release() + return nil, response, exchangeDone, nil } else if !isStale { logCachedResponse(c.logger, ctx, response, ttl) response.Id = message.Id - return response, nil + operation.release() + return nil, response, exchangeDone, nil } } } - messageId := message.Id - contextTransport, clientSubnetLoaded := transportTagFromContext(ctx) - if clientSubnetLoaded && transport.Tag() == contextTransport { - return nil, E.New("DNS query loopback in transport[", contextTransport, "]") + contextTransport, transportTagLoaded := transportTagFromContext(ctx) + if transportTagLoaded && transport.Tag() == contextTransport { + operation.release() + return nil, nil, exchangeDone, E.New("DNS query loopback in transport[", contextTransport, "]") } - ctx = contextWithTransportTag(ctx, transport.Tag()) + operation.ctx = contextWithTransportTag(ctx, transport.Tag()) if !disableCache && responseChecker != nil && c.rdrc != nil { rejected := c.rdrc.LoadRDRC(transport.Tag(), question.Name, question.Qtype) if rejected { - return nil, ErrResponseRejectedCached + operation.release() + return nil, nil, exchangeDone, ErrResponseRejectedCached } } - response, err := c.exchangeToTransport(ctx, transport, message) - if err != nil { - return nil, err - } - disableCache = disableCache || (response.Rcode != dns.RcodeSuccess && response.Rcode != dns.RcodeNameError) - if responseChecker != nil { + return operation, nil, exchangeReady, nil +} + +func (c *Client) finishExchange(transport adapter.DNSTransport, operation *exchangeOperation, response *dns.Msg) (*dns.Msg, error) { + ctx := operation.ctx + question := operation.question + disableCache := operation.disableCache || (response.Rcode != dns.RcodeSuccess && response.Rcode != dns.RcodeNameError) + if operation.responseChecker != nil { var rejected bool if response.Rcode != dns.RcodeSuccess && response.Rcode != dns.RcodeNameError { rejected = true } else { - rejected = !responseChecker(response) + rejected = !operation.responseChecker(response) } if rejected { if !disableCache && c.rdrc != nil { @@ -244,12 +306,12 @@ func (c *Client) Exchange(ctx context.Context, transport adapter.DNSTransport, m return response, ErrResponseRejected } } - timeToLive := applyResponseOptions(question, response, options) + timeToLive := applyResponseOptions(question, response, operation.options) if !disableCache { - c.storeCache(transport, question, response, timeToLive) + c.storeCache(operation.cacheKey, response, timeToLive) } - response.Id = messageId - requestEDNSOpt := message.IsEdns0() + response.Id = operation.messageId + requestEDNSOpt := operation.message.IsEdns0() responseEDNSOpt := response.IsEdns0() if responseEDNSOpt != nil && (requestEDNSOpt == nil || requestEDNSOpt.Version() < responseEDNSOpt.Version()) { response.Extra = common.Filter(response.Extra, func(it dns.RR) bool { @@ -263,6 +325,44 @@ func (c *Client) Exchange(ctx context.Context, transport adapter.DNSTransport, m return response, nil } +func (c *Client) Exchange(ctx context.Context, transport adapter.DNSTransport, message *dns.Msg, options adapter.DNSQueryOptions, responseChecker func(response *dns.Msg) bool) (*dns.Msg, error) { + operation, earlyResponse, status, err := c.beginExchange(ctx, transport, message, options, responseChecker, true) + if status != exchangeReady { + return earlyResponse, err + } + defer operation.release() + response, err := c.exchangeToTransport(operation.ctx, transport, operation.message, options.Timeout) + if err != nil { + return nil, err + } + return c.finishExchange(transport, operation, response) +} + +func (c *Client) ExchangeAsync(ctx context.Context, transport adapter.DNSTransport, message *dns.Msg, options adapter.DNSQueryOptions, responseChecker func(response *dns.Msg) bool, callback func(response *dns.Msg, err error)) { + operation, earlyResponse, status, err := c.beginExchange(ctx, transport, message, options, responseChecker, false) + switch status { + case exchangeDone: + callback(earlyResponse, err) + return + case exchangeWait: + go func() { + callback(c.Exchange(ctx, transport, message, options, responseChecker)) + }() + return + } + finish := func(response *dns.Msg, exchangeErr error) { + if exchangeErr != nil { + operation.release() + callback(nil, exchangeErr) + return + } + finishedResponse, finishErr := c.finishExchange(transport, operation, response) + operation.release() + callback(finishedResponse, finishErr) + } + c.exchangeToTransportAsync(operation.ctx, transport, operation.message, options.Timeout, finish) +} + func (c *Client) Lookup(ctx context.Context, transport adapter.DNSTransport, domain string, options adapter.DNSQueryOptions, responseChecker func(response *dns.Msg) bool) ([]netip.Addr, error) { domain = FqdnToDomain(domain) dnsName := dns.Fqdn(domain) @@ -327,7 +427,7 @@ func sortAddresses(response4 []netip.Addr, response6 []netip.Addr, strategy C.Do } } -func (c *Client) storeCache(transport adapter.DNSTransport, question dns.Question, message *dns.Msg, timeToLive uint32) { +func (c *Client) storeCache(key dnsCacheKey, message *dns.Msg, timeToLive uint32) { if timeToLive == 0 { return } @@ -335,14 +435,13 @@ func (c *Client) storeCache(transport adapter.DNSTransport, question dns.Questio packed, err := message.Pack() if err == nil { expireAt := time.Now().Add(time.Second * time.Duration(timeToLive)) - c.dnsCache.SaveDNSCacheAsync(transport.Tag(), question.Name, question.Qtype, packed, expireAt, c.logger) + c.dnsCache.SaveDNSCacheAsync(key.persistentName(), key.Name, key.Qtype, packed, expireAt, c.logger) } return } if c.cache == nil { return } - key := dnsCacheKey{Question: question, transportTag: transport.Tag()} if c.disableExpire { c.cache.Add(key, message.Copy()) } else { @@ -381,7 +480,8 @@ func (c *Client) lookupToExchange(ctx context.Context, transport adapter.DNSTran func (c *Client) questionCache(ctx context.Context, transport adapter.DNSTransport, message *dns.Msg, options adapter.DNSQueryOptions, responseChecker func(response *dns.Msg) bool) ([]netip.Addr, error) { question := message.Question[0] - response, _, isStale := c.loadResponse(question, transport) + cacheKey := dnsCacheKey{Question: question, transportTag: transport.Tag(), clientSubnet: c.effectiveClientSubnet(message, options)} + response, _, isStale := c.loadResponse(cacheKey) if response == nil { return nil, ErrNotCached } @@ -389,7 +489,7 @@ func (c *Client) questionCache(ctx context.Context, transport adapter.DNSTranspo if options.DisableOptimisticCache { return nil, ErrNotCached } - c.backgroundRefreshDNS(transport, question, c.prepareExchangeMessage(message.Copy(), options), options, responseChecker) + c.backgroundRefreshDNS(transport, cacheKey, c.prepareExchangeMessage(message.Copy(), options), options, responseChecker) logOptimisticResponse(c.logger, ctx, response) } if response.Rcode != dns.RcodeSuccess { @@ -398,14 +498,13 @@ func (c *Client) questionCache(ctx context.Context, transport adapter.DNSTranspo return MessageToAddresses(response), nil } -func (c *Client) loadResponse(question dns.Question, transport adapter.DNSTransport) (*dns.Msg, int, bool) { +func (c *Client) loadResponse(key dnsCacheKey) (*dns.Msg, int, bool) { if c.dnsCache != nil { - return c.loadPersistentResponse(question, transport) + return c.loadPersistentResponse(key) } if c.cache == nil { return nil, 0, false } - key := dnsCacheKey{Question: question, transportTag: transport.Tag()} if c.disableExpire { response, loaded := c.cache.Get(key) if !loaded { @@ -436,8 +535,8 @@ func (c *Client) loadResponse(question dns.Question, transport adapter.DNSTransp return response, nowTTL, false } -func (c *Client) loadPersistentResponse(question dns.Question, transport adapter.DNSTransport) (*dns.Msg, int, bool) { - rawMessage, expireAt, loaded := c.dnsCache.LoadDNSCache(transport.Tag(), question.Name, question.Qtype) +func (c *Client) loadPersistentResponse(key dnsCacheKey) (*dns.Msg, int, bool) { + rawMessage, expireAt, loaded := c.dnsCache.LoadDNSCache(key.persistentName(), key.Name, key.Qtype) if !loaded { return nil, 0, false } @@ -490,8 +589,7 @@ func applyResponseOptions(question dns.Question, response *dns.Msg, options adap return timeToLive } -func (c *Client) backgroundRefreshDNS(transport adapter.DNSTransport, question dns.Question, message *dns.Msg, options adapter.DNSQueryOptions, responseChecker func(response *dns.Msg) bool) { - key := dnsCacheKey{Question: question, transportTag: transport.Tag()} +func (c *Client) backgroundRefreshDNS(transport adapter.DNSTransport, key dnsCacheKey, message *dns.Msg, options adapter.DNSQueryOptions, responseChecker func(response *dns.Msg) bool) { _, loaded := c.backgroundRefresh.LoadOrStore(key, struct{}{}) if loaded { return @@ -499,10 +597,10 @@ func (c *Client) backgroundRefreshDNS(transport adapter.DNSTransport, question d go func() { defer c.backgroundRefresh.Delete(key) ctx := contextWithTransportTag(c.ctx, transport.Tag()) - response, err := c.exchangeToTransport(ctx, transport, message) + response, err := c.exchangeToTransport(ctx, transport, message, 0) if err != nil { if c.logger != nil { - c.logger.Debug("optimistic refresh failed for ", FqdnToDomain(question.Name), ": ", err) + c.logger.DebugContext(ctx, "optimistic refresh failed for ", FqdnToDomain(key.Name), ": ", err) } return } @@ -514,16 +612,20 @@ func (c *Client) backgroundRefreshDNS(transport adapter.DNSTransport, question d rejected = !responseChecker(response) } if rejected { + if c.logger != nil { + c.logger.DebugContext(ctx, "optimistic refresh rejected for ", FqdnToDomain(key.Name)) + } if c.rdrc != nil { - c.rdrc.SaveRDRCAsync(transport.Tag(), question.Name, question.Qtype, c.logger) + c.rdrc.SaveRDRCAsync(transport.Tag(), key.Name, key.Qtype, c.logger) } return } } else if response.Rcode != dns.RcodeSuccess && response.Rcode != dns.RcodeNameError { return } - timeToLive := applyResponseOptions(question, response, options) - c.storeCache(transport, question, response, timeToLive) + timeToLive := applyResponseOptions(key.Question, response, options) + c.storeCache(key, response, timeToLive) + logRefreshedResponse(c.logger, ctx, response, timeToLive) }() } @@ -550,8 +652,11 @@ func stripDNSPadding(response *dns.Msg) { } } -func (c *Client) exchangeToTransport(ctx context.Context, transport adapter.DNSTransport, message *dns.Msg) (*dns.Msg, error) { - ctx, cancel := context.WithTimeout(ctx, c.timeout) +func (c *Client) exchangeToTransport(ctx context.Context, transport adapter.DNSTransport, message *dns.Msg, timeout time.Duration) (*dns.Msg, error) { + if timeout == 0 { + timeout = c.timeout + } + ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() response, err := transport.Exchange(ctx, message) if err == nil { @@ -565,6 +670,27 @@ func (c *Client) exchangeToTransport(ctx context.Context, transport adapter.DNST return nil, err } +func (c *Client) exchangeToTransportAsync(ctx context.Context, transport adapter.DNSTransport, message *dns.Msg, timeout time.Duration, callback func(response *dns.Msg, err error)) { + if timeout == 0 { + timeout = c.timeout + } + ctx, cancel := context.WithTimeout(ctx, timeout) + transport.ExchangeAsync(ctx, message, func(response *dns.Msg, err error) { + cancel() + if err == nil { + stripDNSPadding(response) + callback(response, nil) + return + } + var rcodeError RcodeError + if errors.As(err, &rcodeError) { + callback(FixedResponseStatus(message, int(rcodeError)), nil) + return + } + callback(nil, err) + }) +} + func MessageToAddresses(response *dns.Msg) []netip.Addr { return adapter.DNSResponseAddresses(response) } diff --git a/dns/extension_edns0_subnet.go b/dns/extension_edns0_subnet.go index e804fb6cd1..7725809753 100644 --- a/dns/extension_edns0_subnet.go +++ b/dns/extension_edns0_subnet.go @@ -10,6 +10,27 @@ func SetClientSubnet(message *dns.Msg, clientSubnet netip.Prefix) *dns.Msg { return setClientSubnet(message, clientSubnet, true) } +func clientSubnetFromMessage(message *dns.Msg) netip.Prefix { + for _, record := range message.Extra { + optRecord, isOPTRecord := record.(*dns.OPT) + if !isOPTRecord { + continue + } + for _, option := range optRecord.Option { + subnetOption, isEDNS0Subnet := option.(*dns.EDNS0_SUBNET) + if !isEDNS0Subnet { + continue + } + address, addressLoaded := netip.AddrFromSlice(subnetOption.Address) + if !addressLoaded { + return netip.Prefix{} + } + return netip.PrefixFrom(address, int(subnetOption.SourceNetmask)) + } + } + return netip.Prefix{} +} + func setClientSubnet(message *dns.Msg, clientSubnet netip.Prefix, clone bool) *dns.Msg { var ( optRecord *dns.OPT diff --git a/dns/repro_test.go b/dns/repro_test.go index 113f7c49b9..62a240a3fb 100644 --- a/dns/repro_test.go +++ b/dns/repro_test.go @@ -74,8 +74,8 @@ func TestReproLogicalMatchResponseIPCIDR(t *testing.T) { Domain: badoption.Listable[string]{"example.com"}, }, DNSRuleAction: option.DNSRuleAction{ - Action: C.RuleActionTypeEvaluate, - RouteOptions: option.DNSRouteActionOptions{Server: "upstream"}, + Action: C.RuleActionTypeEvaluate, + EvaluateOptions: option.DNSEvaluateActionOptions{Server: "upstream"}, }, }, }, @@ -88,7 +88,7 @@ func TestReproLogicalMatchResponseIPCIDR(t *testing.T) { Type: C.RuleTypeDefault, DefaultOptions: option.DefaultDNSRule{ RawDefaultDNSRule: option.RawDefaultDNSRule{ - MatchResponse: true, + MatchResponse: &option.DNSRuleMatchResponse{Enabled: true}, IPCIDR: badoption.Listable[string]{"1.1.1.0/24"}, }, }, diff --git a/dns/router.go b/dns/router.go index e40636bb70..c6cdd2188c 100644 --- a/dns/router.go +++ b/dns/router.go @@ -3,6 +3,7 @@ package dns import ( "context" "errors" + "maps" "net/netip" "strings" "sync" @@ -42,7 +43,7 @@ type Router struct { rawRules []option.DNSRule rules []adapter.DNSRule defaultDomainStrategy C.DomainStrategy - dnsReverseMapping freelru.Cache[netip.Addr, string] + dnsReverseMapping *freelru.Cache[netip.Addr, string] platformInterface adapter.PlatformInterface legacyDNSMode bool rulesAccess sync.RWMutex @@ -109,7 +110,7 @@ func NewRouter(ctx context.Context, logFactory log.Factory, options option.DNSOp Logger: router.logger, }) if options.ReverseMapping { - router.dnsReverseMapping = common.Must1(freelru.NewSharded[netip.Addr, string](1024, maphash.NewHasher[netip.Addr]().Hash32)) + router.dnsReverseMapping = common.Must1(freelru.New[netip.Addr, string](1024, maphash.NewHasher[netip.Addr]().Hash32, true)) } return router, nil } @@ -185,10 +186,14 @@ func (r *Router) buildRules(startRules bool) ([]adapter.DNSRule, bool, dnsRuleMo return nil, false, dnsRuleModeFlags{}, err } if !legacyDNSMode { - err = validateLegacyDNSModeDisabledRules(router, r.rawRules, nil) + var validationWarnings []string + validationWarnings, err = validateLegacyDNSModeDisabledRules(router, r.rawRules, nil) if err != nil { return nil, false, dnsRuleModeFlags{}, err } + for _, warning := range validationWarnings { + r.logger.Warn(warning) + } } err = validateEvaluateFakeIPRules(r.rawRules, r.transport) if err != nil { @@ -247,7 +252,8 @@ func (r *Router) ValidateRuleSetMetadataUpdate(tag string, metadata adapter.Rule return err } if !candidateLegacyDNSMode { - return validateLegacyDNSModeDisabledRules(router, r.rawRules, overrides) + _, err = validateLegacyDNSModeDisabledRules(router, r.rawRules, overrides) + return err } return nil } @@ -257,7 +263,7 @@ func (r *Router) ValidateRuleSetMetadataUpdate(tag string, metadata adapter.Rule } if legacyDNSMode { if !candidateLegacyDNSMode && flags.disabled { - err := validateLegacyDNSModeDisabledRules(router, r.rawRules, overrides) + _, err = validateLegacyDNSModeDisabledRules(router, r.rawRules, overrides) if err != nil { return err } @@ -268,7 +274,8 @@ func (r *Router) ValidateRuleSetMetadataUpdate(tag string, metadata adapter.Rule if candidateLegacyDNSMode { return E.New(deprecated.OptionLegacyDNSAddressFilter.MessageWithLink()) } - return validateLegacyDNSModeDisabledRules(router, r.rawRules, overrides) + _, err = validateLegacyDNSModeDisabledRules(router, r.rawRules, overrides) + return err } func (r *Router) matchDNS(ctx context.Context, rules []adapter.DNSRule, allowFakeIP bool, ruleIndex int, isAddressQuery bool, options *adapter.DNSQueryOptions) (adapter.DNSTransport, adapter.DNSRule, int) { @@ -400,60 +407,277 @@ type exchangeWithRulesResult struct { const dnsRespondMissingResponseMessage = "respond action requires an evaluated response from a preceding evaluate action" -func (r *Router) exchangeWithRules(ctx context.Context, rules []adapter.DNSRule, message *mDNS.Msg, options adapter.DNSQueryOptions, allowFakeIP bool) exchangeWithRulesResult { +type dnsRuleWalkState struct { + ruleIndex int + lastLoggedIndex int + effectiveOptions adapter.DNSQueryOptions + anonymousFuture *dnsEvaluatedFuture + namedFutures map[string]*dnsEvaluatedFuture + namedResponses map[string]*mDNS.Msg + namedTransports map[string]adapter.DNSTransport + futures []*dnsEvaluatedFuture + armedRules []*dnsArmedRule + terminalFuture *dnsEvaluatedFuture + terminalIndex int + wake chan struct{} +} + +func (s *dnsRuleWalkState) anonymousResponse() *mDNS.Msg { + if s.anonymousFuture == nil { + return nil + } + return s.anonymousFuture.view() +} + +type dnsEvaluatedFuture struct { + tag string + terminal bool + transport adapter.DNSTransport + cancel context.CancelFunc + done chan struct{} + response *mDNS.Msg + err error + settled bool +} + +func (f *dnsEvaluatedFuture) resolved() bool { + select { + case <-f.done: + return true + default: + return false + } +} + +func (f *dnsEvaluatedFuture) view() *mDNS.Msg { + if !f.resolved() || f.err != nil { + return nil + } + return f.response +} + +type dnsArmedRule struct { + ruleIndex int + rule adapter.DNSRule + futures []*dnsEvaluatedFuture + anonymousFuture *dnsEvaluatedFuture + bindsAnonymous bool + options adapter.DNSQueryOptions +} + +type dnsPendingExchange struct { + transport adapter.DNSTransport + options adapter.DNSQueryOptions + future *dnsEvaluatedFuture +} + +type dnsWalkSuspension struct { + await *dnsEvaluatedFuture + drain bool + pending *dnsPendingExchange +} + +func (r *Router) launchDNSEvaluate(ctx context.Context, state *dnsRuleWalkState, tag string, transport adapter.DNSTransport, message *mDNS.Msg, options adapter.DNSQueryOptions) *dnsEvaluatedFuture { + if state.wake == nil { + state.wake = make(chan struct{}, 1) + } + wake := state.wake + exchangeCtx, cancel := context.WithCancel(adapter.OverrideContext(ctx)) + future := &dnsEvaluatedFuture{ + tag: tag, + transport: transport, + cancel: cancel, + done: make(chan struct{}), + } + state.futures = append(state.futures, future) + r.client.ExchangeAsync(exchangeCtx, transport, message, r.finalizeExchangeOptions(options), nil, func(response *mDNS.Msg, err error) { + future.response = response + future.err = err + close(future.done) + select { + case wake <- struct{}{}: + default: + } + }) + return future +} + +func (r *Router) settleDNSFutures(ctx context.Context, message *mDNS.Msg, state *dnsRuleWalkState) { + for _, future := range state.futures { + if future.settled || !future.resolved() { + continue + } + future.settled = true + if future.err != nil && !future.terminal { + r.logger.ErrorContext(ctx, E.Cause(future.err, "exchange failed for ", FormatQuestion(message.Question[0].String()))) + } + if future.tag == "" { + continue + } + newResponses := make(map[string]*mDNS.Msg, len(state.namedResponses)+1) + maps.Copy(newResponses, state.namedResponses) + newResponses[future.tag] = future.view() + state.namedResponses = newResponses + newTransports := make(map[string]adapter.DNSTransport, len(state.namedTransports)+1) + maps.Copy(newTransports, state.namedTransports) + newTransports[future.tag] = future.transport + state.namedTransports = newTransports + } +} + +func cancelDNSFutures(state *dnsRuleWalkState) { + for _, future := range state.futures { + future.cancel() + } +} + +func dnsRefusedResponse(message *mDNS.Msg) *mDNS.Msg { + return &mDNS.Msg{ + MsgHdr: mDNS.MsgHdr{ + Id: message.Id, + Rcode: mDNS.RcodeRefused, + Response: true, + }, + Question: []mDNS.Question{message.Question[0]}, + } +} + +func (r *Router) finalizeExchangeOptions(options adapter.DNSQueryOptions) adapter.DNSQueryOptions { + if options.Strategy == C.DomainStrategyAsIS { + options.Strategy = r.defaultDomainStrategy + } + return options +} + +func (r *Router) walkDNSRules(ctx context.Context, rules []adapter.DNSRule, message *mDNS.Msg, state *dnsRuleWalkState, allowFakeIP bool) (exchangeWithRulesResult, *dnsWalkSuspension) { metadata := adapter.ContextFrom(ctx) if metadata == nil { panic("no context") } - effectiveOptions := options - var evaluatedResponse *mDNS.Msg - var evaluatedTransport adapter.DNSTransport - for currentRuleIndex, currentRule := range rules { + for ; state.ruleIndex < len(rules); state.ruleIndex++ { + currentRule := rules[state.ruleIndex] + hasBindings := len(currentRule.MatchResponseTags()) > 0 || currentRule.MatchResponseAnonymous() + if hasBindings { + r.settleDNSFutures(ctx, message, state) + if currentRule.Race() { + var ( + pendingFutures []*dnsEvaluatedFuture + anonymousFuture *dnsEvaluatedFuture + ) + for _, responseTag := range currentRule.MatchResponseTags() { + future := state.namedFutures[responseTag] + if future != nil && !future.resolved() { + pendingFutures = append(pendingFutures, future) + } + } + bindsAnonymous := currentRule.MatchResponseAnonymous() + if bindsAnonymous { + anonymousFuture = state.anonymousFuture + if anonymousFuture != nil && !anonymousFuture.resolved() { + pendingFutures = append(pendingFutures, anonymousFuture) + } + } + if len(pendingFutures) > 0 { + r.logger.DebugContext(ctx, "armed[", state.ruleIndex, "] ", currentRule, " => ", currentRule.Action()) + state.armedRules = append(state.armedRules, &dnsArmedRule{ + ruleIndex: state.ruleIndex, + rule: currentRule, + futures: pendingFutures, + anonymousFuture: anonymousFuture, + bindsAnonymous: bindsAnonymous, + options: state.effectiveOptions, + }) + continue + } + } else { + var awaitFuture *dnsEvaluatedFuture + for _, responseTag := range currentRule.MatchResponseTags() { + future := state.namedFutures[responseTag] + if future != nil && !future.resolved() { + awaitFuture = future + break + } + } + if awaitFuture == nil && currentRule.MatchResponseAnonymous() { + if future := state.anonymousFuture; future != nil && !future.resolved() { + awaitFuture = future + } + } + if awaitFuture != nil { + return exchangeWithRulesResult{}, &dnsWalkSuspension{await: awaitFuture} + } + } + } metadata.ResetRuleCache() - metadata.DNSResponse = evaluatedResponse + metadata.DNSResponse = state.anonymousResponse() + metadata.NamedDNSResponses = state.namedResponses metadata.DestinationAddressMatchFromResponse = false if !currentRule.Match(metadata) { continue } - r.logRuleMatch(ctx, currentRuleIndex, currentRule) + if state.lastLoggedIndex != state.ruleIndex { + state.lastLoggedIndex = state.ruleIndex + r.logRuleMatch(ctx, state.ruleIndex, currentRule) + } switch action := currentRule.Action().(type) { case *R.RuleActionDNSRouteOptions: - r.applyDNSRouteOptions(&effectiveOptions, *action) + r.applyDNSRouteOptions(&state.effectiveOptions, *action) case *R.RuleActionEvaluate: - queryOptions := effectiveOptions transport, loaded := r.transport.Transport(action.Server) if !loaded { r.logger.ErrorContext(ctx, "transport not found: ", action.Server) - evaluatedResponse = nil - evaluatedTransport = nil + if action.Tag == "" { + state.anonymousFuture = nil + } continue } - r.applyDNSRouteOptions(&queryOptions, action.RuleActionDNSRouteOptions) - exchangeOptions := queryOptions - if exchangeOptions.Strategy == C.DomainStrategyAsIS { - exchangeOptions.Strategy = r.defaultDomainStrategy + if !action.Speculative && len(state.armedRules) > 0 { + return exchangeWithRulesResult{}, &dnsWalkSuspension{drain: true} } - response, err := r.client.Exchange(adapter.OverrideContext(ctx), transport, message, exchangeOptions, nil) - if err != nil { - r.logger.ErrorContext(ctx, E.Cause(err, "exchange failed for ", FormatQuestion(message.Question[0].String()))) - evaluatedResponse = nil - evaluatedTransport = nil - continue + queryOptions := state.effectiveOptions + r.applyDNSRouteOptions(&queryOptions, action.RuleActionDNSRouteOptions) + future := r.launchDNSEvaluate(ctx, state, action.Tag, transport, message, queryOptions) + if action.Tag == "" { + state.anonymousFuture = future + } else { + if state.namedFutures == nil { + state.namedFutures = make(map[string]*dnsEvaluatedFuture) + } + state.namedFutures[action.Tag] = future } - evaluatedResponse = response - evaluatedTransport = transport case *R.RuleActionRespond: - if evaluatedResponse == nil { + if len(state.armedRules) > 0 { + return exchangeWithRulesResult{}, &dnsWalkSuspension{drain: true} + } + if responseTag := currentRule.MatchResponseTag(); responseTag != "" { + namedResponse := state.namedResponses[responseTag] + if namedResponse == nil { + return exchangeWithRulesResult{ + err: E.New(dnsRespondMissingResponseMessage), + }, nil + } return exchangeWithRulesResult{ - err: E.New(dnsRespondMissingResponseMessage), + response: namedResponse, + transport: state.namedTransports[responseTag], + }, nil + } + if !hasBindings { + if future := state.anonymousFuture; future != nil && !future.resolved() { + return exchangeWithRulesResult{}, &dnsWalkSuspension{await: future} } } - return exchangeWithRulesResult{ - response: evaluatedResponse, - transport: evaluatedTransport, + response := state.anonymousResponse() + if response == nil { + return exchangeWithRulesResult{ + err: E.New(dnsRespondMissingResponseMessage), + }, nil } + return exchangeWithRulesResult{ + response: response, + transport: state.anonymousFuture.transport, + }, nil case *R.RuleActionDNSRoute: - queryOptions := effectiveOptions + queryOptions := state.effectiveOptions transport, status := r.resolveDNSRoute(action.Server, action.RuleActionDNSRouteOptions, allowFakeIP, &queryOptions) switch status { case dnsRouteStatusMissing: @@ -462,55 +686,233 @@ func (r *Router) exchangeWithRules(ctx context.Context, rules []adapter.DNSRule, case dnsRouteStatusSkipped: continue } - exchangeOptions := queryOptions - if exchangeOptions.Strategy == C.DomainStrategyAsIS { - exchangeOptions.Strategy = r.defaultDomainStrategy + if len(state.armedRules) > 0 { + if action.Speculative && state.terminalFuture == nil { + future := r.launchDNSEvaluate(ctx, state, "", transport, message, queryOptions) + future.terminal = true + state.terminalFuture = future + state.terminalIndex = state.ruleIndex + } + return exchangeWithRulesResult{}, &dnsWalkSuspension{drain: true} + } + if state.terminalFuture != nil && state.terminalIndex == state.ruleIndex { + return exchangeWithRulesResult{}, &dnsWalkSuspension{pending: &dnsPendingExchange{transport: state.terminalFuture.transport, future: state.terminalFuture}} + } + return exchangeWithRulesResult{}, &dnsWalkSuspension{pending: &dnsPendingExchange{transport: transport, options: queryOptions}} + case *R.RuleActionReject: + if len(state.armedRules) > 0 { + return exchangeWithRulesResult{}, &dnsWalkSuspension{drain: true} + } + switch action.Method { + case C.RuleActionRejectMethodDefault: + return exchangeWithRulesResult{ + response: dnsRefusedResponse(message), + rejectAction: action, + }, nil + case C.RuleActionRejectMethodDrop: + return exchangeWithRulesResult{ + rejectAction: action, + err: R.ErrDrop, + }, nil + } + case *R.RuleActionPredefined: + if len(state.armedRules) > 0 { + return exchangeWithRulesResult{}, &dnsWalkSuspension{drain: true} + } + return exchangeWithRulesResult{ + response: action.Response(message), + }, nil + } + } + if len(state.armedRules) > 0 { + return exchangeWithRulesResult{}, &dnsWalkSuspension{drain: true} + } + return exchangeWithRulesResult{}, &dnsWalkSuspension{pending: &dnsPendingExchange{transport: r.transport.Default(), options: state.effectiveOptions}} +} + +func (r *Router) exchangeWithRules(ctx context.Context, rules []adapter.DNSRule, message *mDNS.Msg, options adapter.DNSQueryOptions, allowFakeIP bool) exchangeWithRulesResult { + state := dnsRuleWalkState{effectiveOptions: options, lastLoggedIndex: -1} + result, suspension := r.walkDNSRules(ctx, rules, message, &state, allowFakeIP) + if suspension == nil { + cancelDNSFutures(&state) + return result + } + return r.resumeExchangeWithRules(ctx, rules, message, &state, allowFakeIP, suspension) +} + +func (r *Router) resumeExchangeWithRules(ctx context.Context, rules []adapter.DNSRule, message *mDNS.Msg, state *dnsRuleWalkState, allowFakeIP bool, suspension *dnsWalkSuspension) exchangeWithRulesResult { + defer cancelDNSFutures(state) + for { + r.settleDNSFutures(ctx, message, state) + sweepResult, sweepPending, committed := r.sweepArmedDNSRules(ctx, message, state, allowFakeIP) + if committed { + if sweepPending != nil { + return r.finishPendingExchange(ctx, message, state, sweepPending) + } + return sweepResult + } + if suspension != nil { + if suspension.pending != nil { + return r.finishPendingExchange(ctx, message, state, suspension.pending) + } + if (suspension.await != nil && !suspension.await.resolved()) || (suspension.drain && len(state.armedRules) > 0) { + select { + case <-state.wake: + case <-ctx.Done(): + return exchangeWithRulesResult{err: ctx.Err()} + } + continue + } + } + var result exchangeWithRulesResult + result, suspension = r.walkDNSRules(ctx, rules, message, state, allowFakeIP) + if suspension == nil { + return result + } + } +} + +func (r *Router) sweepArmedDNSRules(ctx context.Context, message *mDNS.Msg, state *dnsRuleWalkState, allowFakeIP bool) (exchangeWithRulesResult, *dnsPendingExchange, bool) { + metadata := adapter.ContextFrom(ctx) + for index := 0; index < len(state.armedRules); { + armed := state.armedRules[index] + ready := true + for _, future := range armed.futures { + if !future.resolved() { + ready = false + break + } + } + if !ready { + index++ + continue + } + state.armedRules = append(state.armedRules[:index], state.armedRules[index+1:]...) + metadata.ResetRuleCache() + if armed.bindsAnonymous { + if armed.anonymousFuture != nil { + metadata.DNSResponse = armed.anonymousFuture.view() + } else { + metadata.DNSResponse = nil + } + } else { + metadata.DNSResponse = state.anonymousResponse() + } + metadata.NamedDNSResponses = state.namedResponses + metadata.DestinationAddressMatchFromResponse = false + if !armed.rule.Match(metadata) { + continue + } + r.logRuleMatch(ctx, armed.ruleIndex, armed.rule) + switch action := armed.rule.Action().(type) { + case *R.RuleActionRespond: + var ( + response *mDNS.Msg + transport adapter.DNSTransport + ) + if responseTag := armed.rule.MatchResponseTag(); responseTag != "" { + response = state.namedResponses[responseTag] + transport = state.namedTransports[responseTag] + } else if armed.anonymousFuture != nil { + response = armed.anonymousFuture.view() + transport = armed.anonymousFuture.transport + } else if state.anonymousFuture != nil { + response = state.anonymousResponse() + transport = state.anonymousFuture.transport + } + if response == nil { + return exchangeWithRulesResult{ + err: E.New(dnsRespondMissingResponseMessage), + }, nil, true } - response, err := r.client.Exchange(adapter.OverrideContext(ctx), transport, message, exchangeOptions, nil) return exchangeWithRulesResult{ response: response, transport: transport, - err: err, + }, nil, true + case *R.RuleActionDNSRoute: + queryOptions := armed.options + transport, status := r.resolveDNSRoute(action.Server, action.RuleActionDNSRouteOptions, allowFakeIP, &queryOptions) + switch status { + case dnsRouteStatusMissing: + r.logger.ErrorContext(ctx, "transport not found: ", action.Server) + continue + case dnsRouteStatusSkipped: + continue } + return exchangeWithRulesResult{}, &dnsPendingExchange{transport: transport, options: queryOptions}, true case *R.RuleActionReject: switch action.Method { case C.RuleActionRejectMethodDefault: return exchangeWithRulesResult{ - response: &mDNS.Msg{ - MsgHdr: mDNS.MsgHdr{ - Id: message.Id, - Rcode: mDNS.RcodeRefused, - Response: true, - }, - Question: []mDNS.Question{message.Question[0]}, - }, + response: dnsRefusedResponse(message), rejectAction: action, - } + }, nil, true case C.RuleActionRejectMethodDrop: return exchangeWithRulesResult{ rejectAction: action, err: R.ErrDrop, - } + }, nil, true } case *R.RuleActionPredefined: return exchangeWithRulesResult{ response: action.Response(message), - } + }, nil, true } } - transport := r.transport.Default() - exchangeOptions := effectiveOptions - if exchangeOptions.Strategy == C.DomainStrategyAsIS { - exchangeOptions.Strategy = r.defaultDomainStrategy + return exchangeWithRulesResult{}, nil, false +} + +func (r *Router) finishPendingExchange(ctx context.Context, message *mDNS.Msg, state *dnsRuleWalkState, pending *dnsPendingExchange) exchangeWithRulesResult { + for _, future := range state.futures { + if future != pending.future { + future.cancel() + } } - response, err := r.client.Exchange(adapter.OverrideContext(ctx), transport, message, exchangeOptions, nil) + if pending.future != nil { + select { + case <-pending.future.done: + case <-ctx.Done(): + return exchangeWithRulesResult{err: ctx.Err()} + } + return exchangeWithRulesResult{ + response: pending.future.view(), + transport: pending.future.transport, + err: pending.future.err, + } + } + response, err := r.client.Exchange(adapter.OverrideContext(ctx), pending.transport, message, r.finalizeExchangeOptions(pending.options), nil) return exchangeWithRulesResult{ response: response, - transport: transport, + transport: pending.transport, err: err, } } +func (r *Router) exchangeWithRulesAsync(ctx context.Context, rules []adapter.DNSRule, message *mDNS.Msg, options adapter.DNSQueryOptions, allowFakeIP bool, callback func(result exchangeWithRulesResult)) { + state := &dnsRuleWalkState{effectiveOptions: options, lastLoggedIndex: -1} + result, suspension := r.walkDNSRules(ctx, rules, message, state, allowFakeIP) + if suspension == nil { + cancelDNSFutures(state) + callback(result) + return + } + if suspension.pending != nil && suspension.pending.future == nil { + cancelDNSFutures(state) + pending := suspension.pending + r.client.ExchangeAsync(adapter.OverrideContext(ctx), pending.transport, message, r.finalizeExchangeOptions(pending.options), nil, func(response *mDNS.Msg, err error) { + callback(exchangeWithRulesResult{ + response: response, + transport: pending.transport, + err: err, + }) + }) + return + } + go func() { + callback(r.resumeExchangeWithRules(ctx, rules, message, state, allowFakeIP, suspension)) + }() +} + func (r *Router) resolveLookupStrategy(options adapter.DNSQueryOptions) C.DomainStrategy { if options.LookupStrategy != C.DomainStrategyAsIS { return options.LookupStrategy @@ -607,38 +1009,39 @@ func (r *Router) lookupWithRulesType(ctx context.Context, rules []adapter.DNSRul return filterAddressesByQueryType(MessageToAddresses(exchangeResult.response), qType), nil } -func (r *Router) Exchange(ctx context.Context, message *mDNS.Msg, options adapter.DNSQueryOptions) (*mDNS.Msg, error) { +type dnsExchangeContext struct { + ctx context.Context + rules []adapter.DNSRule + legacyDNSMode bool + metadata *adapter.InboundContext +} + +func (r *Router) prepareExchange(ctx context.Context, message *mDNS.Msg) (*dnsExchangeContext, *mDNS.Msg, error) { if len(message.Question) != 1 { r.logger.WarnContext(ctx, "bad question size: ", len(message.Question)) - responseMessage := mDNS.Msg{ + return nil, &mDNS.Msg{ MsgHdr: mDNS.MsgHdr{ Id: message.Id, Response: true, Rcode: mDNS.RcodeFormatError, }, Question: message.Question, - } - return &responseMessage, nil + }, nil } r.rulesAccess.RLock() if r.closing { r.rulesAccess.RUnlock() - return nil, E.New("dns router closed") + return nil, nil, E.New("dns router closed") } rules := r.rules legacyDNSMode := r.legacyDNSMode r.rulesAccess.RUnlock() r.logger.DebugContext(ctx, "exchange ", FormatQuestion(message.Question[0].String())) - var ( - response *mDNS.Msg - transport adapter.DNSTransport - err error - ) - var metadata *adapter.InboundContext - ctx, metadata = adapter.ExtendContext(ctx) + ctx, metadata := adapter.ExtendContext(ctx) metadata.Destination = M.Socksaddr{} metadata.QueryType = message.Question[0].Qtype metadata.DNSResponse = nil + metadata.NamedDNSResponses = nil metadata.DestinationAddressMatchFromResponse = false switch metadata.QueryType { case mDNS.TypeA: @@ -647,88 +1050,15 @@ func (r *Router) Exchange(ctx context.Context, message *mDNS.Msg, options adapte metadata.IPVersion = 6 } metadata.Domain = FqdnToDomain(message.Question[0].Name) - if options.Transport != nil { - transport = options.Transport - if options.Strategy == C.DomainStrategyAsIS { - options.Strategy = r.defaultDomainStrategy - } - response, err = r.client.Exchange(ctx, transport, message, options, nil) - } else if !legacyDNSMode { - exchangeResult := r.exchangeWithRules(ctx, rules, message, options, true) - response, transport, err = exchangeResult.response, exchangeResult.transport, exchangeResult.err - } else { - var ( - rule adapter.DNSRule - ruleIndex int - ) - ruleIndex = -1 - for { - dnsCtx := adapter.OverrideContext(ctx) - dnsOptions := options - transport, rule, ruleIndex = r.matchDNS(ctx, rules, true, ruleIndex, isAddressQuery(message), &dnsOptions) - if rule != nil { - switch action := rule.Action().(type) { - case *R.RuleActionReject: - switch action.Method { - case C.RuleActionRejectMethodDefault: - return &mDNS.Msg{ - MsgHdr: mDNS.MsgHdr{ - Id: message.Id, - Rcode: mDNS.RcodeRefused, - Response: true, - }, - Question: []mDNS.Question{message.Question[0]}, - }, nil - case C.RuleActionRejectMethodDrop: - return nil, R.ErrDrop - } - case *R.RuleActionPredefined: - err = nil - response = action.Response(message) - goto done - } - } - responseCheck := addressLimitResponseCheck(rule, metadata) - if dnsOptions.Strategy == C.DomainStrategyAsIS { - dnsOptions.Strategy = r.defaultDomainStrategy - } - response, err = r.client.Exchange(dnsCtx, transport, message, dnsOptions, responseCheck) - var rejected bool - var bypass bool - if err != nil { - if errors.Is(err, ErrResponseRejectedCached) { - rejected = true - r.logger.DebugContext(ctx, E.Cause(err, "response ", transport.Tag(), " rejected for ", FormatQuestion(message.Question[0].String())), " (cached)") - } else if errors.Is(err, ErrResponseRejected) { - rejected = true - r.logger.DebugContext(ctx, E.Cause(err, "response ", transport.Tag(), " rejected for ", FormatQuestion(message.Question[0].String()))) - } else if len(message.Question) > 0 { - r.logger.ErrorContext(ctx, E.Cause(err, "exchange ", transport.Tag(), " failed for ", FormatQuestion(message.Question[0].String()))) - } else { - r.logger.ErrorContext(ctx, E.Cause(err, "exchange ", transport.Tag(), " failed for ")) - } - if rule != nil && rule.BypassIfFailed() && ruleIndex != -1 { - select { - case <-ctx.Done(): - default: - bypass = true - } - } - } - if responseCheck != nil && rejected { - continue - } - if bypass { - continue - } + return &dnsExchangeContext{ + ctx: ctx, + rules: rules, + legacyDNSMode: legacyDNSMode, + metadata: metadata, + }, nil, nil +} - break - } - } -done: - if err != nil { - return nil, err - } +func (r *Router) recordReverseMapping(message *mDNS.Msg, response *mDNS.Msg, transport adapter.DNSTransport) { if r.dnsReverseMapping != nil && len(message.Question) > 0 && response != nil && len(response.Answer) > 0 { if transport == nil || transport.Type() != C.DNSTypeFakeIP { for _, answer := range response.Answer { @@ -741,9 +1071,133 @@ done: } } } +} + +func (r *Router) exchangeLegacy(ctx context.Context, exchangeCtx *dnsExchangeContext, message *mDNS.Msg, options adapter.DNSQueryOptions) (*mDNS.Msg, adapter.DNSTransport, error) { + var ( + transport adapter.DNSTransport + rule adapter.DNSRule + ruleIndex int + ) + ruleIndex = -1 + for { + dnsCtx := adapter.OverrideContext(ctx) + dnsOptions := options + transport, rule, ruleIndex = r.matchDNS(ctx, exchangeCtx.rules, true, ruleIndex, isAddressQuery(message), &dnsOptions) + if rule != nil { + switch action := rule.Action().(type) { + case *R.RuleActionReject: + switch action.Method { + case C.RuleActionRejectMethodDefault: + return &mDNS.Msg{ + MsgHdr: mDNS.MsgHdr{ + Id: message.Id, + Rcode: mDNS.RcodeRefused, + Response: true, + }, + Question: []mDNS.Question{message.Question[0]}, + }, nil, nil + case C.RuleActionRejectMethodDrop: + return nil, nil, R.ErrDrop + } + case *R.RuleActionPredefined: + return action.Response(message), nil, nil + } + } + responseCheck := addressLimitResponseCheck(rule, exchangeCtx.metadata) + response, err := r.client.Exchange(dnsCtx, transport, message, r.finalizeExchangeOptions(dnsOptions), responseCheck) + var rejected bool + var bypass bool //H + if err != nil { + if errors.Is(err, ErrResponseRejectedCached) { + rejected = true + r.logger.DebugContext(ctx, E.Cause(err, "response rejected for ", FormatQuestion(message.Question[0].String())), " (cached)") + } else if errors.Is(err, ErrResponseRejected) { + rejected = true + r.logger.DebugContext(ctx, E.Cause(err, "response rejected for ", FormatQuestion(message.Question[0].String()))) + } else if len(message.Question) > 0 { + r.logger.ErrorContext(ctx, E.Cause(err, "exchange failed for ", FormatQuestion(message.Question[0].String()))) + } else { + r.logger.ErrorContext(ctx, E.Cause(err, "exchange failed for ")) + } + //H: bypass to next rule if this rule allows bypass-on-failure + if rule != nil && rule.BypassIfFailed() && ruleIndex != -1 { + select { + case <-ctx.Done(): + default: + bypass = true + } + } + } + if responseCheck != nil && rejected { + continue + } + if bypass { //H + continue + } + return response, transport, err + } +} + +func (r *Router) Exchange(ctx context.Context, message *mDNS.Msg, options adapter.DNSQueryOptions) (*mDNS.Msg, error) { + exchangeCtx, earlyResponse, err := r.prepareExchange(ctx, message) + if exchangeCtx == nil { + return earlyResponse, err + } + ctx = exchangeCtx.ctx + var ( + response *mDNS.Msg + transport adapter.DNSTransport + ) + if options.Transport != nil { + transport = options.Transport + response, err = r.client.Exchange(ctx, transport, message, r.finalizeExchangeOptions(options), nil) + } else if !exchangeCtx.legacyDNSMode { + exchangeResult := r.exchangeWithRules(ctx, exchangeCtx.rules, message, options, true) + response, transport, err = exchangeResult.response, exchangeResult.transport, exchangeResult.err + } else { + response, transport, err = r.exchangeLegacy(ctx, exchangeCtx, message, options) + } + if err != nil { + return nil, err + } + r.recordReverseMapping(message, response, transport) return response, nil } +func (r *Router) ExchangeAsync(ctx context.Context, message *mDNS.Msg, options adapter.DNSQueryOptions, callback func(response *mDNS.Msg, err error)) { + exchangeCtx, earlyResponse, err := r.prepareExchange(ctx, message) + if exchangeCtx == nil { + callback(earlyResponse, err) + return + } + ctx = exchangeCtx.ctx + if options.Transport != nil { + transport := options.Transport + r.client.ExchangeAsync(ctx, transport, message, r.finalizeExchangeOptions(options), nil, func(response *mDNS.Msg, exchangeErr error) { + r.finishExchangeAsync(message, transport, response, exchangeErr, callback) + }) + } else if !exchangeCtx.legacyDNSMode { + r.exchangeWithRulesAsync(ctx, exchangeCtx.rules, message, options, true, func(result exchangeWithRulesResult) { + r.finishExchangeAsync(message, result.transport, result.response, result.err, callback) + }) + } else { + go func() { + response, transport, exchangeErr := r.exchangeLegacy(ctx, exchangeCtx, message, options) + r.finishExchangeAsync(message, transport, response, exchangeErr, callback) + }() + } +} + +func (r *Router) finishExchangeAsync(message *mDNS.Msg, transport adapter.DNSTransport, response *mDNS.Msg, err error, callback func(response *mDNS.Msg, err error)) { + if err != nil { + callback(nil, err) + return + } + r.recordReverseMapping(message, response, transport) + callback(response, nil) +} + func (r *Router) Lookup(ctx context.Context, domain string, options adapter.DNSQueryOptions) ([]netip.Addr, error) { r.rulesAccess.RLock() if r.closing { @@ -781,6 +1235,7 @@ func (r *Router) Lookup(ctx context.Context, domain string, options adapter.DNSQ metadata.Destination = M.Socksaddr{} metadata.Domain = FqdnToDomain(domain) metadata.DNSResponse = nil + metadata.NamedDNSResponses = nil metadata.DestinationAddressMatchFromResponse = false if options.Transport != nil { transport := options.Transport @@ -893,7 +1348,7 @@ func defaultRuleNeedsLegacyDNSModeFromAddressFilter(rule option.DefaultDNSRule) if rule.RuleSetIPCIDRAcceptEmpty { //nolint:staticcheck return true } - return !rule.MatchResponse && (rule.IPAcceptAny || len(rule.IPCIDR) > 0 || rule.IPIsPrivate) + return !rule.MatchResponse.IsEnabled() && (rule.IPAcceptAny || len(rule.IPCIDR) > 0 || rule.IPIsPrivate) } func hasResponseMatchFields(rule option.DefaultDNSRule) bool { @@ -904,7 +1359,7 @@ func hasResponseMatchFields(rule option.DefaultDNSRule) bool { } func defaultRuleDisablesLegacyDNSMode(rule option.DefaultDNSRule) bool { - return rule.MatchResponse || + return rule.MatchResponse.IsEnabled() || hasResponseMatchFields(rule) || rule.Action == C.RuleActionTypeEvaluate || rule.Action == C.RuleActionTypeRespond || @@ -1014,48 +1469,72 @@ func lookupDNSRuleSetMetadata(router adapter.Router, tag string, metadataOverrid return ruleSet.Metadata(), nil } -func referencedDNSRuleSetTags(rules []option.DNSRule) []string { - tagMap := make(map[string]bool) - var walkRule func(rule option.DNSRule) - walkRule = func(rule option.DNSRule) { - switch rule.Type { - case "", C.RuleTypeDefault: - for _, tag := range rule.DefaultOptions.RuleSet { - tagMap[tag] = true - } - case C.RuleTypeLogical: - for _, subRule := range rule.LogicalOptions.Rules { - walkRule(subRule) - } - } - } - for _, rule := range rules { - walkRule(rule) - } - tags := make([]string, 0, len(tagMap)) - for tag := range tagMap { - if tag != "" { - tags = append(tags, tag) - } - } - return tags +type dnsRuleResponseUse struct { + needsAnonymous bool + referencedTags []string } -func validateLegacyDNSModeDisabledRules(router adapter.Router, rules []option.DNSRule, metadataOverrides map[string]adapter.RuleSetMetadata) error { - var seenEvaluate bool +func validateLegacyDNSModeDisabledRules(router adapter.Router, rules []option.DNSRule, metadataOverrides map[string]adapter.RuleSetMetadata) ([]string, error) { + var ( + warnings []string + seenAnonymousEvaluate bool + seenRace bool + definedTags = make(map[string]bool) + definedTagOrder []string + referencedTags = make(map[string]bool) + lastAnonymousEvaluate = -1 + anonymousReadSinceLast bool + ) for i, rule := range rules { - requiresPriorEvaluate, err := validateLegacyDNSModeDisabledRuleTree(router, rule, metadataOverrides) + use, err := validateLegacyDNSModeDisabledRuleTree(router, rule, metadataOverrides) if err != nil { - return E.Cause(err, "validate dns rule[", i, "]") + return nil, E.Cause(err, "validate dns rule[", i, "]") + } + if dnsRuleActionSpeculative(rule) && !seenRace { + warnings = append(warnings, F.ToString("dns rule[", i, "]: `speculative` has no effect without a preceding `race` rule")) + } + if dnsRuleRace(rule) { + seenRace = true + } + if use.needsAnonymous { + if !seenAnonymousEvaluate { + if len(definedTagOrder) > 0 { + return nil, E.New("dns rule[", i, "]: response-based matching requires a preceding evaluate action without `tag`; use `match_response` with an evaluate tag to reference a tagged result") + } + return nil, E.New("dns rule[", i, "]: response-based matching requires a preceding evaluate action") + } + anonymousReadSinceLast = true } - if requiresPriorEvaluate && !seenEvaluate { - return E.New("dns rule[", i, "]: response-based matching requires a preceding evaluate action") + for _, tag := range use.referencedTags { + if !definedTags[tag] { + return nil, E.New("dns rule[", i, "]: undefined evaluate tag: ", tag) + } + referencedTags[tag] = true } if dnsRuleActionType(rule) == C.RuleActionTypeEvaluate { - seenEvaluate = true + tag := dnsRuleActionEvaluateTag(rule) + if tag == "" { + if lastAnonymousEvaluate >= 0 && !anonymousReadSinceLast { + warnings = append(warnings, F.ToString("dns rule[", lastAnonymousEvaluate, "]: evaluated response is overwritten by dns rule[", i, "] before any use")) + } + seenAnonymousEvaluate = true + lastAnonymousEvaluate = i + anonymousReadSinceLast = false + } else { + if definedTags[tag] { + return nil, E.New("dns rule[", i, "]: duplicate evaluate tag: ", tag) + } + definedTags[tag] = true + definedTagOrder = append(definedTagOrder, tag) + } } } - return nil + for _, tag := range definedTagOrder { + if !referencedTags[tag] { + warnings = append(warnings, F.ToString("evaluate tag is never referenced: ", tag)) + } + } + return warnings, nil } func validateEvaluateFakeIPRules(rules []option.DNSRule, transportManager adapter.DNSTransportManager) error { @@ -1079,54 +1558,77 @@ func validateEvaluateFakeIPRules(rules []option.DNSRule, transportManager adapte return nil } -func validateLegacyDNSModeDisabledRuleTree(router adapter.Router, rule option.DNSRule, metadataOverrides map[string]adapter.RuleSetMetadata) (bool, error) { +func validateLegacyDNSModeDisabledRuleTree(router adapter.Router, rule option.DNSRule, metadataOverrides map[string]adapter.RuleSetMetadata) (dnsRuleResponseUse, error) { switch rule.Type { case "", C.RuleTypeDefault: return validateLegacyDNSModeDisabledDefaultRule(router, rule.DefaultOptions, metadataOverrides) case C.RuleTypeLogical: - requiresPriorEvaluate := dnsRuleActionType(rule) == C.RuleActionTypeRespond + var use dnsRuleResponseUse for i, subRule := range rule.LogicalOptions.Rules { - subRequiresPriorEvaluate, err := validateLegacyDNSModeDisabledRuleTree(router, subRule, metadataOverrides) + subUse, err := validateLegacyDNSModeDisabledRuleTree(router, subRule, metadataOverrides) if err != nil { - return false, E.Cause(err, "sub rule[", i, "]") + return dnsRuleResponseUse{}, E.Cause(err, "sub rule[", i, "]") + } + use.needsAnonymous = use.needsAnonymous || subUse.needsAnonymous + use.referencedTags = append(use.referencedTags, subUse.referencedTags...) + } + if rule.LogicalOptions.Action == C.RuleActionTypeRespond { + if len(use.referencedTags) > 0 { + return dnsRuleResponseUse{}, E.New("respond on a logical rule cannot bind a `match_response` tag from its sub rules; use a non-logical rule") } - requiresPriorEvaluate = requiresPriorEvaluate || subRequiresPriorEvaluate + use.needsAnonymous = true } - return requiresPriorEvaluate, nil + return use, nil default: - return false, nil + return dnsRuleResponseUse{}, nil } } -func validateLegacyDNSModeDisabledDefaultRule(router adapter.Router, rule option.DefaultDNSRule, metadataOverrides map[string]adapter.RuleSetMetadata) (bool, error) { +func validateLegacyDNSModeDisabledDefaultRule(router adapter.Router, rule option.DefaultDNSRule, metadataOverrides map[string]adapter.RuleSetMetadata) (dnsRuleResponseUse, error) { hasResponseRecords := hasResponseMatchFields(rule) - if (hasResponseRecords || len(rule.IPCIDR) > 0 || rule.IPIsPrivate || rule.IPAcceptAny) && !rule.MatchResponse { - return false, E.New("Response Match Fields (ip_cidr, ip_is_private, ip_accept_any, response_rcode, response_answer, response_ns, response_extra) require match_response to be enabled") + if (hasResponseRecords || len(rule.IPCIDR) > 0 || rule.IPIsPrivate || rule.IPAcceptAny) && !rule.MatchResponse.IsEnabled() { + return dnsRuleResponseUse{}, E.New("Response Match Fields (ip_cidr, ip_is_private, ip_accept_any, response_rcode, response_answer, response_ns, response_extra) require match_response to be enabled") } // rule_set entries are only rejected when every referenced set is pure-IP; // mixed sets still fall through because their non-IP branches remain matchable // before a DNS response is available. - if !rule.MatchResponse && len(rule.RuleSet) > 0 { + if !rule.MatchResponse.IsEnabled() && len(rule.RuleSet) > 0 { for _, tag := range rule.RuleSet { metadata, err := lookupDNSRuleSetMetadata(router, tag, metadataOverrides) if err != nil { - return false, err + return dnsRuleResponseUse{}, err } if metadata.ContainsIPCIDRRule && !metadata.ContainsNonIPCIDRRule { - return false, E.New(deprecated.OptionLegacyDNSAddressFilter.MessageWithLink()) + return dnsRuleResponseUse{}, E.New(deprecated.OptionLegacyDNSAddressFilter.MessageWithLink()) } } } if rule.RuleSetIPCIDRAcceptEmpty { //nolint:staticcheck - return false, E.New(deprecated.OptionRuleSetIPCIDRAcceptEmpty.MessageWithLink()) + return dnsRuleResponseUse{}, E.New(deprecated.OptionRuleSetIPCIDRAcceptEmpty.MessageWithLink()) + } + var use dnsRuleResponseUse + if rule.MatchResponse.IsEnabled() { + if responseTag := rule.MatchResponse.ResponseTag(); responseTag != "" { + use.referencedTags = append(use.referencedTags, responseTag) + } else { + use.needsAnonymous = true + } + } + if rule.Action == C.RuleActionTypeRespond && rule.MatchResponse.ResponseTag() == "" { + use.needsAnonymous = true } - return rule.MatchResponse || rule.Action == C.RuleActionTypeRespond, nil + return use, nil } func dnsRuleActionDisablesLegacyDNSMode(action option.DNSRuleAction) bool { + if action.Race { + return true + } switch action.Action { - case "", C.RuleActionTypeRoute, C.RuleActionTypeEvaluate: - return action.RouteOptions.DisableOptimisticCache + case "", C.RuleActionTypeRoute: + return action.RouteOptions.DisableOptimisticCache || action.RouteOptions.Speculative + case C.RuleActionTypeEvaluate: + return action.EvaluateOptions.DisableOptimisticCache || action.EvaluateOptions.Speculative case C.RuleActionTypeRouteOptions: return action.RouteOptionsOptions.DisableOptimisticCache default: @@ -1136,8 +1638,10 @@ func dnsRuleActionDisablesLegacyDNSMode(action option.DNSRuleAction) bool { func dnsRuleActionHasStrategy(action option.DNSRuleAction) bool { switch action.Action { - case "", C.RuleActionTypeRoute, C.RuleActionTypeEvaluate: + case "", C.RuleActionTypeRoute: return C.DomainStrategy(action.RouteOptions.Strategy) != C.DomainStrategyAsIS + case C.RuleActionTypeEvaluate: + return C.DomainStrategy(action.EvaluateOptions.Strategy) != C.DomainStrategyAsIS case C.RuleActionTypeRouteOptions: return C.DomainStrategy(action.RouteOptionsOptions.Strategy) != C.DomainStrategyAsIS default: @@ -1165,10 +1669,55 @@ func dnsRuleActionType(rule option.DNSRule) string { func dnsRuleActionServer(rule option.DNSRule) string { switch rule.Type { case "", C.RuleTypeDefault: + if dnsRuleActionType(rule) == C.RuleActionTypeEvaluate { + return rule.DefaultOptions.EvaluateOptions.Server + } return rule.DefaultOptions.RouteOptions.Server case C.RuleTypeLogical: + if dnsRuleActionType(rule) == C.RuleActionTypeEvaluate { + return rule.LogicalOptions.EvaluateOptions.Server + } return rule.LogicalOptions.RouteOptions.Server default: return "" } } + +func dnsRuleActionEvaluateTag(rule option.DNSRule) string { + switch rule.Type { + case "", C.RuleTypeDefault: + return rule.DefaultOptions.EvaluateOptions.Tag + case C.RuleTypeLogical: + return rule.LogicalOptions.EvaluateOptions.Tag + default: + return "" + } +} + +func dnsRuleActionSpeculative(rule option.DNSRule) bool { + switch rule.Type { + case "", C.RuleTypeDefault: + if dnsRuleActionType(rule) == C.RuleActionTypeEvaluate { + return rule.DefaultOptions.EvaluateOptions.Speculative + } + return rule.DefaultOptions.RouteOptions.Speculative + case C.RuleTypeLogical: + if dnsRuleActionType(rule) == C.RuleActionTypeEvaluate { + return rule.LogicalOptions.EvaluateOptions.Speculative + } + return rule.LogicalOptions.RouteOptions.Speculative + default: + return false + } +} + +func dnsRuleRace(rule option.DNSRule) bool { + switch rule.Type { + case "", C.RuleTypeDefault: + return rule.DefaultOptions.Race + case C.RuleTypeLogical: + return rule.LogicalOptions.Race + default: + return false + } +} diff --git a/dns/router_race_test.go b/dns/router_race_test.go new file mode 100644 index 0000000000..73c34ff944 --- /dev/null +++ b/dns/router_race_test.go @@ -0,0 +1,527 @@ +package dns + +import ( + "context" + "net/netip" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + R "github.com/sagernet/sing-box/route/rule" + + mDNS "github.com/miekg/dns" + "github.com/stretchr/testify/require" +) + +type raceFakeDNSTransport struct { + tag string + delay time.Duration + rcode int + address netip.Addr + exchangeErr error + + access sync.Mutex + queryCount atomic.Int32 + firstQueried time.Time +} + +func (t *raceFakeDNSTransport) Start(stage adapter.StartStage) error { + return nil +} + +func (t *raceFakeDNSTransport) Close() error { + return nil +} + +func (t *raceFakeDNSTransport) Type() string { + return "fake" +} + +func (t *raceFakeDNSTransport) Tag() string { + return t.tag +} + +func (t *raceFakeDNSTransport) Dependencies() []string { + return nil +} + +func (t *raceFakeDNSTransport) Reset() { +} + +func (t *raceFakeDNSTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + t.access.Lock() + if t.firstQueried.IsZero() { + t.firstQueried = time.Now() + } + t.access.Unlock() + t.queryCount.Add(1) + select { + case <-time.After(t.delay): + case <-ctx.Done(): + return nil, ctx.Err() + } + if t.exchangeErr != nil { + return nil, t.exchangeErr + } + if t.rcode != mDNS.RcodeSuccess { + return FixedResponseStatus(message, t.rcode), nil + } + return FixedResponse(message.Id, message.Question[0], []netip.Addr{t.address}, 300), nil +} + +func (t *raceFakeDNSTransport) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { + go func() { + callback(t.Exchange(ctx, message)) + }() +} + +type raceFakeDNSTransportManager struct { + transports map[string]adapter.DNSTransport + defaultTransport adapter.DNSTransport +} + +func (m *raceFakeDNSTransportManager) Start(stage adapter.StartStage) error { + return nil +} + +func (m *raceFakeDNSTransportManager) Close() error { + return nil +} + +func (m *raceFakeDNSTransportManager) Transports() []adapter.DNSTransport { + return nil +} + +func (m *raceFakeDNSTransportManager) Transport(tag string) (adapter.DNSTransport, bool) { + transport, loaded := m.transports[tag] + return transport, loaded +} + +func (m *raceFakeDNSTransportManager) Default() adapter.DNSTransport { + return m.defaultTransport +} + +func (m *raceFakeDNSTransportManager) FakeIP() adapter.FakeIPTransport { + return nil +} + +func (m *raceFakeDNSTransportManager) Remove(tag string) error { + return nil +} + +func (m *raceFakeDNSTransportManager) Create(ctx context.Context, logger log.ContextLogger, tag string, outboundType string, options any) error { + return nil +} + +func raceTestRouter(t *testing.T, transports ...*raceFakeDNSTransport) *Router { + transportMap := make(map[string]adapter.DNSTransport) + for _, transport := range transports { + transportMap[transport.tag] = transport + } + return &Router{ + ctx: context.Background(), + logger: log.NewNOPFactory().Logger(), + transport: &raceFakeDNSTransportManager{ + transports: transportMap, + defaultTransport: transportMap["final"], + }, + client: NewClient(ClientOptions{ + Context: context.Background(), + DisableCache: true, + Logger: log.NewNOPFactory().Logger(), + }), + } +} + +func raceTestRules(t *testing.T, rawRules []option.DNSRule) []adapter.DNSRule { + rules := make([]adapter.DNSRule, 0, len(rawRules)) + for _, rawRule := range rawRules { + rule, err := R.NewDNSRule(context.Background(), log.NewNOPFactory().Logger(), rawRule, true, false) + require.NoError(t, err) + rules = append(rules, rule) + } + return rules +} + +func raceTestExchange(router *Router, rules []adapter.DNSRule) exchangeWithRulesResult { + message := &mDNS.Msg{ + MsgHdr: mDNS.MsgHdr{ + Id: 1, + RecursionDesired: true, + }, + Question: []mDNS.Question{{ + Name: "race.example.org.", + Qtype: mDNS.TypeA, + Qclass: mDNS.ClassINET, + }}, + } + metadata := &adapter.InboundContext{ + Domain: "race.example.org", + QueryType: mDNS.TypeA, + } + ctx := adapter.WithContext(context.Background(), metadata) + return router.exchangeWithRules(ctx, rules, message, adapter.DNSQueryOptions{}, false) +} + +func evaluateRule(server string, tag string, speculative bool) option.DNSRule { + return option.DNSRule{ + Type: "", + DefaultOptions: option.DefaultDNSRule{ + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeEvaluate, + EvaluateOptions: option.DNSEvaluateActionOptions{ + Server: server, + Tag: tag, + Speculative: speculative, + }, + }, + }, + } +} + +func respondRule(responseTag string, race bool, requireSuccess bool) option.DNSRule { + rule := option.DNSRule{ + Type: "", + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + MatchResponse: &option.DNSRuleMatchResponse{Enabled: true, Tag: responseTag}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRespond, + Race: race, + }, + }, + } + if requireSuccess { + successRcode := option.DNSRCode(mDNS.RcodeSuccess) + rule.DefaultOptions.ResponseRcode = &successRcode + } + return rule +} + +func routeRule(server string, speculative bool) option.DNSRule { + return option.DNSRule{ + Type: "", + DefaultOptions: option.DefaultDNSRule{ + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{ + Server: server, + Speculative: speculative, + }, + }, + }, + } +} + +func responseAddress(t *testing.T, response *mDNS.Msg) netip.Addr { + require.NotNil(t, response) + require.Len(t, response.Answer, 1) + record, isA := response.Answer[0].(*mDNS.A) + require.True(t, isA) + address, _ := netip.AddrFromSlice(record.A) + return address.Unmap() +} + +// Both evaluate queries must launch in parallel, and a failed primary must +// fall through to the secondary instead of failing the request. +func TestDNSEvaluateParallelFallback(t *testing.T) { + t.Parallel() + transportX := &raceFakeDNSTransport{tag: "x", delay: 200 * time.Millisecond, exchangeErr: context.DeadlineExceeded} + transportY := &raceFakeDNSTransport{tag: "y", delay: 10 * time.Millisecond, rcode: mDNS.RcodeSuccess, address: netip.MustParseAddr("192.0.2.2")} + router := raceTestRouter(t, transportX, transportY) + rules := raceTestRules(t, []option.DNSRule{ + evaluateRule("x", "x", false), + evaluateRule("y", "y", false), + respondRule("x", false, true), + respondRule("y", false, true), + }) + startTime := time.Now() + result := raceTestExchange(router, rules) + require.NoError(t, result.err) + require.Equal(t, netip.MustParseAddr("192.0.2.2"), responseAddress(t, result.response)) + require.Equal(t, int32(1), transportX.queryCount.Load()) + require.Equal(t, int32(1), transportY.queryCount.Load()) + require.Less(t, transportY.firstQueried.Sub(transportX.firstQueried), 100*time.Millisecond) + require.Less(t, time.Since(startTime), 350*time.Millisecond) +} + +// The first race rule whose response arrives and matches must commit +// immediately, without waiting for the slower rule written before it. +func TestDNSRaceFastestWins(t *testing.T) { + t.Parallel() + transportX := &raceFakeDNSTransport{tag: "x", delay: 500 * time.Millisecond, rcode: mDNS.RcodeSuccess, address: netip.MustParseAddr("192.0.2.1")} + transportY := &raceFakeDNSTransport{tag: "y", delay: 20 * time.Millisecond, rcode: mDNS.RcodeSuccess, address: netip.MustParseAddr("192.0.2.2")} + router := raceTestRouter(t, transportX, transportY) + rules := raceTestRules(t, []option.DNSRule{ + evaluateRule("x", "x", false), + evaluateRule("y", "y", false), + respondRule("x", true, true), + respondRule("y", true, true), + }) + startTime := time.Now() + result := raceTestExchange(router, rules) + require.NoError(t, result.err) + require.Equal(t, netip.MustParseAddr("192.0.2.2"), responseAddress(t, result.response)) + require.Less(t, time.Since(startTime), 400*time.Millisecond) +} + +// Without race, rule order decides even when a later response arrives first. +func TestDNSOrderedReadsPreferEarlierRule(t *testing.T) { + t.Parallel() + transportX := &raceFakeDNSTransport{tag: "x", delay: 200 * time.Millisecond, rcode: mDNS.RcodeSuccess, address: netip.MustParseAddr("192.0.2.1")} + transportY := &raceFakeDNSTransport{tag: "y", delay: 10 * time.Millisecond, rcode: mDNS.RcodeSuccess, address: netip.MustParseAddr("192.0.2.2")} + router := raceTestRouter(t, transportX, transportY) + rules := raceTestRules(t, []option.DNSRule{ + evaluateRule("x", "x", false), + evaluateRule("y", "y", false), + respondRule("x", false, true), + respondRule("y", false, true), + }) + result := raceTestExchange(router, rules) + require.NoError(t, result.err) + require.Equal(t, netip.MustParseAddr("192.0.2.1"), responseAddress(t, result.response)) +} + +// A pending race rule must hold back the default route: the default server +// is never queried when the race rule hits, and is queried only after the +// race decision resolved when it misses. +func TestDNSRaceBarrierProtectsDefaultRoute(t *testing.T) { + t.Parallel() + transportHit := &raceFakeDNSTransport{tag: "x", delay: 100 * time.Millisecond, rcode: mDNS.RcodeSuccess, address: netip.MustParseAddr("192.0.2.1")} + transportFinal := &raceFakeDNSTransport{tag: "final", delay: 10 * time.Millisecond, rcode: mDNS.RcodeSuccess, address: netip.MustParseAddr("192.0.2.9")} + router := raceTestRouter(t, transportHit, transportFinal) + rules := raceTestRules(t, []option.DNSRule{ + evaluateRule("x", "x", false), + respondRule("x", true, true), + }) + result := raceTestExchange(router, rules) + require.NoError(t, result.err) + require.Equal(t, netip.MustParseAddr("192.0.2.1"), responseAddress(t, result.response)) + require.Equal(t, int32(0), transportFinal.queryCount.Load()) + + transportMiss := &raceFakeDNSTransport{tag: "x", delay: 100 * time.Millisecond, rcode: mDNS.RcodeNameError} + transportFinal = &raceFakeDNSTransport{tag: "final", delay: 10 * time.Millisecond, rcode: mDNS.RcodeSuccess, address: netip.MustParseAddr("192.0.2.9")} + router = raceTestRouter(t, transportMiss, transportFinal) + rules = raceTestRules(t, []option.DNSRule{ + evaluateRule("x", "x", false), + respondRule("x", true, true), + }) + startTime := time.Now() + result = raceTestExchange(router, rules) + require.NoError(t, result.err) + require.Equal(t, netip.MustParseAddr("192.0.2.9"), responseAddress(t, result.response)) + require.Equal(t, int32(1), transportFinal.queryCount.Load()) + require.GreaterOrEqual(t, transportFinal.firstQueried.Sub(startTime), 90*time.Millisecond) +} + +// A speculative route launches while the race decision is pending, but its +// response is only used after the race rule missed. +func TestDNSSpeculativeRoute(t *testing.T) { + t.Parallel() + transportMiss := &raceFakeDNSTransport{tag: "x", delay: 100 * time.Millisecond, rcode: mDNS.RcodeNameError} + transportFinal := &raceFakeDNSTransport{tag: "final", delay: 10 * time.Millisecond, rcode: mDNS.RcodeSuccess, address: netip.MustParseAddr("192.0.2.9")} + router := raceTestRouter(t, transportMiss, transportFinal) + rules := raceTestRules(t, []option.DNSRule{ + evaluateRule("x", "x", false), + respondRule("x", true, true), + routeRule("final", true), + }) + startTime := time.Now() + result := raceTestExchange(router, rules) + require.NoError(t, result.err) + require.Equal(t, netip.MustParseAddr("192.0.2.9"), responseAddress(t, result.response)) + require.Equal(t, int32(1), transportFinal.queryCount.Load()) + require.Less(t, transportFinal.firstQueried.Sub(startTime), 90*time.Millisecond) + require.GreaterOrEqual(t, time.Since(startTime), 90*time.Millisecond) + + transportHit := &raceFakeDNSTransport{tag: "x", delay: 100 * time.Millisecond, rcode: mDNS.RcodeSuccess, address: netip.MustParseAddr("192.0.2.1")} + transportFinal = &raceFakeDNSTransport{tag: "final", delay: 10 * time.Millisecond, rcode: mDNS.RcodeSuccess, address: netip.MustParseAddr("192.0.2.9")} + router = raceTestRouter(t, transportHit, transportFinal) + rules = raceTestRules(t, []option.DNSRule{ + evaluateRule("x", "x", false), + respondRule("x", true, true), + routeRule("final", true), + }) + result = raceTestExchange(router, rules) + require.NoError(t, result.err) + require.Equal(t, netip.MustParseAddr("192.0.2.1"), responseAddress(t, result.response)) + require.Equal(t, int32(1), transportFinal.queryCount.Load()) +} + +// A matched rule without race must not take effect while a race rule is +// still pending: a race hit wins even when the other rule matched earlier, +// and on a race miss the other rule takes effect only after that decision. +func TestDNSNonRaceCommitWaitsForPendingRace(t *testing.T) { + t.Parallel() + transportHit := &raceFakeDNSTransport{tag: "x", delay: 150 * time.Millisecond, rcode: mDNS.RcodeSuccess, address: netip.MustParseAddr("192.0.2.1")} + transportY := &raceFakeDNSTransport{tag: "y", delay: 10 * time.Millisecond, rcode: mDNS.RcodeSuccess, address: netip.MustParseAddr("192.0.2.2")} + router := raceTestRouter(t, transportHit, transportY) + rules := raceTestRules(t, []option.DNSRule{ + evaluateRule("x", "x", false), + evaluateRule("y", "y", false), + respondRule("x", true, true), + respondRule("y", false, true), + }) + startTime := time.Now() + result := raceTestExchange(router, rules) + require.NoError(t, result.err) + require.Equal(t, netip.MustParseAddr("192.0.2.1"), responseAddress(t, result.response)) + require.GreaterOrEqual(t, time.Since(startTime), 140*time.Millisecond) + + transportMiss := &raceFakeDNSTransport{tag: "x", delay: 150 * time.Millisecond, rcode: mDNS.RcodeNameError} + transportY = &raceFakeDNSTransport{tag: "y", delay: 10 * time.Millisecond, rcode: mDNS.RcodeSuccess, address: netip.MustParseAddr("192.0.2.2")} + router = raceTestRouter(t, transportMiss, transportY) + rules = raceTestRules(t, []option.DNSRule{ + evaluateRule("x", "x", false), + evaluateRule("y", "y", false), + respondRule("x", true, true), + respondRule("y", false, true), + }) + startTime = time.Now() + result = raceTestExchange(router, rules) + require.NoError(t, result.err) + require.Equal(t, netip.MustParseAddr("192.0.2.2"), responseAddress(t, result.response)) + require.GreaterOrEqual(t, time.Since(startTime), 140*time.Millisecond) +} + +// speculative on a route rule with match_response launches the route query as +// soon as the rule matched, while its response is only used after the pending +// race rule missed. +func TestDNSSpeculativeRouteOnBindingRule(t *testing.T) { + t.Parallel() + transportMiss := &raceFakeDNSTransport{tag: "x", delay: 100 * time.Millisecond, rcode: mDNS.RcodeNameError} + transportY := &raceFakeDNSTransport{tag: "y", delay: 10 * time.Millisecond, rcode: mDNS.RcodeSuccess, address: netip.MustParseAddr("192.0.2.2")} + transportFinal := &raceFakeDNSTransport{tag: "final", delay: 10 * time.Millisecond, rcode: mDNS.RcodeSuccess, address: netip.MustParseAddr("192.0.2.9")} + router := raceTestRouter(t, transportMiss, transportY, transportFinal) + successRcode := option.DNSRCode(mDNS.RcodeSuccess) + boundRouteRule := option.DNSRule{ + Type: "", + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + MatchResponse: &option.DNSRuleMatchResponse{Enabled: true, Tag: "y"}, + ResponseRcode: &successRcode, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{ + Server: "final", + Speculative: true, + }, + }, + }, + } + rules := raceTestRules(t, []option.DNSRule{ + evaluateRule("x", "x", false), + evaluateRule("y", "y", false), + respondRule("x", true, true), + boundRouteRule, + }) + startTime := time.Now() + result := raceTestExchange(router, rules) + require.NoError(t, result.err) + require.Equal(t, netip.MustParseAddr("192.0.2.9"), responseAddress(t, result.response)) + require.Equal(t, int32(1), transportFinal.queryCount.Load()) + require.Less(t, transportFinal.firstQueried.Sub(startTime), 90*time.Millisecond) + require.GreaterOrEqual(t, time.Since(startTime), 90*time.Millisecond) +} + +// A race rule that rejects its response (NXDOMAIN vs required success) +// disarms and lets the other race rule win. +func TestDNSRaceSkipsRejectedResponse(t *testing.T) { + t.Parallel() + transportX := &raceFakeDNSTransport{tag: "x", delay: 10 * time.Millisecond, rcode: mDNS.RcodeNameError} + transportY := &raceFakeDNSTransport{tag: "y", delay: 100 * time.Millisecond, rcode: mDNS.RcodeSuccess, address: netip.MustParseAddr("192.0.2.2")} + router := raceTestRouter(t, transportX, transportY) + rules := raceTestRules(t, []option.DNSRule{ + evaluateRule("x", "x", false), + evaluateRule("y", "y", false), + respondRule("x", true, true), + respondRule("y", true, true), + }) + result := raceTestExchange(router, rules) + require.NoError(t, result.err) + require.Equal(t, netip.MustParseAddr("192.0.2.2"), responseAddress(t, result.response)) +} + +// A logical race rule is judged once all of its referenced responses arrived: +// it wins over a slower race rule when its sub-rules match, and on a miss the +// slower race rule takes over. +func TestDNSLogicalRace(t *testing.T) { + t.Parallel() + successRcode := option.DNSRCode(mDNS.RcodeSuccess) + logicalRule := func() option.DNSRule { + return option.DNSRule{ + Type: C.RuleTypeLogical, + LogicalOptions: option.LogicalDNSRule{ + RawLogicalDNSRule: option.RawLogicalDNSRule{ + Mode: C.LogicalTypeAnd, + Rules: []option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + MatchResponse: &option.DNSRuleMatchResponse{Enabled: true}, + ResponseRcode: &successRcode, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + MatchResponse: &option.DNSRuleMatchResponse{Enabled: true, Tag: "y"}, + ResponseRcode: &successRcode, + }, + }, + }, + }, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRespond, + Race: true, + }, + }, + } + } + + transportX := &raceFakeDNSTransport{tag: "x", delay: 100 * time.Millisecond, rcode: mDNS.RcodeSuccess, address: netip.MustParseAddr("192.0.2.1")} + transportY := &raceFakeDNSTransport{tag: "y", delay: 10 * time.Millisecond, rcode: mDNS.RcodeSuccess, address: netip.MustParseAddr("192.0.2.2")} + transportZ := &raceFakeDNSTransport{tag: "z", delay: 250 * time.Millisecond, rcode: mDNS.RcodeSuccess, address: netip.MustParseAddr("192.0.2.3")} + router := raceTestRouter(t, transportX, transportY, transportZ) + rules := raceTestRules(t, []option.DNSRule{ + evaluateRule("x", "", false), + evaluateRule("y", "y", false), + evaluateRule("z", "z", false), + logicalRule(), + respondRule("z", true, true), + }) + startTime := time.Now() + result := raceTestExchange(router, rules) + require.NoError(t, result.err) + require.Equal(t, netip.MustParseAddr("192.0.2.1"), responseAddress(t, result.response)) + require.GreaterOrEqual(t, time.Since(startTime), 90*time.Millisecond) + require.Less(t, time.Since(startTime), 240*time.Millisecond) + + transportX = &raceFakeDNSTransport{tag: "x", delay: 100 * time.Millisecond, rcode: mDNS.RcodeNameError} + transportY = &raceFakeDNSTransport{tag: "y", delay: 10 * time.Millisecond, rcode: mDNS.RcodeSuccess, address: netip.MustParseAddr("192.0.2.2")} + transportZ = &raceFakeDNSTransport{tag: "z", delay: 250 * time.Millisecond, rcode: mDNS.RcodeSuccess, address: netip.MustParseAddr("192.0.2.3")} + router = raceTestRouter(t, transportX, transportY, transportZ) + rules = raceTestRules(t, []option.DNSRule{ + evaluateRule("x", "", false), + evaluateRule("y", "y", false), + evaluateRule("z", "z", false), + logicalRule(), + respondRule("z", true, true), + }) + startTime = time.Now() + result = raceTestExchange(router, rules) + require.NoError(t, result.err) + require.Equal(t, netip.MustParseAddr("192.0.2.3"), responseAddress(t, result.response)) + require.GreaterOrEqual(t, time.Since(startTime), 240*time.Millisecond) +} diff --git a/dns/router_test.go b/dns/router_test.go index 8c2fc4833d..92ae47e830 100644 --- a/dns/router_test.go +++ b/dns/router_test.go @@ -40,6 +40,12 @@ func (t *fakeDNSTransport) Exchange(context.Context, *mDNS.Msg) (*mDNS.Msg, erro return nil, E.New("unused transport exchange") } +func (t *fakeDNSTransport) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { + go func() { + callback(t.Exchange(ctx, message)) + }() +} + type fakeDNSTransportManager struct { defaultTransport adapter.DNSTransport transports map[string]adapter.DNSTransport @@ -90,6 +96,9 @@ func (r *fakeRouter) PreMatch(metadata adapter.InboundContext, firstPacket []byt return adapter.PreMatchResult{} } +func (r *fakeRouter) HijackDNSPacket(ctx context.Context, payload []byte, writer N.PacketWriter, metadata adapter.InboundContext) { +} + func (r *fakeRouter) RouteConnection(context.Context, net.Conn, adapter.InboundContext) error { return nil } @@ -243,6 +252,12 @@ func (c *fakeDNSClient) Exchange(ctx context.Context, transport adapter.DNSTrans return c.exchange(transport, message) } +func (c *fakeDNSClient) ExchangeAsync(ctx context.Context, transport adapter.DNSTransport, message *mDNS.Msg, options adapter.DNSQueryOptions, responseChecker func(*mDNS.Msg) bool, callback func(response *mDNS.Msg, err error)) { + go func() { + callback(c.Exchange(ctx, transport, message, options, responseChecker)) + }() +} + func (c *fakeDNSClient) Lookup(ctx context.Context, transport adapter.DNSTransport, domain string, options adapter.DNSQueryOptions, responseChecker func(*mDNS.Msg) bool) ([]netip.Addr, error) { if c.lookup == nil && c.lookupWithCtx == nil { return nil, E.New("unused client lookup") @@ -325,9 +340,9 @@ func TestInitializeRejectsDirectLegacyRuleWhenRuleSetForcesNew(t *testing.T) { t.Parallel() ctx := context.Background() - ruleSet, err := rulepkg.NewRuleSet(ctx, log.NewNOPFactory().NewLogger("router"), option.RuleSet{ + ruleSet, err := rulepkg.NewRuleSet(ctx, log.NewNOPFactory().NewLogger("router"), "query-set", option.RuleSet{ Type: C.RuleSetTypeInline, - Tag: "query-set", + Tag: badoption.Listable[string]{"query-set"}, InlineOptions: option.PlainRuleSet{ Rules: []option.HeadlessRule{{ Type: C.RuleTypeDefault, @@ -386,9 +401,9 @@ func TestLookupLegacyDNSModeDefersRuleSetDestinationIPMatch(t *testing.T) { t.Parallel() ctx := context.Background() - ruleSet, err := rulepkg.NewRuleSet(ctx, log.NewNOPFactory().NewLogger("router"), option.RuleSet{ + ruleSet, err := rulepkg.NewRuleSet(ctx, log.NewNOPFactory().NewLogger("router"), "legacy-ipcidr-set", option.RuleSet{ Type: C.RuleSetTypeInline, - Tag: "legacy-ipcidr-set", + Tag: badoption.Listable[string]{"legacy-ipcidr-set"}, InlineOptions: option.PlainRuleSet{ Rules: []option.HeadlessRule{{ Type: C.RuleTypeDefault, @@ -698,7 +713,7 @@ func TestValidateRuleSetMetadataUpdateAllowsRuleSetThatKeepsNonLegacyDNSMode(t * }, DNSRuleAction: option.DNSRuleAction{ Action: C.RuleActionTypeEvaluate, - RouteOptions: option.DNSRouteActionOptions{Server: "upstream"}, + EvaluateOptions: option.DNSEvaluateActionOptions{Server: "upstream"}, }, }, }, @@ -1124,9 +1139,9 @@ func TestLookupLegacyDNSModeRuleSetAcceptEmptyDoesNotTreatMismatchAsEmpty(t *tes t.Parallel() ctx := context.Background() - ruleSet, err := rulepkg.NewRuleSet(ctx, log.NewNOPFactory().NewLogger("router"), option.RuleSet{ + ruleSet, err := rulepkg.NewRuleSet(ctx, log.NewNOPFactory().NewLogger("router"), "legacy-ipcidr-set", option.RuleSet{ Type: C.RuleSetTypeInline, - Tag: "legacy-ipcidr-set", + Tag: badoption.Listable[string]{"legacy-ipcidr-set"}, InlineOptions: option.PlainRuleSet{ Rules: []option.HeadlessRule{{ Type: C.RuleTypeDefault, @@ -1245,7 +1260,7 @@ func TestExchangeLegacyDNSModeDisabledEvaluateMatchResponseRoute(t *testing.T) { }, DNSRuleAction: option.DNSRuleAction{ Action: C.RuleActionTypeEvaluate, - RouteOptions: option.DNSRouteActionOptions{Server: "upstream"}, + EvaluateOptions: option.DNSEvaluateActionOptions{Server: "upstream"}, }, }, }, @@ -1253,7 +1268,7 @@ func TestExchangeLegacyDNSModeDisabledEvaluateMatchResponseRoute(t *testing.T) { Type: C.RuleTypeDefault, DefaultOptions: option.DefaultDNSRule{ RawDefaultDNSRule: option.RawDefaultDNSRule{ - MatchResponse: true, + MatchResponse: &option.DNSRuleMatchResponse{Enabled: true}, ResponseAnswer: badoption.Listable[option.DNSRecordOptions]{mustRecord(t, "example.com. IN A 1.1.1.1")}, }, DNSRuleAction: option.DNSRuleAction{ @@ -1311,7 +1326,7 @@ func TestExchangeLegacyDNSModeDisabledEvaluateMatchResponseRcodeRoute(t *testing }, DNSRuleAction: option.DNSRuleAction{ Action: C.RuleActionTypeEvaluate, - RouteOptions: option.DNSRouteActionOptions{Server: "upstream"}, + EvaluateOptions: option.DNSEvaluateActionOptions{Server: "upstream"}, }, }, }, @@ -1319,7 +1334,7 @@ func TestExchangeLegacyDNSModeDisabledEvaluateMatchResponseRcodeRoute(t *testing Type: C.RuleTypeDefault, DefaultOptions: option.DefaultDNSRule{ RawDefaultDNSRule: option.RawDefaultDNSRule{ - MatchResponse: true, + MatchResponse: &option.DNSRuleMatchResponse{Enabled: true}, ResponseRcode: &rcode, }, DNSRuleAction: option.DNSRuleAction{ @@ -1378,7 +1393,7 @@ func TestExchangeLegacyDNSModeDisabledEvaluateMatchResponseNsRoute(t *testing.T) }, DNSRuleAction: option.DNSRuleAction{ Action: C.RuleActionTypeEvaluate, - RouteOptions: option.DNSRouteActionOptions{Server: "upstream"}, + EvaluateOptions: option.DNSEvaluateActionOptions{Server: "upstream"}, }, }, }, @@ -1386,7 +1401,7 @@ func TestExchangeLegacyDNSModeDisabledEvaluateMatchResponseNsRoute(t *testing.T) Type: C.RuleTypeDefault, DefaultOptions: option.DefaultDNSRule{ RawDefaultDNSRule: option.RawDefaultDNSRule{ - MatchResponse: true, + MatchResponse: &option.DNSRuleMatchResponse{Enabled: true}, ResponseNs: badoption.Listable[option.DNSRecordOptions]{nsRecord}, }, DNSRuleAction: option.DNSRuleAction{ @@ -1445,7 +1460,7 @@ func TestExchangeLegacyDNSModeDisabledEvaluateMatchResponseExtraRoute(t *testing }, DNSRuleAction: option.DNSRuleAction{ Action: C.RuleActionTypeEvaluate, - RouteOptions: option.DNSRouteActionOptions{Server: "upstream"}, + EvaluateOptions: option.DNSEvaluateActionOptions{Server: "upstream"}, }, }, }, @@ -1453,7 +1468,7 @@ func TestExchangeLegacyDNSModeDisabledEvaluateMatchResponseExtraRoute(t *testing Type: C.RuleTypeDefault, DefaultOptions: option.DefaultDNSRule{ RawDefaultDNSRule: option.RawDefaultDNSRule{ - MatchResponse: true, + MatchResponse: &option.DNSRuleMatchResponse{Enabled: true}, ResponseExtra: badoption.Listable[option.DNSRecordOptions]{extraRecord}, }, DNSRuleAction: option.DNSRuleAction{ @@ -1515,7 +1530,7 @@ func TestExchangeLegacyDNSModeDisabledEvaluateDoesNotLeakAddressesToNextQuery(t }, DNSRuleAction: option.DNSRuleAction{ Action: C.RuleActionTypeEvaluate, - RouteOptions: option.DNSRouteActionOptions{Server: "upstream"}, + EvaluateOptions: option.DNSEvaluateActionOptions{Server: "upstream"}, }, }, }, @@ -1523,7 +1538,7 @@ func TestExchangeLegacyDNSModeDisabledEvaluateDoesNotLeakAddressesToNextQuery(t Type: C.RuleTypeDefault, DefaultOptions: option.DefaultDNSRule{ RawDefaultDNSRule: option.RawDefaultDNSRule{ - MatchResponse: true, + MatchResponse: &option.DNSRuleMatchResponse{Enabled: true}, ResponseAnswer: badoption.Listable[option.DNSRecordOptions]{mustRecord(t, "example.com. IN A 1.1.1.1")}, }, DNSRuleAction: option.DNSRuleAction{ @@ -1577,7 +1592,7 @@ func TestExchangeLegacyDNSModeDisabledEvaluateRouteResolutionFailureClearsRespon }, DNSRuleAction: option.DNSRuleAction{ Action: C.RuleActionTypeEvaluate, - RouteOptions: option.DNSRouteActionOptions{Server: "upstream"}, + EvaluateOptions: option.DNSEvaluateActionOptions{Server: "upstream"}, }, }, }, @@ -1589,7 +1604,7 @@ func TestExchangeLegacyDNSModeDisabledEvaluateRouteResolutionFailureClearsRespon }, DNSRuleAction: option.DNSRuleAction{ Action: C.RuleActionTypeEvaluate, - RouteOptions: option.DNSRouteActionOptions{Server: "missing"}, + EvaluateOptions: option.DNSEvaluateActionOptions{Server: "missing"}, }, }, }, @@ -1597,7 +1612,7 @@ func TestExchangeLegacyDNSModeDisabledEvaluateRouteResolutionFailureClearsRespon Type: C.RuleTypeDefault, DefaultOptions: option.DefaultDNSRule{ RawDefaultDNSRule: option.RawDefaultDNSRule{ - MatchResponse: true, + MatchResponse: &option.DNSRuleMatchResponse{Enabled: true}, ResponseAnswer: badoption.Listable[option.DNSRecordOptions]{mustRecord(t, "example.com. IN A 1.1.1.1")}, }, DNSRuleAction: option.DNSRuleAction{ @@ -1656,7 +1671,7 @@ func TestExchangeLegacyDNSModeDisabledSecondEvaluateOverwritesFirstResponse(t *t }, DNSRuleAction: option.DNSRuleAction{ Action: C.RuleActionTypeEvaluate, - RouteOptions: option.DNSRouteActionOptions{Server: "first-upstream"}, + EvaluateOptions: option.DNSEvaluateActionOptions{Server: "first-upstream"}, }, }, }, @@ -1668,7 +1683,7 @@ func TestExchangeLegacyDNSModeDisabledSecondEvaluateOverwritesFirstResponse(t *t }, DNSRuleAction: option.DNSRuleAction{ Action: C.RuleActionTypeEvaluate, - RouteOptions: option.DNSRouteActionOptions{Server: "second-upstream"}, + EvaluateOptions: option.DNSEvaluateActionOptions{Server: "second-upstream"}, }, }, }, @@ -1676,7 +1691,7 @@ func TestExchangeLegacyDNSModeDisabledSecondEvaluateOverwritesFirstResponse(t *t Type: C.RuleTypeDefault, DefaultOptions: option.DefaultDNSRule{ RawDefaultDNSRule: option.RawDefaultDNSRule{ - MatchResponse: true, + MatchResponse: &option.DNSRuleMatchResponse{Enabled: true}, ResponseAnswer: badoption.Listable[option.DNSRecordOptions]{mustRecord(t, "example.com. IN A 1.1.1.1")}, }, DNSRuleAction: option.DNSRuleAction{ @@ -1689,7 +1704,7 @@ func TestExchangeLegacyDNSModeDisabledSecondEvaluateOverwritesFirstResponse(t *t Type: C.RuleTypeDefault, DefaultOptions: option.DefaultDNSRule{ RawDefaultDNSRule: option.RawDefaultDNSRule{ - MatchResponse: true, + MatchResponse: &option.DNSRuleMatchResponse{Enabled: true}, ResponseAnswer: badoption.Listable[option.DNSRecordOptions]{mustRecord(t, "example.com. IN A 2.2.2.2")}, }, DNSRuleAction: option.DNSRuleAction{ @@ -1761,7 +1776,7 @@ func TestExchangeLegacyDNSModeDisabledEvaluateExchangeFailureUsesMatchResponseBo }, DNSRuleAction: option.DNSRuleAction{ Action: C.RuleActionTypeEvaluate, - RouteOptions: option.DNSRouteActionOptions{Server: "upstream"}, + EvaluateOptions: option.DNSEvaluateActionOptions{Server: "upstream"}, }, }, }, @@ -1769,7 +1784,7 @@ func TestExchangeLegacyDNSModeDisabledEvaluateExchangeFailureUsesMatchResponseBo Type: C.RuleTypeDefault, DefaultOptions: option.DefaultDNSRule{ RawDefaultDNSRule: option.RawDefaultDNSRule{ - MatchResponse: true, + MatchResponse: &option.DNSRuleMatchResponse{Enabled: true}, Invert: testCase.invert, }, DNSRuleAction: option.DNSRuleAction{ @@ -1804,7 +1819,7 @@ func TestExchangeLegacyDNSModeDisabledRespondReturnsEvaluatedResponse(t *testing }, DNSRuleAction: option.DNSRuleAction{ Action: C.RuleActionTypeEvaluate, - RouteOptions: option.DNSRouteActionOptions{Server: "upstream"}, + EvaluateOptions: option.DNSEvaluateActionOptions{Server: "upstream"}, }, }, }, @@ -1855,7 +1870,7 @@ func TestLookupLegacyDNSModeDisabledRespondReturnsEvaluatedResponse(t *testing.T }, DNSRuleAction: option.DNSRuleAction{ Action: C.RuleActionTypeEvaluate, - RouteOptions: option.DNSRouteActionOptions{Server: "upstream"}, + EvaluateOptions: option.DNSEvaluateActionOptions{Server: "upstream"}, }, }, }, @@ -1912,7 +1927,7 @@ func TestExchangeLegacyDNSModeDisabledRespondWithoutEvaluatedResponseReturnsErro }, DNSRuleAction: option.DNSRuleAction{ Action: C.RuleActionTypeEvaluate, - RouteOptions: option.DNSRouteActionOptions{Server: "upstream"}, + EvaluateOptions: option.DNSEvaluateActionOptions{Server: "upstream"}, }, }, }, @@ -2103,9 +2118,11 @@ func TestInitializeRejectsDNSRuleStrategyWhenLegacyDNSModeIsDisabledByEvaluate(t }, DNSRuleAction: option.DNSRuleAction{ Action: C.RuleActionTypeEvaluate, - RouteOptions: option.DNSRouteActionOptions{ - Server: "default", - Strategy: option.DomainStrategy(C.DomainStrategyIPv4Only), + EvaluateOptions: option.DNSEvaluateActionOptions{ + Server: "default", + AbstractDNSRouteActionOptions: option.AbstractDNSRouteActionOptions{ + Strategy: option.DomainStrategy(C.DomainStrategyIPv4Only), + }, }, }, }, @@ -2133,7 +2150,7 @@ func TestInitializeRejectsEvaluateFakeIPServerInDefaultRule(t *testing.T) { }, DNSRuleAction: option.DNSRuleAction{ Action: C.RuleActionTypeEvaluate, - RouteOptions: option.DNSRouteActionOptions{Server: "fake"}, + EvaluateOptions: option.DNSEvaluateActionOptions{Server: "fake"}, }, }, }}) @@ -2168,7 +2185,7 @@ func TestInitializeRejectsEvaluateFakeIPServerInLogicalRule(t *testing.T) { }, DNSRuleAction: option.DNSRuleAction{ Action: C.RuleActionTypeEvaluate, - RouteOptions: option.DNSRouteActionOptions{Server: "fake"}, + EvaluateOptions: option.DNSEvaluateActionOptions{Server: "fake"}, }, }, }}) @@ -2191,7 +2208,7 @@ func TestInitializeRejectsDNSRuleStrategyWhenLegacyDNSModeIsDisabledByMatchRespo Type: C.RuleTypeDefault, DefaultOptions: option.DefaultDNSRule{ RawDefaultDNSRule: option.RawDefaultDNSRule{ - MatchResponse: true, + MatchResponse: &option.DNSRuleMatchResponse{Enabled: true}, }, DNSRuleAction: option.DNSRuleAction{ Action: C.RuleActionTypeRouteOptions, @@ -2220,7 +2237,7 @@ func TestInitializeRejectsDNSMatchResponseWithoutPrecedingEvaluate(t *testing.T) Type: C.RuleTypeDefault, DefaultOptions: option.DefaultDNSRule{ RawDefaultDNSRule: option.RawDefaultDNSRule{ - MatchResponse: true, + MatchResponse: &option.DNSRuleMatchResponse{Enabled: true}, ResponseAnswer: badoption.Listable[option.DNSRecordOptions]{mustRecord(t, "example.com. IN A 1.1.1.1")}, }, DNSRuleAction: option.DNSRuleAction{ @@ -2319,7 +2336,7 @@ func TestInitializeRejectsEvaluateRuleWithResponseMatchWithoutPrecedingEvaluate( Type: C.RuleTypeDefault, DefaultOptions: option.DefaultDNSRule{ RawDefaultDNSRule: option.RawDefaultDNSRule{ - MatchResponse: true, + MatchResponse: &option.DNSRuleMatchResponse{Enabled: true}, ResponseAnswer: badoption.Listable[option.DNSRecordOptions]{mustRecord(t, "example.com. IN A 1.1.1.1")}, }, }, @@ -2328,7 +2345,7 @@ func TestInitializeRejectsEvaluateRuleWithResponseMatchWithoutPrecedingEvaluate( }, DNSRuleAction: option.DNSRuleAction{ Action: C.RuleActionTypeEvaluate, - RouteOptions: option.DNSRouteActionOptions{Server: "default"}, + EvaluateOptions: option.DNSEvaluateActionOptions{Server: "default"}, }, }, }}) @@ -2355,7 +2372,7 @@ func TestInitializeAllowsEvaluateRuleWithResponseMatchAfterPrecedingEvaluate(t * }, DNSRuleAction: option.DNSRuleAction{ Action: C.RuleActionTypeEvaluate, - RouteOptions: option.DNSRouteActionOptions{Server: "bootstrap"}, + EvaluateOptions: option.DNSEvaluateActionOptions{Server: "bootstrap"}, }, }, }, @@ -2377,7 +2394,7 @@ func TestInitializeAllowsEvaluateRuleWithResponseMatchAfterPrecedingEvaluate(t * Type: C.RuleTypeDefault, DefaultOptions: option.DefaultDNSRule{ RawDefaultDNSRule: option.RawDefaultDNSRule{ - MatchResponse: true, + MatchResponse: &option.DNSRuleMatchResponse{Enabled: true}, ResponseAnswer: badoption.Listable[option.DNSRecordOptions]{mustRecord(t, "example.com. IN A 1.1.1.1")}, }, }, @@ -2386,7 +2403,7 @@ func TestInitializeAllowsEvaluateRuleWithResponseMatchAfterPrecedingEvaluate(t * }, DNSRuleAction: option.DNSRuleAction{ Action: C.RuleActionTypeEvaluate, - RouteOptions: option.DNSRouteActionOptions{Server: "default"}, + EvaluateOptions: option.DNSEvaluateActionOptions{Server: "default"}, }, }, }, @@ -2568,7 +2585,7 @@ func TestExchangeLegacyDNSModeDisabledLogicalMatchResponseIPCIDRFallsThrough(t * }, DNSRuleAction: option.DNSRuleAction{ Action: C.RuleActionTypeEvaluate, - RouteOptions: option.DNSRouteActionOptions{Server: "upstream"}, + EvaluateOptions: option.DNSEvaluateActionOptions{Server: "upstream"}, }, }, }, @@ -2581,7 +2598,7 @@ func TestExchangeLegacyDNSModeDisabledLogicalMatchResponseIPCIDRFallsThrough(t * Type: C.RuleTypeDefault, DefaultOptions: option.DefaultDNSRule{ RawDefaultDNSRule: option.RawDefaultDNSRule{ - MatchResponse: true, + MatchResponse: &option.DNSRuleMatchResponse{Enabled: true}, IPCIDR: badoption.Listable[string]{"1.1.1.0/24"}, }, }, @@ -2654,8 +2671,10 @@ func TestLegacyDNSModeReportsDNSRuleStrategyDeprecation(t *testing.T) { DNSRuleAction: option.DNSRuleAction{ Action: C.RuleActionTypeRoute, RouteOptions: option.DNSRouteActionOptions{ - Server: "default", - Strategy: option.DomainStrategy(C.DomainStrategyIPv4Only), + Server: "default", + AbstractDNSRouteActionOptions: option.AbstractDNSRouteActionOptions{ + Strategy: option.DomainStrategy(C.DomainStrategyIPv4Only), + }, }, }, }, diff --git a/dns/transport/dhcp/dhcp.go b/dns/transport/dhcp/dhcp.go index 8e0a1bf684..3abc7cf530 100644 --- a/dns/transport/dhcp/dhcp.go +++ b/dns/transport/dhcp/dhcp.go @@ -8,12 +8,14 @@ import ( "runtime" "strings" "sync" + "sync/atomic" "syscall" "time" "github.com/sagernet/sing-box/adapter" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/dns/transport" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" "github.com/sagernet/sing-tun" @@ -54,6 +56,8 @@ type Transport struct { updatedAt time.Time lastError error servers []M.Socksaddr + serverTransports []adapter.DNSTransport + refreshing atomic.Bool search []string ndots int attempts int @@ -100,7 +104,7 @@ func (t *Transport) Start(stage adapter.StartStage) error { t.interfaceCallback = t.networkManager.InterfaceMonitor().RegisterCallback(t.interfaceUpdated) } go func() { - _, err := t.fetch() + err := t.fetch() if err != nil { if errors.Is(err, errInterfaceIsCellular) && t.optional { t.logger.Debug(E.Cause(errInterfaceIsCellular, "dhcp: fetch DNS servers")) @@ -116,6 +120,9 @@ func (t *Transport) Close() error { if t.interfaceCallback != nil { t.networkManager.InterfaceMonitor().UnregisterCallback(t.interfaceCallback) } + t.transportLock.Lock() + defer t.transportLock.Unlock() + t.closeServerTransports() return nil } @@ -124,57 +131,122 @@ func (t *Transport) Reset() { t.updatedAt = time.Time{} t.lastError = nil t.servers = nil + t.closeServerTransports() t.transportLock.Unlock() } +func (t *Transport) closeServerTransports() { + for _, serverTransport := range t.serverTransports { + serverTransport.Close() + } + t.serverTransports = nil +} + func (t *Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { - servers, err := t.fetch() - if err != nil { - return nil, E.Cause(err, "dhcp: fetch DNS servers") + done := make(chan struct{}) + var ( + response *mDNS.Msg + err error + ) + t.ExchangeAsync(ctx, message, func(callbackResponse *mDNS.Msg, callbackErr error) { + response = callbackResponse + err = callbackErr + close(done) + }) + <-done + return response, err +} + +func (t *Transport) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { + t.transportLock.RLock() + updatedAt := t.updatedAt + lastError := t.lastError + serverTransports := t.serverTransports + t.transportLock.RUnlock() + if lastError != nil { + callback(nil, E.Cause(lastError, "dhcp: fetch DNS servers")) + return + } + if len(serverTransports) == 0 { + go t.exchangeCold(ctx, message, callback) + return } - if len(servers) == 0 { - return nil, E.New("dhcp: empty DNS servers from response") + if time.Since(updatedAt) >= C.DHCPTTL { + t.startRefresh() } - return t.Exchange0(ctx, message, servers) + t.exchangeWithTransports(ctx, message, serverTransports, callback) } -func (t *Transport) Exchange0(ctx context.Context, message *mDNS.Msg, servers []M.Socksaddr) (*mDNS.Msg, error) { - question := message.Question[0] - domain := dns.FqdnToDomain(question.Name) - if len(servers) == 1 || !(message.Question[0].Qtype == mDNS.TypeA || message.Question[0].Qtype == mDNS.TypeAAAA) { - return t.exchangeSingleRequest(ctx, servers, message, domain) - } else { - return t.exchangeParallel(ctx, servers, message, domain) +func (t *Transport) exchangeCold(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { + err := t.fetch() + if err != nil { + callback(nil, E.Cause(err, "dhcp: fetch DNS servers")) + return } + t.transportLock.RLock() + serverTransports := t.serverTransports + t.transportLock.RUnlock() + if len(serverTransports) == 0 { + callback(nil, E.New("dhcp: empty DNS servers from response")) + return + } + t.exchangeWithTransports(ctx, message, serverTransports, callback) } func (t *Transport) Fetch() []M.Socksaddr { - servers, _ := t.fetch() + t.transportLock.RLock() + updatedAt := t.updatedAt + lastError := t.lastError + servers := t.servers + t.transportLock.RUnlock() + if lastError != nil { + return nil + } + if len(servers) > 0 && time.Since(updatedAt) >= C.DHCPTTL { + t.startRefresh() + } return servers } -func (t *Transport) fetch() ([]M.Socksaddr, error) { +func (t *Transport) fetch() error { t.transportLock.RLock() updatedAt := t.updatedAt lastError := t.lastError - servers := t.servers t.transportLock.RUnlock() if lastError != nil { - return nil, lastError + return lastError } if time.Since(updatedAt) < C.DHCPTTL { - return servers, nil + return nil } t.transportLock.Lock() defer t.transportLock.Unlock() if time.Since(t.updatedAt) < C.DHCPTTL { - return t.servers, nil + return nil } - err := t.updateServers() - if err != nil { - return servers, err + return t.updateServers() +} + +func (t *Transport) startRefresh() { + if !t.refreshing.CompareAndSwap(false, true) { + return } - return t.servers, nil + go func() { + defer t.refreshing.Store(false) + t.transportLock.Lock() + defer t.transportLock.Unlock() + if time.Since(t.updatedAt) < C.DHCPTTL { + return + } + err := t.updateServers() + if err != nil { + if errors.Is(err, errInterfaceIsCellular) && t.optional { + t.logger.Debug(E.Cause(err, "dhcp: refresh DNS servers")) + } else { + t.logger.Error(E.Cause(err, "dhcp: refresh DNS servers")) + } + } + }() } func (t *Transport) fetchInterface() (*control.Interface, error) { @@ -228,7 +300,9 @@ func (t *Transport) updateServers() error { } func (t *Transport) interfaceUpdated(defaultInterface *control.Interface, flags int) { + t.transportLock.Lock() err := t.updateServers() + t.transportLock.Unlock() if err != nil { if errors.Is(err, errInterfaceIsCellular) && t.optional { t.logger.Debug(E.Cause(errInterfaceIsCellular, "dhcp: update DNS servers")) @@ -323,9 +397,14 @@ func (t *Transport) fetchServersResponse(iface *control.Interface, packetConn ne func (t *Transport) recreateServers(iface *control.Interface, dhcpPacket *dhcpv4.DHCPv4) error { searchList := dhcpPacket.DomainSearch() if searchList != nil && len(searchList.Labels) > 0 { - t.search = searchList.Labels + t.search = common.Filter(common.Map(searchList.Labels, mDNS.Fqdn), func(it string) bool { + return it != "." + }) } else if dhcpPacket.DomainName() != "" { - t.search = []string{dhcpPacket.DomainName()} + domainName := mDNS.Fqdn(dhcpPacket.DomainName()) + if domainName != "." { + t.search = []string{domainName} + } } serverAddrs := common.Map(dhcpPacket.DNS(), func(it net.IP) M.Socksaddr { return M.SocksaddrFrom(M.AddrFromIP(it), 53) @@ -333,6 +412,22 @@ func (t *Transport) recreateServers(iface *control.Interface, dhcpPacket *dhcpv4 if len(serverAddrs) > 0 && !slices.Equal(t.servers, serverAddrs) { t.logger.Info("dhcp: updated DNS servers from ", iface.Name, ": [", strings.Join(common.Map(serverAddrs, M.Socksaddr.String), ","), "], search: [", strings.Join(t.search, ","), "]") } + if !slices.Equal(t.servers, serverAddrs) || t.serverTransports == nil { + t.closeServerTransports() + serverTransports := make([]adapter.DNSTransport, 0, len(serverAddrs)) + for _, serverAddr := range serverAddrs { + serverTransport := transport.NewUDPRaw(t.logger, dns.NewTransportAdapter(C.DNSTypeUDP, "", nil), t.dialer, serverAddr) + err := serverTransport.Start(adapter.StartStateStart) + if err != nil { + for _, startedTransport := range serverTransports { + startedTransport.Close() + } + return E.Cause(err, "initialize transport for ", serverAddr) + } + serverTransports = append(serverTransports, serverTransport) + } + t.serverTransports = serverTransports + } t.servers = serverAddrs return nil } diff --git a/dns/transport/dhcp/dhcp_shared.go b/dns/transport/dhcp/dhcp_shared.go index 16e319bac3..3123d8f900 100644 --- a/dns/transport/dhcp/dhcp_shared.go +++ b/dns/transport/dhcp/dhcp_shared.go @@ -2,162 +2,52 @@ package dhcp import ( "context" - "errors" - "math/rand" "strings" - "syscall" + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/dns" "github.com/sagernet/sing-box/dns/transport" - "github.com/sagernet/sing/common/buf" E "github.com/sagernet/sing/common/exceptions" - M "github.com/sagernet/sing/common/metadata" - N "github.com/sagernet/sing/common/network" mDNS "github.com/miekg/dns" ) -func (t *Transport) exchangeSingleRequest(ctx context.Context, servers []M.Socksaddr, message *mDNS.Msg, domain string) (*mDNS.Msg, error) { - var lastErr error - for _, fqdn := range t.nameList(domain) { - response, err := t.tryOneName(ctx, servers, fqdn, message) - if err != nil { - lastErr = err - continue - } - return response, nil +func (t *Transport) exchangeWithTransports(ctx context.Context, message *mDNS.Msg, serverTransports []adapter.DNSTransport, callback func(response *mDNS.Msg, err error)) { + question := message.Question[0] + domain := dns.FqdnToDomain(question.Name) + names := t.nameList(domain) + if len(names) == 0 { + callback(nil, E.New("invalid domain: ", domain)) + return + } + nameExchangers := make([]transport.AsyncExchanger, 0, len(names)) + for _, fqdn := range names { + nameExchangers = append(nameExchangers, t.newNameExchanger(message, fqdn, serverTransports)) + } + if len(serverTransports) == 1 || !(question.Qtype == mDNS.TypeA || question.Qtype == mDNS.TypeAAAA) { + transport.ExchangeSequential(ctx, nameExchangers, nil, callback) + } else { + transport.ExchangeRace(ctx, nameExchangers, callback) } - return nil, lastErr } -func (t *Transport) exchangeParallel(ctx context.Context, servers []M.Socksaddr, message *mDNS.Msg, domain string) (*mDNS.Msg, error) { - returned := make(chan struct{}) - defer close(returned) - type queryResult struct { - response *mDNS.Msg - err error - } - results := make(chan queryResult) - startRacer := func(ctx context.Context, fqdn string) { - response, err := t.tryOneName(ctx, servers, fqdn, message) - select { - case results <- queryResult{response, err}: - case <-returned: - } - } - queryCtx, queryCancel := context.WithCancel(ctx) - defer queryCancel() - var nameCount int - for _, fqdn := range t.nameList(domain) { - nameCount++ - go startRacer(queryCtx, fqdn) - } - var errors []error - for { - select { - case <-ctx.Done(): - return nil, ctx.Err() - case result := <-results: - if result.err == nil { - return result.response, nil - } - errors = append(errors, result.err) - if len(errors) == nameCount { - return nil, E.Errors(errors...) - } +func (t *Transport) newNameExchanger(message *mDNS.Msg, fqdn string, serverTransports []adapter.DNSTransport) transport.AsyncExchanger { + attemptExchangers := make([]transport.AsyncExchanger, 0, t.attempts*len(serverTransports)) + for range t.attempts { + for _, serverTransport := range serverTransports { + attemptExchangers = append(attemptExchangers, func(ctx context.Context, callback func(response *mDNS.Msg, err error)) { + serverTransport.ExchangeAsync(ctx, transport.NewFanOutRequest(message, fqdn, true), callback) + }) } } -} - -func (t *Transport) tryOneName(ctx context.Context, servers []M.Socksaddr, fqdn string, message *mDNS.Msg) (*mDNS.Msg, error) { - sLen := len(servers) - var lastErr error - for i := 0; i < t.attempts; i++ { - for j := range sLen { - server := servers[j] - question := message.Question[0] - question.Name = fqdn - response, err := t.exchangeOne(ctx, server, question) + return func(ctx context.Context, callback func(response *mDNS.Msg, err error)) { + transport.ExchangeSequential(ctx, attemptExchangers, nil, func(response *mDNS.Msg, err error) { if err != nil { - lastErr = err - continue + err = E.Cause(err, fqdn) } - return response, nil - } - } - return nil, E.Cause(lastErr, fqdn) -} - -func (t *Transport) exchangeOne(ctx context.Context, server M.Socksaddr, question mDNS.Question) (*mDNS.Msg, error) { - if server.Port == 0 { - server.Port = 53 - } - request := &mDNS.Msg{ - MsgHdr: mDNS.MsgHdr{ - Id: uint16(rand.Uint32()), - RecursionDesired: true, - AuthenticatedData: true, - }, - Question: []mDNS.Question{question}, - Compress: true, - } - request.SetEdns0(buf.UDPBufferSize, false) - return t.exchangeUDP(ctx, server, request) -} - -func (t *Transport) exchangeUDP(ctx context.Context, server M.Socksaddr, request *mDNS.Msg) (*mDNS.Msg, error) { - conn, err := t.dialer.DialContext(ctx, N.NetworkUDP, server) - if err != nil { - return nil, err - } - defer conn.Close() - if deadline, loaded := ctx.Deadline(); loaded && !deadline.IsZero() { - conn.SetDeadline(deadline) - } - buffer := buf.Get(buf.UDPBufferSize) - defer buf.Put(buffer) - rawMessage, err := request.PackBuffer(buffer) - if err != nil { - return nil, E.Cause(err, "pack request") - } - _, err = conn.Write(rawMessage) - if err != nil { - if errors.Is(err, syscall.EMSGSIZE) { - return t.exchangeTCP(ctx, server, request) - } - return nil, E.Cause(err, "write request") - } - n, err := conn.Read(buffer) - if err != nil { - if errors.Is(err, syscall.EMSGSIZE) { - return t.exchangeTCP(ctx, server, request) - } - return nil, E.Cause(err, "read response") - } - var response mDNS.Msg - err = response.Unpack(buffer[:n]) - if err != nil { - return nil, E.Cause(err, "unpack response") - } - if response.Truncated { - return t.exchangeTCP(ctx, server, request) - } - return &response, nil -} - -func (t *Transport) exchangeTCP(ctx context.Context, server M.Socksaddr, request *mDNS.Msg) (*mDNS.Msg, error) { - conn, err := t.dialer.DialContext(ctx, N.NetworkTCP, server) - if err != nil { - return nil, err - } - defer conn.Close() - if deadline, loaded := ctx.Deadline(); loaded && !deadline.IsZero() { - conn.SetDeadline(deadline) - } - err = transport.WriteMessage(conn, 0, request) - if err != nil { - return nil, err + callback(response, err) + }) } - return transport.ReadMessage(conn) } func (t *Transport) nameList(name string) []string { diff --git a/dns/transport/exchange_strategy.go b/dns/transport/exchange_strategy.go new file mode 100644 index 0000000000..92faff1ca1 --- /dev/null +++ b/dns/transport/exchange_strategy.go @@ -0,0 +1,153 @@ +package transport + +import ( + "context" + "sync" + + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" + + mDNS "github.com/miekg/dns" +) + +type AsyncExchanger = func(ctx context.Context, callback func(response *mDNS.Msg, err error)) + +// ExchangeSequential tries exchangers in order until accept returns true +// (nil accept means err == nil); the last result is delivered as-is. +func ExchangeSequential(ctx context.Context, exchangers []AsyncExchanger, accept func(response *mDNS.Msg, err error) bool, callback func(response *mDNS.Msg, err error)) { + if len(exchangers) == 0 { + callback(nil, E.New("missing exchangers")) + return + } + if accept == nil { + accept = func(response *mDNS.Msg, err error) bool { + return err == nil + } + } + sequential := &sequentialExchange{ + ctx: ctx, + exchangers: exchangers, + accept: accept, + callback: callback, + } + sequential.run(0) +} + +type sequentialExchange struct { + ctx context.Context + exchangers []AsyncExchanger + accept func(response *mDNS.Msg, err error) bool + callback func(response *mDNS.Msg, err error) +} + +func (s *sequentialExchange) run(index int) { + for index < len(s.exchangers) { + ctxErr := s.ctx.Err() + if ctxErr != nil { + s.callback(nil, ctxErr) + return + } + currentIndex := index + state := &sequentialCallState{} + s.exchangers[currentIndex](s.ctx, func(response *mDNS.Msg, err error) { + if currentIndex == len(s.exchangers)-1 || s.accept(response, err) { + s.callback(response, err) + return + } + state.access.Lock() + if state.returned { + state.access.Unlock() + s.run(currentIndex + 1) + return + } + state.continued = true + state.access.Unlock() + }) + state.access.Lock() + state.returned = true + continued := state.continued + state.access.Unlock() + if !continued { + return + } + index = currentIndex + 1 + } +} + +type sequentialCallState struct { + access sync.Mutex + returned bool + continued bool +} + +// ExchangeRace runs all exchangers concurrently; the first success wins and +// cancels the rest, and when all fail the errors are aggregated. +func ExchangeRace(ctx context.Context, exchangers []AsyncExchanger, callback func(response *mDNS.Msg, err error)) { + if len(exchangers) == 0 { + callback(nil, E.New("missing exchangers")) + return + } + if len(exchangers) == 1 { + exchangers[0](ctx, callback) + return + } + raceCtx, raceCancel := context.WithCancel(ctx) + state := &raceState{ + cancel: raceCancel, + remaining: len(exchangers), + callback: callback, + } + for _, exchanger := range exchangers { + exchanger(raceCtx, state.complete) + } +} + +type raceState struct { + access sync.Mutex + done bool + remaining int + errors []error + cancel context.CancelFunc + callback func(response *mDNS.Msg, err error) +} + +func (s *raceState) complete(response *mDNS.Msg, err error) { + s.access.Lock() + if s.done { + s.access.Unlock() + return + } + if err != nil { + s.errors = append(s.errors, err) + if len(s.errors) < s.remaining { + s.access.Unlock() + return + } + raceErrors := s.errors + s.done = true + s.access.Unlock() + s.cancel() + s.callback(nil, E.Errors(raceErrors...)) + return + } + s.done = true + s.access.Unlock() + s.cancel() + s.callback(response, nil) +} + +func NewFanOutRequest(message *mDNS.Msg, fqdn string, authenticatedData bool) *mDNS.Msg { + question := message.Question[0] + question.Name = fqdn + request := &mDNS.Msg{ + MsgHdr: mDNS.MsgHdr{ + Id: message.Id, + RecursionDesired: true, + AuthenticatedData: authenticatedData, + }, + Question: []mDNS.Question{question}, + Compress: true, + } + request.SetEdns0(buf.UDPBufferSize, false) + return request +} diff --git a/dns/transport/fakeip/fakeip.go b/dns/transport/fakeip/fakeip.go index 9aa41e586f..75db1cf881 100644 --- a/dns/transport/fakeip/fakeip.go +++ b/dns/transport/fakeip/fakeip.go @@ -74,6 +74,10 @@ func (t *Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, return dns.FixedResponse(message.Id, question, []netip.Addr{address}, C.DefaultDNSTTL), nil } +func (t *Transport) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { + callback(t.Exchange(ctx, message)) +} + func (t *Transport) Store() adapter.FakeIPStore { return t.store } diff --git a/dns/transport/hosts/hosts.go b/dns/transport/hosts/hosts.go index d593ef8cc9..1ccaf39dfc 100644 --- a/dns/transport/hosts/hosts.go +++ b/dns/transport/hosts/hosts.go @@ -45,7 +45,7 @@ func NewTransport(ctx context.Context, logger log.ContextLogger, tag string, opt files = append(files, defaultFile) } else { for _, path := range options.Path { - files = append(files, NewFile(filemanager.BasePath(ctx, os.ExpandEnv(path)))) + files = append(files, NewFile(ctx, filemanager.BasePath(ctx, os.ExpandEnv(path)))) } } if options.Predefined != nil { @@ -111,6 +111,10 @@ func (t *Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, }, nil } +func (t *Transport) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { + callback(t.Exchange(ctx, message)) +} + const ( ipv4Pattern = `((25[0-5]|2[0-4][0-9]|[0-1]?[0-9]?[0-9])[\.-](25[0-5]|2[0-4][0-9]|[0-1]?[0-9]?[0-9])[\.-](25[0-5]|2[0-4][0-9]|[0-1]?[0-9]?[0-9])[\.-](25[0-5]|2[0-4][0-9]|[0-1]?[0-9]?[0-9])).sslip.io$` ipv6Pattern = `((([0-9a-fA-F]{1,4}-){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}-){1,7}-|([0-9a-fA-F]{1,4}-){1,6}-[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}-){1,5}(-[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}-){1,4}(-[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}-){1,3}(-[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}-){1,2}(-[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}-((-[0-9a-fA-F]{1,4}){1,6})|-((-[0-9a-fA-F]{1,4}){1,7}|-)|fe80-(-[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|--(ffff(-0{1,4}){0,1}-){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}-){1,4}-((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))).sslip.io$` @@ -141,5 +145,4 @@ func getIpOfSslip(sni string) []netip.Addr { return nil } return []netip.Addr{ip} - } diff --git a/dns/transport/hosts/hosts_file.go b/dns/transport/hosts/hosts_file.go index af507f012a..5d5605b19a 100644 --- a/dns/transport/hosts/hosts_file.go +++ b/dns/transport/hosts/hosts_file.go @@ -2,15 +2,16 @@ package hosts import ( "bufio" + "context" "errors" "io" "net/netip" - "os" "strings" "sync" "time" E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/service/filemanager" "github.com/miekg/dns" ) @@ -18,6 +19,7 @@ import ( const cacheMaxAge = 5 * time.Second type File struct { + ctx context.Context path string access sync.Mutex byName map[string][]netip.Addr @@ -26,8 +28,9 @@ type File struct { size int64 } -func NewFile(path string) *File { +func NewFile(ctx context.Context, path string) *File { return &File{ + ctx: ctx, path: path, } } @@ -37,7 +40,7 @@ func NewDefault() (*File, error) { if err != nil { return nil, E.Cause(err, "resolve default hosts path") } - return NewFile(defaultPathResolved), nil + return NewFile(context.Background(), defaultPathResolved), nil } func (f *File) Lookup(name string) []netip.Addr { @@ -52,7 +55,7 @@ func (f *File) update() { if now.Before(f.expire) && len(f.byName) > 0 { return } - stat, err := os.Stat(f.path) + stat, err := filemanager.Stat(f.ctx, f.path) if err != nil { return } @@ -61,7 +64,7 @@ func (f *File) update() { return } byName := make(map[string][]netip.Addr) - file, err := os.Open(f.path) + file, err := filemanager.Open(f.ctx, f.path) if err != nil { return } diff --git a/dns/transport/hosts/hosts_test.go b/dns/transport/hosts/hosts_test.go index 61d20e0c63..5c9628bfd8 100644 --- a/dns/transport/hosts/hosts_test.go +++ b/dns/transport/hosts/hosts_test.go @@ -1,6 +1,7 @@ package hosts import ( + "context" "net/netip" "os" "runtime" @@ -13,7 +14,7 @@ import ( func TestHosts(t *testing.T) { t.Parallel() - require.Equal(t, []netip.Addr{netip.AddrFrom4([4]byte{127, 0, 0, 1}), netip.IPv6Loopback()}, NewFile("testdata/hosts").Lookup("localhost")) + require.Equal(t, []netip.Addr{netip.AddrFrom4([4]byte{127, 0, 0, 1}), netip.IPv6Loopback()}, NewFile(context.Background(), "testdata/hosts").Lookup("localhost")) if runtime.GOOS != "windows" { defaultPathResolved, err := defaultPath() if err != nil { @@ -21,7 +22,7 @@ func TestHosts(t *testing.T) { } content, readErr := os.ReadFile(defaultPathResolved) require.NoError(t, readErr) - hFile := NewFile(defaultPathResolved) + hFile := NewFile(context.Background(), defaultPathResolved) if len(hFile.Lookup("localhost")) == 0 { t.Fatal("failed to resolve localhost: ", defaultPathResolved, ": \n", string(content)) } diff --git a/dns/transport/https.go b/dns/transport/https.go index db89799cdf..05e9f8c5f2 100644 --- a/dns/transport/https.go +++ b/dns/transport/https.go @@ -10,6 +10,7 @@ import ( "net/url" "strconv" "sync" + "sync/atomic" "time" "github.com/sagernet/sing-box/adapter" @@ -45,6 +46,8 @@ type HTTPSTransport struct { dialer N.Dialer destination *url.URL headers http.Header + serverAddr M.Socksaddr + fallback *atomic.Bool transportAccess sync.Mutex transport *HTTPSTransportWrapper transportResetAt time.Time @@ -123,13 +126,20 @@ func NewHTTPSRaw( if tlsConfig != nil { dialer = tls.NewDialer(dialer, tlsConfig) } + fallback := new(atomic.Bool) + if destination.Scheme == "http" { + // plain HTTP DoH used by Tailscale + fallback.Store(true) + } return &HTTPSTransport{ TransportAdapter: adapter, logger: logger, dialer: dialer, destination: destination, headers: headers, - transport: NewHTTPSTransportWrapper(dialer, serverAddr, destination), + serverAddr: serverAddr, + fallback: fallback, + transport: NewHTTPSTransportWrapper(dialer, serverAddr, fallback), } } @@ -148,8 +158,14 @@ func (t *HTTPSTransport) Close() error { func (t *HTTPSTransport) Reset() { t.transportAccess.Lock() defer t.transportAccess.Unlock() - t.transport.CloseIdleConnections() - t.transport = t.transport.Clone() + t.resetTransportLocked() +} + +func (t *HTTPSTransport) resetTransportLocked() { + oldTransport := t.transport + t.transport = NewHTTPSTransportWrapper(t.dialer, t.serverAddr, t.fallback) + t.transportResetAt = time.Now() + oldTransport.Close() } func (t *HTTPSTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { @@ -162,15 +178,19 @@ func (t *HTTPSTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS if t.transportResetAt.After(startAt) { return nil, err } - t.transport.CloseIdleConnections() - t.transport = t.transport.Clone() - t.transportResetAt = time.Now() + t.resetTransportLocked() } return nil, err } return response, nil } +func (t *HTTPSTransport) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { + go func() { + callback(t.Exchange(ctx, message)) + }() +} + func (t *HTTPSTransport) exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { exMessage := *message exMessage.Id = 0 diff --git a/dns/transport/https_transport.go b/dns/transport/https_transport.go index c823718aa1..c41655354a 100644 --- a/dns/transport/https_transport.go +++ b/dns/transport/https_transport.go @@ -5,7 +5,7 @@ import ( "errors" "net" "net/http" - "net/url" + "sync" "sync/atomic" "github.com/sagernet/sing-box/common/tls" @@ -22,42 +22,50 @@ type HTTPSTransportWrapper struct { http2Transport *http2.Transport httpTransport *http.Transport fallback *atomic.Bool + connAccess sync.Mutex + connections map[*httpsTrackedConn]struct{} + closed bool } -func NewHTTPSTransportWrapper(dialer N.Dialer, serverAddr M.Socksaddr, destination *url.URL) *HTTPSTransportWrapper { - var fallback atomic.Bool - if destination.Scheme == "http" { - // plain HTTP DoH used by Tailscale - fallback.Store(true) +func NewHTTPSTransportWrapper(dialer N.Dialer, serverAddr M.Socksaddr, fallback *atomic.Bool) *HTTPSTransportWrapper { + wrapper := &HTTPSTransportWrapper{ + fallback: fallback, + connections: make(map[*httpsTrackedConn]struct{}), } - return &HTTPSTransportWrapper{ - http2Transport: &http2.Transport{ - DialTLSContext: func(ctx context.Context, _, _ string, _ *tls.STDConfig) (net.Conn, error) { - resultConn, err := dialer.DialContext(ctx, N.NetworkTCP, serverAddr) - if err != nil { - return nil, err + wrapper.http2Transport = &http2.Transport{ + DialTLSContext: func(ctx context.Context, _, _ string, _ *tls.STDConfig) (net.Conn, error) { + resultConn, err := dialer.DialContext(ctx, N.NetworkTCP, serverAddr) + if err != nil { + return nil, err + } + if tlsConn, isTLSConn := resultConn.(tls.Conn); isTLSConn { + state := tlsConn.ConnectionState() + if state.NegotiatedProtocol != http2.NextProtoTLS { + tlsConn.Close() + fallback.Store(true) + return nil, errFallback } - if tlsConn, isTLSConn := resultConn.(tls.Conn); isTLSConn { - state := tlsConn.ConnectionState() - if state.NegotiatedProtocol != http2.NextProtoTLS { - tlsConn.Close() - fallback.Store(true) - return nil, errFallback - } - } - return resultConn, nil - }, + } + return wrapper.trackConn(resultConn) + }, + } + wrapper.httpTransport = &http.Transport{ + DialContext: func(ctx context.Context, _, addr string) (net.Conn, error) { + resultConn, err := dialer.DialContext(ctx, N.NetworkTCP, serverAddr) + if err != nil { + return nil, err + } + return wrapper.trackConn(resultConn) }, - httpTransport: &http.Transport{ - DialContext: func(ctx context.Context, _, addr string) (net.Conn, error) { - return dialer.DialContext(ctx, N.NetworkTCP, serverAddr) - }, - DialTLSContext: func(ctx context.Context, _, _ string) (net.Conn, error) { - return dialer.DialContext(ctx, N.NetworkTCP, serverAddr) - }, + DialTLSContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + resultConn, err := dialer.DialContext(ctx, N.NetworkTCP, serverAddr) + if err != nil { + return nil, err + } + return wrapper.trackConn(resultConn) }, - fallback: &fallback, } + return wrapper } func (h *HTTPSTransportWrapper) RoundTrip(request *http.Request) (*http.Response, error) { @@ -74,17 +82,47 @@ func (h *HTTPSTransportWrapper) RoundTrip(request *http.Request) (*http.Response return response, nil } -func (h *HTTPSTransportWrapper) CloseIdleConnections() { +func (h *HTTPSTransportWrapper) trackConn(conn net.Conn) (net.Conn, error) { + trackedConn := &httpsTrackedConn{Conn: conn, wrapper: h} + h.connAccess.Lock() + if h.closed { + h.connAccess.Unlock() + conn.Close() + return nil, net.ErrClosed + } + h.connections[trackedConn] = struct{}{} + h.connAccess.Unlock() + return trackedConn, nil +} + +func (h *HTTPSTransportWrapper) Close() { + h.connAccess.Lock() + if h.closed { + h.connAccess.Unlock() + return + } + h.closed = true + connections := make([]*httpsTrackedConn, 0, len(h.connections)) + for trackedConn := range h.connections { + connections = append(connections, trackedConn) + } + h.connections = nil + h.connAccess.Unlock() + for _, trackedConn := range connections { + trackedConn.Conn.Close() + } h.http2Transport.CloseIdleConnections() h.httpTransport.CloseIdleConnections() } -func (h *HTTPSTransportWrapper) Clone() *HTTPSTransportWrapper { - return &HTTPSTransportWrapper{ - httpTransport: h.httpTransport, - http2Transport: &http2.Transport{ - DialTLSContext: h.http2Transport.DialTLSContext, - }, - fallback: h.fallback, - } +type httpsTrackedConn struct { + net.Conn + wrapper *HTTPSTransportWrapper +} + +func (c *httpsTrackedConn) Close() error { + c.wrapper.connAccess.Lock() + delete(c.wrapper.connections, c) + c.wrapper.connAccess.Unlock() + return c.Conn.Close() } diff --git a/dns/transport/local/local.go b/dns/transport/local/local.go index 34897e128e..ddb8683569 100644 --- a/dns/transport/local/local.go +++ b/dns/transport/local/local.go @@ -2,6 +2,8 @@ package local import ( "context" + "sync" + "sync/atomic" "github.com/sagernet/sing-box/adapter" C "github.com/sagernet/sing-box/constant" @@ -31,15 +33,19 @@ var ( type Transport struct { dns.TransportAdapter - ctx context.Context - logger logger.ContextLogger - hosts *hosts.File - dialer N.Dialer - preferGo bool - fallback bool - resolved ResolvedResolver - mdnsTransport adapter.DNSTransport - dhcpTransport dhcpTransport + ctx context.Context + logger logger.ContextLogger + hosts *hosts.File + dialer N.Dialer + preferGo bool + fallback bool + resolved ResolvedResolver + mdnsTransport adapter.DNSTransport + dhcpTransport dhcpTransport + system systemResolver + serverSet atomic.Pointer[localServerSet] + serverSetAccess sync.Mutex + neighborResolver adapter.NeighborResolver neighborSuffixes []string } @@ -47,7 +53,6 @@ type Transport struct { type dhcpTransport interface { adapter.DNSTransport Fetch() []M.Socksaddr - Exchange0(ctx context.Context, message *mDNS.Msg, servers []M.Socksaddr) (*mDNS.Msg, error) } func NewTransport(ctx context.Context, logger log.ContextLogger, tag string, options option.LocalDNSServerOptions) (adapter.DNSTransport, error) { @@ -127,10 +132,25 @@ func (t *Transport) Start(stage adapter.StartStage) error { } func (t *Transport) Close() error { + serverSet := t.serverSet.Swap(nil) + if serverSet != nil { + serverSet.Close() + } + t.system.close() return common.Close(t.resolved, t.dhcpTransport, t.mdnsTransport) } func (t *Transport) Reset() { + serverSet := t.serverSet.Load() + if serverSet != nil { + for _, serverTransport := range serverSet.transports { + serverTransport.Reset() + } + } + t.system.reset() + if t.resolved != nil { + t.resolved.Reset() + } if t.dhcpTransport != nil { t.dhcpTransport.Reset() } @@ -149,34 +169,56 @@ func (t *Transport) PreferredDomain(domain string) bool { } func (t *Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + done := make(chan struct{}) + var ( + response *mDNS.Msg + err error + ) + t.ExchangeAsync(ctx, message, func(callbackResponse *mDNS.Msg, callbackErr error) { + response = callbackResponse + err = callbackErr + close(done) + }) + <-done + return response, err +} + +func (t *Transport) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { question := message.Question[0] if t.hosts != nil && (question.Qtype == mDNS.TypeA || question.Qtype == mDNS.TypeAAAA) { addresses := t.hosts.Lookup(dns.FqdnToDomain(question.Name)) if len(addresses) > 0 { - return dns.FixedResponse(message.Id, question, addresses, C.DefaultDNSTTL), nil + callback(dns.FixedResponse(message.Id, question, addresses, C.DefaultDNSTTL), nil) + return } } response := t.lookupNeighbor(message) if response != nil { - return response, nil + callback(response, nil) + return } if mdns.IsLocalDomain(question.Name) { if C.IsDarwin { - return t.systemExchange(ctx, message) + t.systemExchangeAsync(ctx, message, callback) + return } - return t.mdnsTransport.Exchange(ctx, message) + t.mdnsTransport.ExchangeAsync(ctx, message, callback) + return } if t.resolved != nil { - return t.resolved.Exchange(ctx, message) + t.resolved.ExchangeAsync(ctx, message, callback) + return } if t.dhcpTransport != nil { servers := t.dhcpTransport.Fetch() if len(servers) > 0 { - return t.dhcpTransport.Exchange0(ctx, message, servers) + t.dhcpTransport.ExchangeAsync(ctx, message, callback) + return } } if t.fallback { - return t.systemExchange(ctx, message) + t.systemExchangeAsync(ctx, message, callback) + return } - return t.exchange(ctx, message, question.Name) + t.exchangeAsync(ctx, message, question.Name, callback) } diff --git a/dns/transport/local/local_darwin.go b/dns/transport/local/local_darwin.go index 9227aeeefc..7d715df17b 100644 --- a/dns/transport/local/local_darwin.go +++ b/dns/transport/local/local_darwin.go @@ -10,138 +10,390 @@ import ( "io" "net" "os" + "sync" "github.com/sagernet/sing-box/dns" + dnsTransport "github.com/sagernet/sing-box/dns/transport" E "github.com/sagernet/sing/common/exceptions" mDNS "github.com/miekg/dns" ) -func (t *Transport) systemExchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { +func (t *Transport) systemExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { question := message.Question[0] - response, err := darwinLookupSystemDNS(ctx, question.Name, question.Qtype, question.Qclass) - if err != nil { - var rcodeError dns.RcodeError - if errors.As(err, &rcodeError) { - return dns.FixedResponseStatus(message, int(rcodeError)), nil + t.system.exchangeAsync(ctx, question.Name, question.Qtype, question.Qclass, func(response *mDNS.Msg, err error) { + if err != nil { + var rcodeError dns.RcodeError + if errors.As(err, &rcodeError) { + callback(dns.FixedResponseStatus(message, int(rcodeError)), nil) + return + } + callback(nil, err) + return } - return nil, err - } - response.Id = message.Id - response.Response = true - response.RecursionAvailable = true - return response, nil + response.Id = message.Id + response.Response = true + response.RecursionAvailable = true + callback(response, nil) + }) } // The mDNSResponder daemon speaks an undocumented binary protocol over a // AF_UNIX SOCK_STREAM socket. The framing below is taken from the client -// stub of Apple's open-source mDNSResponder (mDNSShared/dnssd_ipc.h and -// dnssd_clientstub.c). All multi-byte fields are big-endian; for a one-shot -// query on a fresh, non-shared connection the request and every reply travel -// over the single connected stream (no SCM_RIGHTS, no return socket). +// stub of Apple's open-source mDNSResponder (mDNSShared/dnssd_ipc.h, +// dnssd_clientstub.c and uds_daemon.c). All multi-byte fields are +// big-endian. A connection opened with connection_request acts as a shared +// connection (DNSServiceCreateConnection): subsequent requests on the same +// stream carry a unique client_context in header bytes 16-24, which the +// daemon echoes back in every reply, allowing concurrent queries to be +// demultiplexed. With IPC_FLAGS_NOERRSD set the daemon does not expect the +// SCM_RIGHTS error-return socket used by Apple's stub; request errors are +// instead delivered as async_error_op replies, and success produces no +// acknowledgment at all. A query is cancelled by sending cancel_request +// with the same client_context and no payload. const ( - mdnsResponderSocketPath = "/var/run/mDNSResponder" - mdnsResponderSocketEnv = "DNSSD_UDS_PATH" - mdnsResponderVersion = 1 - mdnsResponderHeaderLength = 28 - mdnsResponderQueryRequest = 8 // query_request - mdnsResponderQueryReply = 68 // query_reply_op + mdnsResponderSocketPath = "/var/run/mDNSResponder" + mdnsResponderSocketEnv = "DNSSD_UDS_PATH" + mdnsResponderVersion = 1 + mdnsResponderHeaderLength = 28 + mdnsResponderConnectionRequest = 1 // connection_request + mdnsResponderQueryRequest = 8 // query_request + mdnsResponderCancelRequest = 63 // cancel_request + mdnsResponderQueryReply = 68 // query_reply_op + mdnsResponderAsyncErrorReply = 73 // async_error_op mdnsResponderFlagMoreComing = 0x1 mdnsResponderFlagAdd = 0x2 mdnsResponderFlagReturnIntermediates = 0x1000 + mdnsResponderFlagShareConnection = 0x4000 mdnsResponderFlagTimeout = 0x10000 + mdnsResponderIPCFlagNoErrorSocket = 0x4 // IPC_FLAGS_NOERRSD + mdnsResponderErrNoError = 0 mdnsResponderErrNoSuchName = -65538 mdnsResponderErrNoSuchRecord = -65554 mdnsResponderErrTimeout = -65568 + + mdnsResponderMaxReplyLength = 1 << 20 ) -func darwinLookupSystemDNS(ctx context.Context, name string, qtype, qclass uint16) (*mDNS.Msg, error) { +type systemResolver struct { + initOnce sync.Once + connection *dnsTransport.ConnPool[net.Conn] + queryAccess sync.Mutex + queryId uint64 + queries map[uint64]*systemPendingQuery +} + +type systemPendingQuery struct { + conn net.Conn + name string + qtype uint16 + qclass uint16 + answers []mDNS.RR + hasFinalAnswer bool + ready bool + callback func(response *mDNS.Msg, err error) + stopContext func() bool + stopConn func() bool +} + +type systemCompletion struct { + pending *systemPendingQuery + err error +} + +func (r *systemResolver) init() { + r.queries = make(map[uint64]*systemPendingQuery) + r.connection = dnsTransport.NewConnPool(dnsTransport.ConnPoolOptions[net.Conn]{ + Mode: dnsTransport.ConnPoolSingle, + IsAlive: func(conn net.Conn) bool { + return conn != nil + }, + Close: func(conn net.Conn, cause error) { + conn.Close() + }, + }) +} + +func (r *systemResolver) close() { + r.initOnce.Do(r.init) + _ = r.connection.Close() +} + +func (r *systemResolver) reset() { + r.initOnce.Do(r.init) + r.connection.Reset() +} + +func (r *systemResolver) exchangeAsync(ctx context.Context, name string, qtype uint16, qclass uint16, callback func(response *mDNS.Msg, err error)) { + r.initOnce.Do(r.init) + for firstAttempt := true; ; firstAttempt = false { + conn, connCtx, created, err := r.connection.AcquireShared(ctx, r.dial) + if err != nil { + callback(nil, err) + return + } + if created { + go r.recvLoop(conn) + } + queryId := r.register(ctx, connCtx, conn, name, qtype, qclass, callback) + _, writeErr := conn.Write(buildQueryRequest(queryId, name, qtype, qclass)) + if writeErr == nil { + return + } + pending := r.take(queryId) + r.connection.Invalidate(conn, writeErr) + if pending == nil { + return + } + if !created && firstAttempt { + continue + } + callback(nil, E.Cause(writeErr, "write mDNSResponder query")) + return + } +} + +func (r *systemResolver) dial(ctx context.Context) (net.Conn, error) { socketPath := cmp.Or(os.Getenv(mdnsResponderSocketEnv), mdnsResponderSocketPath) var dialer net.Dialer conn, err := dialer.DialContext(ctx, "unix", socketPath) if err != nil { return nil, E.Cause(err, "connect mDNSResponder") } - defer conn.Close() stopCancel := context.AfterFunc(ctx, func() { conn.Close() }) - defer stopCancel() - - _, err = conn.Write(buildQueryRequest(name, qtype, qclass)) + err = writeConnectionRequest(conn) + stopCancel() if err != nil { - return nil, contextError(ctx, E.Cause(err, "write mDNSResponder query")) + conn.Close() + return nil, contextError(ctx, err) } + return conn, nil +} +func writeConnectionRequest(conn net.Conn) error { + _, err := conn.Write(appendResponderHeader(make([]byte, 0, mdnsResponderHeaderLength), mdnsResponderConnectionRequest, 0, 0, 0)) + if err != nil { + return E.Cause(err, "write mDNSResponder connection request") + } var status [4]byte _, err = io.ReadFull(conn, status[:]) if err != nil { - return nil, contextError(ctx, E.Cause(err, "read mDNSResponder status")) + return E.Cause(err, "read mDNSResponder connection status") } statusCode := int32(binary.BigEndian.Uint32(status[:])) if statusCode != mdnsResponderErrNoError { - return nil, darwinResolverError(name, statusCode) + return E.New("mDNSResponder connection request failed: error ", statusCode) } + return nil +} - return readQueryResponse(ctx, conn, name, qtype, qclass) +func (r *systemResolver) register(ctx context.Context, connCtx context.Context, conn net.Conn, name string, qtype uint16, qclass uint16, callback func(response *mDNS.Msg, err error)) uint64 { + r.queryAccess.Lock() + defer r.queryAccess.Unlock() + r.queryId++ + queryId := r.queryId + pending := &systemPendingQuery{ + conn: conn, + name: name, + qtype: qtype, + qclass: qclass, + callback: callback, + } + r.queries[queryId] = pending + pending.stopContext = context.AfterFunc(ctx, func() { + r.cancelQuery(queryId, ctx) + }) + pending.stopConn = context.AfterFunc(connCtx, func() { + r.completeConnClosed(queryId, connCtx) + }) + return queryId } -func readQueryResponse(ctx context.Context, conn net.Conn, name string, qtype, qclass uint16) (*mDNS.Msg, error) { - var answers []mDNS.RR - var hasFinalAnswer bool +func (r *systemResolver) take(queryId uint64) *systemPendingQuery { + r.queryAccess.Lock() + pending, loaded := r.queries[queryId] + if !loaded { + r.queryAccess.Unlock() + return nil + } + delete(r.queries, queryId) + r.queryAccess.Unlock() + pending.stopContext() + pending.stopConn() + return pending +} + +func (r *systemResolver) cancelQuery(queryId uint64, ctx context.Context) { + pending := r.take(queryId) + if pending == nil { + return + } + _, err := pending.conn.Write(appendResponderHeader(make([]byte, 0, mdnsResponderHeaderLength), mdnsResponderCancelRequest, 0, queryId, 0)) + if err != nil { + r.connection.Invalidate(pending.conn, err) + } else { + r.connection.Release(pending.conn, true) + } + pending.callback(nil, ctx.Err()) +} + +func (r *systemResolver) completeConnClosed(queryId uint64, connCtx context.Context) { + pending := r.take(queryId) + if pending == nil { + return + } + pending.callback(nil, context.Cause(connCtx)) +} + +func (r *systemResolver) finish(pending *systemPendingQuery, err error) { + pending.stopContext() + pending.stopConn() + r.connection.Release(pending.conn, true) + if err != nil { + pending.callback(nil, err) + return + } + pending.callback(&mDNS.Msg{ + Question: []mDNS.Question{{Name: mDNS.Fqdn(pending.name), Qtype: pending.qtype, Qclass: pending.qclass}}, + Answer: pending.answers, + }, nil) +} + +func (r *systemResolver) recvLoop(conn net.Conn) { for { - reply, replyErr := readReply(conn) - if replyErr != nil { - return nil, contextError(ctx, E.Cause(replyErr, "read mDNSResponder reply")) + operation, clientContext, data, err := readResponderReply(conn) + if err != nil { + r.connection.Invalidate(conn, err) + return } - if reply.errorCode != mdnsResponderErrNoError { - if len(answers) == 0 { - return nil, darwinResolverError(name, reply.errorCode) + switch operation { + case mdnsResponderQueryReply: + reply, parseErr := parseResponderReply(data) + if parseErr != nil { + r.connection.Invalidate(conn, parseErr) + return + } + r.handleQueryReply(clientContext, reply) + case mdnsResponderAsyncErrorReply: + if len(data) >= 12 { + r.completeQueryError(clientContext, binary.BigEndian.Uint32(data[0:4]), int32(binary.BigEndian.Uint32(data[8:12]))) } - break } - if reply.flags&mdnsResponderFlagAdd != 0 && len(reply.rdata) > 0 { - record, buildErr := buildResourceRecord(reply) - if buildErr == nil { - answers = append(answers, record) - if record.Header().Rrtype == qtype { - hasFinalAnswer = true + } +} + +// On a shared connection MoreComing applies collectively to all operations +// (dns_sd.h "Collective kDNSServiceFlagsMoreComing flag"): the daemon sets it +// whenever another reply, for any query, is queued behind this one. A reply +// without it is therefore a connection-wide flush point, at which every query +// that already collected its final answer is completed. +func (r *systemResolver) handleQueryReply(queryId uint64, reply mdnsResponderReply) { + var completions []systemCompletion + r.queryAccess.Lock() + pending, loaded := r.queries[queryId] + if loaded { + if reply.errorCode != mdnsResponderErrNoError { + delete(r.queries, queryId) + if len(pending.answers) > 0 { + completions = append(completions, systemCompletion{pending: pending}) + } else { + completions = append(completions, systemCompletion{pending: pending, err: darwinResolverError(pending.name, reply.errorCode)}) + } + } else { + if reply.flags&mdnsResponderFlagAdd != 0 && len(reply.rdata) > 0 { + record, buildErr := buildResourceRecord(reply) + if buildErr == nil { + pending.answers = append(pending.answers, record) + if record.Header().Rrtype == pending.qtype { + pending.hasFinalAnswer = true + } } } + if pending.hasFinalAnswer && reply.rrtype == pending.qtype { + pending.ready = true + } } - if reply.flags&mdnsResponderFlagMoreComing != 0 { - continue - } - if hasFinalAnswer && reply.rrtype == qtype { - break + } + if reply.flags&mdnsResponderFlagMoreComing == 0 { + completions = r.collectReadyLocked(completions) + } + r.queryAccess.Unlock() + for _, completion := range completions { + r.finish(completion.pending, completion.err) + } +} + +func (r *systemResolver) completeQueryError(queryId uint64, flags uint32, errorCode int32) { + var completions []systemCompletion + r.queryAccess.Lock() + pending, loaded := r.queries[queryId] + if loaded { + delete(r.queries, queryId) + completions = append(completions, systemCompletion{pending: pending, err: darwinResolverError(pending.name, errorCode)}) + } + if flags&mdnsResponderFlagMoreComing == 0 { + completions = r.collectReadyLocked(completions) + } + r.queryAccess.Unlock() + for _, completion := range completions { + r.finish(completion.pending, completion.err) + } +} + +func (r *systemResolver) collectReadyLocked(completions []systemCompletion) []systemCompletion { + for queryId, pending := range r.queries { + if pending.ready { + delete(r.queries, queryId) + completions = append(completions, systemCompletion{pending: pending}) } } + return completions +} - response := new(mDNS.Msg) - response.Question = []mDNS.Question{{Name: mDNS.Fqdn(name), Qtype: qtype, Qclass: qclass}} - response.Answer = answers - return response, nil +func appendResponderHeader(buffer []byte, operation uint32, dataLength int, clientContext uint64, ipcFlags uint32) []byte { + buffer = binary.BigEndian.AppendUint32(buffer, mdnsResponderVersion) + buffer = binary.BigEndian.AppendUint32(buffer, uint32(dataLength)) + buffer = binary.BigEndian.AppendUint32(buffer, ipcFlags) + buffer = binary.BigEndian.AppendUint32(buffer, operation) + buffer = binary.BigEndian.AppendUint64(buffer, clientContext) + buffer = binary.BigEndian.AppendUint32(buffer, 0) // reg_index + return buffer } -func buildQueryRequest(name string, qtype, qclass uint16) []byte { - payload := make([]byte, 0, 8+len(name)+1+4) - payload = binary.BigEndian.AppendUint32(payload, mdnsResponderFlagReturnIntermediates|mdnsResponderFlagTimeout) - payload = binary.BigEndian.AppendUint32(payload, 0) // interfaceIndex - payload = append(payload, name...) - payload = append(payload, 0) // C string terminator - payload = binary.BigEndian.AppendUint16(payload, qtype) - payload = binary.BigEndian.AppendUint16(payload, qclass) +func buildQueryRequest(queryId uint64, name string, qtype uint16, qclass uint16) []byte { + payloadLength := 4 + 4 + len(name) + 1 + 2 + 2 + message := make([]byte, 0, mdnsResponderHeaderLength+payloadLength) + message = appendResponderHeader(message, mdnsResponderQueryRequest, payloadLength, queryId, mdnsResponderIPCFlagNoErrorSocket) + message = binary.BigEndian.AppendUint32(message, mdnsResponderFlagShareConnection|mdnsResponderFlagReturnIntermediates|mdnsResponderFlagTimeout) + message = binary.BigEndian.AppendUint32(message, 0) // interfaceIndex + message = append(message, name...) + message = append(message, 0) // C string terminator + message = binary.BigEndian.AppendUint16(message, qtype) + message = binary.BigEndian.AppendUint16(message, qclass) + return message +} - message := make([]byte, mdnsResponderHeaderLength, mdnsResponderHeaderLength+len(payload)) - binary.BigEndian.PutUint32(message[0:], mdnsResponderVersion) - binary.BigEndian.PutUint32(message[4:], uint32(len(payload))) - binary.BigEndian.PutUint32(message[8:], 0) // ipc_flags - binary.BigEndian.PutUint32(message[12:], mdnsResponderQueryRequest) - // message[16:24] client_context and message[24:28] reg_index stay zero. - return append(message, payload...) +func readResponderReply(conn net.Conn) (operation uint32, clientContext uint64, data []byte, err error) { + var header [mdnsResponderHeaderLength]byte + _, err = io.ReadFull(conn, header[:]) + if err != nil { + return + } + dataLength := binary.BigEndian.Uint32(header[4:8]) + if dataLength > mdnsResponderMaxReplyLength { + err = E.New("oversized mDNSResponder reply: ", dataLength) + return + } + operation = binary.BigEndian.Uint32(header[12:16]) + clientContext = binary.BigEndian.Uint64(header[16:24]) + data = make([]byte, dataLength) + _, err = io.ReadFull(conn, data) + return } type mdnsResponderReply struct { @@ -154,24 +406,8 @@ type mdnsResponderReply struct { rdata []byte } -func readReply(conn net.Conn) (mdnsResponderReply, error) { +func parseResponderReply(data []byte) (mdnsResponderReply, error) { var reply mdnsResponderReply - var header [mdnsResponderHeaderLength]byte - _, err := io.ReadFull(conn, header[:]) - if err != nil { - return reply, err - } - dataLength := binary.BigEndian.Uint32(header[4:8]) - operation := binary.BigEndian.Uint32(header[12:16]) - if operation != mdnsResponderQueryReply { - return reply, E.New("unexpected mDNSResponder reply op ", operation) - } - data := make([]byte, dataLength) - _, err = io.ReadFull(conn, data) - if err != nil { - return reply, err - } - reader := replyReader{data: data} reply.flags = reader.uint32() reader.uint32() // interfaceIndex diff --git a/dns/transport/local/local_darwin_cgo.go b/dns/transport/local/local_darwin_cgo.go index 1088779968..c7fc423027 100644 --- a/dns/transport/local/local_darwin_cgo.go +++ b/dns/transport/local/local_darwin_cgo.go @@ -77,6 +77,18 @@ func darwinResolverHErrno(name string, hErrno int) error { } } +type systemResolver struct{} + +func (r *systemResolver) close() {} + +func (r *systemResolver) reset() {} + +func (t *Transport) systemExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { + go func() { + callback(t.systemExchange(ctx, message)) + }() +} + func (t *Transport) systemExchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { question := message.Question[0] type resolvResult struct { diff --git a/dns/transport/local/local_darwin_test.go b/dns/transport/local/local_darwin_test.go index 43aa59e327..7db18c8dcd 100644 --- a/dns/transport/local/local_darwin_test.go +++ b/dns/transport/local/local_darwin_test.go @@ -7,6 +7,7 @@ import ( "context" "net" "os" + "sync" "testing" "time" @@ -26,9 +27,25 @@ func requireMDNSResponder(t *testing.T) { conn.Close() } +func systemExchangeForTest(ctx context.Context, transport *Transport, message *mDNS.Msg) (*mDNS.Msg, error) { + done := make(chan struct{}) + var ( + response *mDNS.Msg + err error + ) + transport.systemExchangeAsync(ctx, message, func(callbackResponse *mDNS.Msg, callbackErr error) { + response = callbackResponse + err = callbackErr + close(done) + }) + <-done + return response, err +} + func TestSystemExchangeLoopback(t *testing.T) { requireMDNSResponder(t) transport := &Transport{} + defer transport.system.close() for _, testCase := range []struct { qtype uint16 expected net.IP @@ -39,7 +56,7 @@ func TestSystemExchangeLoopback(t *testing.T) { message := new(mDNS.Msg) message.SetQuestion("localhost.", testCase.qtype) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - response, err := transport.systemExchange(ctx, message) + response, err := systemExchangeForTest(ctx, transport, message) cancel() if err != nil { t.Fatalf("%s localhost: %v", mDNS.TypeToString[testCase.qtype], err) @@ -67,13 +84,15 @@ func TestSystemExchangeLoopback(t *testing.T) { func TestSystemExchangeNoData(t *testing.T) { requireMDNSResponder(t) + transport := &Transport{} + defer transport.system.close() message := new(mDNS.Msg) // localhost has no MX record, so the daemon reports NoSuchRecord, which must // surface as an empty NOERROR response rather than an error. message.SetQuestion("localhost.", mDNS.TypeMX) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - response, err := (&Transport{}).systemExchange(ctx, message) + response, err := systemExchangeForTest(ctx, transport, message) if err != nil { t.Fatalf("MX localhost: %v", err) } @@ -87,12 +106,14 @@ func TestSystemExchangeNoData(t *testing.T) { func TestSystemExchangeCancel(t *testing.T) { requireMDNSResponder(t) + transport := &Transport{} + defer transport.system.close() message := new(mDNS.Msg) message.SetQuestion("localhost.", mDNS.TypeA) ctx, cancel := context.WithCancel(context.Background()) cancel() start := time.Now() - _, err := (&Transport{}).systemExchange(ctx, message) + _, err := systemExchangeForTest(ctx, transport, message) elapsed := time.Since(start) if err == nil { t.Fatal("expected error for cancelled context") @@ -101,3 +122,44 @@ func TestSystemExchangeCancel(t *testing.T) { t.Fatalf("cancellation too slow: %s", elapsed) } } + +func TestSystemExchangeConcurrent(t *testing.T) { + requireMDNSResponder(t) + transport := &Transport{} + defer transport.system.close() + var waitGroup sync.WaitGroup + errors := make(chan error, 16) + for i := range 16 { + qtype := mDNS.TypeA + if i%2 == 1 { + qtype = mDNS.TypeAAAA + } + waitGroup.Add(1) + go func() { + defer waitGroup.Done() + message := new(mDNS.Msg) + message.SetQuestion("localhost.", qtype) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + response, exchangeErr := systemExchangeForTest(ctx, transport, message) + if exchangeErr != nil { + errors <- exchangeErr + return + } + if len(response.Answer) == 0 { + errors <- context.DeadlineExceeded + } + }() + } + waitGroup.Wait() + close(errors) + for exchangeErr := range errors { + t.Fatal("concurrent query failed: ", exchangeErr) + } + transport.system.queryAccess.Lock() + pendingCount := len(transport.system.queries) + transport.system.queryAccess.Unlock() + if pendingCount != 0 { + t.Fatalf("expected no pending queries after completion, got %d", pendingCount) + } +} diff --git a/dns/transport/local/local_other.go b/dns/transport/local/local_other.go index 9bb3d7777a..832f1dedd2 100644 --- a/dns/transport/local/local_other.go +++ b/dns/transport/local/local_other.go @@ -9,6 +9,12 @@ import ( mDNS "github.com/miekg/dns" ) -func (t *Transport) systemExchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { - return nil, os.ErrInvalid +type systemResolver struct{} + +func (r *systemResolver) close() {} + +func (r *systemResolver) reset() {} + +func (t *Transport) systemExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { + callback(nil, os.ErrInvalid) } diff --git a/dns/transport/local/local_resolved.go b/dns/transport/local/local_resolved.go index e0128d6de5..13b2a43459 100644 --- a/dns/transport/local/local_resolved.go +++ b/dns/transport/local/local_resolved.go @@ -9,5 +9,7 @@ import ( type ResolvedResolver interface { Start() error Close() error + Reset() Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) + ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) } diff --git a/dns/transport/local/local_resolved_linux.go b/dns/transport/local/local_resolved_linux.go index fc3ca2b7a9..7f8359463b 100644 --- a/dns/transport/local/local_resolved_linux.go +++ b/dns/transport/local/local_resolved_linux.go @@ -134,6 +134,19 @@ func (t *DBusResolvedResolver) Close() error { return closeErr } +func (t *DBusResolvedResolver) Reset() { + serverSet := t.savedServerSet.Load() + if serverSet == nil { + return + } + for _, server := range serverSet.servers { + server.primaryTransport.Reset() + if server.fallbackTransport != nil { + server.fallbackTransport.Reset() + } + } +} + func (t *DBusResolvedResolver) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { serverSet := t.savedServerSet.Load() if serverSet == nil { @@ -159,6 +172,51 @@ func (t *DBusResolvedResolver) Exchange(ctx context.Context, message *mDNS.Msg) return t.exchangeServerSet(ctx, message, refreshedServerSet) } +func (t *DBusResolvedResolver) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { + serverSet := t.savedServerSet.Load() + if serverSet == nil { + go func() { + callback(t.Exchange(ctx, message)) + }() + return + } + t.exchangeServerSetAsync(ctx, message, serverSet, func(response *mDNS.Msg, err error) { + if err == nil { + callback(response, nil) + return + } + go func() { + t.updateStatus() + refreshedServerSet := t.savedServerSet.Load() + if refreshedServerSet == nil || refreshedServerSet == serverSet { + callback(nil, err) + return + } + t.exchangeServerSetAsync(ctx, message, refreshedServerSet, callback) + }() + }) +} + +func (t *DBusResolvedResolver) exchangeServerSetAsync(ctx context.Context, message *mDNS.Msg, serverSet *resolvedServerSet, callback func(response *mDNS.Msg, err error)) { + if len(serverSet.servers) == 0 { + callback(nil, E.New("link has no DNS servers configured")) + return + } + serverExchangers := make([]dnsTransport.AsyncExchanger, 0, len(serverSet.servers)) + for _, server := range serverSet.servers { + serverExchangers = append(serverExchangers, func(exchangeCtx context.Context, exchangeCallback func(response *mDNS.Msg, err error)) { + server.primaryTransport.ExchangeAsync(exchangeCtx, message, func(response *mDNS.Msg, exchangeErr error) { + if exchangeErr != nil && server.fallbackTransport != nil { + server.fallbackTransport.ExchangeAsync(exchangeCtx, message, exchangeCallback) + return + } + exchangeCallback(response, exchangeErr) + }) + }) + } + dnsTransport.ExchangeSequential(ctx, serverExchangers, nil, callback) +} + func (t *DBusResolvedResolver) loopUpdateStatus() { signalChan := make(chan *dbus.Signal, 1) t.systemBus.Signal(signalChan) @@ -270,8 +328,10 @@ func (t *DBusResolvedResolver) checkResolved(ctx context.Context) (*resolvedServ return nil, E.New("link has no DNS servers configured") } serverDialer, err := dialer.NewDefault(t.ctx, option.DialerOptions{ - BindInterface: defaultInterface.Name, - UDPFragmentDefault: true, + AbstractDialerOptions: option.AbstractDialerOptions{ + BindInterface: defaultInterface.Name, + UDPFragmentDefault: true, + }, }) if err != nil { return nil, err diff --git a/dns/transport/local/local_shared.go b/dns/transport/local/local_shared.go index 070409119c..cd011d78c0 100644 --- a/dns/transport/local/local_shared.go +++ b/dns/transport/local/local_shared.go @@ -2,182 +2,117 @@ package local import ( "context" - "errors" - "math/rand" - "syscall" - "time" + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/dns" "github.com/sagernet/sing-box/dns/transport" - "github.com/sagernet/sing/common/buf" E "github.com/sagernet/sing/common/exceptions" M "github.com/sagernet/sing/common/metadata" - N "github.com/sagernet/sing/common/network" mDNS "github.com/miekg/dns" ) -func (t *Transport) exchange(ctx context.Context, message *mDNS.Msg, domain string) (*mDNS.Msg, error) { - systemConfig := getSystemDNSConfig(t.ctx) - if systemConfig.singleRequest || !(message.Question[0].Qtype == mDNS.TypeA || message.Question[0].Qtype == mDNS.TypeAAAA) { - return t.exchangeSingleRequest(ctx, systemConfig, message, domain) - } else { - return t.exchangeParallel(ctx, systemConfig, message, domain) - } +type localServerSet struct { + config *dnsConfig + transports []adapter.DNSTransport } -func (t *Transport) exchangeSingleRequest(ctx context.Context, systemConfig *dnsConfig, message *mDNS.Msg, domain string) (*mDNS.Msg, error) { - var lastErr error - for _, fqdn := range systemConfig.nameList(domain) { - response, err := t.tryOneName(ctx, systemConfig, fqdn, message) - if err != nil { - lastErr = err - continue - } - return response, nil +func (s *localServerSet) Close() { + for _, serverTransport := range s.transports { + serverTransport.Close() } - return nil, lastErr } -func (t *Transport) exchangeParallel(ctx context.Context, systemConfig *dnsConfig, message *mDNS.Msg, domain string) (*mDNS.Msg, error) { - returned := make(chan struct{}) - defer close(returned) - type queryResult struct { - response *mDNS.Msg - err error - } - results := make(chan queryResult) - startRacer := func(ctx context.Context, fqdn string) { - response, err := t.tryOneName(ctx, systemConfig, fqdn, message) - select { - case results <- queryResult{response, err}: - case <-returned: +func (t *Transport) serverSetFor(systemConfig *dnsConfig) (*localServerSet, error) { + serverSet := t.serverSet.Load() + if serverSet != nil && serverSet.config == systemConfig { + return serverSet, nil + } + t.serverSetAccess.Lock() + defer t.serverSetAccess.Unlock() + serverSet = t.serverSet.Load() + if serverSet != nil && serverSet.config == systemConfig { + return serverSet, nil + } + transports := make([]adapter.DNSTransport, 0, len(systemConfig.servers)) + for _, server := range systemConfig.servers { + serverAddr := M.ParseSocksaddr(server) + if serverAddr.Port == 0 { + serverAddr.Port = 53 } - } - queryCtx, queryCancel := context.WithCancel(ctx) - defer queryCancel() - var nameCount int - for _, fqdn := range systemConfig.nameList(domain) { - nameCount++ - go startRacer(queryCtx, fqdn) - } - var errors []error - for { - select { - case <-ctx.Done(): - return nil, ctx.Err() - case result := <-results: - if result.err == nil { - return result.response, nil - } - errors = append(errors, result.err) - if len(errors) == nameCount { - return nil, E.Errors(errors...) - } + var serverTransport adapter.DNSTransport + if systemConfig.useTCP { + serverTransport = transport.NewTCPRaw(dns.NewTransportAdapter(C.DNSTypeTCP, "", nil), t.dialer, serverAddr) + } else { + serverTransport = transport.NewUDPRaw(t.logger, dns.NewTransportAdapter(C.DNSTypeUDP, "", nil), t.dialer, serverAddr) } - } -} - -func (t *Transport) tryOneName(ctx context.Context, config *dnsConfig, fqdn string, message *mDNS.Msg) (*mDNS.Msg, error) { - serverOffset := config.serverOffset() - sLen := uint32(len(config.servers)) - var lastErr error - for i := 0; i < config.attempts; i++ { - for j := range sLen { - server := config.servers[(serverOffset+j)%sLen] - question := message.Question[0] - question.Name = fqdn - response, err := t.exchangeOne(ctx, M.ParseSocksaddr(server), question, config.timeout, config.useTCP, config.trustAD) - if err != nil { - lastErr = err - continue + err := serverTransport.Start(adapter.StartStateStart) + if err != nil { + for _, startedTransport := range transports { + startedTransport.Close() } - return response, nil + return nil, E.Cause(err, "initialize transport for ", serverAddr) } + transports = append(transports, serverTransport) } - return nil, E.Cause(lastErr, fqdn) -} - -func (t *Transport) exchangeOne(ctx context.Context, server M.Socksaddr, question mDNS.Question, timeout time.Duration, useTCP, ad bool) (*mDNS.Msg, error) { - if server.Port == 0 { - server.Port = 53 - } - request := &mDNS.Msg{ - MsgHdr: mDNS.MsgHdr{ - Id: uint16(rand.Uint32()), - RecursionDesired: true, - AuthenticatedData: ad, - }, - Question: []mDNS.Question{question}, - Compress: true, + newServerSet := &localServerSet{ + config: systemConfig, + transports: transports, } - request.SetEdns0(buf.UDPBufferSize, false) - if !useTCP { - return t.exchangeUDP(ctx, server, request, timeout) - } else { - return t.exchangeTCP(ctx, server, request, timeout) + oldServerSet := t.serverSet.Swap(newServerSet) + if oldServerSet != nil { + oldServerSet.Close() } + return newServerSet, nil } -func (t *Transport) exchangeUDP(ctx context.Context, server M.Socksaddr, request *mDNS.Msg, timeout time.Duration) (*mDNS.Msg, error) { - conn, err := t.dialer.DialContext(ctx, N.NetworkUDP, server) - if err != nil { - return nil, err - } - defer conn.Close() - if deadline, loaded := ctx.Deadline(); loaded && !deadline.IsZero() { - newDeadline := time.Now().Add(timeout) - if deadline.After(newDeadline) { - deadline = newDeadline - } - conn.SetDeadline(deadline) - } - buffer := buf.Get(buf.UDPBufferSize) - defer buf.Put(buffer) - rawMessage, err := request.PackBuffer(buffer) - if err != nil { - return nil, E.Cause(err, "pack request") - } - _, err = conn.Write(rawMessage) +func (t *Transport) exchangeAsync(ctx context.Context, message *mDNS.Msg, domain string, callback func(response *mDNS.Msg, err error)) { + systemConfig := getSystemDNSConfig(t.ctx) + serverSet, err := t.serverSetFor(systemConfig) if err != nil { - if errors.Is(err, syscall.EMSGSIZE) { - return t.exchangeTCP(ctx, server, request, timeout) - } - return nil, E.Cause(err, "write request") + callback(nil, err) + return } - n, err := conn.Read(buffer) - if err != nil { - if errors.Is(err, syscall.EMSGSIZE) { - return t.exchangeTCP(ctx, server, request, timeout) - } - return nil, E.Cause(err, "read response") + names := systemConfig.nameList(domain) + if len(names) == 0 { + callback(nil, E.New("invalid domain: ", domain)) + return } - var response mDNS.Msg - err = response.Unpack(buffer[:n]) - if err != nil { - return nil, E.Cause(err, "unpack response") + nameExchangers := make([]transport.AsyncExchanger, 0, len(names)) + for _, fqdn := range names { + nameExchangers = append(nameExchangers, newNameExchanger(systemConfig, serverSet, message, fqdn)) } - if response.Truncated { - return t.exchangeTCP(ctx, server, request, timeout) + question := message.Question[0] + if systemConfig.singleRequest || !(question.Qtype == mDNS.TypeA || question.Qtype == mDNS.TypeAAAA) { + transport.ExchangeSequential(ctx, nameExchangers, nil, callback) + } else { + transport.ExchangeRace(ctx, nameExchangers, callback) } - return &response, nil } -func (t *Transport) exchangeTCP(ctx context.Context, server M.Socksaddr, request *mDNS.Msg, timeout time.Duration) (*mDNS.Msg, error) { - conn, err := t.dialer.DialContext(ctx, N.NetworkTCP, server) - if err != nil { - return nil, err - } - defer conn.Close() - if deadline, loaded := ctx.Deadline(); loaded && !deadline.IsZero() { - newDeadline := time.Now().Add(timeout) - if deadline.After(newDeadline) { - deadline = newDeadline +func newNameExchanger(systemConfig *dnsConfig, serverSet *localServerSet, message *mDNS.Msg, fqdn string) transport.AsyncExchanger { + serverOffset := systemConfig.serverOffset() + serverCount := uint32(len(serverSet.transports)) + attemptExchangers := make([]transport.AsyncExchanger, 0, systemConfig.attempts*int(serverCount)) + for i := 0; i < systemConfig.attempts; i++ { + for j := range serverCount { + serverTransport := serverSet.transports[(serverOffset+j)%serverCount] + attemptExchangers = append(attemptExchangers, func(ctx context.Context, callback func(response *mDNS.Msg, err error)) { + attemptCtx, cancel := context.WithTimeout(ctx, systemConfig.timeout) + serverTransport.ExchangeAsync(attemptCtx, transport.NewFanOutRequest(message, fqdn, systemConfig.trustAD), func(response *mDNS.Msg, err error) { + cancel() + callback(response, err) + }) + }) } - conn.SetDeadline(deadline) } - err = transport.WriteMessage(conn, 0, request) - if err != nil { - return nil, err + return func(ctx context.Context, callback func(response *mDNS.Msg, err error)) { + transport.ExchangeSequential(ctx, attemptExchangers, nil, func(response *mDNS.Msg, err error) { + if err != nil { + err = E.Cause(err, fqdn) + } + callback(response, err) + }) } - return transport.ReadMessage(conn) } diff --git a/dns/transport/mdns/mdns.go b/dns/transport/mdns/mdns.go index 2db3390d53..76851ebc6c 100644 --- a/dns/transport/mdns/mdns.go +++ b/dns/transport/mdns/mdns.go @@ -159,6 +159,12 @@ func (t *Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, return nil, E.New("mdns: query timeout") } +func (t *Transport) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { + go func() { + callback(t.Exchange(ctx, message)) + }() +} + type exchangeResult struct { response *mDNS.Msg err error diff --git a/dns/transport/multi/multi.go b/dns/transport/multi/multi.go index 7e36a62286..314e1cd261 100644 --- a/dns/transport/multi/multi.go +++ b/dns/transport/multi/multi.go @@ -73,6 +73,12 @@ func (t *Transport) Close() error { func (t *Transport) Reset() { } +func (m *Transport) ExchangeAsync(ctx context.Context, msg *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { + go func() { + callback(m.Exchange(ctx, msg)) + }() +} + func (m *Transport) Exchange(ctx context.Context, msg *mDNS.Msg) (*mDNS.Msg, error) { defer func() { select { diff --git a/dns/transport/multiplexer.go b/dns/transport/multiplexer.go new file mode 100644 index 0000000000..6b275ae9d6 --- /dev/null +++ b/dns/transport/multiplexer.go @@ -0,0 +1,441 @@ +package transport + +import ( + "context" + "errors" + "net" + "sync" + "sync/atomic" + "time" + + E "github.com/sagernet/sing/common/exceptions" + + mDNS "github.com/miekg/dns" +) + +const ( + reuseStateUnknown int32 = iota + reuseStateProbing + reuseStateSupported + reuseStateUnsupported +) + +const ( + reuseProbeTimeout = 5 * time.Second + reuseProbeRetryInterval = time.Minute + reuseDemoteFailureLimit = 3 + + reuseProbeQueryIdA uint16 = 1 + reuseProbeQueryIdB uint16 = 2 +) + +type queryMultiplexerOptions struct { + dial func(ctx context.Context) (net.Conn, error) + write func(conn net.Conn, message *mDNS.Msg, queryId uint16) error + readNext func(conn net.Conn) (*mDNS.Msg, error) + retryReadError bool + probeReuse bool +} + +type queryMultiplexer struct { + options queryMultiplexerOptions + connection *ConnPool[*multiplexConn] + + queryAccess sync.Mutex + queryId uint16 + queries map[uint16]*pendingQuery + + reuseState atomic.Int32 + demoteFailures atomic.Int32 + + probeAccess sync.Mutex + probeEpoch uint32 + lastProbeTime time.Time +} + +type multiplexConn struct { + net.Conn + readEpoch atomic.Uint64 +} + +type queryMultiplexerReadError struct { + cause error +} + +func (e *queryMultiplexerReadError) Error() string { + return e.cause.Error() +} + +func (e *queryMultiplexerReadError) Unwrap() error { + return e.cause +} + +type pendingQuery struct { + conn *multiplexConn + message *mDNS.Msg + readEpoch uint64 + callback func(response *mDNS.Msg, err error) + stopContext func() bool + stopConn func() bool + retryCtx context.Context +} + +func newQueryMultiplexer(options queryMultiplexerOptions) *queryMultiplexer { + return &queryMultiplexer{ + options: options, + queries: make(map[uint16]*pendingQuery), + connection: NewConnPool(ConnPoolOptions[*multiplexConn]{ + Mode: ConnPoolSingle, + IsAlive: func(conn *multiplexConn) bool { + return conn != nil + }, + Close: func(conn *multiplexConn, cause error) { + conn.Close() + }, + }), + } +} + +func (m *queryMultiplexer) Close() error { + return m.connection.Close() +} + +func (m *queryMultiplexer) Reset() { + if m.options.probeReuse { + m.probeAccess.Lock() + m.probeEpoch++ + m.reuseState.Store(reuseStateUnknown) + m.lastProbeTime = time.Time{} + m.probeAccess.Unlock() + m.demoteFailures.Store(0) + } + m.connection.Reset() +} + +func (m *queryMultiplexer) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + done := make(chan struct{}) + var ( + response *mDNS.Msg + err error + ) + m.ExchangeAsync(ctx, message, func(callbackResponse *mDNS.Msg, callbackErr error) { + response = callbackResponse + err = callbackErr + close(done) + }) + <-done + return response, err +} + +func (m *queryMultiplexer) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { + m.dispatch(ctx, message, callback, true) +} + +func (m *queryMultiplexer) dispatch(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error), retryReadError bool) { + if m.options.probeReuse && m.reuseState.Load() != reuseStateSupported { + m.maybeStartProbe(ctx, message) + go m.exchangeSingle(ctx, message, callback) + return + } + m.exchangeAsync(ctx, message, callback, retryReadError) +} + +func (m *queryMultiplexer) exchangeSingle(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { + conn, err := m.options.dial(ctx) + if err != nil { + callback(nil, err) + return + } + defer conn.Close() + stop := context.AfterFunc(ctx, func() { + conn.Close() + }) + defer stop() + err = m.options.write(conn, message, message.Id) + if err != nil { + ctxErr := ctx.Err() + if ctxErr != nil { + callback(nil, ctxErr) + return + } + callback(nil, E.Cause(err, "write request")) + return + } + for { + var response *mDNS.Msg + response, err = m.options.readNext(conn) + if err != nil { + ctxErr := ctx.Err() + if ctxErr != nil { + callback(nil, ctxErr) + return + } + callback(nil, E.Cause(err, "read response")) + return + } + if response == nil { + continue + } + response.Id = message.Id + callback(response, nil) + return + } +} + +func (m *queryMultiplexer) maybeStartProbe(ctx context.Context, message *mDNS.Msg) { + if len(message.Question) == 0 { + return + } + m.probeAccess.Lock() + if m.reuseState.Load() == reuseStateProbing { + m.probeAccess.Unlock() + return + } + if !m.lastProbeTime.IsZero() && time.Since(m.lastProbeTime) < reuseProbeRetryInterval { + m.probeAccess.Unlock() + return + } + m.reuseState.Store(reuseStateProbing) + m.lastProbeTime = time.Now() + epoch := m.probeEpoch + m.probeAccess.Unlock() + go m.runReuseProbe(context.WithoutCancel(ctx), message.Question[0].Name, epoch) +} + +func (m *queryMultiplexer) runReuseProbe(ctx context.Context, questionName string, epoch uint32) { + supported, dialFailed := m.executeReuseProbe(ctx, questionName) + m.probeAccess.Lock() + defer m.probeAccess.Unlock() + if m.probeEpoch != epoch { + return + } + switch { + case supported: + m.reuseState.Store(reuseStateSupported) + m.demoteFailures.Store(0) + case dialFailed: + m.reuseState.Store(reuseStateUnknown) + default: + m.reuseState.Store(reuseStateUnsupported) + } +} + +func (m *queryMultiplexer) executeReuseProbe(ctx context.Context, questionName string) (supported bool, dialFailed bool) { + probeCtx, cancel := context.WithTimeout(ctx, reuseProbeTimeout) + defer cancel() + conn, err := m.options.dial(probeCtx) + if err != nil { + return false, true + } + defer conn.Close() + stop := context.AfterFunc(probeCtx, func() { + conn.Close() + }) + defer stop() + queryA := new(mDNS.Msg) + queryA.SetQuestion(questionName, mDNS.TypeA) + queryAAAA := new(mDNS.Msg) + queryAAAA.SetQuestion(questionName, mDNS.TypeAAAA) + err = m.options.write(conn, queryA, reuseProbeQueryIdA) + if err == nil { + err = m.options.write(conn, queryAAAA, reuseProbeQueryIdB) + } + if err != nil { + return false, false + } + var seenA, seenAAAA bool + for !seenA || !seenAAAA { + var response *mDNS.Msg + response, err = m.options.readNext(conn) + if err != nil { + return false, false + } + if response == nil { + continue + } + switch response.Id { + case reuseProbeQueryIdA: + seenA = true + case reuseProbeQueryIdB: + seenAAAA = true + } + } + return true, false +} + +func (m *queryMultiplexer) recordConnDeath(conn *multiplexConn) { + if !m.options.probeReuse || m.reuseState.Load() != reuseStateSupported { + return + } + if conn.readEpoch.Load() == 0 { + return + } + m.queryAccess.Lock() + var pendingOnConn int + for _, pending := range m.queries { + if pending.conn == conn { + pendingOnConn++ + } + } + m.queryAccess.Unlock() + if pendingOnConn == 0 { + m.demoteFailures.Store(0) + return + } + if m.demoteFailures.Add(1) < reuseDemoteFailureLimit { + return + } + m.probeAccess.Lock() + if m.reuseState.Load() == reuseStateSupported { + m.reuseState.Store(reuseStateUnsupported) + m.lastProbeTime = time.Now() + } + m.probeAccess.Unlock() + m.demoteFailures.Store(0) +} + +func (m *queryMultiplexer) exchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error), retryReadError bool) { + for firstAttempt := true; ; firstAttempt = false { + conn, connCtx, created, err := m.connection.AcquireShared(ctx, m.dialConn) + if err != nil { + callback(nil, err) + return + } + if created { + go m.recvLoop(conn) + } + queryId, err := m.register(ctx, connCtx, conn, message, callback, retryReadError && m.options.retryReadError && !created) + if err != nil { + m.connection.Release(conn, true) + callback(nil, err) + return + } + writeErr := m.options.write(conn, message, queryId) + if writeErr == nil { + return + } + pending := m.take(queryId) + m.connection.Invalidate(conn, writeErr) + if pending == nil { + return + } + if !created && firstAttempt { + continue + } + callback(nil, E.Cause(writeErr, "write request")) + return + } +} + +func (m *queryMultiplexer) dialConn(ctx context.Context) (*multiplexConn, error) { + conn, err := m.options.dial(ctx) + if err != nil { + return nil, err + } + return &multiplexConn{Conn: conn}, nil +} + +func (m *queryMultiplexer) register(ctx context.Context, connCtx context.Context, conn *multiplexConn, message *mDNS.Msg, callback func(response *mDNS.Msg, err error), retryReadError bool) (uint16, error) { + m.queryAccess.Lock() + defer m.queryAccess.Unlock() + start := m.queryId + for { + m.queryId++ + if _, exists := m.queries[m.queryId]; !exists { + break + } + if m.queryId == start { + return 0, E.New("no available query ID") + } + } + queryId := m.queryId + pending := &pendingQuery{ + conn: conn, + message: message, + readEpoch: conn.readEpoch.Load(), + callback: callback, + } + if retryReadError { + pending.retryCtx = ctx + } + m.queries[queryId] = pending + pending.stopContext = context.AfterFunc(ctx, func() { + m.completeContextDone(queryId, ctx) + }) + pending.stopConn = context.AfterFunc(connCtx, func() { + m.completeConnDone(queryId, connCtx) + }) + return queryId, nil +} + +func (m *queryMultiplexer) completeConnDone(queryId uint16, connCtx context.Context) { + pending := m.take(queryId) + if pending == nil { + return + } + connErr := context.Cause(connCtx) + _, readFailed := connErr.(*queryMultiplexerReadError) + if pending.retryCtx != nil && readFailed { + m.dispatch(pending.retryCtx, pending.message, pending.callback, false) + return + } + pending.callback(nil, connErr) +} + +func (m *queryMultiplexer) take(queryId uint16) *pendingQuery { + m.queryAccess.Lock() + pending, loaded := m.queries[queryId] + if !loaded { + m.queryAccess.Unlock() + return nil + } + delete(m.queries, queryId) + m.queryAccess.Unlock() + pending.stopContext() + pending.stopConn() + return pending +} + +func (m *queryMultiplexer) complete(queryId uint16, response *mDNS.Msg, err error, releaseConn bool) { + pending := m.take(queryId) + if pending == nil { + return + } + if releaseConn { + m.connection.Release(pending.conn, true) + } + if response != nil { + response.Id = pending.message.Id + } + pending.callback(response, err) +} + +func (m *queryMultiplexer) completeContextDone(queryId uint16, ctx context.Context) { + pending := m.take(queryId) + if pending == nil { + return + } + err := ctx.Err() + if errors.Is(err, context.DeadlineExceeded) && pending.conn.readEpoch.Load() == pending.readEpoch { + m.connection.Invalidate(pending.conn, err) + } else { + m.connection.Release(pending.conn, true) + } + pending.callback(nil, err) +} + +func (m *queryMultiplexer) recvLoop(conn *multiplexConn) { + for { + message, err := m.options.readNext(conn) + if err != nil { + m.recordConnDeath(conn) + m.connection.Invalidate(conn, &queryMultiplexerReadError{cause: err}) + return + } + conn.readEpoch.Add(1) + if message == nil { + continue + } + m.complete(message.Id, message, nil, true) + } +} diff --git a/dns/transport/multiplexer_test.go b/dns/transport/multiplexer_test.go new file mode 100644 index 0000000000..b5bd02b62e --- /dev/null +++ b/dns/transport/multiplexer_test.go @@ -0,0 +1,546 @@ +package transport + +import ( + "context" + "errors" + "io" + "net" + "sync/atomic" + "testing" + "time" + + "github.com/sagernet/sing-box/common/dialer" + C "github.com/sagernet/sing-box/constant" + boxDNS "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/option" + M "github.com/sagernet/sing/common/metadata" + + mDNS "github.com/miekg/dns" +) + +func TestTCPTransportRetriesReadErrorOnReusedConn(t *testing.T) { + t.Parallel() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + serverDone := make(chan error, 1) + go func() { + firstConn, acceptErr := listener.Accept() + if acceptErr != nil { + serverDone <- acceptErr + return + } + firstRequest, readErr := ReadMessage(firstConn) + if readErr != nil { + firstConn.Close() + serverDone <- readErr + return + } + firstResponse := new(mDNS.Msg) + firstResponse.SetReply(firstRequest) + writeErr := WriteMessage(firstConn, firstRequest.Id, firstResponse) + if writeErr != nil { + firstConn.Close() + serverDone <- writeErr + return + } + _, readErr = ReadMessage(firstConn) + firstConn.Close() + if readErr != nil { + serverDone <- readErr + return + } + secondConn, acceptErr := listener.Accept() + if acceptErr != nil { + serverDone <- acceptErr + return + } + defer secondConn.Close() + secondRequest, readErr := ReadMessage(secondConn) + if readErr != nil { + serverDone <- readErr + return + } + secondResponse := new(mDNS.Msg) + secondResponse.SetReply(secondRequest) + serverDone <- WriteMessage(secondConn, secondRequest.Id, secondResponse) + }() + + multiplexer := newQueryMultiplexer(queryMultiplexerOptions{ + dial: func(ctx context.Context) (net.Conn, error) { + return net.Dial("tcp", listener.Addr().String()) + }, + write: func(conn net.Conn, message *mDNS.Msg, queryId uint16) error { + return WriteMessage(conn, queryId, message) + }, + readNext: func(conn net.Conn) (*mDNS.Msg, error) { + return ReadMessage(conn) + }, + retryReadError: true, + }) + defer multiplexer.Close() + + firstMessage := new(mDNS.Msg) + firstMessage.SetQuestion("first.example.com.", mDNS.TypeA) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + _, err = multiplexer.Exchange(ctx, firstMessage) + cancel() + if err != nil { + t.Fatal("first query failed: ", err) + } + + secondMessage := new(mDNS.Msg) + secondMessage.SetQuestion("second.example.com.", mDNS.TypeAAAA) + ctx, cancel = context.WithTimeout(context.Background(), time.Second) + _, err = multiplexer.Exchange(ctx, secondMessage) + cancel() + if err != nil { + t.Fatal("second query failed: ", err) + } + select { + case err = <-serverDone: + if err != nil { + t.Fatal("DNS server failed: ", err) + } + case <-time.After(time.Second): + t.Fatal("DNS server did not finish") + } +} + +func newTestTCPTransport(t *testing.T, listener net.Listener) *TCPTransport { + transportDialer, err := dialer.NewDefault(context.Background(), option.DialerOptions{}) + if err != nil { + t.Fatal(err) + } + return NewTCPRaw(boxDNS.NewTransportAdapter(C.DNSTypeTCP, "test", nil), transportDialer, M.SocksaddrFromNet(listener.Addr())) +} + +func testExchange(transport *TCPTransport, questionName string) error { + message := new(mDNS.Msg) + message.SetQuestion(questionName, mDNS.TypeA) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _, err := transport.Exchange(ctx, message) + return err +} + +func TestTCPTransportSingleQueryServer(t *testing.T) { + t.Parallel() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + var accepted atomic.Int32 + go func() { + for { + conn, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + accepted.Add(1) + go func() { + defer conn.Close() + request, readErr := ReadMessage(conn) + if readErr != nil { + return + } + response := new(mDNS.Msg) + response.SetReply(request) + WriteMessage(conn, request.Id, response) + }() + } + }() + + transport := newTestTCPTransport(t, listener) + defer transport.Close() + + const queryCount = 8 + results := make(chan error, queryCount) + for range queryCount { + go func() { + results <- testExchange(transport, "example.com.") + }() + } + for range queryCount { + err = <-results + if err != nil { + t.Fatal("query failed: ", err) + } + } + deadline := time.Now().Add(time.Second) + for accepted.Load() < queryCount+1 { + if time.Now().After(deadline) { + t.Fatal("expected a probe connection, accepted ", accepted.Load()) + } + time.Sleep(10 * time.Millisecond) + } + time.Sleep(100 * time.Millisecond) + if count := accepted.Load(); count != queryCount+1 { + t.Fatal("expected one connection per query plus probe, accepted ", count) + } +} + +func TestTCPTransportProbeEnablesReuse(t *testing.T) { + t.Parallel() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + var maxServedOnConn atomic.Int32 + go func() { + for { + conn, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + go func() { + defer conn.Close() + var served int32 + for { + request, readErr := ReadMessage(conn) + if readErr != nil { + return + } + served++ + for { + current := maxServedOnConn.Load() + if served <= current || maxServedOnConn.CompareAndSwap(current, served) { + break + } + } + response := new(mDNS.Msg) + response.SetReply(request) + WriteMessage(conn, request.Id, response) + } + }() + } + }() + + transport := newTestTCPTransport(t, listener) + defer transport.Close() + + deadline := time.Now().Add(3 * time.Second) + for maxServedOnConn.Load() < 3 { + if time.Now().After(deadline) { + t.Fatal("reuse was not enabled after successful probe") + } + err = testExchange(transport, "example.com.") + if err != nil { + t.Fatal("query failed: ", err) + } + time.Sleep(10 * time.Millisecond) + } + + const burstCount = 5 + results := make(chan error, burstCount) + for range burstCount { + go func() { + results <- testExchange(transport, "example.com.") + }() + } + for range burstCount { + err = <-results + if err != nil { + t.Fatal("burst query failed: ", err) + } + } +} + +func TestTCPTransportDemotesBrokenReuse(t *testing.T) { + t.Parallel() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + var accepted atomic.Int32 + go func() { + for { + conn, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + accepted.Add(1) + go func() { + defer conn.Close() + for served := 0; ; served++ { + request, readErr := ReadMessage(conn) + if readErr != nil { + return + } + if served >= 2 { + return + } + response := new(mDNS.Msg) + response.SetReply(request) + WriteMessage(conn, request.Id, response) + } + }() + } + }() + + transport := newTestTCPTransport(t, listener) + defer transport.Close() + + deadline := time.Now().Add(3 * time.Second) + for { + before := accepted.Load() + err = testExchange(transport, "example.com.") + if err != nil { + t.Fatal("query failed: ", err) + } + if accepted.Load() == before { + break + } + if time.Now().After(deadline) { + t.Fatal("reuse was not enabled after successful probe") + } + } + + for range 15 { + err = testExchange(transport, "example.com.") + if err != nil { + t.Fatal("query failed during demotion: ", err) + } + } + if transport.multiplexer.reuseState.Load() != reuseStateUnsupported { + t.Fatal("expected demotion to single connection mode") + } + + time.Sleep(100 * time.Millisecond) + before := accepted.Load() + const singleCount = 4 + for range singleCount { + err = testExchange(transport, "example.com.") + if err != nil { + t.Fatal("query failed after demotion: ", err) + } + } + if count := accepted.Load() - before; count != singleCount { + t.Fatal("expected one connection per query after demotion, got ", count) + } +} + +func TestTCPTransportSilentPipelineServer(t *testing.T) { + t.Parallel() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + go func() { + for { + conn, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + go func() { + defer conn.Close() + request, readErr := ReadMessage(conn) + if readErr != nil { + return + } + conn.SetReadDeadline(time.Now().Add(300 * time.Millisecond)) + _, secondErr := ReadMessage(conn) + if secondErr == nil { + conn.SetReadDeadline(time.Time{}) + io.Copy(io.Discard, conn) + return + } + var netErr net.Error + if !errors.As(secondErr, &netErr) || !netErr.Timeout() { + return + } + conn.SetReadDeadline(time.Time{}) + response := new(mDNS.Msg) + response.SetReply(request) + WriteMessage(conn, request.Id, response) + }() + } + }() + + transport := newTestTCPTransport(t, listener) + defer transport.Close() + + const queryCount = 5 + results := make(chan error, queryCount) + for range queryCount { + go func() { + results <- testExchange(transport, "example.com.") + }() + } + for range queryCount { + err = <-results + if err != nil { + t.Fatal("query failed: ", err) + } + } + + deadline := time.Now().Add(8 * time.Second) + for transport.multiplexer.reuseState.Load() != reuseStateUnsupported { + if time.Now().After(deadline) { + t.Fatal("expected probe timeout to disable reuse") + } + time.Sleep(100 * time.Millisecond) + } + err = testExchange(transport, "example.com.") + if err != nil { + t.Fatal("query failed after probe timeout: ", err) + } +} + +func TestMultiplexerTimeoutInvalidatesConn(t *testing.T) { + t.Parallel() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + accepted := make(chan net.Conn, 16) + go func() { + for { + conn, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + accepted <- conn + } + }() + multiplexer := newQueryMultiplexer(queryMultiplexerOptions{ + dial: func(ctx context.Context) (net.Conn, error) { + return net.Dial("tcp", listener.Addr().String()) + }, + write: func(conn net.Conn, message *mDNS.Msg, queryId uint16) error { + return WriteMessage(conn, queryId, message) + }, + readNext: func(conn net.Conn) (*mDNS.Msg, error) { + return ReadMessage(conn) + }, + }) + defer multiplexer.Close() + + message := new(mDNS.Msg) + message.SetQuestion("example.com.", mDNS.TypeA) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + start := time.Now() + _, err = multiplexer.Exchange(ctx, message) + elapsed := time.Since(start) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatal("expected deadline exceeded, got ", err) + } + if elapsed > 2*time.Second { + t.Fatal("timeout not enforced, took ", elapsed) + } + + firstConn := <-accepted + firstConn.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, err = io.Copy(io.Discard, firstConn) + if err != nil { + t.Fatal("expected the client side to close the connection, got ", err) + } + + ctx2, cancel2 := context.WithTimeout(context.Background(), time.Second) + defer cancel2() + multiplexer.Exchange(ctx2, message) + select { + case <-accepted: + case <-time.After(time.Second): + t.Fatal("expected a fresh connection for the second query") + } +} + +func TestMultiplexerSlowQueryKeepsActiveConn(t *testing.T) { + t.Parallel() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + accepted := make(chan net.Conn, 16) + go func() { + for { + conn, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + accepted <- conn + go func() { + for { + request, readErr := ReadMessage(conn) + if readErr != nil { + return + } + if request.Question[0].Name == "slow.example.com." { + continue + } + response := new(mDNS.Msg) + response.SetReply(request) + WriteMessage(conn, request.Id, response) + } + }() + } + }() + multiplexer := newQueryMultiplexer(queryMultiplexerOptions{ + dial: func(ctx context.Context) (net.Conn, error) { + return net.Dial("tcp", listener.Addr().String()) + }, + write: func(conn net.Conn, message *mDNS.Msg, queryId uint16) error { + return WriteMessage(conn, queryId, message) + }, + readNext: func(conn net.Conn) (*mDNS.Msg, error) { + return ReadMessage(conn) + }, + }) + defer multiplexer.Close() + + slowMessage := new(mDNS.Msg) + slowMessage.SetQuestion("slow.example.com.", mDNS.TypeA) + slowCtx, slowCancel := context.WithTimeout(context.Background(), time.Second) + defer slowCancel() + slowDone := make(chan error, 1) + go func() { + _, slowErr := multiplexer.Exchange(slowCtx, slowMessage) + slowDone <- slowErr + }() + select { + case <-accepted: + case <-time.After(time.Second): + t.Fatal("expected a connection for the slow query") + } + + fastMessage := new(mDNS.Msg) + fastMessage.SetQuestion("fast.example.com.", mDNS.TypeA) + exchangeFast := func() { + fastCtx, fastCancel := context.WithTimeout(context.Background(), time.Second) + defer fastCancel() + _, fastErr := multiplexer.Exchange(fastCtx, fastMessage) + if fastErr != nil { + t.Fatal("fast query failed: ", fastErr) + } + } + deadline := time.Now().Add(3 * time.Second) + for { + if !time.Now().Before(deadline) { + t.Fatal("slow query did not complete") + } + exchangeFast() + select { + case slowErr := <-slowDone: + if !errors.Is(slowErr, context.DeadlineExceeded) { + t.Fatal("expected deadline exceeded for slow query, got ", slowErr) + } + exchangeFast() + if len(accepted) > 0 { + t.Fatal("slow query timeout must not replace the active connection") + } + return + case <-time.After(50 * time.Millisecond): + } + } +} diff --git a/dns/transport/quic/http3.go b/dns/transport/quic/http3.go index 0a93e51519..3a6c3fc1ad 100644 --- a/dns/transport/quic/http3.go +++ b/dns/transport/quic/http3.go @@ -209,3 +209,9 @@ func (t *HTTP3Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS } return &responseMessage, nil } + +func (t *HTTP3Transport) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { + go func() { + callback(t.Exchange(ctx, message)) + }() +} diff --git a/dns/transport/quic/quic.go b/dns/transport/quic/quic.go index 8d45bd82e4..dc2e22fc33 100644 --- a/dns/transport/quic/quic.go +++ b/dns/transport/quic/quic.go @@ -145,6 +145,12 @@ func (t *Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, return nil, err } +func (t *Transport) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { + go func() { + callback(t.Exchange(ctx, message)) + }() +} + func (t *Transport) exchange(ctx context.Context, message *mDNS.Msg, conn *quic.Conn) (*mDNS.Msg, error) { stream, err := conn.OpenStreamSync(ctx) if err != nil { diff --git a/dns/transport/sdns.go b/dns/transport/sdns.go index 1c4a1a2390..bbd483d48f 100644 --- a/dns/transport/sdns.go +++ b/dns/transport/sdns.go @@ -61,6 +61,12 @@ func (t *SDNSTransport) Close() error { return nil } +func (t *SDNSTransport) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { + go func() { + callback(t.Exchange(ctx, message)) + }() +} + func (t *SDNSTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { resolverInfo, err := t.client.Dial(t.stamp) if err != nil { diff --git a/dns/transport/tcp.go b/dns/transport/tcp.go index f8249437a1..d406aeb26b 100644 --- a/dns/transport/tcp.go +++ b/dns/transport/tcp.go @@ -15,7 +15,6 @@ import ( "github.com/sagernet/sing-box/option" "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/buf" - "github.com/sagernet/sing/common/bufio/deadline" E "github.com/sagernet/sing/common/exceptions" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" @@ -31,8 +30,9 @@ func RegisterTCP(registry *dns.TransportRegistry) { type TCPTransport struct { dns.TransportAdapter - dialer N.Dialer - serverAddr M.Socksaddr + dialer N.Dialer + serverAddr M.Socksaddr + multiplexer *queryMultiplexer } func NewTCP(ctx context.Context, logger log.ContextLogger, tag string, options option.RemoteDNSServerOptions) (adapter.DNSTransport, error) { @@ -47,11 +47,33 @@ func NewTCP(ctx context.Context, logger log.ContextLogger, tag string, options o if !serverAddr.IsValid() { return nil, E.New("invalid server address: ", serverAddr) } - return &TCPTransport{ - TransportAdapter: dns.NewTransportAdapterWithRemoteOptions(C.DNSTypeTCP, tag, options), - dialer: transportDialer, + return NewTCPRaw(dns.NewTransportAdapterWithRemoteOptions(C.DNSTypeTCP, tag, options), transportDialer, serverAddr), nil +} + +func NewTCPRaw(adapter dns.TransportAdapter, dialer N.Dialer, serverAddr M.Socksaddr) *TCPTransport { + t := &TCPTransport{ + TransportAdapter: adapter, + dialer: dialer, serverAddr: serverAddr, - }, nil + } + t.multiplexer = newQueryMultiplexer(queryMultiplexerOptions{ + dial: func(ctx context.Context) (net.Conn, error) { + conn, err := t.dialer.DialContext(ctx, N.NetworkTCP, t.serverAddr) + if err != nil { + return nil, E.Cause(err, "dial TCP connection") + } + return conn, nil + }, + write: func(conn net.Conn, message *mDNS.Msg, queryId uint16) error { + return WriteMessage(conn, queryId, message) + }, + readNext: func(conn net.Conn) (*mDNS.Msg, error) { + return ReadMessage(conn) + }, + retryReadError: true, + probeReuse: true, + }) + return t } func (t *TCPTransport) Start(stage adapter.StartStage) error { @@ -62,28 +84,19 @@ func (t *TCPTransport) Start(stage adapter.StartStage) error { } func (t *TCPTransport) Close() error { - return nil + return t.multiplexer.Close() } func (t *TCPTransport) Reset() { + t.multiplexer.Reset() } func (t *TCPTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { - conn, err := t.dialer.DialContext(ctx, N.NetworkTCP, t.serverAddr) - if err != nil { - return nil, E.Cause(err, "dial TCP connection") - } - defer conn.Close() - defer setConnDeadline(ctx, conn, deadline.NeedAdditionalReadDeadline(conn))() - err = WriteMessage(conn, 0, message) - if err != nil { - return nil, E.Cause(err, "write request") - } - response, err := ReadMessage(conn) - if err != nil { - return nil, E.Cause(err, "read response") - } - return response, nil + return t.multiplexer.Exchange(ctx, message) +} + +func (t *TCPTransport) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { + t.multiplexer.ExchangeAsync(ctx, message, callback) } func setConnDeadline(ctx context.Context, conn net.Conn, needClose bool) func() { diff --git a/dns/transport/tls.go b/dns/transport/tls.go index fdb485637b..da1db319ac 100644 --- a/dns/transport/tls.go +++ b/dns/transport/tls.go @@ -2,6 +2,7 @@ package transport import ( "context" + "net" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/common/dialer" @@ -11,7 +12,6 @@ import ( "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" "github.com/sagernet/sing/common" - "github.com/sagernet/sing/common/bufio/deadline" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/logger" M "github.com/sagernet/sing/common/metadata" @@ -22,26 +22,16 @@ import ( var _ adapter.DNSTransport = (*TLSTransport)(nil) -const tlsDNSMaxInflight = 8 - func RegisterTLS(registry *dns.TransportRegistry) { dns.RegisterTransport[option.RemoteTLSDNSServerOptions](registry, C.DNSTypeTLS, NewTLS) } type TLSTransport struct { dns.TransportAdapter - logger logger.ContextLogger - + logger logger.ContextLogger dialer tls.Dialer serverAddr M.Socksaddr - tlsConfig tls.Config - connections *ConnPool[*tlsDNSConn] -} - -type tlsDNSConn struct { - tls.Conn - queryId uint16 - needDeadlineClose bool + multiplexer *queryMultiplexer } func NewTLS(ctx context.Context, logger log.ContextLogger, tag string, options option.RemoteTLSDNSServerOptions) (adapter.DNSTransport, error) { @@ -66,23 +56,30 @@ func NewTLS(ctx context.Context, logger log.ContextLogger, tag string, options o } func NewTLSRaw(logger logger.ContextLogger, adapter dns.TransportAdapter, dialer N.Dialer, serverAddr M.Socksaddr, tlsConfig tls.Config) *TLSTransport { - return &TLSTransport{ + t := &TLSTransport{ TransportAdapter: adapter, logger: logger, dialer: tls.NewDialer(dialer, tlsConfig), serverAddr: serverAddr, - tlsConfig: tlsConfig, - connections: NewConnPool(ConnPoolOptions[*tlsDNSConn]{ - Mode: ConnPoolOrdered, - MaxInflight: tlsDNSMaxInflight, - IsAlive: func(conn *tlsDNSConn) bool { - return conn != nil - }, - Close: func(conn *tlsDNSConn, _ error) { - conn.Close() - }, - }), } + t.multiplexer = newQueryMultiplexer(queryMultiplexerOptions{ + dial: func(ctx context.Context) (net.Conn, error) { + conn, err := t.dialer.DialTLSContext(ctx, t.serverAddr) + if err != nil { + return nil, E.Cause(err, "dial TLS connection") + } + return conn, nil + }, + write: func(conn net.Conn, message *mDNS.Msg, queryId uint16) error { + return WriteMessage(conn, queryId, message) + }, + readNext: func(conn net.Conn) (*mDNS.Msg, error) { + return ReadMessage(conn) + }, + retryReadError: true, + probeReuse: true, + }) + return t } func (t *TLSTransport) Start(stage adapter.StartStage) error { @@ -93,54 +90,17 @@ func (t *TLSTransport) Start(stage adapter.StartStage) error { } func (t *TLSTransport) Close() error { - return t.connections.Close() + return t.multiplexer.Close() } func (t *TLSTransport) Reset() { - t.connections.Reset() + t.multiplexer.Reset() } func (t *TLSTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { - var lastErr error - for range 2 { - conn, created, err := t.connections.Acquire(ctx, func(ctx context.Context) (*tlsDNSConn, error) { - tlsConn, err := t.dialer.DialTLSContext(ctx, t.serverAddr) - if err != nil { - return nil, E.Cause(err, "dial TLS connection") - } - return &tlsDNSConn{ - Conn: tlsConn, - needDeadlineClose: deadline.NeedAdditionalReadDeadline(tlsConn.NetConn()), - }, nil - }) - if err != nil { - return nil, err - } - response, err := t.exchange(ctx, message, conn) - if err == nil { - t.connections.Release(conn, true) - return response, nil - } - lastErr = err - t.logger.DebugContext(ctx, "discarded pooled connection: ", err) - t.connections.Release(conn, false) - if created { - return nil, err - } - } - return nil, lastErr + return t.multiplexer.Exchange(ctx, message) } -func (t *TLSTransport) exchange(ctx context.Context, message *mDNS.Msg, conn *tlsDNSConn) (*mDNS.Msg, error) { - defer setConnDeadline(ctx, conn, conn.needDeadlineClose)() - conn.queryId++ - err := WriteMessage(conn, conn.queryId, message) - if err != nil { - return nil, E.Cause(err, "write request") - } - response, err := ReadMessage(conn) - if err != nil { - return nil, E.Cause(err, "read response") - } - return response, nil +func (t *TLSTransport) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { + t.multiplexer.ExchangeAsync(ctx, message, callback) } diff --git a/dns/transport/udp.go b/dns/transport/udp.go index 7203b5ad4d..4d5370c44b 100644 --- a/dns/transport/udp.go +++ b/dns/transport/udp.go @@ -3,7 +3,6 @@ package transport import ( "context" "net" - "sync" "sync/atomic" "github.com/sagernet/sing-box/adapter" @@ -12,6 +11,7 @@ import ( "github.com/sagernet/sing-box/dns" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/buf" "github.com/sagernet/sing/common/bufio/deadline" E "github.com/sagernet/sing/common/exceptions" @@ -36,17 +36,7 @@ type UDPTransport struct { serverAddr M.Socksaddr udpSize atomic.Int32 - connection *ConnPool[net.Conn] - - callbackAccess sync.RWMutex - queryId uint16 - callbacks map[uint16]*udpCallback -} - -type udpCallback struct { - access sync.Mutex - response *mDNS.Msg - done chan struct{} + multiplexer *queryMultiplexer } func NewUDP(ctx context.Context, logger log.ContextLogger, tag string, options option.RemoteDNSServerOptions) (adapter.DNSTransport, error) { @@ -70,18 +60,19 @@ func NewUDPRaw(logger logger.ContextLogger, adapter dns.TransportAdapter, dialer logger: logger, dialer: dialerInstance, serverAddr: serverAddr, - callbacks: make(map[uint16]*udpCallback), - connection: NewConnPool(ConnPoolOptions[net.Conn]{ - Mode: ConnPoolSingle, - IsAlive: func(conn net.Conn) bool { - return conn != nil - }, - Close: func(conn net.Conn, cause error) { - conn.Close() - }, - }), } t.udpSize.Store(2048) + t.multiplexer = newQueryMultiplexer(queryMultiplexerOptions{ + dial: func(ctx context.Context) (net.Conn, error) { + conn, err := t.dialer.DialContext(ctx, N.NetworkUDP, t.serverAddr) + if err != nil { + return nil, E.Cause(err, "dial UDP connection") + } + return conn, nil + }, + write: t.writeQuery, + readNext: t.readResponse, + }) return t } @@ -93,28 +84,16 @@ func (t *UDPTransport) Start(stage adapter.StartStage) error { } func (t *UDPTransport) Close() error { - return t.connection.Close() + return t.multiplexer.Close() } func (t *UDPTransport) Reset() { - t.connection.Reset() -} - -func (t *UDPTransport) nextAvailableQueryId() (uint16, error) { - start := t.queryId - for { - t.queryId++ - if _, exists := t.callbacks[t.queryId]; !exists { - return t.queryId, nil - } - if t.queryId == start { - return 0, E.New("no available query ID") - } - } + t.multiplexer.Reset() } func (t *UDPTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { - response, err := t.exchange(ctx, message) + t.updateUDPSize(message) + response, err := t.multiplexer.Exchange(ctx, message) if err != nil { return nil, err } @@ -125,138 +104,81 @@ func (t *UDPTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.M return response, nil } -func (t *UDPTransport) exchangeTCP(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { - conn, err := t.dialer.DialContext(ctx, N.NetworkTCP, t.serverAddr) - if err != nil { - return nil, E.Cause(err, "dial TCP connection") - } - defer conn.Close() - defer setConnDeadline(ctx, conn, deadline.NeedAdditionalReadDeadline(conn))() - err = WriteMessage(conn, message.Id, message) - if err != nil { - return nil, E.Cause(err, "write request") - } - response, err := ReadMessage(conn) - if err != nil { - return nil, E.Cause(err, "read response") - } - return response, nil -} - -func (t *UDPTransport) exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { - if edns0Opt := message.IsEdns0(); edns0Opt != nil { - udpSize := int32(edns0Opt.UDPSize()) - for { - current := t.udpSize.Load() - if udpSize <= current { - break - } - if t.udpSize.CompareAndSwap(current, udpSize) { - t.Reset() - break - } - } - } - - conn, connCtx, created, err := t.connection.AcquireShared(ctx, func(ctx context.Context) (net.Conn, error) { - rawConn, err := t.dialer.DialContext(ctx, N.NetworkUDP, t.serverAddr) - if err != nil { - return nil, E.Cause(err, "dial UDP connection") +func (t *UDPTransport) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { + t.updateUDPSize(message) + t.multiplexer.ExchangeAsync(ctx, message, func(response *mDNS.Msg, err error) { + if err == nil && response.Truncated { + t.logger.InfoContext(ctx, "response truncated, retrying with TCP") + go func() { + callback(t.exchangeTCP(ctx, message)) + }() + return } - return rawConn, nil + callback(response, err) }) - if err != nil { - return nil, err - } - if created { - go t.recvLoop(conn) - } +} - callback := &udpCallback{ - done: make(chan struct{}), +func (t *UDPTransport) updateUDPSize(message *mDNS.Msg) { + edns0Opt := message.IsEdns0() + if edns0Opt == nil { + return } - - t.callbackAccess.Lock() - queryId, err := t.nextAvailableQueryId() - if err != nil { - t.callbackAccess.Unlock() - t.connection.Release(conn, true) - return nil, err + udpSize := int32(edns0Opt.UDPSize()) + for { + current := t.udpSize.Load() + if udpSize <= current { + return + } + if t.udpSize.CompareAndSwap(current, udpSize) { + t.Reset() + return + } } - t.callbacks[queryId] = callback - t.callbackAccess.Unlock() - - defer func() { - t.callbackAccess.Lock() - delete(t.callbacks, queryId) - t.callbackAccess.Unlock() - }() +} +func (t *UDPTransport) writeQuery(conn net.Conn, message *mDNS.Msg, queryId uint16) error { buffer := buf.NewSize(1 + message.Len()) defer buffer.Release() - exMessage := *message exMessage.Compress = true - originalId := message.Id exMessage.Id = queryId - rawMessage, err := exMessage.PackBuffer(buffer.FreeBytes()) if err != nil { - return nil, err + return err } + return common.Error(conn.Write(rawMessage)) +} - _, err = conn.Write(rawMessage) +func (t *UDPTransport) readResponse(conn net.Conn) (*mDNS.Msg, error) { + buffer := buf.NewSize(int(t.udpSize.Load())) + defer buffer.Release() + _, err := buffer.ReadOnceFrom(conn) if err != nil { - t.connection.Invalidate(conn, err) - return nil, E.Cause(err, "write request") + return nil, err } - - select { - case <-callback.done: - t.connection.Release(conn, true) - callback.response.Id = originalId - return callback.response, nil - case <-connCtx.Done(): - return nil, context.Cause(connCtx) - case <-ctx.Done(): - t.connection.Release(conn, true) - return nil, ctx.Err() + var message mDNS.Msg + err = message.Unpack(buffer.Bytes()) + if err != nil { + t.logger.Debug("discarded malformed UDP response: ", err) + return nil, nil } + return &message, nil } -func (t *UDPTransport) recvLoop(conn net.Conn) { - for { - buffer := buf.NewSize(int(t.udpSize.Load())) - _, err := buffer.ReadOnceFrom(conn) - if err != nil { - buffer.Release() - t.connection.Invalidate(conn, err) - return - } - - var message mDNS.Msg - err = message.Unpack(buffer.Bytes()) - buffer.Release() - if err != nil { - t.logger.Debug("discarded malformed UDP response: ", err) - continue - } - - t.callbackAccess.RLock() - callback, loaded := t.callbacks[message.Id] - t.callbackAccess.RUnlock() - - if !loaded { - continue - } - - callback.access.Lock() - select { - case <-callback.done: - default: - callback.response = &message - close(callback.done) - } - callback.access.Unlock() +func (t *UDPTransport) exchangeTCP(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + conn, err := t.dialer.DialContext(ctx, N.NetworkTCP, t.serverAddr) + if err != nil { + return nil, E.Cause(err, "dial TCP connection") } + defer conn.Close() + defer setConnDeadline(ctx, conn, deadline.NeedAdditionalReadDeadline(conn))() + err = WriteMessage(conn, message.Id, message) + if err != nil { + return nil, E.Cause(err, "write request") + } + response, err := ReadMessage(conn) + if err != nil { + return nil, E.Cause(err, "read response") + } + return response, nil } diff --git a/dns/transport_registry.go b/dns/transport_registry.go index d838158b22..0e98b19074 100644 --- a/dns/transport_registry.go +++ b/dns/transport_registry.go @@ -2,6 +2,8 @@ package dns import ( "context" + "maps" + "slices" "sync" "github.com/sagernet/sing-box/adapter" @@ -44,6 +46,12 @@ func NewTransportRegistry() *TransportRegistry { } } +func (r *TransportRegistry) OptionTypes() []string { + r.access.Lock() + defer r.access.Unlock() + return slices.Sorted(maps.Keys(r.optionsType)) +} + func (r *TransportRegistry) CreateOptions(transportType string) (any, bool) { r.access.Lock() defer r.access.Unlock() diff --git a/docs/assets/icon.svg b/docs/assets/icon.svg index 146d085a27..1fbb11cf33 100644 --- a/docs/assets/icon.svg +++ b/docs/assets/icon.svg @@ -1,37 +1,81 @@ - + - - - - - - - - + + + + + + + + + + - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - \ No newline at end of file + diff --git a/docs/changelog.md b/docs/changelog.md index e1ac041f75..91adee0636 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,400 @@ icon: material/alert-decagram --- +#### 1.14.0-beta.2 + +* Add [JSON Schema](/configuration/schema/) support **1** +* Fixes and improvements + +**1**: + +sing-box now provides a JSON Schema for its configuration, enabling completion +and validation in compatible editors. The schema published with the +documentation can be selected with the new top-level `$schema` field, while +the new `sing-box schema` command generates a schema matching the current +binary and its build tags. + +We have also improved the JSON editor experience in the graphical clients on +macOS, Android, Windows, and Linux, and added schema-based completion support. + +#### 1.14.0-beta.1 + +* Correct undefined rule-set matching semantics **1** +* Add search domain rule items **2** +* Add parallel DNS response evaluation support **3** +* Fixes and improvements + +**1**: + +Rule-set matching has always been described as merged matching: fields of +rule-set rules are considered merged into the referencing rule. However, this +description is only intuitive when a rule-set contains only a single `default` +rule without `invert`. Merged matching is now limited to exactly this case; +any other referenced rule-set is matched as an `other field`, which matches +when any of its rules matches on its own. +Since the previous behavior in the corrected cases was effectively undefined, +counterintuitive, and hard to understand, we do not consider this a breaking +change — except for configurations that worked without their author +understanding why. + +**2**: + +The new DNS rule items +[`domain_label_count`](/configuration/dns/rule/#domain_label_count) and +[`search_domain_available`](/configuration/dns/rule/#search_domain_available) +match the number of labels in the query name and whether a DNS server +currently holds search domains; combined with `racing`, they allow unqualified +name queries to race a server that can expand them against a public resolver. +Additionally, [`preferred_by`](/configuration/dns/rule/#preferred_by) now +matches search domain suffixes and supports `local` and `dhcp` servers. + +**3**: + +The [`evaluate`](/configuration/dns/rule_action/#evaluate) action can now assign +a `tag` to each response, allowing multiple evaluated responses to coexist and +be selected through tagged +[`match_response`](/configuration/dns/rule/#match_response) rules. The new +[`race`](/configuration/dns/rule_action/#race) field allows response-dependent +rules to compete in parallel, with the first matching rule taking effect and +the remaining queries canceled. The new `speculative` option can start +`evaluate` and `route` queries while race rules are still pending, reducing +latency at the cost of potentially unused queries. + +#### 1.14.0-alpha.50 + +* Improve OpenVPN interoperability **1** +* Improve OpenConnect interoperability **2** +* Add Fortinet host check support **3** +* Fixes and improvements + +**1**: + +The OpenVPN client and server now interoperate with more existing deployments +through static-key mode, additional legacy ciphers and digests, and +OpenVPN-compatible certificate purpose, key usage, extended key usage, and +certificate profile checks. They also support more OpenVPN options for tunnel +addressing, MSS calculation, replay windows, timers, and TLS renegotiation. The +new [OpenVPN DNS server](/configuration/dns/server/openvpn/) can use both modern +and legacy DNS options pushed by OpenVPN servers, while the sing-box server can +push both forms. + +**2**: + +The OpenConnect client now supports existing authentication sessions, OIDC +Bearer authentication, additional platform and AnyConnect mobile identity +fields, AnyConnect compression, and controls for MTU, DPD and reconnect timing, +TCP keep alive, and TLS trust and certificate pinning. The new +[OpenConnect DNS server](/configuration/dns/server/openconnect/) can use pushed +split-DNS resolvers and, when enabled, general pushed resolvers. + +**3**: + +The [OpenConnect Client](/configuration/endpoint/openconnect/) endpoint can now +submit Fortinet host check results using the new +[`fortinet_host_check`](/configuration/endpoint/openconnect/#fortinet_host_check) +option. This behavior is modeled after openfortivpn and is not an OpenConnect +feature. sing-box only submits explicitly configured values when requested by +the Fortinet server and does not collect system information automatically. + +#### 1.14.0-alpha.48 + +* Add SSO support for AnyConnect **1** +* Add Linux support for the [desktop client application](/clients/desktop/) **2** +* Fixes and improvements + +**1**: + +The [OpenConnect Client](/configuration/endpoint/openconnect/) endpoint now +supports SSO (single sign-on) authentication for Cisco AnyConnect servers, +available through the sing-box graphical clients. + +**2**: + +The [sing-box for Desktop](/clients/desktop/) client is now available for Linux +(x64 / arm64 / armv7l) from +[GitHub Releases](https://github.com/SagerNet/sing-box/releases). + +#### 1.14.0-alpha.47 + +* Add OpenVPN client and server support **1** +* Add OpenConnect client support **2** +* Fixes and improvements + +**1**: + +The new [OpenVPN Client](/configuration/endpoint/openvpn-client/) and +[OpenVPN Server](/configuration/endpoint/openvpn-server/) endpoints are +compatible with standard OpenVPN clients and servers. Interactive client +authentication is available through the sing-box graphical clients and +[Dashboard](https://github.com/SagerNet/sing-box-dashboard). + +**2**: + +The new [OpenConnect Client](/configuration/endpoint/openconnect/) endpoint +supports Cisco AnyConnect, GlobalProtect, Fortinet, F5, Pulse Connect Secure, +and Juniper Network Connect VPN servers. Interactive authentication is +available through the sing-box graphical clients and +[Dashboard](https://github.com/SagerNet/sing-box-dashboard). + +#### 1.14.0-alpha.46 + +* Add multiple tags support to rule-sets **1** +* Add new UDP NAT options **2** +* Fixes and improvements + +**1**: + +The rule-set [`tag`](/configuration/rule-set/#tag) field now accepts a list of +tags to define multiple rule-sets sharing other options at once, with the +`{tag}` placeholder in `path` or `url` replaced by each tag. + +**2**: + +The new [UDP NAT](/configuration/shared/udp-nat/) fields +[`udp_mapping`](/configuration/shared/udp-nat/#udp_mapping), +[`udp_filtering`](/configuration/shared/udp-nat/#udp_filtering) and +[`udp_nat_max`](/configuration/shared/udp-nat/#udp_nat_max) configure the NAT +mapping and filtering behaviors and the maximum number of UDP NAT sessions for +TUN and TProxy inbounds and the WireGuard endpoint. + +#### 1.14.0-alpha.45 + +* Improve the Windows client application **1** +* Fixes and improvements + +**1**: + +The [Windows client](/clients/desktop/) now includes an updater, adds support +for Windows native sharing of sing-box profile and JSON files, and fixes the +Tailscale SSH terminal. The +[Tailscale SSH server](/configuration/endpoint/tailscale/#ssh_server) can now +open sessions for any local user in the graphical client, while the command +line client remains limited to the user sing-box runs as. Additionally, +configurations that use privileges unrelated to networking are now rejected by +default; an insecure mode is available to allow them. + +#### 1.14.0-alpha.44 + +* Introducing our [new Windows client application](/clients/desktop/) **1** +* Fixes and improvements + +**1**: + +The new [Windows client](/clients/desktop/) provides an experience equal to +other standard sing-box graphical clients, is available for Windows 10+ +(x64 / x86 / arm64), and is distributed as an installer from +[GitHub Releases](https://github.com/SagerNet/sing-box/releases) +(`SFW--.exe`). + +#### 1.14.0-alpha.43 + +* Add network namespace support **1** +* Fixes and improvements + +**1**: + +The new [`network_namespaces`](/configuration/network-namespace/) option defines +Linux network namespaces for inbounds and outbounds, referenced by tag from the +new tun [`netns`](/configuration/inbound/tun/#netns) field and the existing +[Listen](/configuration/shared/listen/#netns) and +[Dial](/configuration/shared/dial/#netns) `netns` fields. + +The [`unshare`](/configuration/network-namespace/unshare/) type creates the +namespace at startup without requiring root privileges: a rootless sing-box can +provide a tun (including `auto_route` and `auto_redirect`) inside a namespace, +which can be entered with `nsenter`. + +#### 1.14.0-alpha.42 + +* Fixes and improvements + +#### 1.14.0-alpha.41 + +* Add windows bridge **1** +* Add `preferred_by` support for bridge **2** +* Add hysteria2 realm IP version restriction **3** +* Add hysteria2 realm port mapping **4** +* Fixes and improvements + +**1**: + +The [`bridge`](/configuration/outbound/bridge/) outbound is now supported on +Windows, implemented via WinDivert and requiring Administrator privileges. + +**2**: + +The [`bridge`](/configuration/outbound/bridge/) outbound now works with the +[`preferred_by`](/configuration/route/rule/#preferred_by) route rule item. +It is recommended to use `preferred_by` as a gate in the `route` rule: it only +matches in [pre-match](/configuration/shared/pre-match/) and excludes local +addresses that cannot be routed. + +**3**: + +The new [`realm.ip_version`](/configuration/outbound/hysteria2/#realmip_version) +inbound and outbound field restricts realm connections (STUN, hole punching, +and the resulting QUIC path) to a single IP version. + +**4**: + +The new [`realm.port_mapping`](/configuration/outbound/hysteria2/#realmport_mapping) +inbound and outbound field maintains a UDP port mapping on the local gateway +via UPnP or NAT-PMP, improving hole-punching reliability behind gateways that +support it. + +#### 1.14.0-alpha.40 + +* Add bridge outbound **1** +* Fixes and improvements + +**1**: + +The new `bridge` outbound is the L3 counterpart of `direct`: it forwards L3 +traffic (TCP, UDP and ICMP) from a TUN or other L3 endpoints directly out of a +network interface, without going through L3 to L4 translation. It requires +privileges and is supported on Linux, macOS, rooted Android, and jailbroken iOS. + +See [Bridge](/configuration/outbound/bridge/). + +#### 1.14.0-alpha.39 + +* Add L3 forwarding support **1** +* Fixes and improvements + +**1**: + +Building on the ICMP proxy support introduced in sing-box 1.13.0, TCP and UDP +traffic from L3 inbounds (TUN, WireGuard, and Tailscale) can now be forwarded +directly to WireGuard and Tailscale endpoints at L3, without going through +L3 to L4 translation. + +See [Pre-match](/configuration/shared/pre-match/). + +#### 1.14.0-alpha.38 + +* Add Snell protocol support **1** +* Fixes and improvements + +**1**: + +Surge believes that being closed-source and not proliferated can keep +[Snell](https://kb.nssurge.com/surge-knowledge-base/release-notes/snell) +covert, but this is already impossible in 2026; considering that Snell still +has advantages that other random-traffic protocols do not possess, such as +multiplexing support with complete TCP semantics and traffic-characteristic +diversity, we [implemented it in Go](https://github.com/SagerNet/sing-snell) +instead of reinventing the wheel, with all features except the v5 QUIC proxy, +behavior as consistent with the official implementation as possible, and +performance at least on par with it. + +See [Snell Inbound](/configuration/inbound/snell/) and +[Snell Outbound](/configuration/outbound/snell/). + +#### 1.13.14 + +* Fixes and improvements + +#### 1.14.0-alpha.33 + +* Add iOS jailbreak release **1** +* Fixes and improvements + +**1**: + +A new jailbreak build of the iOS [sing-box for Apple](/clients/apple/) client is +available, distributed as a `.deb` for rootless iOS 15.0+ from +[GitHub Releases](https://github.com/SagerNet/sing-box/releases) +(`SFI-iphoneos-arm64.deb`). Unlike the App Store and TestFlight builds, it can run +a [Tailscale SSH server](/configuration/endpoint/tailscale/#ssh_server) on the +device and supports [process matching](/configuration/route/rule/#process_name) +(`process_name`, `process_path`, `user`, and so on) in route and DNS rules. + +#### 1.14.0-alpha.32 + +* Add dashboard support for the API service **1** +* Add USB/IP service **2** +* Fixes and improvements + +**1**: + +The [sing-box API service](/configuration/service/api/) can now download, update +and serve [sing-box-dashboard](https://github.com/SagerNet/sing-box-dashboard) +directly over its listener, configured via the new +[`dashboard`](/configuration/service/api/#dashboard) option. + +**2**: + +New [USB/IP Server](/configuration/service/usbip-server/) and +[USB/IP Client](/configuration/service/usbip-client/) services export and import +USB devices over the [USB/IP](https://usbip.sourceforge.net/) protocol, built on +[sing-usbip](https://github.com/SagerNet/sing-usbip), which adds hotplug while +staying interoperable with standard USB/IP. Exporting config-selected local +devices (`provider: default`) runs via the CLI on Linux, Windows, and macOS and +requires elevated privileges (macOS additionally needs a CGO build and disabled +System Integrity Protection). With `provider: dynamic`, devices are instead +supplied at runtime through the API service by the graphical clients on macOS and +Android, or the [sing-box Dashboard](https://github.com/SagerNet/sing-box-dashboard). + +#### 1.14.0-alpha.31 + +* Fixes and improvements + +#### 1.14.0-alpha.30 + +* Introducing sing-box API service **1** +* Apple/Android: Introducing remote control **2** +* Introducing sing-box Dashboard **3** +* Fixes and improvements + +**1**: + +The new [sing-box API service](/configuration/service/api/) is a gRPC +server for observing and controlling the running sing-box instance, +exposing the same interface the graphical clients use locally: service +status, logs, outbound groups (selection and URL tests), Clash mode, +connection tracking, and tools such as network quality tests, STUN +tests, and Tailscale operations. + +**2**: + +The graphical clients for Apple platforms and Android can now control +remote sing-box instances running the API service. Remote servers (URL +and secret) are managed in settings; the dashboard, logs, connections, +groups, and tools pages can then switch between the local service and +remote instances. + +**3**: + +[sing-box Dashboard](https://github.com/SagerNet/sing-box-dashboard) is +a new web client for the API service, providing almost the same +experience as the graphical clients. A public instance is available at +http://sing-box-dashboard.sagernet.org (shortcut: dash.sing-box.app). + +#### 1.14.0-alpha.29 + +* Fixes and improvements + +#### 1.13.13 + +* Fixes and improvements + +#### 1.14.0-alpha.27 + +* Add Tailscale SSH server **1** +* Fixes and improvements + +**1**: + +Adds an [`ssh_server`](/configuration/endpoint/tailscale/#ssh_server) field to +[Tailscale](/configuration/endpoint/tailscale/) endpoints, running a Tailscale SSH +server on tailnet port 22. Access is controlled by the SSH ACL in the Tailscale +admin console, which maps each connection to a local user (behavior varies by +platform; iOS and tvOS are not yet supported). The value may be `true` (equivalent +to `{ "enabled": true }`), or an object that additionally sets +[`disable_pty`](/configuration/endpoint/tailscale/#ssh_serverdisable_pty), +[`disable_sftp`](/configuration/endpoint/tailscale/#ssh_serverdisable_sftp), and +[`disable_forwarding`](/configuration/endpoint/tailscale/#ssh_serverdisable_forwarding). + #### 1.14.0-alpha.26 * Add gecko obfs for Hysteria2 **1** diff --git a/docs/clients/desktop/features.md b/docs/clients/desktop/features.md new file mode 100644 index 0000000000..bb399a03b1 --- /dev/null +++ b/docs/clients/desktop/features.md @@ -0,0 +1,9 @@ +# :material-decagram: Features + +#### Service + +SFW runs sing-box as a system service, so no administrator elevation is required for daily use. + +### Chore + +* The working directory is located at `C:\ProgramData\sing-box-daemon` diff --git a/docs/clients/desktop/index.md b/docs/clients/desktop/index.md new file mode 100644 index 0000000000..2dc8fe2884 --- /dev/null +++ b/docs/clients/desktop/index.md @@ -0,0 +1,21 @@ +--- +icon: material/laptop +--- + +# sing-box for Desktop + +sing-box for Desktop allows users to manage and run local or remote sing-box configuration files, +and provides platform-specific functionality, such as TUN transparent proxying. + +## :material-graph: Requirements + +* Windows 10+ (x64 / x86 / arm64) +* Linux (x64 / arm64 / armv7l) + +## :material-download: Download + +* [GitHub Releases](https://github.com/SagerNet/sing-box/releases) + +## :material-source-repository: Source code + +* [GitHub](https://github.com/SagerNet/sing-box-for-desktop) diff --git a/docs/clients/index.md b/docs/clients/index.md index 45d2c9a948..a527e6be8e 100644 --- a/docs/clients/index.md +++ b/docs/clients/index.md @@ -6,7 +6,8 @@ Maintained by Project S to provide a unified experience and platform-specific fu |---------------------------------------|------------------------------------------| | :material-android: Android | [sing-box for Android](./android/) | | :material-apple: iOS/macOS/Apple tvOS | [sing-box for Apple platforms](./apple/) | -| :material-laptop: Desktop | Working in progress | +| :material-microsoft-windows: Windows | [sing-box for Desktop](./desktop/) | +| :material-linux: Linux | [sing-box for Desktop](./desktop/) | Some third-party projects that claim to use sing-box or use sing-box as a selling point are not listed here. The core motivation of the maintainers of such projects is to acquire more users, and even though they provide friendly VPN diff --git a/docs/clients/index.zh.md b/docs/clients/index.zh.md index 736b42ea20..45747d2fe0 100644 --- a/docs/clients/index.zh.md +++ b/docs/clients/index.zh.md @@ -6,7 +6,8 @@ |---------------------------------------|------------------------------------------| | :material-android: Android | [sing-box for Android](./android/) | | :material-apple: iOS/macOS/Apple tvOS | [sing-box for Apple platforms](./apple/) | -| :material-laptop: Desktop | 施工中 | +| :material-microsoft-windows: Windows | [sing-box for Desktop](./desktop/) | +| :material-linux: Linux | [sing-box for Desktop](./desktop/) | 此处没有列出一些声称使用或以 sing-box 为卖点的第三方项目。此类项目维护者的动机是获得更多用户,即使它们提供友好的商业 VPN 客户端功能, 但代码质量很差且包含广告。 diff --git a/docs/configuration/dns/rule.md b/docs/configuration/dns/rule.md index 1fc4eafaf3..f87709d11a 100644 --- a/docs/configuration/dns/rule.md +++ b/docs/configuration/dns/rule.md @@ -242,7 +242,7 @@ icon: material/alert-decagram (`source_port` || `source_port_range`) && `other fields` - Additionally, each branch inside an included rule-set can be considered merged into the outer rule, while different branches keep OR semantics. + When a rule-set contains only a single default rule without `invert`, its fields are considered merged into the outer rule per the logic above; otherwise, it is matched as an `other field`; different rule-sets always keep OR semantics. #### inbound @@ -507,13 +507,14 @@ Match source device hostname from DHCP leases. Match specified DNS servers' preferred domains. -| Type | Match | -|-------------|------------------------------------------------------------------------------| -| `hosts` | Match predefined entries and entries in hosts files | -| `local` | Match hosts entries, neighbor-resolved hosts, and mDNS local domains | -| `mdns` | Match mDNS local domains (`*.local.` and IPv4/IPv6 link-local reverse zones) | -| `tailscale` | Match MagicDNS hosts and DNS route suffixes | -| `resolved` | Match split DNS and search domains from systemd-resolved links | +| Type | Match | +|---------------|------------------------------------------------------------------------------| +| `hosts` | Match predefined entries and entries in hosts files | +| `local` | Match hosts entries, neighbor-resolved hosts, and mDNS local domains | +| `mdns` | Match mDNS local domains (`*.local.` and IPv4/IPv6 link-local reverse zones) | +| `tailscale` | Match MagicDNS hosts and DNS route suffixes | +| `openconnect` | Match split DNS and search domains pushed by the VPN server | +| `resolved` | Match split DNS and search domains from systemd-resolved links | #### wifi_ssid @@ -561,7 +562,11 @@ Enable response-based matching. When enabled, this rule matches against the eval (set by a preceding [`evaluate`](/configuration/dns/rule_action/#evaluate) action) instead of only matching the original query. -The evaluated response can also be returned directly by a later [`respond`](/configuration/dns/rule_action/#respond) action. +`true` or the `tag` of an `evaluate` action: `true` matches against the response of the latest +`evaluate` action without `tag`; a tag matches against the response of the `evaluate` action with the tag. + +The evaluated response can also be returned directly by a later [`respond`](/configuration/dns/rule_action/#respond) action; +in a rule with a `match_response` tag, `respond` returns the tagged response. Required for Response Match Fields (`response_rcode`, `response_answer`, `response_ns`, `response_extra`). Also required for `ip_cidr`, `ip_is_private`, and `ip_accept_any` when used with `evaluate` or Response Match Fields. diff --git a/docs/configuration/dns/rule.zh.md b/docs/configuration/dns/rule.zh.md index c56e7f5bfc..af398506b6 100644 --- a/docs/configuration/dns/rule.zh.md +++ b/docs/configuration/dns/rule.zh.md @@ -240,9 +240,9 @@ icon: material/alert-decagram (`port` || `port_range`) && (`source_geoip` || `source_ip_cidr` || `source_ip_is_private`) && (`source_port` || `source_port_range`) && - `other fields` + `其他字段` - 另外,引用规则集中的每个分支都可视为与外层规则合并,不同分支之间仍保持 OR 语义。 + 当规则集仅包含一条默认规则且非 invert 时,其中字段视为按以上规则与外层规则合并;否则,作为一条 `其他字段` 匹配;不同规则集之间始终保持 or。 #### inbound @@ -499,13 +499,14 @@ Available values: `wifi`, `cellular`, `ethernet` and `other`. 匹配指定 DNS 服务器的首选域名。 -| 类型 | 匹配 | -|-------------|-------------------------------------------------------------| -| `hosts` | 匹配预定义条目和 hosts 文件中的条目 | -| `local` | 匹配 hosts 中的条目、邻居解析得到的主机名以及 mDNS 本地域名 | -| `mdns` | 匹配 mDNS 本地域名(`*.local.` 以及 IPv4/IPv6 链路本地反向区域) | -| `tailscale` | 匹配 MagicDNS 主机和 DNS 路由后缀 | -| `resolved` | 匹配 systemd-resolved 链路中的分流域名和搜索域 | +| 类型 | 匹配 | +|---------------|-------------------------------------------------------------| +| `hosts` | 匹配预定义条目和 hosts 文件中的条目 | +| `local` | 匹配 hosts 中的条目、邻居解析得到的主机名以及 mDNS 本地域名 | +| `mdns` | 匹配 mDNS 本地域名(`*.local.` 以及 IPv4/IPv6 链路本地反向区域) | +| `tailscale` | 匹配 MagicDNS 主机和 DNS 路由后缀 | +| `openconnect` | 匹配 VPN 服务器推送的分流 DNS 和搜索域 | +| `resolved` | 匹配 systemd-resolved 链路中的分流域名和搜索域 | #### wifi_ssid @@ -551,7 +552,9 @@ Available values: `wifi`, `cellular`, `ethernet` and `other`. 启用响应匹配。启用后,此规则将匹配已评估的响应(由前序 [`evaluate`](/zh/configuration/dns/rule_action/#evaluate) 动作设置),而不仅是匹配原始查询。 -该已评估的响应也可以被后续的 [`respond`](/zh/configuration/dns/rule_action/#respond) 动作直接返回。 +可以为 `true` 或 `evaluate` 动作的 `tag`:`true` 匹配最近一条无 `tag` 的 `evaluate` 动作的响应;标签则匹配对应 `evaluate` 动作的响应。 + +该已评估的响应也可以被后续的 [`respond`](/zh/configuration/dns/rule_action/#respond) 动作直接返回;在带 `match_response` 标签的规则中,`respond` 返回该标签的响应。 响应匹配字段(`response_rcode`、`response_answer`、`response_ns`、`response_extra`)需要此选项。 当与 `evaluate` 或响应匹配字段一起使用时,`ip_cidr`、`ip_is_private` 和 `ip_accept_any` 也需要此选项。 diff --git a/docs/configuration/dns/rule_action.md b/docs/configuration/dns/rule_action.md index 3555d6ede3..c9f9e16dd7 100644 --- a/docs/configuration/dns/rule_action.md +++ b/docs/configuration/dns/rule_action.md @@ -8,7 +8,9 @@ icon: material/new-box :material-plus: [evaluate](#evaluate) :material-plus: [respond](#respond) :material-plus: [disable_optimistic_cache](#disable_optimistic_cache) - :material-plus: [timeout](#timeout) + :material-plus: [timeout](#timeout) + :material-plus: [race](#race) + :material-plus: [speculative](#speculative) !!! quote "Changes in sing-box 1.12.0" @@ -17,12 +19,50 @@ icon: material/new-box !!! question "Since sing-box 1.11.0" +### Structure + +```json +{ + "action": "", + "race": false, + + ... // Action Fields +} +``` + +#### action + +The action to perform. `route` will be used by default. + +#### race + +!!! question "Since sing-box 1.14.0" + +Only available with `route`, `respond`, `reject` and `predefined` actions. + +Requires [`match_response`](/configuration/dns/rule/#match_response) (for logical rules, in sub-rules). +Conflict with `speculative`. + +By default, rules are matched one after another in listed order: a rule with `match_response` +waits for its referenced responses, and no later rule is matched until it has been judged. + +A rule with `race` enabled does not hold this order: rule matching continues past it while its +referenced responses are still pending, so the matching of race rules runs in parallel — with +each other and with the rules after them. Each race rule is judged once its referenced +responses are available, and the first race rule that matches terminates rule evaluation +immediately; the remaining queries are canceled. + +Rules without `race` still take effect strictly in listed order: while a preceding race rule is +not yet judged, the action of any other matched rule is held until none of the race rules +matched. The result may therefore depend on server speed only among race rules. + ### route ```json { "action": "route", // default "server": "", + "speculative": false, "strategy": "", "disable_cache": false, "disable_optimistic_cache": false, @@ -40,6 +80,19 @@ icon: material/new-box Tag of target server. +#### speculative + +!!! question "Since sing-box 1.14.0" + +Conflict with `race`. Has no effect without a preceding `race` rule. + +By default, no query is sent in parallel with pending race rules: a matched `route` action +holds its query until none of the race rules matched. + +When `speculative` is enabled, the query is sent as soon as the rule matches, in parallel with +the pending race rules, and may be wasted: its response is still used only after none of the +race rules matched. + #### strategy !!! question "Since sing-box 1.12.0" @@ -90,6 +143,8 @@ Will override `dns.client_subnet`. { "action": "evaluate", "server": "", + "tag": "", + "speculative": false, "disable_cache": false, "disable_optimistic_cache": false, "rewrite_ttl": null, @@ -113,6 +168,25 @@ does not satisfy this requirement, because matching happens before the action ru Tag of target server. +#### tag + +Tag of the evaluated response. + +A tagged response is only referenced via [`match_response`](/configuration/dns/rule/#match_response) with the tag; +`match_response: true` references the response of the latest `evaluate` action without `tag`. + +#### speculative + +!!! question "Since sing-box 1.14.0" + +Has no effect without a preceding `race` rule. + +By default, no query is sent in parallel with pending race rules: a matched `evaluate` action +holds its query, and rule matching stops there, until none of the race rules matched. + +When `speculative` is enabled, the query is sent as soon as the rule matches, in parallel with +the pending race rules, and may be wasted: rule matching continues without waiting for them. + #### disable_cache Disable cache and save cache in this query. @@ -155,7 +229,7 @@ Will override `dns.client_subnet`. `respond` terminates rule evaluation and returns the evaluated response from a preceding [`evaluate`](/configuration/dns/rule_action/#evaluate) action. -This action does not send a new DNS query and has no extra options. +This action does not send a new DNS query. Only allowed after a preceding top-level `evaluate` rule. If the action is reached without an evaluated response at runtime, the request fails with an error instead of falling through to later rules. diff --git a/docs/configuration/dns/rule_action.zh.md b/docs/configuration/dns/rule_action.zh.md index 756051dd9b..c5d1f1b4f5 100644 --- a/docs/configuration/dns/rule_action.zh.md +++ b/docs/configuration/dns/rule_action.zh.md @@ -8,7 +8,9 @@ icon: material/new-box :material-plus: [evaluate](#evaluate) :material-plus: [respond](#respond) :material-plus: [disable_optimistic_cache](#disable_optimistic_cache) - :material-plus: [timeout](#timeout) + :material-plus: [timeout](#timeout) + :material-plus: [race](#race) + :material-plus: [speculative](#speculative) !!! quote "sing-box 1.12.0 中的更改" @@ -17,12 +19,43 @@ icon: material/new-box !!! question "自 sing-box 1.11.0 起" +### 结构 + +```json +{ + "action": "", + "race": false, + + ... // 动作字段 +} +``` + +#### action + +要执行的动作。默认使用 `route`。 + +#### race + +!!! question "自 sing-box 1.14.0 起" + +仅可用于 `route`、`respond`、`reject` 和 `predefined` 动作。 + +需要 [`match_response`](/zh/configuration/dns/rule/#match_response)(对 logical 规则,位于子规则中)。 +与 `speculative` 冲突。 + +默认情况下,规则逐条按顺序匹配:带 `match_response` 的规则等待其引用的响应,在它被判定之前不会匹配任何后续规则。 + +启用 `race` 的规则成为竞态规则,不再保持这一顺序:其引用的响应尚未到达时,规则匹配会越过它继续进行,因此竞态规则的匹配相互并行、也与后续规则并行。每条竞态规则在其引用的响应可用时被判定,首个匹配的竞态规则立即终止规则评估,其余查询将被取消。 + +未启用 `race` 的规则仍严格按顺序生效:只要前面还有未判定的竞态规则,其他已匹配规则的动作就被扣住,直到所有竞态规则均未匹配。因此只有竞态规则之间的结果取决于服务器速度。 + ### route ```json { "action": "route", // 默认 "server": "", + "speculative": false, "strategy": "", "disable_cache": false, "disable_optimistic_cache": false, @@ -40,6 +73,16 @@ icon: material/new-box 目标 DNS 服务器的标签。 +#### speculative + +!!! question "自 sing-box 1.14.0 起" + +与 `race` 冲突。没有前序竞态规则时无效果。 + +默认情况下,查询决不与未判定的竞态规则并行发出:已匹配的 `route` 动作扣住其查询,直到所有竞态规则均未匹配后才发送。 + +启用 `speculative` 后,查询成为投机查询:在规则匹配时立即发出、与未判定的竞态规则并行,且可能被浪费;其响应仍仅在所有竞态规则均未匹配后才被使用。 + #### strategy !!! question "自 sing-box 1.12.0 起" @@ -90,6 +133,8 @@ icon: material/new-box { "action": "evaluate", "server": "", + "tag": "", + "speculative": false, "disable_cache": false, "disable_optimistic_cache": false, "rewrite_ttl": null, @@ -111,6 +156,23 @@ icon: material/new-box 目标 DNS 服务器的标签。 +#### tag + +已评估响应的标签。 + +带标签的响应仅能通过 [`match_response`](/zh/configuration/dns/rule/#match_response) 以标签引用; +`match_response: true` 引用最近一条无 `tag` 的 `evaluate` 动作的响应。 + +#### speculative + +!!! question "自 sing-box 1.14.0 起" + +没有前序竞态规则时无效果。 + +默认情况下,查询决不与未判定的竞态规则并行发出:已匹配的 `evaluate` 动作扣住其查询,规则匹配在此处停止,直到所有竞态规则均未匹配。 + +启用 `speculative` 后,查询成为投机查询:在规则匹配时立即发出、与未判定的竞态规则并行,且可能被浪费;规则匹配继续进行而不等待竞态规则。 + #### disable_cache 在此查询中禁用缓存。 @@ -153,7 +215,7 @@ icon: material/new-box `respond` 会终止规则评估,并直接返回前序 [`evaluate`](/zh/configuration/dns/rule_action/#evaluate) 动作保存的已评估的响应。 -此动作不会发起新的 DNS 查询,也没有额外选项。 +此动作不会发起新的 DNS 查询。 只能用于前面已有顶层 `evaluate` 规则的场景。如果运行时命中该动作时没有已评估的响应,则请求会直接返回错误,而不是继续匹配后续规则。 diff --git a/docs/configuration/dns/server/index.md b/docs/configuration/dns/server/index.md index bcb0586175..436627c4b9 100644 --- a/docs/configuration/dns/server/index.md +++ b/docs/configuration/dns/server/index.md @@ -46,6 +46,8 @@ The type of the DNS server. | `mdns` | [mDNS](./mdns/) | | `fakeip` | [Fake IP](./fakeip/) | | `tailscale` | [Tailscale](./tailscale/) | +| `openconnect` | [OpenConnect](./openconnect/) | +| `openvpn` | [OpenVPN](./openvpn/) | | `resolved` | [Resolved](./resolved/) | #### tag diff --git a/docs/configuration/dns/server/index.zh.md b/docs/configuration/dns/server/index.zh.md index 54dd97e7f9..70707fb121 100644 --- a/docs/configuration/dns/server/index.zh.md +++ b/docs/configuration/dns/server/index.zh.md @@ -46,6 +46,8 @@ DNS 服务器的类型。 | `mdns` | [mDNS](./mdns/) | | `fakeip` | [Fake IP](./fakeip/) | | `tailscale` | [Tailscale](./tailscale/) | +| `openconnect` | [OpenConnect](./openconnect/) | +| `openvpn` | [OpenVPN](./openvpn/) | | `resolved` | [Resolved](./resolved/) | #### tag diff --git a/docs/configuration/dns/server/openconnect.md b/docs/configuration/dns/server/openconnect.md new file mode 100644 index 0000000000..0ba053be3e --- /dev/null +++ b/docs/configuration/dns/server/openconnect.md @@ -0,0 +1,97 @@ +--- +icon: material/new-box +--- + +!!! question "Since sing-box 1.14.0" + +# OpenConnect + +### Structure + +```json +{ + "dns": { + "servers": [ + { + "type": "openconnect", + "tag": "", + + "endpoint": "oc-client", + "accept_default_resolvers": false, + "accept_search_domain": false + } + ] + } +} +``` + +### Fields + +#### endpoint + +==Required== + +The tag of the [OpenConnect Endpoint](/configuration/endpoint/openconnect). + +DNS queries are sent to the resolvers pushed by the VPN server through the OpenConnect endpoint. Pushed split-DNS rules use their dedicated resolvers, while pushed split-DNS and search-domain suffixes use the general pushed resolvers. The most specific matching suffix takes precedence. + +Pushed DNS settings are not installed into the operating system. + +#### accept_default_resolvers + +Accept the general resolvers pushed by the VPN server for unmatched queries. + +When enabled, the general resolvers are used as the default only if the server requests all DNS through the tunnel, or if it does not provide split-DNS rules or suffixes. Otherwise, unmatched queries return `NXDOMAIN`. + +#### accept_search_domain + +When enabled and pushed search domains are available, single-label queries (for example, `intranet`) are retried with each search domain until one resolves. + +If every search-domain expansion returns `NXDOMAIN`, the original unqualified name follows normal default-resolver behavior. + +### Examples + +=== "Split DNS only" + + ```json + { + "dns": { + "servers": [ + { + "type": "local", + "tag": "local" + }, + { + "type": "openconnect", + "tag": "oc", + "endpoint": "oc-client" + } + ], + "rules": [ + { + "preferred_by": "oc", + "action": "route", + "server": "oc" + } + ], + "final": "local" + } + } + ``` + +=== "Accept pushed default resolvers" + + ```json + { + "dns": { + "servers": [ + { + "type": "openconnect", + "endpoint": "oc-client", + "accept_default_resolvers": true, + "accept_search_domain": true + } + ] + } + } + ``` diff --git a/docs/configuration/dns/server/openconnect.zh.md b/docs/configuration/dns/server/openconnect.zh.md new file mode 100644 index 0000000000..af81285147 --- /dev/null +++ b/docs/configuration/dns/server/openconnect.zh.md @@ -0,0 +1,97 @@ +--- +icon: material/new-box +--- + +!!! question "自 sing-box 1.14.0 起" + +# OpenConnect + +### 结构 + +```json +{ + "dns": { + "servers": [ + { + "type": "openconnect", + "tag": "", + + "endpoint": "oc-client", + "accept_default_resolvers": false, + "accept_search_domain": false + } + ] + } +} +``` + +### 字段 + +#### endpoint + +==必填== + +[OpenConnect 端点](/zh/configuration/endpoint/openconnect) 的标签。 + +DNS 查询会通过 OpenConnect 端点发送到 VPN 服务器推送的解析器。推送的分流 DNS 规则使用各自的专用解析器,推送的分流 DNS 和搜索域后缀则使用通用推送解析器。匹配时优先使用最具体的后缀。 + +推送的 DNS 设置不会安装到操作系统中。 + +#### accept_default_resolvers + +接受 VPN 服务器推送的通用解析器,用于未匹配的查询。 + +启用时,仅当服务器要求所有 DNS 通过隧道,或未提供分流 DNS 规则及后缀时,通用解析器才会作为默认解析器。否则,未匹配的查询将返回 `NXDOMAIN`。 + +#### accept_search_domain + +启用且存在推送的搜索域时,单标签查询(例如 `intranet`)会依次附加各个搜索域进行重试,直到其中一个解析成功。 + +如果所有搜索域扩展均返回 `NXDOMAIN`,原始未限定名称将按普通默认解析器行为处理。 + +### 示例 + +=== "仅分流 DNS" + + ```json + { + "dns": { + "servers": [ + { + "type": "local", + "tag": "local" + }, + { + "type": "openconnect", + "tag": "oc", + "endpoint": "oc-client" + } + ], + "rules": [ + { + "preferred_by": "oc", + "action": "route", + "server": "oc" + } + ], + "final": "local" + } + } + ``` + +=== "接受推送的默认解析器" + + ```json + { + "dns": { + "servers": [ + { + "type": "openconnect", + "endpoint": "oc-client", + "accept_default_resolvers": true, + "accept_search_domain": true + } + ] + } + } + ``` diff --git a/docs/configuration/dns/server/openvpn.md b/docs/configuration/dns/server/openvpn.md new file mode 100644 index 0000000000..d11a457306 --- /dev/null +++ b/docs/configuration/dns/server/openvpn.md @@ -0,0 +1,82 @@ +--- +icon: material/new-box +--- + +!!! question "Since sing-box 1.14.0" + +# OpenVPN + +### Structure + +```json +{ + "dns": { + "servers": [ + { + "type": "openvpn", + "tag": "", + + "endpoint": "ovpn-client", + "accept_default_resolvers": false, + "accept_search_domain": false + } + ] + } +} +``` + +### Fields + +#### endpoint + +==Required== + +The tag of the [OpenVPN Client Endpoint](/configuration/endpoint/openvpn-client). + +DNS queries are sent through the endpoint to resolvers pushed by the OpenVPN server. Modern OpenVPN `dns server` options support plain DNS, DNS over TLS, DNS over HTTPS, custom ports, SNI, and `resolve-domains`. Only the server group with the lowest priority number is active. Legacy `dhcp-option DNS`/`DNS6` and `DOMAIN-ROUTE` are used when no modern server group is present. + +A modern server group overrides legacy DHCP DNS resolver and domain options. A standalone modern `dns search-domains` option does not remove legacy resolvers. Required DNSSEC validation (`dnssec yes`) is rejected because this transport does not provide DNSSEC validation. + +Pushed DNS settings are not installed into the operating system. + +#### accept_default_resolvers + +Use pushed resolvers for queries that do not match a pushed `resolve-domains`, `DOMAIN-ROUTE`, or search-domain suffix. + +When disabled, unmatched queries return `NXDOMAIN`. + +#### accept_search_domain + +When enabled and pushed search domains are available, single-label queries (for example, `intranet`) are retried with each search domain until one resolves. + +If no search domain is available, the original single-label query follows normal default-resolver behavior. + +### Example + +```json +{ + "dns": { + "servers": [ + { + "type": "local", + "tag": "local" + }, + { + "type": "openvpn", + "tag": "ovpn-dns", + "endpoint": "ovpn-client", + "accept_default_resolvers": true, + "accept_search_domain": true + } + ], + "rules": [ + { + "preferred_by": "ovpn-dns", + "action": "route", + "server": "ovpn-dns" + } + ], + "final": "local" + } +} +``` diff --git a/docs/configuration/dns/server/openvpn.zh.md b/docs/configuration/dns/server/openvpn.zh.md new file mode 100644 index 0000000000..29d1d6684a --- /dev/null +++ b/docs/configuration/dns/server/openvpn.zh.md @@ -0,0 +1,82 @@ +--- +icon: material/new-box +--- + +!!! question "自 sing-box 1.14.0 起" + +# OpenVPN + +### 结构 + +```json +{ + "dns": { + "servers": [ + { + "type": "openvpn", + "tag": "", + + "endpoint": "ovpn-client", + "accept_default_resolvers": false, + "accept_search_domain": false + } + ] + } +} +``` + +### 字段 + +#### endpoint + +==必填== + +[OpenVPN 客户端端点](/zh/configuration/endpoint/openvpn-client) 的标签。 + +DNS 查询会通过该端点发送到 OpenVPN 服务器推送的解析器。现代 OpenVPN `dns server` 选项支持普通 DNS、DNS over TLS、DNS over HTTPS、自定义端口、SNI 和 `resolve-domains`。只有优先级数字最低的服务器组会生效。没有现代服务器组时,使用传统的 `dhcp-option DNS`/`DNS6` 和 `DOMAIN-ROUTE`。 + +现代服务器组会覆盖传统 DHCP DNS 解析器及相关域选项。只有现代 `dns search-domains` 而没有现代服务器组时,不会移除传统解析器。由于此传输不提供 DNSSEC 验证,需要强制验证的 `dnssec yes` 会被拒绝。 + +推送的 DNS 设置不会安装到操作系统中。 + +#### accept_default_resolvers + +对未匹配推送的 `resolve-domains`、`DOMAIN-ROUTE` 或搜索域后缀的查询使用推送解析器。 + +禁用时,未匹配查询返回 `NXDOMAIN`。 + +#### accept_search_domain + +启用且存在推送的搜索域时,单标签查询(例如 `intranet`)会依次附加各个搜索域重试,直到其中一个解析成功。 + +不存在搜索域时,原始单标签查询按普通默认解析器规则处理。 + +### 示例 + +```json +{ + "dns": { + "servers": [ + { + "type": "local", + "tag": "local" + }, + { + "type": "openvpn", + "tag": "ovpn-dns", + "endpoint": "ovpn-client", + "accept_default_resolvers": true, + "accept_search_domain": true + } + ], + "rules": [ + { + "preferred_by": "ovpn-dns", + "action": "route", + "server": "ovpn-dns" + } + ], + "final": "local" + } +} +``` diff --git a/docs/configuration/endpoint/index.md b/docs/configuration/endpoint/index.md index b409a7839f..6e7614b057 100644 --- a/docs/configuration/endpoint/index.md +++ b/docs/configuration/endpoint/index.md @@ -19,10 +19,13 @@ An endpoint is a protocol with inbound and outbound behavior. ### Fields -| Type | Format | -|-------------|---------------------------| -| `wireguard` | [WireGuard](./wireguard/) | -| `tailscale` | [Tailscale](./tailscale/) | +| Type | Format | +|------------------|-----------------------------------------| +| `wireguard` | [WireGuard](./wireguard/) | +| `tailscale` | [Tailscale](./tailscale/) | +| `openconnect` | [OpenConnect Client](./openconnect/) | +| `openvpn-client` | [OpenVPN Client](./openvpn-client/) | +| `openvpn-server` | [OpenVPN Server](./openvpn-server/) | #### tag diff --git a/docs/configuration/endpoint/index.zh.md b/docs/configuration/endpoint/index.zh.md index f7e71b7574..a75bc63162 100644 --- a/docs/configuration/endpoint/index.zh.md +++ b/docs/configuration/endpoint/index.zh.md @@ -19,10 +19,13 @@ ### 字段 -| 类型 | 格式 | -|-------------|---------------------------| -| `wireguard` | [WireGuard](./wireguard/) | -| `tailscale` | [Tailscale](./tailscale/) | +| 类型 | 格式 | +|------------------|-----------------------------------------| +| `wireguard` | [WireGuard](./wireguard/) | +| `tailscale` | [Tailscale](./tailscale/) | +| `openconnect` | [OpenConnect 客户端](./openconnect/) | +| `openvpn-client` | [OpenVPN 客户端](./openvpn-client/) | +| `openvpn-server` | [OpenVPN 服务器](./openvpn-server/) | #### tag diff --git a/docs/configuration/endpoint/openconnect.md b/docs/configuration/endpoint/openconnect.md new file mode 100644 index 0000000000..8afb65ba3c --- /dev/null +++ b/docs/configuration/endpoint/openconnect.md @@ -0,0 +1,651 @@ +# OpenConnect Client + +!!! question "Since sing-box 1.14.0" + +==Client only== + +## Structure + +```json +{ + "type": "openconnect", + "tag": "oc-client", + + "system": false, + "name": "", + + ... // UDP NAT Fields + + "server": "vpn.example.com", + "flavor": "anyconnect", + "username": "", + "password": "", + "auth_group": "", + "cookie": "", + "token": { + "mode": "", + "secret": "", + "secret_path": "", + "pin": "", + "password": "", + "device_id": "", + "counter": 0 + }, + "reported_os": "", + "user_agent": "", + "version": "", + "local_hostname": "", + "mobile": { + "platform_version": "", + "device_type": "", + "device_unique_id": "" + }, + "csd": { + "wrapper_path": "" + }, + "hip": { + "wrapper_path": "" + }, + "tncc": { + "wrapper_path": "", + "device_id": "", + "user_agent": "", + "machine_identification_enabled": false, + "certificates": [ + { + "certificate": [], + "certificate_path": "" + } + ] + }, + "fortinet_host_check": { + "hostcheck": "", + "check_virtual_desktop": "" + }, + "no_udp": false, + "dtls_local_port": 0, + "compression_disabled": false, + "compression_mode": "", + "ipv6_disabled": false, + "http_keepalive_disabled": false, + "xml_post_disabled": false, + "external_auth_disabled": false, + "password_authentication_disabled": false, + "tcp_keep_alive_enabled": false, + "pfs": false, + "mtu": 0, + "base_mtu": 0, + "dpd_interval": "", + "reconnect_timeout": "", + "trojan_interval": "", + "queue_length": 0, + "allow_insecure_crypto": false, + "tls": { + "insecure": false, + "server_name": "", + "peer_fingerprint": [], + "system_trust_disabled": false, + "certificate_authority": [], + "certificate_authority_path": "", + "client_certificate": [], + "client_certificate_path": "", + "client_key": [], + "client_key_path": "", + "client_key_password": "", + "mca_certificate": [], + "mca_certificate_path": "", + "mca_key": [], + "mca_key_path": "", + "mca_key_password": "" + }, + "form_entries": [ + { + "form_id": "", + "submission_key": "", + "name": "", + "value": "", + "promote": false + } + ], + + ... // Dial Fields +} +``` + +!!! note "" + + You can ignore the JSON Array [] tag when the content is only one item. + +## Fields + +### system + +Use a system interface. + +Requires privilege and cannot conflict with existing system interfaces. + +If disabled, sing-box uses the internal network stack. + +### name + +Custom interface name for the system interface. + +An automatically generated `oc` interface name is used by default. + +### server + +==Required== + +OpenConnect VPN server HTTPS URL. + +The `https://` scheme is added if omitted. URL user information, queries, and fragments are not supported. + +### flavor + +OpenConnect protocol flavor, one of `anyconnect`, `gp`, `fortinet`, `f5`, `pulse`, or `nc`. + +`anyconnect` is used by default. + +### username + +Username used to fill matching authentication form fields. + +### password + +Password used to fill matching authentication form fields. + +### auth_group + +Authentication group used to preselect a matching group, realm, domain, or gateway choice when supported by the selected flavor. + +### cookie + +Existing authentication session used to connect without first prompting for credentials. + +The accepted format depends on `flavor`: + +- `anyconnect`: A `webvpn` value, or a semicolon-separated cookie list containing `webvpn`. +- `gp`: The complete authenticated query string returned by GlobalProtect authentication. +- `nc`: A `DSID` value, or a semicolon-separated cookie list containing `DSID`. +- `pulse`: The raw Pulse authentication cookie value. +- `f5`: An `MRHSession` value, or a semicolon-separated cookie list containing `MRHSession` and optionally `F5_ST`. +- `fortinet`: An `SVPNCOOKIE` value, or a semicolon-separated cookie list containing `SVPNCOOKIE`. + +If the server rejects the supplied session, normal authentication is attempted. + +### token + +Token configuration for automatically answering matching token fields or HTTP Bearer authentication. + +One of `token.secret` or `token.secret_path` is required. + +### token.mode + +==Required== + +Token mode, one of: + +- `totp`: Time-based One-Time Password. +- `hotp`: HMAC-based One-Time Password. +- `stoken`: RSA SecurID software token. +- `oidc`: OIDC access token used for HTTP Bearer authentication. + +### token.secret + +Software token secret. + +For `totp` and `hotp`, this can be a Base32 secret, a `base32:`-prefixed secret, or an `otpauth://` URI of the matching type. + +For `stoken`, this is the encoded RSA SecurID CTF token content. + +For `oidc`, this is the access token value. It is sent only after the VPN server requests HTTP Bearer authentication. + +Conflict with `token.secret_path`. + +### token.secret_path + +Path to the software token secret or OIDC access token. + +Conflict with `token.secret`. + +### token.pin + +RSA SecurID PIN for `stoken` mode. + +### token.password + +Password for decrypting a password-protected RSA SecurID token in `stoken` mode. + +### token.device_id + +Device ID for decrypting a device-bound RSA SecurID token in `stoken` mode. + +### token.counter + +Initial counter for `hotp` mode. + +If zero, the counter from an `otpauth://` URI is used when present; otherwise the counter starts at zero. + +### reported_os + +Operating system identity reported to the VPN server when supported by the selected flavor. + +For `anyconnect`, `gp`, and `pulse`, the supported values are `linux`, `linux-64`, `win`, `mac-intel`, `android`, and `apple-ios`. + +The default is selected from the system platform: `win` on Windows, `mac-intel` on macOS, `android` on Android, `apple-ios` on iOS, and `linux-64` or `linux` on other 64-bit or 32-bit systems. + +### user_agent + +User agent reported to the VPN server when supported by the selected flavor. + +The default is flavor-specific. AnyConnect, Network Connect, Pulse, and F5 use `AnyConnect-compatible OpenConnect VPN Agent v9.21`; GlobalProtect uses `PAN GlobalProtect`; Fortinet uses `Mozilla/5.0 SV1`. + +### version + +Client version reported separately from `user_agent` when supported by the selected flavor. + +`v9.21` is used by default. Currently used by AnyConnect XML authentication. + +### local_hostname + +Local hostname reported to the VPN server when supported by the selected flavor. + +The system hostname is used by default, or `localhost` if it is unavailable. + +### mobile + +AnyConnect mobile client identity. When configured, all three fields are required and are reported during XML authentication and tunnel establishment. + +### mobile.platform_version + +Mobile operating system version reported to the AnyConnect server. + +### mobile.device_type + +Mobile device model or type reported to the AnyConnect server. + +### mobile.device_unique_id + +Mobile device identifier reported to the AnyConnect server. + +### csd + +AnyConnect CSD/host scan compliance options. + +Built-in CSD handling is used by default when requested by the server. + +### csd.wrapper_path + +Path to an external AnyConnect CSD wrapper executable. + +Built-in CSD handling is used if empty. + +### hip + +GlobalProtect HIP check and report options. + +Built-in HIP reporting is used by default when requested by the server. + +### hip.wrapper_path + +Path to an external GlobalProtect HIP report wrapper executable. + +Built-in HIP reporting is used if empty. + +### tncc + +Network Connect TNCC compliance options. + +Built-in TNCC handling is used by default when requested by the server. + +### tncc.wrapper_path + +Path to an external Network Connect TNCC wrapper executable. + +Built-in TNCC handling is used if empty. + +Conflict with `tncc.device_id`, `tncc.user_agent`, `tncc.machine_identification_enabled`, and `tncc.certificates`. + +### tncc.device_id + +Device ID reported by the built-in TNCC handler. + +Conflict with `tncc.wrapper_path`. + +### tncc.user_agent + +User agent used by the built-in TNCC handler. + +`Neoteris HC Http` is used by default. + +Conflict with `tncc.wrapper_path`. + +### tncc.machine_identification_enabled + +Enable built-in TNCC machine identification, including the platform, hostname, and observed MAC addresses. + +Conflict with `tncc.wrapper_path`. + +### tncc.certificates + +Machine certificates used by the built-in TNCC handler to answer certificate requests. + +Requires `tncc.machine_identification_enabled`. + +Conflict with `tncc.wrapper_path`. + +### tncc.certificates.certificate + +TNCC machine certificate content in PEM format. + +Conflict with `tncc.certificates.certificate_path`. + +### tncc.certificates.certificate_path + +TNCC machine certificate path in PEM format. + +Conflict with `tncc.certificates.certificate`. + +### fortinet_host_check + +Fortinet hostcheck result override. + +Hostcheck is disabled by default. It is enabled only when `fortinet_host_check.hostcheck` is non-empty. No operating system, security product, or network interface information is collected automatically. + +When enabled and a successful Fortinet login response requests hostcheck, both configured values are submitted to the server before the VPN session is used. The values are sent unchanged as `application/x-www-form-urlencoded` fields. + +Some Fortinet servers only request hostcheck from recognized FortiClient user agents. Configure `user_agent` when required by the server policy. + +### fortinet_host_check.hostcheck + +Fortinet hostcheck result string. + +The conventional format is `,`, for example `0100,10.0.19042`. `security-status` contains four `0` or `1` characters representing, in order, third-party firewall, third-party antivirus, FortiClient firewall, and FortiClient antivirus. + +An empty value disables Fortinet hostcheck, even if `fortinet_host_check.check_virtual_desktop` is configured. + +### fortinet_host_check.check_virtual_desktop + +Fortinet virtual desktop check result string. + +FortiClient conventionally sends colon-separated MAC addresses joined by `|`, for example `74:78:27:4d:81:93|84:1b:77:3a:95:84`. An empty value is submitted as an empty field when hostcheck is enabled. + +### no_udp + +Disable the DTLS or ESP secondary data channel and use the TLS data channel only. + +### dtls_local_port + +Local UDP port used by the direct DTLS or ESP secondary data channel. + +An automatically selected ephemeral port is used by default. + +### compression_disabled + +Disable AnyConnect compression negotiation. + +By default, stateless `oc-lz4` and `lzs` compression is negotiated for CSTP and DTLS when supported by the server. + +Compression can weaken traffic confidentiality when an attacker can influence plaintext sent through the VPN tunnel. + +Conflict with `compression_mode` set to `all`. + +### compression_mode + +AnyConnect compression mode, one of: + +- `stateless`: Advertise stateless `oc-lz4` and `lzs` compression. +- `all`: Additionally advertise stateful `deflate` compression for CSTP. + +`stateless` is used by default. DTLS always uses stateless compression, including when `all` is selected. + +Stateful compression has additional traffic confidentiality risks and should only be enabled when required by the VPN server. + +### ipv6_disabled + +Disable requesting and using IPv6 tunnel configuration. + +### http_keepalive_disabled + +Disable HTTP connection reuse during authentication and configuration requests. + +### xml_post_disabled + +Disable AnyConnect XML POST authentication and start authentication with the legacy GET flow. + +### external_auth_disabled + +Disable external browser authentication such as SSO and SAML for AnyConnect, GlobalProtect, and Fortinet. + +When enabled, external authentication is not advertised for AnyConnect or GlobalProtect, and any unexpected external authentication request, including Fortinet SAML, is rejected. + +### password_authentication_disabled + +Abort AnyConnect authentication if the server returns a non-success authentication form, matching OpenConnect `--no-passwd` behavior. + +This does not affect the other flavors or a session supplied by `cookie`. + +### tcp_keep_alive_enabled + +Enable TCP keep alive for direct VPN server connections. + +Disabled by default to match OpenConnect. Setting `tcp_keep_alive` or `tcp_keep_alive_interval` also enables it without requiring this field. When enabled without either duration, the operating system TCP keep alive timing is retained. + +Conflict with `disable_tcp_keep_alive`. + +### pfs + +Require forward-secret TLS cipher suites for TLS 1.2 and earlier. + +Disabled by default for compatibility with VPN servers that require RSA key exchange. This does not enable deprecated cipher suites; see `allow_insecure_crypto` for legacy crypto support. + +### mtu + +Preferred tunnel MTU. + +The negotiated MTU is limited to this value for all flavors. For AnyConnect, this value is also sent to the server. GlobalProtect, F5, and Fortinet remove their protocol overhead before using it as the tunnel MTU. + +Non-zero values below `576` are treated as `576`. The maximum value is `65535`. + +### base_mtu + +Base path MTU used to calculate the AnyConnect, GlobalProtect, F5, and Fortinet tunnel MTU after outer IP, transport, and protocol overhead. + +`1406` is used by default. + +These flavors treat values below `1280` as `1280`. The maximum value is `65535`. + +### dpd_interval + +Override the Dead Peer Detection interval. + +The server-provided or flavor-specific interval is used by default. + +Positive values below `2s` are treated as `2s`. The value must not be negative. + +### reconnect_timeout + +Maximum accumulated backoff time after failed reconnect attempts. The first reconnect attempt starts immediately, and this timeout does not cancel an attempt already in progress. + +`300s` is used by default. + +The value must not be negative. + +### trojan_interval + +Override the interval between GlobalProtect HIP reports or Network Connect TNCC checks. + +The server-provided interval is used by default. GlobalProtect uses `1h` when the server does not provide one. + +The value must not be negative. + +### queue_length + +Inbound and outbound packet queue length between the VPN transport and the tunnel interface. + +`32` is used by default. A full queue applies backpressure until its consumer makes room; queued packets are not discarded. + +### allow_insecure_crypto + +Enable weak TLS and DTLS cipher suites and TLS 1.0 compatibility required by legacy VPN servers. + +Disabled by default; TLS versions below 1.2 are otherwise rejected. This option does not disable server certificate verification. + +### tls + +OpenConnect TLS configuration. + +### tls.insecure + +Disable verification of the VPN server certificate and hostname. + +Disabled by default. Enabling this permits an active attacker to impersonate the VPN server. Prefer `tls.certificate_authority` or `tls.peer_fingerprint` when possible. + +### tls.server_name + +Server name used for TLS SNI and certificate hostname verification. + +The hostname from `server` is used by default. + +### tls.peer_fingerprint + +Allowed server certificate fingerprints. A single string or a list can be specified. + +Supported formats: + +- An unprefixed SHA-1 certificate fingerprint compatible with OpenConnect `--servercert`. +- `sha1:`: SHA-1 SPKI fingerprint. +- `sha256:`: SHA-256 SPKI fingerprint. +- `pin-sha256:`: Base64-encoded SHA-256 SPKI pin. + +The encoded fingerprint in every format can be abbreviated to a prefix of at least four characters. When configured, the peer certificate must match one of these fingerprints; a match can authorize a certificate that is not otherwise trusted. + +### tls.system_trust_disabled + +Disable the system CA certificate pool. + +Use `tls.certificate_authority` or `tls.peer_fingerprint` to establish trust when enabled. + +### tls.certificate_authority + +Additional trusted CA certificate content in PEM format. + +The certificates are added to the system certificate pool. + +Conflict with `tls.certificate_authority_path`. + +### tls.certificate_authority_path + +Path to additional trusted CA certificates in PEM format. + +The certificates are added to the system certificate pool. + +Conflict with `tls.certificate_authority`. + +### tls.client_certificate + +Client certificate chain content in PEM format. + +Conflict with `tls.client_certificate_path`. + +### tls.client_certificate_path + +Client certificate chain path in PEM format. + +Conflict with `tls.client_certificate`. + +### tls.client_key + +Client private key content in PEM format. + +Conflict with `tls.client_key_path`. + +### tls.client_key_path + +Client private key path in PEM format. + +Conflict with `tls.client_key`. + +The client certificate and key must both be set or both be empty. + +### tls.client_key_password + +Password for the encrypted client private key. + +### tls.mca_certificate + +AnyConnect multiple-certificate authentication (MCA) certificate chain content in PEM format. + +Conflict with `tls.mca_certificate_path`. + +### tls.mca_certificate_path + +AnyConnect multiple-certificate authentication (MCA) certificate chain path in PEM format. + +Conflict with `tls.mca_certificate`. + +### tls.mca_key + +AnyConnect multiple-certificate authentication (MCA) private key content in PEM format. + +Conflict with `tls.mca_key_path`. + +### tls.mca_key_path + +AnyConnect multiple-certificate authentication (MCA) private key path in PEM format. + +Conflict with `tls.mca_key`. + +The MCA certificate and key must both be set or both be empty. + +### tls.mca_key_password + +Password for the encrypted MCA private key. + +### form_entries + +Authentication form field overrides. + +An entry matches by `submission_key` when set, or by the combination of `form_id` and `name`. Later matching entries take precedence. + +### form_entries.form_id + +Authentication form identifier used with `form_entries.name` when `form_entries.submission_key` is empty. + +### form_entries.submission_key + +Authentication field submission key. + +Either `form_entries.submission_key` or both `form_entries.form_id` and `form_entries.name` are required. + +### form_entries.name + +Authentication field name used with `form_entries.form_id` when `form_entries.submission_key` is empty. + +### form_entries.value + +Value supplied automatically for the matching authentication field. + +Conflict with `form_entries.promote`. + +### form_entries.promote + +Ask for the matching authentication field interactively instead of supplying an automatic value. + +Conflict with `form_entries.value`. + +## UDP NAT Fields + +See [UDP NAT Fields](/configuration/shared/udp-nat/) for details. + +## Dial Fields + +See [Dial Fields](/configuration/shared/dial/) for details. + +## Interactive authentication + +Use `Tools` > `Endpoints` in the sing-box dashboard or any sing-box graphical client to authenticate and manage the endpoint. + +## DNS + +Pushed DNS settings are not installed into the operating system. Configure an [OpenConnect DNS server](/configuration/dns/server/openconnect/) to use them through sing-box. diff --git a/docs/configuration/endpoint/openconnect.zh.md b/docs/configuration/endpoint/openconnect.zh.md new file mode 100644 index 0000000000..904a404127 --- /dev/null +++ b/docs/configuration/endpoint/openconnect.zh.md @@ -0,0 +1,651 @@ +# OpenConnect 客户端 + +!!! question "自 sing-box 1.14.0 起" + +==仅客户端== + +## 结构 + +```json +{ + "type": "openconnect", + "tag": "oc-client", + + "system": false, + "name": "", + + ... // UDP NAT 字段 + + "server": "vpn.example.com", + "flavor": "anyconnect", + "username": "", + "password": "", + "auth_group": "", + "cookie": "", + "token": { + "mode": "", + "secret": "", + "secret_path": "", + "pin": "", + "password": "", + "device_id": "", + "counter": 0 + }, + "reported_os": "", + "user_agent": "", + "version": "", + "local_hostname": "", + "mobile": { + "platform_version": "", + "device_type": "", + "device_unique_id": "" + }, + "csd": { + "wrapper_path": "" + }, + "hip": { + "wrapper_path": "" + }, + "tncc": { + "wrapper_path": "", + "device_id": "", + "user_agent": "", + "machine_identification_enabled": false, + "certificates": [ + { + "certificate": [], + "certificate_path": "" + } + ] + }, + "fortinet_host_check": { + "hostcheck": "", + "check_virtual_desktop": "" + }, + "no_udp": false, + "dtls_local_port": 0, + "compression_disabled": false, + "compression_mode": "", + "ipv6_disabled": false, + "http_keepalive_disabled": false, + "xml_post_disabled": false, + "external_auth_disabled": false, + "password_authentication_disabled": false, + "tcp_keep_alive_enabled": false, + "pfs": false, + "mtu": 0, + "base_mtu": 0, + "dpd_interval": "", + "reconnect_timeout": "", + "trojan_interval": "", + "queue_length": 0, + "allow_insecure_crypto": false, + "tls": { + "insecure": false, + "server_name": "", + "peer_fingerprint": [], + "system_trust_disabled": false, + "certificate_authority": [], + "certificate_authority_path": "", + "client_certificate": [], + "client_certificate_path": "", + "client_key": [], + "client_key_path": "", + "client_key_password": "", + "mca_certificate": [], + "mca_certificate_path": "", + "mca_key": [], + "mca_key_path": "", + "mca_key_password": "" + }, + "form_entries": [ + { + "form_id": "", + "submission_key": "", + "name": "", + "value": "", + "promote": false + } + ], + + ... // 拨号字段 +} +``` + +!!! note "" + + 当内容只有一项时,可以忽略 JSON 数组 [] 标签。 + +## 字段 + +### system + +使用系统接口。 + +需要权限,且不能与现有系统接口冲突。 + +禁用时,sing-box 使用内部网络栈。 + +### name + +系统接口的自定义接口名称。 + +默认使用自动生成的 `oc` 接口名称。 + +### server + +==必填== + +OpenConnect VPN 服务器 HTTPS URL。 + +省略协议时会添加 `https://`。不支持 URL 用户信息、查询和片段。 + +### flavor + +OpenConnect 协议 flavor,可选值为 `anyconnect`、`gp`、`fortinet`、`f5`、`pulse` 或 `nc`。 + +默认使用 `anyconnect`。 + +### username + +用于填充匹配认证表单字段的用户名。 + +### password + +用于填充匹配认证表单字段的密码。 + +### auth_group + +认证组,用于在所选 flavor 支持时预选匹配的组、realm、domain 或 gateway 选项。 + +### cookie + +用于跳过凭据提示并直接连接的现有认证会话。 + +接受的格式取决于 `flavor`: + +- `anyconnect`:`webvpn` 值,或包含 `webvpn` 的分号分隔 cookie 列表。 +- `gp`:GlobalProtect 认证返回的完整 authenticated query string。 +- `nc`:`DSID` 值,或包含 `DSID` 的分号分隔 cookie 列表。 +- `pulse`:原始 Pulse 认证 cookie 值。 +- `f5`:`MRHSession` 值,或包含 `MRHSession` 及可选 `F5_ST` 的分号分隔 cookie 列表。 +- `fortinet`:`SVPNCOOKIE` 值,或包含 `SVPNCOOKIE` 的分号分隔 cookie 列表。 + +如果服务器拒绝提供的会话,将尝试正常认证。 + +### token + +用于自动回答匹配 token 字段或进行 HTTP Bearer 认证的 token 配置。 + +必须设置 `token.secret` 或 `token.secret_path` 之一。 + +### token.mode + +==必填== + +Token 模式,可选值为: + +- `totp`:基于时间的一次性密码。 +- `hotp`:基于 HMAC 的一次性密码。 +- `stoken`:RSA SecurID 软件 token。 +- `oidc`:用于 HTTP Bearer 认证的 OIDC access token。 + +### token.secret + +软件 token 密钥。 + +对于 `totp` 和 `hotp`,可以是 Base32 密钥、带 `base32:` 前缀的密钥或类型匹配的 `otpauth://` URI。 + +对于 `stoken`,这是编码后的 RSA SecurID CTF token 内容。 + +对于 `oidc`,这是 access token 值。仅在 VPN 服务器请求 HTTP Bearer 认证后发送。 + +与 `token.secret_path` 冲突。 + +### token.secret_path + +软件 token 密钥或 OIDC access token 的路径。 + +与 `token.secret` 冲突。 + +### token.pin + +`stoken` 模式的 RSA SecurID PIN。 + +### token.password + +`stoken` 模式下用于解密受密码保护的 RSA SecurID token 的密码。 + +### token.device_id + +`stoken` 模式下用于解密设备绑定 RSA SecurID token 的设备 ID。 + +### token.counter + +`hotp` 模式的初始计数器。 + +为零时,如果 `otpauth://` URI 中存在计数器,则使用该计数器;否则从零开始。 + +### reported_os + +所选 flavor 支持时向 VPN 服务器报告的操作系统标识。 + +对于 `anyconnect`、`gp` 和 `pulse`,支持的值为 `linux`、`linux-64`、`win`、`mac-intel`、`android` 和 `apple-ios`。 + +默认值根据系统平台选择:Windows 使用 `win`,macOS 使用 `mac-intel`,Android 使用 `android`,iOS 使用 `apple-ios`,其他 64 位或 32 位系统使用 `linux-64` 或 `linux`。 + +### user_agent + +所选 flavor 支持时向 VPN 服务器报告的 User-Agent。 + +默认值由 flavor 决定。AnyConnect、Network Connect、Pulse 和 F5 使用 `AnyConnect-compatible OpenConnect VPN Agent v9.21`;GlobalProtect 使用 `PAN GlobalProtect`;Fortinet 使用 `Mozilla/5.0 SV1`。 + +### version + +所选 flavor 支持时,与 `user_agent` 分开报告的客户端版本。 + +默认使用 `v9.21`。当前用于 AnyConnect XML 认证。 + +### local_hostname + +所选 flavor 支持时向 VPN 服务器报告的本地主机名。 + +默认使用系统主机名;无法获取时使用 `localhost`。 + +### mobile + +AnyConnect 移动客户端身份。配置时三个字段均为必填,并会在 XML 认证和隧道建立阶段报告。 + +### mobile.platform_version + +向 AnyConnect 服务器报告的移动操作系统版本。 + +### mobile.device_type + +向 AnyConnect 服务器报告的移动设备型号或类型。 + +### mobile.device_unique_id + +向 AnyConnect 服务器报告的移动设备标识符。 + +### csd + +AnyConnect CSD/host scan 合规性选项。 + +服务器请求 CSD 时,默认使用内置 CSD 处理。 + +### csd.wrapper_path + +外部 AnyConnect CSD wrapper 可执行文件的路径。 + +为空时使用内置 CSD 处理。 + +### hip + +GlobalProtect HIP 检查和报告选项。 + +服务器请求 HIP 时,默认使用内置 HIP 报告。 + +### hip.wrapper_path + +外部 GlobalProtect HIP report wrapper 可执行文件的路径。 + +为空时使用内置 HIP 报告。 + +### tncc + +Network Connect TNCC 合规性选项。 + +服务器请求 TNCC 时,默认使用内置 TNCC 处理。 + +### tncc.wrapper_path + +外部 Network Connect TNCC wrapper 可执行文件的路径。 + +为空时使用内置 TNCC 处理。 + +与 `tncc.device_id`、`tncc.user_agent`、`tncc.machine_identification_enabled` 和 `tncc.certificates` 冲突。 + +### tncc.device_id + +内置 TNCC 处理程序报告的设备 ID。 + +与 `tncc.wrapper_path` 冲突。 + +### tncc.user_agent + +内置 TNCC 处理程序使用的 User-Agent。 + +默认使用 `Neoteris HC Http`。 + +与 `tncc.wrapper_path` 冲突。 + +### tncc.machine_identification_enabled + +启用内置 TNCC 机器标识,包括平台、主机名和观测到的 MAC 地址。 + +与 `tncc.wrapper_path` 冲突。 + +### tncc.certificates + +内置 TNCC 处理程序用于回答证书请求的机器证书。 + +需要启用 `tncc.machine_identification_enabled`。 + +与 `tncc.wrapper_path` 冲突。 + +### tncc.certificates.certificate + +PEM 格式的 TNCC 机器证书内容。 + +与 `tncc.certificates.certificate_path` 冲突。 + +### tncc.certificates.certificate_path + +PEM 格式的 TNCC 机器证书路径。 + +与 `tncc.certificates.certificate` 冲突。 + +### fortinet_host_check + +Fortinet hostcheck 结果覆盖选项。 + +默认禁用 hostcheck。仅当 `fortinet_host_check.hostcheck` 非空时启用。不会自动收集操作系统、安全产品或网络接口信息。 + +启用后,如果成功的 Fortinet 登录响应要求 hostcheck,将在使用 VPN 会话前向服务器提交两个配置值。这些值不经修改,作为 `application/x-www-form-urlencoded` 字段发送。 + +部分 Fortinet 服务器只会要求可识别的 FortiClient User-Agent 执行 hostcheck。服务器策略有要求时请配置 `user_agent`。 + +### fortinet_host_check.hostcheck + +Fortinet hostcheck 结果字符串。 + +通常格式为 `,`,例如 `0100,10.0.19042`。`security-status` 包含四个 `0` 或 `1` 字符,依次表示第三方防火墙、第三方杀毒软件、FortiClient 防火墙和 FortiClient 杀毒软件。 + +空值会禁用 Fortinet hostcheck,即使配置了 `fortinet_host_check.check_virtual_desktop`。 + +### fortinet_host_check.check_virtual_desktop + +Fortinet virtual desktop 检查结果字符串。 + +FortiClient 通常发送以冒号分隔的 MAC 地址,多个地址使用 `|` 连接,例如 `74:78:27:4d:81:93|84:1b:77:3a:95:84`。启用 hostcheck 时,空值会作为空字段提交。 + +### no_udp + +禁用 DTLS 或 ESP 辅助数据通道,仅使用 TLS 数据通道。 + +### dtls_local_port + +直连 DTLS 或 ESP 辅助数据通道使用的本地 UDP 端口。 + +默认自动选择临时端口。 + +### compression_disabled + +禁用 AnyConnect 压缩协商。 + +默认情况下,当服务器支持时,CSTP 和 DTLS 会协商无状态 `oc-lz4` 和 `lzs` 压缩。 + +当攻击者能够影响通过 VPN 隧道发送的明文时,压缩可能削弱流量机密性。 + +与设置为 `all` 的 `compression_mode` 冲突。 + +### compression_mode + +AnyConnect 压缩模式,可选值为: + +- `stateless`:声明支持无状态 `oc-lz4` 和 `lzs` 压缩。 +- `all`:额外声明支持 CSTP 有状态 `deflate` 压缩。 + +默认使用 `stateless`。即使选择 `all`,DTLS 也始终使用无状态压缩。 + +有状态压缩存在额外的流量机密性风险,仅应在 VPN 服务器需要时启用。 + +### ipv6_disabled + +禁用请求和使用 IPv6 隧道配置。 + +### http_keepalive_disabled + +在认证和配置请求中禁用 HTTP 连接复用。 + +### xml_post_disabled + +禁用 AnyConnect XML POST 认证,并直接使用旧版 GET 流程开始认证。 + +### external_auth_disabled + +禁用 AnyConnect、GlobalProtect 和 Fortinet 的 SSO、SAML 等外部浏览器认证。 + +启用时不会为 AnyConnect 或 GlobalProtect 向服务器声明外部认证支持,并会拒绝任何意外收到的外部认证请求,包括 Fortinet SAML。 + +### password_authentication_disabled + +如果服务器返回非成功的认证表单,则中止 AnyConnect 认证,与 OpenConnect `--no-passwd` 行为一致。 + +此选项不影响其他 flavor,也不影响由 `cookie` 提供的会话。 + +### tcp_keep_alive_enabled + +为直接 VPN 服务器连接启用 TCP keep alive。 + +默认禁用以匹配 OpenConnect。设置 `tcp_keep_alive` 或 `tcp_keep_alive_interval` 也会启用,无需同时设置此字段。启用但未设置这两个时间值时,保留操作系统的 TCP keep alive 时间设置。 + +与 `disable_tcp_keep_alive` 冲突。 + +### pfs + +要求 TLS 1.2 及更早版本使用具有前向保密性的 TLS 密码套件。 + +默认禁用,以兼容需要 RSA 密钥交换的 VPN 服务器。此选项不会启用已弃用的密码套件;旧版加密支持参阅 `allow_insecure_crypto`。 + +### mtu + +首选隧道 MTU。 + +所有 flavor 协商的 MTU 都不会超过此值。对于 AnyConnect,此值还会发送给服务器。GlobalProtect、F5 和 Fortinet 会先扣除各自的协议开销,再将结果作为隧道 MTU。 + +非零值小于 `576` 时按 `576` 处理。最大值为 `65535`。 + +### base_mtu + +扣除外层 IP、传输和协议开销后,用于计算 AnyConnect、GlobalProtect、F5 和 Fortinet 隧道 MTU 的基础路径 MTU。 + +默认使用 `1406`。 + +这些 flavor 会将小于 `1280` 的值按 `1280` 处理。最大值为 `65535`。 + +### dpd_interval + +覆盖 Dead Peer Detection 间隔。 + +默认使用服务器提供或 flavor 特定的间隔。 + +大于零且小于 `2s` 的值按 `2s` 处理。值不得为负数。 + +### reconnect_timeout + +重连尝试失败后允许累计使用的最大退避时间。断线后的第一次重连会立即开始,且此超时不会取消已经进行中的尝试。 + +默认使用 `300s`。 + +值不得为负数。 + +### trojan_interval + +覆盖 GlobalProtect HIP report 或 Network Connect TNCC check 的执行间隔。 + +默认使用服务器提供的间隔。服务器未提供时,GlobalProtect 使用 `1h`。 + +值不得为负数。 + +### queue_length + +VPN transport 与隧道接口之间的入站和出站数据包队列长度。 + +默认使用 `32`。队列已满时会施加反压并等待消费者腾出空间,不会丢弃已排队的数据包。 + +### allow_insecure_crypto + +启用旧版 VPN 服务器所需的弱 TLS 和 DTLS 密码套件及 TLS 1.0 兼容性。 + +默认禁用;未启用时会拒绝低于 TLS 1.2 的版本。此选项不会禁用服务器证书验证。 + +### tls + +OpenConnect TLS 配置。 + +### tls.insecure + +禁用 VPN 服务器证书和主机名验证。 + +默认禁用。启用后,主动攻击者可以冒充 VPN 服务器。应尽可能使用 `tls.certificate_authority` 或 `tls.peer_fingerprint`。 + +### tls.server_name + +用于 TLS SNI 和证书主机名验证的服务器名称。 + +默认使用 `server` 中的主机名。 + +### tls.peer_fingerprint + +允许的服务器证书指纹。可以指定单个字符串或列表。 + +支持的格式: + +- 与 OpenConnect `--servercert` 兼容的无前缀 SHA-1 证书指纹。 +- `sha1:`:SHA-1 SPKI 指纹。 +- `sha256:`:SHA-256 SPKI 指纹。 +- `pin-sha256:`:Base64 编码的 SHA-256 SPKI pin。 + +每种格式的编码指纹均可缩写为至少四个字符的前缀。配置后,对端证书必须匹配其中一个指纹;匹配的指纹可以授权未通过其他方式信任的证书。 + +### tls.system_trust_disabled + +禁用系统 CA 证书池。 + +启用时,使用 `tls.certificate_authority` 或 `tls.peer_fingerprint` 建立信任。 + +### tls.certificate_authority + +PEM 格式的附加受信任 CA 证书内容。 + +这些证书会添加到系统证书池。 + +与 `tls.certificate_authority_path` 冲突。 + +### tls.certificate_authority_path + +PEM 格式的附加受信任 CA 证书路径。 + +这些证书会添加到系统证书池。 + +与 `tls.certificate_authority` 冲突。 + +### tls.client_certificate + +PEM 格式的客户端证书链内容。 + +与 `tls.client_certificate_path` 冲突。 + +### tls.client_certificate_path + +PEM 格式的客户端证书链路径。 + +与 `tls.client_certificate` 冲突。 + +### tls.client_key + +PEM 格式的客户端私钥内容。 + +与 `tls.client_key_path` 冲突。 + +### tls.client_key_path + +PEM 格式的客户端私钥路径。 + +与 `tls.client_key` 冲突。 + +客户端证书和私钥必须同时设置或同时为空。 + +### tls.client_key_password + +加密客户端私钥的密码。 + +### tls.mca_certificate + +PEM 格式的 AnyConnect 多证书认证(MCA)证书链内容。 + +与 `tls.mca_certificate_path` 冲突。 + +### tls.mca_certificate_path + +PEM 格式的 AnyConnect 多证书认证(MCA)证书链路径。 + +与 `tls.mca_certificate` 冲突。 + +### tls.mca_key + +PEM 格式的 AnyConnect 多证书认证(MCA)私钥内容。 + +与 `tls.mca_key_path` 冲突。 + +### tls.mca_key_path + +PEM 格式的 AnyConnect 多证书认证(MCA)私钥路径。 + +与 `tls.mca_key` 冲突。 + +MCA 证书和私钥必须同时设置或同时为空。 + +### tls.mca_key_password + +加密 MCA 私钥的密码。 + +### form_entries + +认证表单字段覆盖。 + +设置 `submission_key` 时按该字段匹配,否则按 `form_id` 和 `name` 的组合匹配。后面的匹配项优先。 + +### form_entries.form_id + +`form_entries.submission_key` 为空时,与 `form_entries.name` 一起使用的认证表单标识符。 + +### form_entries.submission_key + +认证字段提交键。 + +`form_entries.submission_key` 或 `form_entries.form_id` 与 `form_entries.name` 的组合之一必填。 + +### form_entries.name + +`form_entries.submission_key` 为空时,与 `form_entries.form_id` 一起使用的认证字段名称。 + +### form_entries.value + +自动提供给匹配认证字段的值。 + +与 `form_entries.promote` 冲突。 + +### form_entries.promote + +交互询问匹配的认证字段,而不是自动提供值。 + +与 `form_entries.value` 冲突。 + +## UDP NAT 字段 + +参阅 [UDP NAT 字段](/zh/configuration/shared/udp-nat/)。 + +## 拨号字段 + +参阅[拨号字段](/zh/configuration/shared/dial/)了解详情。 + +## 交互式认证 + +在 sing-box dashboard 或任意 sing-box 图形客户端的 `工具` > `端点` 中认证和管理 endpoint。 + +## DNS + +推送的 DNS 设置不会安装到操作系统中。配置 [OpenConnect DNS 服务器](/zh/configuration/dns/server/openconnect/) 以通过 sing-box 使用这些设置。 diff --git a/docs/configuration/endpoint/openvpn-client.md b/docs/configuration/endpoint/openvpn-client.md new file mode 100644 index 0000000000..d35a35f77e --- /dev/null +++ b/docs/configuration/endpoint/openvpn-client.md @@ -0,0 +1,737 @@ +# OpenVPN Client + +!!! question "Since sing-box 1.14.0" + +## Structure + +```json +{ + "type": "openvpn-client", + "tag": "ovpn-client", + + "mode": "tls", + "server": "127.0.0.1", + "server_port": 1194, + "servers": [ + { + "server": "127.0.0.1", + "server_port": 1194, + "network": "udp" + } + ], + "remote_random": false, + "network": "udp", + "address": [], + "peer_address": "", + "peer_address_ipv6": "", + "topology": "", + "username": "", + "password": "", + "auth_retry": "none", + "static_challenge": "", + "static_challenge_echo": false, + "static_key": [], + "static_key_path": "", + "key_direction": "", + "tls": { + "server_name": "", + "server_name_type": "name", + "certificate": [], + "certificate_path": "", + "client_certificate": [], + "client_certificate_path": "", + "client_key": [], + "client_key_path": "", + "peer_fingerprint": [], + "crl_path": "", + "remote_certificate_ku": [], + "remote_certificate_eku": "", + "remote_certificate_tls": "", + "certificate_profile": "", + "ns_certificate_type": "", + "version_min": "1.2", + "version_max": "", + "cipher": "", + "groups": "", + "control_wrap": { + "type": "", + "key": [], + "key_path": "", + "direction": "" + } + }, + "cipher": "", + "data_ciphers": [], + "data_ciphers_fallback": "", + "auth": "", + "mss_fix": 0, + "mss_fix_disabled": false, + "mss_fix_mode": "", + "fragment": 0, + "replay_window": 0, + "replay_window_time": "", + "compression": "", + "compression_lzo": "", + "allow_compression": "no", + "route_no_pull": false, + "pull_filters": [ + { + "action": "ignore", + "text": "route " + } + ], + "routes": [], + "route_gateway": "", + "route_metric": 0, + "redirect_gateway": false, + "redirect_gateway_flags": [], + "redirect_private": false, + "block_ipv6": false, + "ping_interval": "", + "ping_restart": "", + "ping_restart_disabled": false, + "renegotiate_interval": "", + "renegotiate_disabled": false, + "renegotiate_bytes": 0, + "renegotiate_packets": 0, + "tls_timeout": "", + "handshake_window": "", + "explicit_exit_notify": 0, + "system": false, + "name": "", + "mtu": 1500, + + ... // UDP NAT Fields + + ... // Dial Fields +} +``` + +!!! note "" + + You can ignore the JSON Array [] tag when the content is only one item. + +## Fields + +### mode + +OpenVPN session mode, one of `tls` or `static_key`. + +`tls` is used by default. + +`static_key` is a deprecated OpenVPN mode without a TLS control channel or +forward secrecy. It is retained as an explicit compatibility option for +immutable enterprise VPN servers. It does not use `tls`, username/password +authentication, pull options, or TLS renegotiation options. + +### server + +OpenVPN server address. + +Either `server` or `servers` is required. + +Conflict with `servers`. + +### server_port + +OpenVPN server port. + +Required when `server` is set. + +### servers + +List of OpenVPN servers. + +The client tries the servers in order and moves to the next server when a connection fails. + +Either `server` or `servers` is required. + +Conflict with `server`. + +### servers.server + +==Required== + +OpenVPN server address. + +### servers.server_port + +==Required== + +OpenVPN server port. + +### servers.network + +OpenVPN transport network for this server, one of `udp` or `tcp`. + +The top-level `network` is used by default. + +### remote_random + +Randomize the `servers` order before connecting. + +Disabled by default. + +### network + +Default OpenVPN transport network, one of `udp` or `tcp`. + +`udp` is used by default. + +This value applies to `server` and to `servers` entries without their own `network`. + +### address + +Local IPv4 and IPv6 tunnel prefixes. + +At least one address is required in `static_key` mode. In TLS mode these +addresses are optional and can be replaced by addresses pulled from the +server. + +### peer_address + +IPv4 tunnel peer address and VPN gateway. + +Required when an IPv4 `address` is configured in `static_key` mode. + +### peer_address_ipv6 + +IPv6 tunnel peer address and VPN gateway. + +Required when an IPv6 `address` is configured in `static_key` mode. + +### topology + +Tunnel topology, one of `net30`, `p2p`, or `subnet`. + +The topology pulled from the server is used when empty in TLS mode. + +### username + +Username for OpenVPN username/password authentication. + +Only available in TLS mode. + +### password + +Password for OpenVPN username/password authentication. + +### auth_retry + +Behavior after username/password authentication fails, one of `none`, `nointeract`, or `interact`. + +`none` is used by default and treats a permanent authentication failure as terminal. + +`nointeract` and `interact` allow authentication retries. + +### static_challenge + +Static challenge text shown when requesting an authentication response. + +### static_challenge_echo + +Show the static challenge response as plain text. + +### static_key + +OpenVPN static key content. + +Required in `static_key` mode. + +Conflict with `static_key_path`. + +### static_key_path + +OpenVPN static key path. + +Required in `static_key` mode when `static_key` is not set. + +Conflict with `static_key`. + +### key_direction + +Static key direction, one of `server` or `client`. + +The key is used bidirectionally if empty. Only available in `static_key` mode. + +### tls + +Required in TLS mode. + +OpenVPN control channel TLS configuration. + +### tls.server_name + +Expected server certificate name. + +Certificate name verification is disabled if empty. The certificate chain or fingerprint and server certificate usage are still verified. + +### tls.server_name_type + +Certificate field matched by `tls.server_name`, one of `subject`, `name`, or `name-prefix`. + +`name` is used by default when `tls.server_name` is set. + +`subject` matches the full certificate subject, `name` matches the common name exactly, and `name-prefix` matches a common name prefix. + +### tls.certificate + +Trusted CA certificate content. + +One of `tls.certificate`, `tls.certificate_path`, or `tls.peer_fingerprint` is required. + +Conflict with `tls.certificate_path`. + +### tls.certificate_path + +Trusted CA certificate path. + +One of `tls.certificate`, `tls.certificate_path`, or `tls.peer_fingerprint` is required. + +Conflict with `tls.certificate`. + +### tls.client_certificate + +Client certificate content. + +Conflict with `tls.client_certificate_path`. + +### tls.client_certificate_path + +Client certificate path. + +Conflict with `tls.client_certificate`. + +### tls.client_key + +Client private key content. + +Conflict with `tls.client_key_path`. + +### tls.client_key_path + +Client private key path. + +Conflict with `tls.client_key`. + +The client certificate and key must both be set or both be empty. + +### tls.peer_fingerprint + +Allowed SHA-256 fingerprints of the server leaf certificate. + +Each fingerprint must be 64 lowercase hexadecimal characters without separators. + +When a trusted CA is also configured, both the certificate chain and fingerprint are verified. Without a trusted CA, the fingerprint, certificate validity period, configured name, and certificate usage are verified, but the certificate chain is not. + +### tls.crl_path + +Path to a PEM or DER certificate revocation list used to reject revoked server certificates. + +The CRL signature and validity period are verified against the trusted certificate chain. + +Disabled by default. + +### tls.remote_certificate_ku + +Required server certificate key usage masks, written as hexadecimal values in OpenVPN `remote-cert-ku` format. + +The certificate must contain all bits from at least one configured mask. + +Disabled by default. + +### tls.remote_certificate_eku + +Required server certificate extended key usage. + +OpenSSL names, object identifiers, and the aliases `server` and `client` are accepted. + +When set, this field replaces the default `tls.remote_certificate_tls` check. + +Conflict with an explicitly configured `tls.remote_certificate_tls`. + +### tls.remote_certificate_tls + +Peer certificate purpose check, one of `server`, `client`, or `none`. + +`server` is used by default. + +`none` disables the certificate purpose check. + +Conflict with `tls.remote_certificate_eku`. + +### tls.certificate_profile + +Certificate profile, one of `insecure`, `legacy`, `preferred`, or `suiteb`. + +`legacy` is used by default. + +`insecure` accepts MD5- and SHA-1-signed certificate chains and smaller legacy +keys for compatibility with immutable peers. Use it only when the peer cannot +be upgraded. `legacy` accepts SHA-1 but rejects MD5 signatures; `preferred` +requires stronger signatures and keys. + +When `suiteb` is selected and `tls.cipher` is empty, the TLS 1.2 cipher list defaults to the Suite B ECDHE-ECDSA AES-GCM suites. Explicit `tls.cipher` and `tls.groups` values are not restricted by the profile. + +### tls.ns_certificate_type + +Deprecated Netscape certificate type check, one of `server` or `client`. + +Disabled by default. Prefer `tls.remote_certificate_tls`. + +### tls.version_min + +Minimum TLS version, one of `1.0`, `1.1`, `1.2`, or `1.3`. + +`1.2` is used by default. + +### tls.version_max + +Maximum TLS version, one of `1.0`, `1.1`, `1.2`, or `1.3`. + +The maximum supported version is used by default. + +The value cannot be lower than `tls.version_min`. + +### tls.cipher + +Colon-separated OpenSSL cipher suite names allowed for TLS 1.2 and earlier. + +The default TLS cipher suites are used when empty. TLS 1.3 cipher suites are not controlled by this field. + +### tls.groups + +Colon-separated TLS key exchange groups in preference order. + +Supported groups are `X25519`, `SECP256R1`, `SECP384R1`, and `SECP521R1`, including their common OpenSSL and NIST aliases. + +The default TLS groups are used when empty. + +### tls.control_wrap + +OpenVPN control channel wrapping. + +Equivalent to OpenVPN `tls-auth`, `tls-crypt`, and `tls-crypt-v2`. + +Disabled if empty. + +### tls.control_wrap.type + +Control channel wrapping type, one of `tls_auth`, `tls_crypt`, or `tls_crypt_v2`. + +### tls.control_wrap.key + +Control channel wrapping key content. + +Conflict with `tls.control_wrap.key_path`. + +### tls.control_wrap.key_path + +Control channel wrapping key path. + +Conflict with `tls.control_wrap.key`. + +### tls.control_wrap.direction + +`tls-auth` key direction, one of `server` or `client`. + +Only available when `tls.control_wrap.type` is `tls_auth`. The key is used bidirectionally if empty. + +### cipher + +Data-channel cipher used in `static_key` mode. + +The upstream static-key default `BF-CBC` is used when empty. `BF-CBC` is a +legacy cipher with a 64-bit block size; configure the cipher required by the +server explicitly whenever possible. Static-key ciphers include `BF-CBC`, +`CAST5-CBC`, `DES-CBC`, `DES-EDE-CBC`, `DES-EDE3-CBC`, the AES-CBC, +ARIA-CBC, and Camellia-CBC families, `SEED-CBC`, `SM4-CBC`, and `NONE`. + +Only available in `static_key` mode. `NONE` provides no confidentiality. + +### data_ciphers + +Allowed OpenVPN data channel ciphers. + +Only available in TLS mode. + +`AES-256-GCM`, `AES-128-GCM`, and `CHACHA20-POLY1305` are used by default. + +The AES-GCM family includes `AES-192-GCM`. Retained ciphers include the CBC, +CFB, and OFB forms of AES, ARIA, Camellia, DES, Blowfish, and CAST5, the CBC, +CFB, and OFB forms of SEED and SM4, and `NONE`. CFB and OFB are available only +in TLS mode. Legacy ciphers provide weaker or no confidentiality and are not +enabled by default. + +### data_ciphers_fallback + +Data channel cipher for peers that do not support cipher negotiation. + +Disabled by default. + +Only available in TLS mode. + +### auth + +OpenVPN data channel authentication digest. + +`SHA1` is used by default. It only applies to non-AEAD data ciphers and `tls_auth`. + +Legacy digests including `MD5` and `RIPEMD160` remain available when explicitly +configured for compatibility. + +### mss_fix + +Maximum OpenVPN UDP packet size used to clamp the MSS of TCP connections sent through the tunnel. + +This prevents TCP packets from exceeding the path MTU after OpenVPN encapsulation. + +When empty, the upstream OpenVPN default is used: `fragment` when configured, +otherwise `1492` for the default tunnel MTU or the configured tunnel MTU. + +### mss_fix_disabled + +Disable MSS clamping, including the default clamp. + +Conflict with `mss_fix` and `mss_fix_mode`. + +### mss_fix_mode + +OpenVPN MSS calculation mode for an explicit `mss_fix`, one of `mtu` or `fixed`. + +An empty value uses the normal OpenVPN encapsulation-aware calculation. `mtu` also accounts for the outer IP and UDP/TCP transport headers. `fixed` treats `mss_fix` as an inner IPv4 packet size. + +Requires `mss_fix`. + +### fragment + +Maximum OpenVPN UDP packet size used for OpenVPN data channel fragmentation. + +Disabled when `0`. A non-zero value must be at least `68`. + +Conflict with TCP transport. + +### replay_window + +UDP data-channel replay window size. `64` is used by default. The maximum is `65536`. + +TCP always requires strictly consecutive packet IDs. + +### replay_window_time + +UDP data-channel replay window duration. `15s` is used by default and the maximum is `10m`. + +The value must use whole seconds. + +### compression + +OpenVPN `compress` framing mode, one of `none`, `no`, `lz4`, `lz4-v2`, `stub`, `stub-v2`, `disabled`, or `off`. + +Disabled by default. + +Compression can weaken traffic confidentiality. Prefer `stub` or `stub-v2` only when framing compatibility is required. + +### compression_lzo + +OpenVPN `comp-lzo` mode, one of `none`, `no`, `yes`, `adaptive`, `asym`, `disabled`, or `off`. + +Disabled by default. + +Compression can weaken traffic confidentiality. Enable it only when required by the server. + +### allow_compression + +Policy for compression pushed by the server, one of `no`, `asym`, or `yes`. + +`no` is used by default and permits only compression stub framing. `asym` accepts compressed packets from the server but does not compress outgoing packets. For OpenVPN 2.7 compatibility, `yes` is accepted as a legacy alias for `asym`; the client never sends compressed packets. + +Conflict with non-stub compression enabled by `compression` or `compression_lzo` when set to `no`. + +### route_no_pull + +Ignore routes, DNS and DHCP settings, route metrics, `redirect-gateway`, +`redirect-private`, `block-ipv6`, and `block-outside-dns` pushed by the server. + +Interface configuration, topology, tunnel MTU, `route-gateway`, and locally configured routes are still used. + +Disabled by default. + +### pull_filters + +Ordered filters for options pushed by the server. + +The first filter whose `text` is a case-sensitive prefix of the complete pushed option is applied. Options that match no filter are accepted. + +### pull_filters.action + +==Required== + +Filter action, one of `accept`, `ignore`, or `reject`. + +`accept` applies the option, `ignore` discards it, and `reject` terminates the connection. + +### pull_filters.text + +==Required== + +Case-sensitive prefix to match against the pushed option name and value. + +For example, `route ` matches pushed IPv4 route options without matching `route-gateway`. + +### routes + +IPv4 and IPv6 prefixes preferred by sing-box routing for this OpenVPN endpoint. + +These routes are used in addition to routes accepted from the server. + +They do not install operating-system routes. Select the endpoint through +sing-box route rules or its preferred-route behavior. + +### route_gateway + +IPv4 gateway for routes through the OpenVPN endpoint. + +When empty, the VPN gateway received from the server is used. + +The value is retained for OpenVPN configuration compatibility; endpoint route +preference is prefix-based and does not install a system gateway route. + +### route_metric + +Default metric for routes through the OpenVPN endpoint. + +The platform default is used when `0`. + +The value is retained for OpenVPN configuration compatibility and does not +install a system route. + +### redirect_gateway + +Prefer the OpenVPN endpoint for all IPv4 destinations in sing-box routing. + +Disabled by default. + +This does not install an operating-system default route. + +### redirect_gateway_flags + +OpenVPN `redirect-gateway` flags. + +`!ipv4` disables IPv4 preference, `def1` represents it with two `/1` +prefixes, and `ipv6` also prefers the upstream-specific IPv6 prefixes. The +OpenVPN control connection always uses its configured outbound dialer rather +than endpoint routes, so `local` and `autolocal` require no system-route +exception. `bypass-dhcp` and `bypass-dns` are not applicable because sing-box +does not install pushed DHCP or DNS settings into the operating system. +`block-local` is unsupported because the endpoint has no cross-platform source +for the physical default gateway needed to preserve the gateway exception. + +Empty by default. + +### redirect_private + +Accept `redirect_gateway_flags` without adding a default-route preference. Routes pushed or configured separately still affect the endpoint's preferred addresses, but no operating-system routes are installed. + +Disabled by default. + +### block_ipv6 + +Reject IPv6 traffic locally instead of sending it through the VPN. + +Disabled by default. + +### ping_interval + +Interval after which the client sends a data-channel ping when no packet has been sent to the server. + +A server-pushed OpenVPN `ping` value overrides this value. + +The value must use whole seconds. + +Disabled by default. + +### ping_restart + +Time without receiving a packet after which the client reconnects to the server. + +A server-pushed OpenVPN `ping-restart` value overrides this value. + +The value must use whole seconds. + +When empty, `120s` is used for UDP connections with pull enabled until the +server pushes another value. No default receive timeout is used for TCP. + +### ping_restart_disabled + +Disable the initial `120s` UDP pull timeout and any locally configured ping restart timeout. + +Conflict with `ping_restart`. + +### renegotiate_interval + +OpenVPN TLS renegotiation interval. + +When empty, the OpenVPN default `1h` is used. + +### renegotiate_disabled + +Disable time-based TLS renegotiation, including the default interval. + +Conflict with `renegotiate_interval`. + +### renegotiate_bytes + +Renegotiate data-channel keys after this many bytes. `0` uses the cipher-dependent OpenVPN default. + +### renegotiate_packets + +Renegotiate data-channel keys after this many packets. `0` uses the cipher-dependent OpenVPN default. + +### tls_timeout + +Initial retransmission timeout for TLS control packets. The OpenVPN default `2s` is used when empty. + +### handshake_window + +Maximum time allowed for the initial TLS handshake and each renegotiation. The OpenVPN default `1m` is used when empty. + +### explicit_exit_notify + +Number of OpenVPN exit notifications sent when closing a UDP connection. + +Notifications are sent one second apart. Disabled when `0`. + +### system + +Use a system interface. + +Requires privilege and cannot conflict with existing system interfaces. + +The endpoint configures interface addresses and MTU but does not install +operating-system routes or DNS settings. + +If disabled, sing-box uses the internal network stack. + +### name + +Custom interface name for the system interface. + +An automatically generated `ovpn` interface name is used by default. + +### mtu + +OpenVPN interface MTU. + +When empty, `1500` is used until a server-pushed MTU is received. + +## UDP NAT Fields + +See [UDP NAT Fields](/configuration/shared/udp-nat/) for details. + +## Dial Fields + +See [Dial Fields](/configuration/shared/dial/) for details. + +## Interactive authentication + +Use `Tools` > `Endpoints` in the sing-box dashboard or any sing-box graphical client to authenticate and manage the endpoint. diff --git a/docs/configuration/endpoint/openvpn-client.zh.md b/docs/configuration/endpoint/openvpn-client.zh.md new file mode 100644 index 0000000000..1ee072dea2 --- /dev/null +++ b/docs/configuration/endpoint/openvpn-client.zh.md @@ -0,0 +1,712 @@ +# OpenVPN 客户端 + +!!! question "自 sing-box 1.14.0 起" + +## 结构 + +```json +{ + "type": "openvpn-client", + "tag": "ovpn-client", + + "mode": "tls", + "server": "127.0.0.1", + "server_port": 1194, + "servers": [ + { + "server": "127.0.0.1", + "server_port": 1194, + "network": "udp" + } + ], + "remote_random": false, + "network": "udp", + "address": [], + "peer_address": "", + "peer_address_ipv6": "", + "topology": "", + "username": "", + "password": "", + "auth_retry": "none", + "static_challenge": "", + "static_challenge_echo": false, + "static_key": [], + "static_key_path": "", + "key_direction": "", + "tls": { + "server_name": "", + "server_name_type": "name", + "certificate": [], + "certificate_path": "", + "client_certificate": [], + "client_certificate_path": "", + "client_key": [], + "client_key_path": "", + "peer_fingerprint": [], + "crl_path": "", + "remote_certificate_ku": [], + "remote_certificate_eku": "", + "remote_certificate_tls": "", + "certificate_profile": "", + "ns_certificate_type": "", + "version_min": "1.2", + "version_max": "", + "cipher": "", + "groups": "", + "control_wrap": { + "type": "", + "key": [], + "key_path": "", + "direction": "" + } + }, + "cipher": "", + "data_ciphers": [], + "data_ciphers_fallback": "", + "auth": "", + "mss_fix": 0, + "mss_fix_disabled": false, + "mss_fix_mode": "", + "fragment": 0, + "replay_window": 0, + "replay_window_time": "", + "compression": "", + "compression_lzo": "", + "allow_compression": "no", + "route_no_pull": false, + "pull_filters": [ + { + "action": "ignore", + "text": "route " + } + ], + "routes": [], + "route_gateway": "", + "route_metric": 0, + "redirect_gateway": false, + "redirect_gateway_flags": [], + "redirect_private": false, + "block_ipv6": false, + "ping_interval": "", + "ping_restart": "", + "ping_restart_disabled": false, + "renegotiate_interval": "", + "renegotiate_disabled": false, + "renegotiate_bytes": 0, + "renegotiate_packets": 0, + "tls_timeout": "", + "handshake_window": "", + "explicit_exit_notify": 0, + "system": false, + "name": "", + "mtu": 1500, + + ... // UDP NAT 字段 + + ... // 拨号字段 +} +``` + +!!! note "" + + 当内容只有一项时,可以忽略 JSON 数组 [] 标签。 + +## 字段 + +### mode + +OpenVPN 会话模式,可选值为 `tls` 或 `static_key`。 + +默认使用 `tls`。 + +`static_key` 是已弃用的 OpenVPN 模式,不使用 TLS 控制通道且不提供前向保密。 +为兼容无法修改的企业 VPN 服务器,此模式仍作为显式兼容选项保留。该模式不使用 +`tls`、用户名/密码认证、拉取选项或 TLS 重协商选项。 + +### server + +OpenVPN 服务器地址。 + +`server` 和 `servers` 之一必填。 + +与 `servers` 冲突。 + +### server_port + +OpenVPN 服务器端口。 + +设置 `server` 时必填。 + +### servers + +OpenVPN 服务器列表。 + +客户端按顺序尝试服务器,并在连接失败时尝试下一台服务器。 + +`server` 和 `servers` 之一必填。 + +与 `server` 冲突。 + +### servers.server + +==必填== + +OpenVPN 服务器地址。 + +### servers.server_port + +==必填== + +OpenVPN 服务器端口。 + +### servers.network + +该服务器的 OpenVPN 传输网络,可选值为 `udp` 或 `tcp`。 + +默认使用顶层 `network`。 + +### remote_random + +连接前随机排列 `servers` 顺序。 + +默认禁用。 + +### network + +默认 OpenVPN 传输网络,可选值为 `udp` 或 `tcp`。 + +默认使用 `udp`。 + +该值应用于 `server` 和未单独设置 `network` 的 `servers` 条目。 + +### address + +本地 IPv4 和 IPv6 隧道前缀。 + +`static_key` 模式至少需要一个地址。在 TLS 模式下该字段可选,并可被服务器推送的地址替换。 + +### peer_address + +IPv4 隧道对端地址及 VPN 网关。 + +在 `static_key` 模式下配置 IPv4 `address` 时必填。 + +### peer_address_ipv6 + +IPv6 隧道对端地址及 VPN 网关。 + +在 `static_key` 模式下配置 IPv6 `address` 时必填。 + +### topology + +隧道拓扑,可选值为 `net30`、`p2p` 或 `subnet`。 + +TLS 模式下为空时使用服务器推送的拓扑。 + +### username + +OpenVPN 用户名/密码认证的用户名。 + +仅在 TLS 模式下可用。 + +### password + +OpenVPN 用户名/密码认证的密码。 + +### auth_retry + +用户名/密码认证失败后的行为,可选值为 `none`、`nointeract` 或 `interact`。 + +默认使用 `none`,并将永久认证失败视为终止错误。 + +`nointeract` 和 `interact` 允许重试认证。 + +### static_challenge + +请求认证响应时显示的静态质询文本。 + +### static_challenge_echo + +以明文显示静态质询响应。 + +### static_key + +OpenVPN 静态密钥内容。 + +在 `static_key` 模式下必填。 + +与 `static_key_path` 冲突。 + +### static_key_path + +OpenVPN 静态密钥路径。 + +在 `static_key` 模式下未设置 `static_key` 时必填。 + +与 `static_key` 冲突。 + +### key_direction + +静态密钥方向,可选值为 `server` 或 `client`。 + +为空时双向使用密钥。仅在 `static_key` 模式下可用。 + +### tls + +在 TLS 模式下必填。 + +OpenVPN 控制通道 TLS 配置。 + +### tls.server_name + +预期的服务器证书名称。 + +为空时禁用证书名称验证,但仍会验证证书链或 fingerprint 与服务器证书用途。 + +### tls.server_name_type + +与 `tls.server_name` 匹配的证书字段,可选值为 `subject`、`name` 或 `name-prefix`。 + +设置 `tls.server_name` 时默认使用 `name`。 + +`subject` 匹配完整证书 subject,`name` 精确匹配 common name,`name-prefix` 匹配 common name 前缀。 + +### tls.certificate + +受信任 CA 证书内容。 + +`tls.certificate`、`tls.certificate_path` 和 `tls.peer_fingerprint` 之一必填。 + +与 `tls.certificate_path` 冲突。 + +### tls.certificate_path + +受信任 CA 证书路径。 + +`tls.certificate`、`tls.certificate_path` 和 `tls.peer_fingerprint` 之一必填。 + +与 `tls.certificate` 冲突。 + +### tls.client_certificate + +客户端证书内容。 + +与 `tls.client_certificate_path` 冲突。 + +### tls.client_certificate_path + +客户端证书路径。 + +与 `tls.client_certificate` 冲突。 + +### tls.client_key + +客户端私钥内容。 + +与 `tls.client_key_path` 冲突。 + +### tls.client_key_path + +客户端私钥路径。 + +与 `tls.client_key` 冲突。 + +客户端证书和私钥必须同时设置或同时为空。 + +### tls.peer_fingerprint + +允许的服务器 leaf certificate 的 SHA-256 fingerprint。 + +每个 fingerprint 必须是不带分隔符的 64 字符小写十六进制字符串。 + +同时配置受信任 CA 时,会同时验证证书链和 fingerprint。未配置受信任 CA 时,会验证 fingerprint、证书有效期、配置的名称和证书用途,但不验证证书链。 + +### tls.crl_path + +用于拒绝已吊销服务器证书的 PEM 或 DER CRL 文件路径。 + +根据受信任证书链验证 CRL 签名和有效期。 + +默认禁用。 + +### tls.remote_certificate_ku + +服务器证书所需的 Key Usage mask,使用 OpenVPN `remote-cert-ku` 格式的十六进制值。 + +证书必须包含至少一个已配置 mask 中的所有 bit。 + +默认禁用。 + +### tls.remote_certificate_eku + +服务器证书所需的 Extended Key Usage。 + +接受 OpenSSL 名称、Object Identifier 以及 `server` 和 `client` 别名。 + +设置后,该字段会替代默认的 `tls.remote_certificate_tls` 检查。 + +与显式配置的 `tls.remote_certificate_tls` 冲突。 + +### tls.remote_certificate_tls + +对端证书用途检查,可选值为 `server`、`client` 或 `none`。 + +默认使用 `server`。 + +`none` 禁用证书用途检查。 + +与 `tls.remote_certificate_eku` 冲突。 + +### tls.certificate_profile + +证书 profile,可选值为 `insecure`、`legacy`、`preferred` 或 `suiteb`。 + +默认使用 `legacy`。 + +`insecure` 为兼容不可变对端而接受使用 MD5 或 SHA-1 签名的证书链和较小的旧密钥,仅应在对端无法升级时使用。`legacy` 接受 SHA-1 但拒绝 MD5 签名;`preferred` 要求更强的签名和密钥。 + +选择 `suiteb` 且 `tls.cipher` 为空时,TLS 1.2 cipher 列表默认使用 Suite B ECDHE-ECDSA AES-GCM 套件。该 profile 不限制显式配置的 `tls.cipher` 和 `tls.groups`。 + +### tls.ns_certificate_type + +已弃用的 Netscape 证书类型检查,`server` 或 `client` 之一。 + +默认禁用。请优先使用 `tls.remote_certificate_tls`。 + +### tls.version_min + +最低 TLS 版本,可选值为 `1.0`、`1.1`、`1.2` 或 `1.3`。 + +默认使用 `1.2`。 + +### tls.version_max + +最高 TLS 版本,可选值为 `1.0`、`1.1`、`1.2` 或 `1.3`。 + +默认使用支持的最高版本。 + +该值不能低于 `tls.version_min`。 + +### tls.cipher + +TLS 1.2 及更低版本允许的 OpenSSL cipher suite 名称,以冒号分隔。 + +为空时使用默认 TLS cipher suite。该字段不控制 TLS 1.3 cipher suite。 + +### tls.groups + +按偏好顺序排列的 TLS key exchange group,以冒号分隔。 + +支持 `X25519`、`SECP256R1`、`SECP384R1` 和 `SECP521R1`,包括其常用 OpenSSL 和 NIST 别名。 + +为空时使用默认 TLS group。 + +### tls.control_wrap + +OpenVPN 控制通道封装。 + +等价于 OpenVPN `tls-auth`、`tls-crypt` 和 `tls-crypt-v2`。 + +为空时禁用。 + +### tls.control_wrap.type + +控制通道封装类型,可选值为 `tls_auth`、`tls_crypt` 或 `tls_crypt_v2`。 + +### tls.control_wrap.key + +控制通道封装密钥内容。 + +与 `tls.control_wrap.key_path` 冲突。 + +### tls.control_wrap.key_path + +控制通道封装密钥路径。 + +与 `tls.control_wrap.key` 冲突。 + +### tls.control_wrap.direction + +`tls-auth` 密钥方向,可选值为 `server` 或 `client`。 + +仅当 `tls.control_wrap.type` 为 `tls_auth` 时可用。为空时双向使用密钥。 + +### cipher + +`static_key` 模式使用的数据通道 cipher。 + +为空时使用上游静态密钥模式的默认值 `BF-CBC`。`BF-CBC` 是采用 64 位 block size +的旧 cipher;应尽可能显式配置服务器要求的 cipher。静态密钥 cipher 包括 +`BF-CBC`、`CAST5-CBC`、`DES-CBC`、`DES-EDE-CBC`、`DES-EDE3-CBC`、 +AES-CBC、ARIA-CBC、Camellia-CBC 系列,以及 `SEED-CBC`、`SM4-CBC` 和 `NONE`。 + +仅在 `static_key` 模式下可用。`NONE` 不提供机密性。 + +### data_ciphers + +允许的 OpenVPN 数据通道 cipher。 + +仅在 TLS 模式下可用。 + +默认使用 `AES-256-GCM`、`AES-128-GCM` 和 `CHACHA20-POLY1305`。 + +AES-GCM 系列还包括 `AES-192-GCM`。保留的 cipher 包括 AES、ARIA、Camellia、DES、Blowfish、CAST5、SEED 和 SM4 的 CBC、CFB、OFB 形式,以及 `NONE`。CFB 和 OFB 仅可用于 TLS 模式。旧 cipher 只能提供较弱的机密性或完全不加密,因此默认不启用。 + +### data_ciphers_fallback + +用于不支持 cipher 协商的对端的数据通道 cipher。 + +默认禁用。 + +仅在 TLS 模式下可用。 + +### auth + +OpenVPN 数据通道认证摘要。 + +默认使用 `SHA1`,仅应用于非 AEAD 数据 cipher 和 `tls_auth`。 + +为兼容既有服务器,显式配置时仍可使用 `MD5` 和 `RIPEMD160` 等旧摘要。 + +### mss_fix + +OpenVPN UDP packet 的最大大小,用于限制通过隧道发送的 TCP 连接 MSS。 + +这可以避免 TCP packet 在 OpenVPN 封装后超过 path MTU。 + +为空时使用上游 OpenVPN 默认值:配置了 `fragment` 时使用其值;否则默认 tunnel MTU 使用 `1492`,自定义 tunnel MTU 使用该 MTU。 + +### mss_fix_disabled + +禁用 MSS 限制,包括默认限制。 + +与 `mss_fix` 和 `mss_fix_mode` 冲突。 + +### mss_fix_mode + +显式 `mss_fix` 的 OpenVPN MSS 计算模式,`mtu` 或 `fixed` 之一。 + +空值使用普通的 OpenVPN 封装开销计算。`mtu` 还会计算外层 IP 和 UDP/TCP 传输头;`fixed` 将 `mss_fix` 视为内层 IPv4 数据包大小。 + +需要 `mss_fix`。 + +### fragment + +用于 OpenVPN 数据通道 fragmentation 的最大 OpenVPN UDP packet 大小。 + +设为 `0` 时禁用。非零值必须至少为 `68`。 + +与 TCP 传输冲突。 + +### replay_window + +UDP 数据通道重放窗口大小。默认使用 `64`,最大值为 `65536`。 + +TCP 始终要求数据包 ID 严格连续。 + +### replay_window_time + +UDP 数据通道重放窗口时长。默认使用 `15s`,最大值为 `10m`。 + +该值必须使用整秒。 + +### compression + +OpenVPN `compress` framing 模式,可选值为 `none`、`no`、`lz4`、`lz4-v2`、`stub`、`stub-v2`、`disabled` 或 `off`。 + +默认禁用。 + +Compression 可能削弱流量机密性。仅在需要 framing 兼容性时使用 `stub` 或 `stub-v2`。 + +### compression_lzo + +OpenVPN `comp-lzo` 模式,可选值为 `none`、`no`、`yes`、`adaptive`、`asym`、`disabled` 或 `off`。 + +默认禁用。 + +Compression 可能削弱流量机密性。仅在服务器要求时启用。 + +### allow_compression + +服务器推送的 compression 策略,可选值为 `no`、`asym` 或 `yes`。 + +默认使用 `no`,仅允许 compression stub framing。`asym` 接受来自服务器的 compressed packet,但不压缩出站 packet。为兼容 OpenVPN 2.7,`yes` 作为 `asym` 的旧别名接受;客户端绝不会发送 compressed packet。 + +当设为 `no` 时,与通过 `compression` 或 `compression_lzo` 启用的非 stub compression 冲突。 + +### route_no_pull + +忽略服务器推送的 route、DNS 和 DHCP 设置、route metric、`redirect-gateway`、 +`redirect-private`、`block-ipv6` 和 `block-outside-dns`。 + +仍会使用接口配置、topology、tunnel MTU、`route-gateway` 和本地配置的 route。 + +默认禁用。 + +### pull_filters + +服务器推送选项的有序 pull filter 列表。 + +应用第一个 `text` 为完整推送选项大小写敏感前缀的 filter。未匹配任何 filter 的选项会被接受。 + +### pull_filters.action + +==必填== + +Filter action,可选值为 `accept`、`ignore` 或 `reject`。 + +`accept` 应用选项,`ignore` 丢弃选项,`reject` 终止连接。 + +### pull_filters.text + +==必填== + +用于匹配推送选项名称和值的大小写敏感前缀。 + +例如,`route ` 会匹配推送的 IPv4 route 选项,但不会匹配 `route-gateway`。 + +### routes + +sing-box 路由优先选择此 OpenVPN endpoint 的 IPv4 和 IPv6 前缀。 + +这些 route 会与从服务器接受的 route 一起使用。 + +它们不会安装操作系统路由。请通过 sing-box 路由规则或 endpoint 的首选路由行为选择此 endpoint。 + +### route_gateway + +通过 OpenVPN endpoint 路由的 IPv4 gateway。 + +为空时使用从服务器接收的 VPN gateway。 + +该值仅为兼容 OpenVPN 配置而保留;endpoint 的路由偏好只按前缀判断,不会安装系统 gateway 路由。 + +### route_metric + +通过 OpenVPN endpoint 路由的默认 metric。 + +设为 `0` 时使用平台默认值。 + +该值仅为兼容 OpenVPN 配置而保留,不会安装系统路由。 + +### redirect_gateway + +在 sing-box 路由中对所有 IPv4 目的地优先选择 OpenVPN endpoint。 + +默认禁用。 + +这不会安装操作系统默认路由。 + +### redirect_gateway_flags + +OpenVPN `redirect-gateway` flag。 + +`!ipv4` 禁用 IPv4 偏好,`def1` 使用两个 `/1` 前缀表示,`ipv6` 还会优先选择上游特定的 IPv6 前缀。OpenVPN 控制连接始终使用其配置的出站拨号器,不经过 endpoint 路由,因此 `local` 和 `autolocal` 不需要系统路由例外。由于 sing-box 不会把推送的 DHCP 或 DNS 设置安装到操作系统,`bypass-dhcp` 和 `bypass-dns` 不适用。`block-local` 不受支持,因为 endpoint 没有可跨平台获取物理默认网关的来源,无法保留网关例外。 + +默认为空。 + +### redirect_private + +接受 `redirect_gateway_flags`,但不添加默认路由偏好。单独推送或配置的路由仍会影响 endpoint 的首选地址,但不会安装操作系统路由。 + +默认禁用。 + +### block_ipv6 + +在本地拒绝 IPv6 流量,而不是通过 VPN 发送。 + +默认禁用。 + +### ping_interval + +客户端未向服务器发送任何 packet 时,发送 data channel ping 的间隔。 + +服务器推送的 OpenVPN `ping` 值优先于该值。 + +该值必须使用整秒。 + +默认禁用。 + +### ping_restart + +客户端未收到任何 packet 后重新连接服务器的时间。 + +服务器推送的 OpenVPN `ping-restart` 值优先于该值。 + +该值必须使用整秒。 + +为空时,启用了 pull 的 UDP 连接会使用 `120s`,直到服务器推送其他值。TCP 不使用默认接收超时。 + +### ping_restart_disabled + +禁用初始 `120s` UDP 拉取超时和本地配置的 ping 重启超时。 + +与 `ping_restart` 冲突。 + +### renegotiate_interval + +OpenVPN TLS 重新协商间隔。 + +为空时使用 OpenVPN 默认值 `1h`。 + +### renegotiate_disabled + +禁用基于时间的 TLS 重新协商,包括默认间隔。 + +与 `renegotiate_interval` 冲突。 + +### renegotiate_bytes + +传输指定字节数后重新协商数据通道密钥。`0` 使用与密码算法相关的 OpenVPN 默认值。 + +### renegotiate_packets + +传输指定数据包数后重新协商数据通道密钥。`0` 使用与密码算法相关的 OpenVPN 默认值。 + +### tls_timeout + +TLS 控制数据包的初始重传超时。为空时使用 OpenVPN 默认值 `2s`。 + +### handshake_window + +初始 TLS 握手及每次重新协商的最长允许时间。为空时使用 OpenVPN 默认值 `1m`。 + +### explicit_exit_notify + +关闭 UDP 连接时发送的 OpenVPN exit notification 数量。 + +Notification 之间间隔一秒。设为 `0` 时禁用。 + +### system + +使用系统接口。 + +需要权限,且不能与现有系统接口冲突。 + +endpoint 会配置接口地址和 MTU,但不会安装操作系统路由或 DNS 设置。 + +禁用时,sing-box 使用内部网络栈。 + +### name + +系统接口的自定义接口名称。 + +默认使用自动生成的 `ovpn` 接口名称。 + +### mtu + +OpenVPN 接口 MTU。 + +为空时使用服务器推送的 MTU;收到服务器配置前使用 `1500`。 + +## UDP NAT 字段 + +参阅 [UDP NAT 字段](/zh/configuration/shared/udp-nat/)。 + +## 拨号字段 + +参阅[拨号字段](/zh/configuration/shared/dial/)。 + +## 交互式认证 + +在 sing-box dashboard 或任意 sing-box 图形客户端的 `工具` > `端点` 中认证和管理 endpoint。 diff --git a/docs/configuration/endpoint/openvpn-server.md b/docs/configuration/endpoint/openvpn-server.md new file mode 100644 index 0000000000..37802ba814 --- /dev/null +++ b/docs/configuration/endpoint/openvpn-server.md @@ -0,0 +1,629 @@ +# OpenVPN Server + +!!! question "Since sing-box 1.14.0" + +## Structure + +```json +{ + "type": "openvpn-server", + "tag": "ovpn-server", + + ... // Listen Fields + + "system": false, + "name": "", + "mtu": 1500, + "mode": "tls", + "network": "udp", + "remote": "", + "remote_port": 0, + "max_clients": 1024, + "address": [], + "peer_address": "", + "peer_address_ipv6": "", + "topology": "subnet", + "duplicate_cn": false, + "users": [ + { + "username": "", + "password": "" + } + ], + "static_key": [], + "static_key_path": "", + "key_direction": "", + "tls": { + "certificate": [], + "certificate_path": "", + "key": [], + "key_path": "", + "client_certificate": [], + "client_certificate_path": "", + "verify_client_certificate": "require", + "client_name": "", + "client_name_type": "name", + "peer_fingerprint": [], + "crl_path": "", + "remote_certificate_ku": [], + "remote_certificate_eku": "", + "remote_certificate_tls": "", + "certificate_profile": "", + "ns_certificate_type": "", + "version_min": "1.2", + "version_max": "", + "cipher": "", + "groups": "", + "control_wrap": { + "type": "tls_crypt", + "key": [], + "key_path": "", + "direction": "", + "force_cookie": false + } + }, + "cipher": "", + "data_ciphers": [], + "data_ciphers_fallback": "", + "auth": "", + "mss_fix": 0, + "mss_fix_disabled": false, + "mss_fix_mode": "", + "replay_window": 0, + "replay_window_time": "", + "push": { + "routes": [], + "dns": [], + "dns_servers": [], + "search_domains": [], + "dhcp_options": [], + "redirect_gateway": false, + "redirect_gateway_flags": [], + "block_outside_dns": false, + "ping_interval": "", + "ping_restart": "" + }, + "ping_interval": "", + "ping_restart": "", + "renegotiate_interval": "", + "renegotiate_disabled": false, + "renegotiate_bytes": 0, + "renegotiate_packets": 0, + "handshake_window": "1m", + + ... // UDP NAT Fields +} +``` + +!!! note "" + + You can ignore the JSON Array [] tag when the content is only one item + +## Listen Fields + +See [Listen Fields](/configuration/shared/listen/) for details. `udp_timeout` is part of the [UDP NAT Fields](#udp-nat-fields) below. + +## Fields + +### system + +Use system interface. + +Requires privilege and cannot conflict with existing system interfaces. + +The endpoint configures interface addresses and MTU but does not install +operating-system routes or DNS settings. + +If disabled, sing-box uses the internal network stack. + +### name + +Custom interface name for system interface. + +An automatically generated `ovpn` interface name is used by default. + +### mtu + +OpenVPN interface MTU. + +`1500` will be used by default. + +### mode + +OpenVPN session mode, one of `tls` or `static_key`. + +`tls` is used by default. + +`static_key` serves one peer without a TLS control channel or forward secrecy. +It is retained as an explicit compatibility option for immutable deployments. +It does not use `tls`, `users`, push options, or TLS renegotiation options. + +### network + +OpenVPN transport network, one of `udp` or `tcp`. + +`udp` will be used by default. + +Only one transport network is served per endpoint; to serve both TCP and UDP, +configure two endpoints with separate `address` subnets, +matching upstream OpenVPN which requires two server processes. + +### remote + +Fixed remote peer address for a UDP `static_key` server. + +Required with `remote_port` in UDP `static_key` mode. TCP servers accept the +single peer from the listening socket and do not use this field. + +### remote_port + +Fixed remote peer port for a UDP `static_key` server. + +Required with `remote` in UDP `static_key` mode. + +### max_clients + +Maximum number of established and pending TLS client sessions. + +`1024` is used by default. The value must be smaller than `16777216`, the size of the OpenVPN peer-id space. + +`static_key` mode supports one peer, so this value must be `0` or `1`. + +### address + +==Required== + +List of OpenVPN server address prefixes. + +At most one IPv4 prefix and one IPv6 prefix are supported. + +The prefix address is assigned to the server interface. The masked prefix is used as the client address pool and route. + +The first IPv4 and IPv6 prefix addresses are used as the endpoint's local addresses. + +In `static_key` mode these are the local tunnel prefixes rather than address pools. + +### peer_address + +IPv4 tunnel peer address. + +Required when an IPv4 `address` is configured in `static_key` mode. + +### peer_address_ipv6 + +IPv6 tunnel peer address. + +Required when an IPv6 `address` is configured in `static_key` mode. + +### topology + +OpenVPN topology pushed to clients, one of `subnet`, `p2p` or `net30`. + +`subnet` is used by default in TLS mode. `p2p` is used by default in +`static_key` mode. + +### duplicate_cn + +Allow multiple active clients with the same authenticated certificate common name or username. + +When disabled, a newly authenticated session replaces the existing session with the same identity and reuses its tunnel address when available. + +Disabled by default. + +Only available in TLS mode. + +### users + +List of OpenVPN username/password users. + +If set, clients must pass username/password authentication in addition to any certificate policy configured by `tls.verify_client_certificate`. + +Only available in TLS mode. + +### users.username + +Username. + +### users.password + +Password. + +### static_key + +OpenVPN static key content. + +Required in `static_key` mode. + +Conflict with `static_key_path`. + +### static_key_path + +OpenVPN static key path. + +Required in `static_key` mode when `static_key` is not set. + +Conflict with `static_key`. + +### key_direction + +Static key direction, one of `server` or `client`. + +The key is used bidirectionally if empty. Conventionally the server uses +`server` and the peer uses `client`. + +Only available in `static_key` mode. + +### tls + +Required in TLS mode. + +OpenVPN control channel TLS configuration. + +### tls.certificate + +TLS server certificate content. + +Either `tls.certificate` or `tls.certificate_path` is required. + +Conflict with `tls.certificate_path`. + +### tls.certificate_path + +TLS server certificate path. + +Either `tls.certificate` or `tls.certificate_path` is required. + +Conflict with `tls.certificate`. + +### tls.key + +TLS server private key content. + +Either `tls.key` or `tls.key_path` is required. + +Conflict with `tls.key_path`. + +### tls.key_path + +TLS server private key path. + +Either `tls.key` or `tls.key_path` is required. + +Conflict with `tls.key`. + +### tls.client_certificate + +TLS CA certificate content, used to verify client certificates. + +One of `tls.client_certificate`, `tls.client_certificate_path`, or `tls.peer_fingerprint` is required when `tls.verify_client_certificate` is `require` or `optional`. + +Conflict with `tls.client_certificate_path`. + +### tls.client_certificate_path + +TLS CA certificate path, used to verify client certificates. + +One of `tls.client_certificate`, `tls.client_certificate_path`, or `tls.peer_fingerprint` is required when `tls.verify_client_certificate` is `require` or `optional`. + +Conflict with `tls.client_certificate`. + +### tls.verify_client_certificate + +OpenVPN client certificate policy, one of `require`, `optional` or `none`. + +`require` will be used by default. + +If set to `optional`, a client certificate is verified when provided, but clients without a certificate are allowed. + +If set to `none`, client certificates are not requested. + +This field does not replace `users`; when `users` is set, username/password authentication is still required. + +### tls.client_name + +Expected client certificate name. Disabled when empty. + +### tls.client_name_type + +Certificate field matched by `tls.client_name`, one of `subject`, `name`, or `name-prefix`. + +`name` is used by default when `tls.client_name` is configured. + +### tls.peer_fingerprint + +Allowed SHA-256 fingerprints of client leaf certificates. Fingerprint-only verification can be used without a client CA. + +### tls.crl_path + +Path to a certificate revocation list used to reject revoked client certificates. + +### tls.remote_certificate_ku + +Required client certificate key usage masks in OpenVPN `remote-cert-ku` format. + +### tls.remote_certificate_eku + +Required client certificate extended key usage. Conflict with an explicitly configured `tls.remote_certificate_tls`. + +### tls.remote_certificate_tls + +Client certificate purpose check, one of `server`, `client`, or `none`. `client` is used by default. + +### tls.certificate_profile + +Certificate profile, one of `insecure`, `legacy`, `preferred`, or `suiteb`. + +`legacy` is used by default. + +`insecure` accepts MD5- and SHA-1-signed certificate chains and smaller legacy +keys for compatibility with immutable peers. Use it only when the peer cannot +be upgraded. `legacy` accepts SHA-1 but rejects MD5 signatures; `preferred` +requires stronger signatures and keys. + +When `suiteb` is selected and `tls.cipher` is empty, the TLS 1.2 cipher list defaults to the Suite B ECDHE-ECDSA AES-GCM suites. Explicit `tls.cipher` and `tls.groups` values are not restricted by the profile. + +### tls.ns_certificate_type + +Deprecated Netscape certificate type check, one of `server` or `client`. + +### tls.version_min + +Minimum TLS version. `1.2` is used by default. + +### tls.version_max + +Maximum TLS version. The maximum supported version is used by default. + +### tls.cipher + +Colon-separated OpenSSL cipher suite names allowed for TLS 1.2 and earlier. + +The default TLS cipher suites are used when empty. TLS 1.3 cipher suites are not controlled by this field. + +### tls.groups + +Colon-separated TLS key exchange groups in preference order. + +### tls.control_wrap + +OpenVPN control channel wrapping. + +Equivalent to OpenVPN `tls-auth`, `tls-crypt` and `tls-crypt-v2`. + +Disabled by default. + +### tls.control_wrap.type + +==Required== + +Control channel wrapping type, one of `tls_auth`, `tls_crypt` or `tls_crypt_v2`. + +For `tls_crypt_v2`, the key is the server key. + +### tls.control_wrap.key + +Control channel wrapping key content. + +Either `tls.control_wrap.key` or `tls.control_wrap.key_path` is required. + +Conflict with `tls.control_wrap.key_path`. + +### tls.control_wrap.key_path + +Control channel wrapping key path. + +Either `tls.control_wrap.key` or `tls.control_wrap.key_path` is required. + +Conflict with `tls.control_wrap.key`. + +### tls.control_wrap.direction + +OpenVPN `tls-auth` key direction, one of `server` or `client`. + +Only available when `tls.control_wrap.type` is `tls_auth`. + +`server` maps to OpenVPN key direction `0`, and `client` maps to `1`; by convention servers use `0` and clients use `1`. + +If empty, the key is used bidirectionally, matching an omitted `key-direction` on both peers. + +### tls.control_wrap.force_cookie + +Require `tls-crypt-v2` clients over UDP to support stateless session cookies. + +Only available when `tls.control_wrap.type` is `tls_crypt_v2`. When disabled, +clients without cookie support are accepted using the upstream `allow-noncookie` behavior. + +Disabled by default. + +### cipher + +Data-channel cipher used in `static_key` mode. + +The upstream static-key default `BF-CBC` is used when empty. Supported +static-key ciphers are the AES-CBC, ARIA-CBC, Camellia-CBC, DES-CBC, +Blowfish-CBC, CAST5-CBC families, `SEED-CBC`, `SM4-CBC`, and `NONE`. + +Only available in `static_key` mode. `NONE` provides no confidentiality. + +### data_ciphers + +Allowed OpenVPN data channel ciphers. + +`AES-256-GCM`, `AES-128-GCM` and `CHACHA20-POLY1305` are used by default. + +The AES-GCM family includes `AES-192-GCM`. Retained ciphers include the CBC, +CFB, and OFB forms of AES, ARIA, Camellia, DES, Blowfish, and CAST5, the CBC, +CFB, and OFB forms of SEED and SM4, and `NONE`. CFB and OFB are available only +in TLS mode. Legacy ciphers provide weaker or no confidentiality and are not +enabled by default. + +Only available in TLS mode. + +### data_ciphers_fallback + +OpenVPN data channel cipher for legacy clients that do not support cipher negotiation. + +Equivalent to OpenVPN `data-ciphers-fallback`. + +Disabled by default. + +Only available in TLS mode. + +### auth + +OpenVPN data channel authentication digest. + +`SHA1` will be used by default, matching the upstream default; it only applies to non-AEAD data ciphers and `tls_auth`. + +Legacy digests including `MD5` and `RIPEMD160` remain available when explicitly +configured for compatibility. + +### mss_fix + +Maximum encapsulated packet size used to clamp TCP MSS. The upstream default calculation uses `1492` with the default MTU. + +### mss_fix_disabled + +Disable MSS clamping, including the default clamp. + +### mss_fix_mode + +Calculation mode for an explicit `mss_fix`, one of `mtu` or `fixed`. Requires `mss_fix`. + +### replay_window + +UDP data-channel replay window size. `64` is used by default; TCP packet IDs remain strictly consecutive. + +### replay_window_time + +UDP replay window duration. `15s` is used by default. The value must use whole seconds. + +### push + +Options pushed to clients. + +### push.routes + +Routes to push to clients. + +IPv4 and IPv6 prefixes can be mixed. + +### push.dns + +DNS server addresses to push to clients. + +Uses legacy `dhcp-option DNS`/`DNS6`. A pushed modern DNS server group overrides these addresses on compatible clients. + +### push.dns_servers + +Modern OpenVPN DNS server groups to push. Each entry contains `priority`, `addresses`, optional `resolve_domains`, `dnssec`, `transport`, and `sni`. + +Addresses accept an IP address or `IP:port` (IPv6 ports use `[IPv6]:port`). `transport` is one of `plain`, `dot`, or `doh`; `dnssec` is one of `yes`, `optional`, or `no`. OpenVPN clients apply only the group with the lowest priority number. + +### push.search_domains + +Modern OpenVPN search domains to push. + +### push.dhcp_options + +Additional legacy `dhcp-option` values to push, without the `dhcp-option` prefix. + +### push.redirect_gateway + +Push `redirect-gateway` to clients, which routes client traffic through the VPN according to `push.redirect_gateway_flags`. + +When `push.redirect_gateway_flags` is empty, `def1` is used by default. + +### push.redirect_gateway_flags + +OpenVPN `redirect-gateway` flags to push to clients. + +Only available when `push.redirect_gateway` is enabled. + +`def1` is used by default. + +### push.block_outside_dns + +Push `block-outside-dns` to clients, which blocks DNS queries outside the VPN on Windows clients. + +### push.ping_interval + +OpenVPN `ping` interval pushed to clients. + +After the interval passes without sending a packet, the client sends a data-channel ping to the server. + +The value must use whole seconds. + +Disabled by default. + +### push.ping_restart + +OpenVPN `ping-restart` timeout pushed to clients. + +After the timeout passes without receiving a packet, the client reconnects to the server. + +The value must use whole seconds. + +Disabled by default. + +### ping_interval + +Interval after which the server sends a data-channel ping when no packet has been sent to a client. + +This value applies to the server. Use `push.ping_interval` to configure clients. + +The value must use whole seconds. + +Disabled by default. + +### ping_restart + +Time without receiving a packet after which the server closes the client session. + +This value applies to the server. Use `push.ping_restart` to configure clients. + +The server timeout should be longer than the client timeout so the client can reconnect before the server discards its session. + +The value must use whole seconds. + +Disabled by default. + +### renegotiate_interval + +OpenVPN TLS renegotiation interval. + +When empty, the OpenVPN default `1h` is used. + +Only available in TLS mode. + +### renegotiate_disabled + +Disable time-based TLS renegotiation, including the default interval. + +Only available in TLS mode. + +### renegotiate_bytes + +Renegotiate data-channel keys after this many bytes. `0` uses the cipher-dependent OpenVPN default. + +Only available in TLS mode. + +### renegotiate_packets + +Renegotiate data-channel keys after this many packets. `0` uses the cipher-dependent OpenVPN default. + +Only available in TLS mode. + +### handshake_window + +Maximum time allowed for the initial TLS handshake and each TLS renegotiation. + +`1m` is used by default. + +Only available in TLS mode. + +## UDP NAT Fields + +These fields configure UDP sessions for traffic through the OpenVPN interface. + +See [UDP NAT Fields](/configuration/shared/udp-nat/) for details. diff --git a/docs/configuration/endpoint/openvpn-server.zh.md b/docs/configuration/endpoint/openvpn-server.zh.md new file mode 100644 index 0000000000..2ebdbce147 --- /dev/null +++ b/docs/configuration/endpoint/openvpn-server.zh.md @@ -0,0 +1,612 @@ +# OpenVPN 服务器 + +!!! question "自 sing-box 1.14.0 起" + +## 结构 + +```json +{ + "type": "openvpn-server", + "tag": "ovpn-server", + + ... // 监听字段 + + "system": false, + "name": "", + "mtu": 1500, + "mode": "tls", + "network": "udp", + "remote": "", + "remote_port": 0, + "max_clients": 1024, + "address": [], + "peer_address": "", + "peer_address_ipv6": "", + "topology": "subnet", + "duplicate_cn": false, + "users": [ + { + "username": "", + "password": "" + } + ], + "static_key": [], + "static_key_path": "", + "key_direction": "", + "tls": { + "certificate": [], + "certificate_path": "", + "key": [], + "key_path": "", + "client_certificate": [], + "client_certificate_path": "", + "verify_client_certificate": "require", + "client_name": "", + "client_name_type": "name", + "peer_fingerprint": [], + "crl_path": "", + "remote_certificate_ku": [], + "remote_certificate_eku": "", + "remote_certificate_tls": "", + "certificate_profile": "", + "ns_certificate_type": "", + "version_min": "1.2", + "version_max": "", + "cipher": "", + "groups": "", + "control_wrap": { + "type": "tls_crypt", + "key": [], + "key_path": "", + "direction": "", + "force_cookie": false + } + }, + "cipher": "", + "data_ciphers": [], + "data_ciphers_fallback": "", + "auth": "", + "mss_fix": 0, + "mss_fix_disabled": false, + "mss_fix_mode": "", + "replay_window": 0, + "replay_window_time": "", + "push": { + "routes": [], + "dns": [], + "dns_servers": [], + "search_domains": [], + "dhcp_options": [], + "redirect_gateway": false, + "redirect_gateway_flags": [], + "block_outside_dns": false, + "ping_interval": "", + "ping_restart": "" + }, + "ping_interval": "", + "ping_restart": "", + "renegotiate_interval": "", + "renegotiate_disabled": false, + "renegotiate_bytes": 0, + "renegotiate_packets": 0, + "handshake_window": "1m", + + ... // UDP NAT 字段 +} +``` + +!!! note "" + + 当内容只有一项时,可以忽略 JSON 数组 [] 标签 + +## 监听字段 + +参阅 [监听字段](/zh/configuration/shared/listen/)。`udp_timeout` 属于下方的 [UDP NAT 字段](#udp-nat-字段)。 + +## 字段 + +### system + +使用系统接口。 + +需要特权且不能与已有系统接口冲突。 + +endpoint 会配置接口地址和 MTU,但不会安装操作系统路由或 DNS 设置。 + +如果禁用,sing-box 将使用内部网络栈。 + +### name + +系统接口的自定义接口名称。 + +默认使用自动生成的 `ovpn` 接口名称。 + +### mtu + +OpenVPN 接口 MTU。 + +默认使用 `1500`。 + +### mode + +OpenVPN 会话模式,`tls` 或 `static_key` 之一。 + +默认使用 `tls`。 + +`static_key` 在没有 TLS 控制信道和前向保密的情况下服务一个对端,仅作为不可变部署的显式兼容选项保留。该模式不使用 `tls`、`users`、推送选项或 TLS 重协商选项。 + +### network + +OpenVPN 传输网络,`udp` 或 `tcp` 之一。 + +默认使用 `udp`。 + +每个端点仅服务一种传输网络;如需同时服务 TCP 与 UDP, +需要配置两个端点并使用互不重叠的 `address` 子网, +与上游 OpenVPN 需要两个服务进程一致。 + +### remote + +UDP `static_key` 服务器的固定远端地址。 + +在 UDP `static_key` 模式下与 `remote_port` 一起必填。TCP 服务器从监听套接字接受单个对端,不使用此字段。 + +### remote_port + +UDP `static_key` 服务器的固定远端端口。 + +在 UDP `static_key` 模式下与 `remote` 一起必填。 + +### max_clients + +已建立与握手中的 TLS 客户端会话的最大数量。 + +默认使用 `1024`。该值必须小于 OpenVPN peer-id 空间的大小 `16777216`。 + +`static_key` 模式仅支持一个对端,因此此值必须为 `0` 或 `1`。 + +### address + +==必填== + +OpenVPN 服务器地址前缀列表。 + +最多支持一个 IPv4 前缀和一个 IPv6 前缀。 + +前缀地址被分配给服务器接口。掩码后的前缀用作客户端地址池和路由。 + +第一个 IPv4 和 IPv6 前缀地址用作端点的本地地址。 + +在 `static_key` 模式下,这些地址是本地隧道前缀,而不是地址池。 + +### peer_address + +IPv4 隧道对端地址。 + +在 `static_key` 模式下配置 IPv4 `address` 时必填。 + +### peer_address_ipv6 + +IPv6 隧道对端地址。 + +在 `static_key` 模式下配置 IPv6 `address` 时必填。 + +### topology + +推送给客户端的 OpenVPN topology,`subnet`、`p2p` 或 `net30` 之一。 + +TLS 模式默认使用 `subnet`,`static_key` 模式默认使用 `p2p`。 + +### duplicate_cn + +允许具有相同认证证书 common name 或用户名的多个客户端同时在线。 + +禁用时,新认证的会话会替换具有相同身份的现有会话,并在可用时复用其 tunnel 地址。 + +默认禁用。 + +仅在 TLS 模式下可用。 + +### users + +OpenVPN 用户名/密码用户列表。 + +如果设置,客户端除了通过 `tls.verify_client_certificate` 配置的证书策略外,还必须通过用户名/密码认证。 + +仅在 TLS 模式下可用。 + +### users.username + +用户名。 + +### users.password + +密码。 + +### static_key + +OpenVPN 静态密钥内容。 + +在 `static_key` 模式下必填。 + +与 `static_key_path` 冲突。 + +### static_key_path + +OpenVPN 静态密钥路径。 + +在 `static_key` 模式下未设置 `static_key` 时必填。 + +与 `static_key` 冲突。 + +### key_direction + +静态密钥方向,`server` 或 `client` 之一。 + +为空时双向使用密钥。按照惯例,服务器使用 `server`,对端使用 `client`。 + +仅在 `static_key` 模式下可用。 + +### tls + +在 TLS 模式下必填。 + +OpenVPN 控制信道 TLS 配置。 + +### tls.certificate + +TLS 服务器证书内容。 + +`tls.certificate` 或 `tls.certificate_path` 必填其一。 + +与 `tls.certificate_path` 冲突。 + +### tls.certificate_path + +TLS 服务器证书路径。 + +`tls.certificate` 或 `tls.certificate_path` 必填其一。 + +与 `tls.certificate` 冲突。 + +### tls.key + +TLS 服务器私钥内容。 + +`tls.key` 或 `tls.key_path` 必填其一。 + +与 `tls.key_path` 冲突。 + +### tls.key_path + +TLS 服务器私钥路径。 + +`tls.key` 或 `tls.key_path` 必填其一。 + +与 `tls.key` 冲突。 + +### tls.client_certificate + +TLS CA 证书内容,用于验证客户端证书。 + +当 `tls.verify_client_certificate` 为 `require` 或 `optional` 时,`tls.client_certificate`、`tls.client_certificate_path` 或 `tls.peer_fingerprint` 必填其一。 + +与 `tls.client_certificate_path` 冲突。 + +### tls.client_certificate_path + +TLS CA 证书路径,用于验证客户端证书。 + +当 `tls.verify_client_certificate` 为 `require` 或 `optional` 时,`tls.client_certificate`、`tls.client_certificate_path` 或 `tls.peer_fingerprint` 必填其一。 + +与 `tls.client_certificate` 冲突。 + +### tls.verify_client_certificate + +OpenVPN 客户端证书策略,`require`、`optional` 或 `none` 之一。 + +默认使用 `require`。 + +设为 `optional` 时,客户端提供证书则验证,不提供证书的客户端也被允许。 + +设为 `none` 时,不请求客户端证书。 + +该字段不替代 `users`;设置 `users` 后仍然要求用户名/密码认证。 + +### tls.client_name + +期望的客户端证书名称。为空时禁用。 + +### tls.client_name_type + +`tls.client_name` 匹配的证书字段,`subject`、`name` 或 `name-prefix` 之一。 + +配置 `tls.client_name` 时默认使用 `name`。 + +### tls.peer_fingerprint + +允许的客户端叶证书 SHA-256 指纹。可以在没有客户端 CA 时仅使用指纹验证。 + +### tls.crl_path + +用于拒绝已吊销客户端证书的证书吊销列表路径。 + +### tls.remote_certificate_ku + +OpenVPN `remote-cert-ku` 格式的客户端证书 Key Usage mask。 + +### tls.remote_certificate_eku + +客户端证书所需的 Extended Key Usage。与显式配置的 `tls.remote_certificate_tls` 冲突。 + +### tls.remote_certificate_tls + +客户端证书用途检查,`server`、`client` 或 `none` 之一。默认使用 `client`。 + +### tls.certificate_profile + +证书 profile,可选值为 `insecure`、`legacy`、`preferred` 或 `suiteb`。 + +默认使用 `legacy`。 + +`insecure` 为兼容不可变对端而接受使用 MD5 或 SHA-1 签名的证书链和较小的旧密钥,仅应在对端无法升级时使用。`legacy` 接受 SHA-1 但拒绝 MD5 签名;`preferred` 要求更强的签名和密钥。 + +选择 `suiteb` 且 `tls.cipher` 为空时,TLS 1.2 cipher 列表默认使用 Suite B ECDHE-ECDSA AES-GCM 套件。该 profile 不限制显式配置的 `tls.cipher` 和 `tls.groups`。 + +### tls.ns_certificate_type + +已弃用的 Netscape 证书类型检查,`server` 或 `client` 之一。 + +### tls.version_min + +最低 TLS 版本。默认使用 `1.2`。 + +### tls.version_max + +最高 TLS 版本。默认使用支持的最高版本。 + +### tls.cipher + +TLS 1.2 及更低版本允许的 OpenSSL cipher suite 名称,以冒号分隔。 + +为空时使用默认 TLS cipher suite。该字段不控制 TLS 1.3 cipher suite。 + +### tls.groups + +按偏好顺序排列的 TLS key exchange group,以冒号分隔。 + +### tls.control_wrap + +OpenVPN 控制信道包装。 + +等价于 OpenVPN `tls-auth`、`tls-crypt` 和 `tls-crypt-v2`。 + +默认禁用。 + +### tls.control_wrap.type + +==必填== + +控制信道包装类型,`tls_auth`、`tls_crypt` 或 `tls_crypt_v2` 之一。 + +对于 `tls_crypt_v2`,密钥为服务器密钥。 + +### tls.control_wrap.key + +控制信道包装密钥内容。 + +`tls.control_wrap.key` 或 `tls.control_wrap.key_path` 必填其一。 + +与 `tls.control_wrap.key_path` 冲突。 + +### tls.control_wrap.key_path + +控制信道包装密钥路径。 + +`tls.control_wrap.key` 或 `tls.control_wrap.key_path` 必填其一。 + +与 `tls.control_wrap.key` 冲突。 + +### tls.control_wrap.direction + +OpenVPN `tls-auth` 密钥方向,`server` 或 `client` 之一。 + +仅当 `tls.control_wrap.type` 为 `tls_auth` 时可用。 + +`server` 对应 OpenVPN 密钥方向 `0`,`client` 对应 `1`;按照惯例服务器使用 `0`,客户端使用 `1`。 + +如果为空,密钥被双向使用,与两端均省略 `key-direction` 的行为一致。 + +### tls.control_wrap.force_cookie + +要求 UDP 上的 `tls-crypt-v2` 客户端支持无状态 session cookie。 + +仅当 `tls.control_wrap.type` 为 `tls_crypt_v2` 时可用。禁用时,不支持 cookie 的客户端会按照上游 `allow-noncookie` 行为被接受。 + +默认禁用。 + +### cipher + +`static_key` 模式使用的数据信道加密方式。 + +为空时使用上游静态密钥模式的默认值 `BF-CBC`。支持 AES-CBC、ARIA-CBC、Camellia-CBC、DES-CBC、Blowfish-CBC、CAST5-CBC 系列,以及 `SEED-CBC`、`SM4-CBC` 和 `NONE`。 + +仅在 `static_key` 模式下可用。`NONE` 不提供机密性。 + +### data_ciphers + +允许的 OpenVPN 数据信道加密方式。 + +默认使用 `AES-256-GCM`、`AES-128-GCM` 和 `CHACHA20-POLY1305`。 + +AES-GCM 系列还包括 `AES-192-GCM`。保留的 cipher 包括 AES、ARIA、Camellia、DES、Blowfish、CAST5、SEED 和 SM4 的 CBC、CFB、OFB 形式,以及 `NONE`。CFB 和 OFB 仅可用于 TLS 模式。旧 cipher 只能提供较弱的机密性或完全不加密,因此默认不启用。 + +仅在 TLS 模式下可用。 + +### data_ciphers_fallback + +用于不支持加密方式协商的遗留客户端的 OpenVPN 数据信道加密方式。 + +等价于 OpenVPN `data-ciphers-fallback`。 + +默认禁用。 + +仅在 TLS 模式下可用。 + +### auth + +OpenVPN 数据信道认证摘要。 + +默认使用 `SHA1`,与上游默认值一致;仅对非 AEAD 数据信道加密方式和 `tls_auth` 生效。 + +为兼容既有客户端,显式配置时仍可使用 `MD5` 和 `RIPEMD160` 等旧摘要。 + +### mss_fix + +用于限制 TCP MSS 的最大封装数据包大小。默认 MTU 下使用上游默认值 `1492` 计算。 + +### mss_fix_disabled + +禁用 MSS 限制,包括默认限制。 + +### mss_fix_mode + +显式 `mss_fix` 的计算模式,`mtu` 或 `fixed` 之一。需要 `mss_fix`。 + +### replay_window + +UDP 数据通道重放窗口大小。默认使用 `64`;TCP 数据包 ID 始终严格连续。 + +### replay_window_time + +UDP 重放窗口时长。默认使用 `15s`。该值必须使用整秒。 + +### push + +推送给客户端的选项。 + +### push.routes + +推送给客户端的路由。 + +IPv4 和 IPv6 前缀可以混用。 + +### push.dns + +推送给客户端的 DNS 服务器地址。 + +使用传统的 `dhcp-option DNS`/`DNS6`。兼容客户端收到现代 DNS 服务器组时会覆盖这些地址。 + +### push.dns_servers + +推送的现代 OpenVPN DNS 服务器组。每项包含 `priority`、`addresses`,以及可选的 `resolve_domains`、`dnssec`、`transport` 和 `sni`。 + +地址接受 IP 或 `IP:port`(带端口的 IPv6 使用 `[IPv6]:port`)。`transport` 为 `plain`、`dot` 或 `doh` 之一;`dnssec` 为 `yes`、`optional` 或 `no` 之一。OpenVPN 客户端仅应用优先级数字最低的服务器组。 + +### push.search_domains + +推送的现代 OpenVPN 搜索域。 + +### push.dhcp_options + +推送的额外传统 `dhcp-option` 值,不包含 `dhcp-option` 前缀。 + +### push.redirect_gateway + +向客户端推送 `redirect-gateway`,根据 `push.redirect_gateway_flags` 通过 VPN 路由客户端流量。 + +当 `push.redirect_gateway_flags` 为空时,默认使用 `def1`。 + +### push.redirect_gateway_flags + +向客户端推送的 OpenVPN `redirect-gateway` flag。 + +仅当启用 `push.redirect_gateway` 时可用。 + +默认使用 `def1`。 + +### push.block_outside_dns + +向客户端推送 `block-outside-dns`,在 Windows 客户端上阻止 VPN 之外的 DNS 查询。 + +### push.ping_interval + +推送给客户端的 OpenVPN `ping` 间隔。 + +在该间隔内未发送任何 packet 后,客户端会向服务器发送一个 data channel ping。 + +该值必须使用整秒。 + +默认禁用。 + +### push.ping_restart + +推送给客户端的 OpenVPN `ping-restart` 超时。 + +在该超时时间内未收到任何 packet 后,客户端会重新连接服务器。 + +该值必须使用整秒。 + +默认禁用。 + +### ping_interval + +服务器未向客户端发送任何 packet 时,发送 data channel ping 的间隔。 + +该值应用于服务器。使用 `push.ping_interval` 配置客户端。 + +该值必须使用整秒。 + +默认禁用。 + +### ping_restart + +服务器未收到任何 packet 后关闭客户端会话的时间。 + +该值应用于服务器。使用 `push.ping_restart` 配置客户端。 + +服务器超时应长于客户端超时,以便客户端在服务器丢弃其会话前重新连接。 + +该值必须使用整秒。 + +默认禁用。 + +### renegotiate_interval + +OpenVPN TLS 重协商间隔。 + +为空时使用 OpenVPN 默认值 `1h`。 + +仅在 TLS 模式下可用。 + +### renegotiate_disabled + +禁用基于时间的 TLS 重协商,包括默认间隔。 + +仅在 TLS 模式下可用。 + +### renegotiate_bytes + +传输指定字节数后重新协商数据通道密钥。`0` 使用与密码算法相关的 OpenVPN 默认值。 + +仅在 TLS 模式下可用。 + +### renegotiate_packets + +传输指定数据包数后重新协商数据通道密钥。`0` 使用与密码算法相关的 OpenVPN 默认值。 + +仅在 TLS 模式下可用。 + +### handshake_window + +初始 TLS 握手和每次 TLS 重协商允许使用的最长时间。 + +默认使用 `1m`。 + +仅在 TLS 模式下可用。 + +## UDP NAT 字段 + +这些字段配置通过 OpenVPN 接口的流量的 UDP 会话。 + +参阅 [UDP NAT 字段](/zh/configuration/shared/udp-nat/)。 diff --git a/docs/configuration/endpoint/tailscale.md b/docs/configuration/endpoint/tailscale.md index 9ecebab9ac..49244d885e 100644 --- a/docs/configuration/endpoint/tailscale.md +++ b/docs/configuration/endpoint/tailscale.md @@ -61,7 +61,7 @@ Example: `$HOME/.tailscale` !!! note - Auth key is not required. By default, sing-box will log the login URL (or popup a notification on graphical clients). + Auth key is not required. By default, sing-box will log the login URL. The auth key to create the node. If the node is already created (from state previously stored), then this field is not used. @@ -162,6 +162,50 @@ HTTP Client for connecting to the Tailscale control plane. See [HTTP Client Fields](/configuration/shared/http-client/) for details. +#### ssh_server + +!!! question "Since sing-box 1.14.0" + +Run a Tailscale SSH server on tailnet port 22. + +Access is controlled by the SSH ACL in the Tailscale admin console, which maps each connection to a local user. How that user is resolved, and which users are allowed, depends on the platform: + +- **Linux** and **macOS**: the user is resolved from the system user database. Switching to a user other than the one sing-box runs as requires running as root; without root, sessions are limited to the current user. +- **Windows**: in the command line client, sessions run as the sing-box process identity; the mapped user is not impersonated, so a session mapped to a different local account is refused. In the graphical client, there is no such restriction. +- **Android**: the user is resolved by the app rather than the system user database. `root` is the superuser (UID 0) and `shell` is the ADB shell user (UID 2000); every other name is resolved as the package name of an installed application, running as that application's UID with its data directory as the home directory, so the target application must be installed. `termux` is a shortcut for `com.termux`, and `sing-box` for the app's own package name; when Termux is installed, the `root` and `termux` users load the Termux environment. Running as the sing-box application itself requires no root, while any other user requires granted root access; without root, sessions are limited to the sing-box user. +- **macOS**: the SSH server is only available in the standalone version and requires the Root Helper; the App Store version is not supported. +- **iOS**: the SSH server is only available in the jailbreak build; the App Store and TestFlight versions are not supported. +- **tvOS**: not yet supported. + +Object format: + +```json +{ + "enabled": true, + "disable_pty": false, + "disable_sftp": false, + "disable_forwarding": false +} +``` + +Setting `ssh_server` value to `true` is equivalent to `{ "enabled": true }`. + +#### ssh_server.enabled + +Enable the SSH server. + +#### ssh_server.disable_pty + +Refuse PTY allocation requests. + +#### ssh_server.disable_sftp + +Refuse the SFTP subsystem. + +#### ssh_server.disable_forwarding + +Refuse local and remote TCP and Unix-socket forwarding, including SSH agent forwarding. + ### Dial Fields !!! failure "Deprecated in sing-box 1.14.0" @@ -169,3 +213,7 @@ See [HTTP Client Fields](/configuration/shared/http-client/) for details. Dial Fields in Tailscale endpoints are deprecated in sing-box 1.14.0 and will be removed in sing-box 1.16.0, use `control_http_client` instead. See [Dial Fields](/configuration/shared/dial/) for details. + +### Interactive authentication + +Use `Tools` > `Endpoints` in the sing-box dashboard or any sing-box graphical client to authenticate and manage the endpoint. diff --git a/docs/configuration/endpoint/tailscale.zh.md b/docs/configuration/endpoint/tailscale.zh.md index 58b4708ae2..c17a6c8368 100644 --- a/docs/configuration/endpoint/tailscale.zh.md +++ b/docs/configuration/endpoint/tailscale.zh.md @@ -61,7 +61,7 @@ icon: material/new-box !!! note - 认证密钥不是必需的。默认情况下,sing-box 将记录登录 URL(或在图形客户端上弹出通知)。 + 认证密钥不是必需的。默认情况下,sing-box 将记录登录 URL。 用于创建节点的认证密钥。如果节点已经创建(从之前存储的状态),则不使用此字段。 @@ -161,6 +161,50 @@ UDP NAT 过期时间。 参阅 [HTTP 客户端字段](/zh/configuration/shared/http-client/) 了解详情。 +#### ssh_server + +!!! question "自 sing-box 1.14.0 起" + +在 tailnet 的 TCP 22 端口上运行 Tailscale SSH 服务器。 + +访问控制由 Tailscale 管理控制台中的 SSH ACL 决定,它将每个连接映射到一个本地用户。该用户如何解析、以及允许哪些用户,取决于平台: + +- **Linux** 和 **macOS**:从系统用户数据库解析用户。要切换到 sing-box 运行身份以外的用户需要以 root 运行;非 root 时,会话仅限于当前用户。 +- **Windows**:在命令行客户端中,会话以 sing-box 进程的身份运行;映射的用户不会被模拟,因此映射到其他本地账户的会话将被拒绝。在图形客户端中没有此限制。 +- **Android**:用户由应用解析,而非系统用户数据库。`root` 即超级用户(UID 0),`shell` 为 ADB shell 用户(UID 2000);其他名称均作为已安装应用的包名解析,以该应用的 UID 运行,并使用其数据目录作为主目录,因此目标应用必须已安装。`termux` 是 `com.termux` 的快捷方式,`sing-box` 是应用自身包名的快捷方式;当 Termux 已安装时,`root` 和 `termux` 用户将加载 Termux 环境。以 sing-box 应用自身身份运行无需 root,其他用户则需要已授予的 root 权限;非 root 时,会话仅限于 sing-box 用户。 +- **macOS**:SSH 服务器仅在独立版本中可用,且需要 Root Helper;App Store 版本不支持。 +- **iOS**:SSH 服务器仅在越狱版本中可用;App Store 和 TestFlight 版本不支持。 +- **tvOS**:暂不支持。 + +对象格式: + +```json +{ + "enabled": true, + "disable_pty": false, + "disable_sftp": false, + "disable_forwarding": false +} +``` + +将 `ssh_server` 值设置为 `true` 等同于 `{ "enabled": true }`。 + +#### ssh_server.enabled + +启用 SSH 服务器。 + +#### ssh_server.disable_pty + +拒绝 PTY 分配请求。 + +#### ssh_server.disable_sftp + +拒绝 SFTP 子系统。 + +#### ssh_server.disable_forwarding + +拒绝本地和远程的 TCP 与 Unix 套接字转发,包括 SSH agent 转发。 + ### 拨号字段 !!! failure "已在 sing-box 1.14.0 废弃" @@ -168,3 +212,7 @@ UDP NAT 过期时间。 Tailscale 端点中的拨号字段已在 sing-box 1.14.0 废弃且将在 sing-box 1.16.0 中被移除,请使用 `control_http_client` 代替。 参阅 [拨号字段](/zh/configuration/shared/dial/) 了解详情。 + +### 交互式认证 + +在 sing-box dashboard 或任意 sing-box 图形客户端的 `工具` > `端点` 中认证和管理 endpoint。 diff --git a/docs/configuration/endpoint/wireguard.md b/docs/configuration/endpoint/wireguard.md index dc3b82289a..070be3977c 100644 --- a/docs/configuration/endpoint/wireguard.md +++ b/docs/configuration/endpoint/wireguard.md @@ -1,3 +1,9 @@ +!!! quote "Changes in sing-box 1.14.0" + + :material-plus: [udp_mapping](/configuration/shared/udp-nat/#udp_mapping) + :material-plus: [udp_filtering](/configuration/shared/udp-nat/#udp_filtering) + :material-plus: [udp_nat_max](/configuration/shared/udp-nat/#udp_nat_max) + !!! question "Since sing-box 1.11.0" ### Structure @@ -24,7 +30,9 @@ "reserved": [0, 0, 0] } ], - "udp_timeout": "", + + ... // UDP NAT Fields + "workers": 0, ... // Dial Fields @@ -112,18 +120,16 @@ Disabled by default. WireGuard reserved field bytes. -#### udp_timeout - -UDP NAT expiration time. - -`5m` will be used by default. - #### workers WireGuard worker count. CPU count is used by default. +### UDP NAT Fields + +See [UDP NAT Fields](/configuration/shared/udp-nat/) for details. + ### Dial Fields See [Dial Fields](/configuration/shared/dial/) for details. diff --git a/docs/configuration/endpoint/wireguard.zh.md b/docs/configuration/endpoint/wireguard.zh.md index 1935135f87..354358d1ce 100644 --- a/docs/configuration/endpoint/wireguard.zh.md +++ b/docs/configuration/endpoint/wireguard.zh.md @@ -1,3 +1,9 @@ +!!! quote "sing-box 1.14.0 中的更改" + + :material-plus: [udp_mapping](/zh/configuration/shared/udp-nat/#udp_mapping) + :material-plus: [udp_filtering](/zh/configuration/shared/udp-nat/#udp_filtering) + :material-plus: [udp_nat_max](/zh/configuration/shared/udp-nat/#udp_nat_max) + !!! question "自 sing-box 1.11.0 起" ### 结构 @@ -24,7 +30,9 @@ "reserved": [0, 0, 0] } ], - "udp_timeout": "", + + ... // UDP NAT 字段 + "workers": 0, ... // 拨号字段 @@ -114,18 +122,16 @@ WireGuard 对等方的列表。 对等方的保留字段字节。 -#### udp_timeout - -UDP NAT 过期时间。 - -默认使用 `5m`。 - #### workers WireGuard worker 数量。 默认使用 CPU 数量。 +### UDP NAT 字段 + +参阅 [UDP NAT 字段](/zh/configuration/shared/udp-nat/)。 + ### 拨号字段 参阅 [拨号字段](/zh/configuration/shared/dial/)。 diff --git a/docs/configuration/inbound/index.md b/docs/configuration/inbound/index.md index 3041a9e716..19a1596983 100644 --- a/docs/configuration/inbound/index.md +++ b/docs/configuration/inbound/index.md @@ -32,7 +32,7 @@ | `hysteria2` | [Hysteria2](./hysteria2/) | :material-close: | | `vless` | [VLESS](./vless/) | TCP | | `anytls` | [AnyTLS](./anytls/) | TCP | -| `snell` | [Snell](./snell/) | TCP | +| `trusttunnel` | [TrustTunnel](./trusttunnel/) | TCP | | `tun` | [Tun](./tun/) | :material-close: | | `redirect` | [Redirect](./redirect/) | :material-close: | | `tproxy` | [TProxy](./tproxy/) | :material-close: | @@ -40,4 +40,4 @@ #### tag -The tag of the inbound. \ No newline at end of file +The tag of the inbound. diff --git a/docs/configuration/inbound/index.zh.md b/docs/configuration/inbound/index.zh.md index 0baf7ed963..628e072cd7 100644 --- a/docs/configuration/inbound/index.zh.md +++ b/docs/configuration/inbound/index.zh.md @@ -32,7 +32,7 @@ | `hysteria2` | [Hysteria2](./hysteria2/) | :material-close: | | `vless` | [VLESS](./vless/) | TCP | | `anytls` | [AnyTLS](./anytls/) | TCP | -| `snell` | [Snell](./snell/) | TCP | +| `trusttunnel` | [TrustTunnel](./trusttunnel/) | TCP | | `tun` | [Tun](./tun/) | :material-close: | | `redirect` | [Redirect](./redirect/) | :material-close: | | `tproxy` | [TProxy](./tproxy/) | :material-close: | @@ -40,4 +40,4 @@ #### tag -入站的标签。 \ No newline at end of file +入站的标签。 diff --git a/docs/configuration/inbound/tproxy.md b/docs/configuration/inbound/tproxy.md index 422885374f..d0032a1141 100644 --- a/docs/configuration/inbound/tproxy.md +++ b/docs/configuration/inbound/tproxy.md @@ -1,3 +1,9 @@ +!!! quote "Changes in sing-box 1.14.0" + + :material-plus: [udp_mapping](/configuration/shared/udp-nat/#udp_mapping) + :material-plus: [udp_filtering](/configuration/shared/udp-nat/#udp_filtering) + :material-plus: [udp_nat_max](/configuration/shared/udp-nat/#udp_nat_max) + !!! quote "" Only supported on Linux. @@ -11,7 +17,9 @@ ... // Listen Fields - "network": "udp" + "network": "udp", + + ... // UDP NAT Fields } ``` @@ -26,3 +34,7 @@ See [Listen Fields](/configuration/shared/listen/) for details. Listen network, one of `tcp` `udp`. Both if empty. + +### UDP NAT Fields + +See [UDP NAT Fields](/configuration/shared/udp-nat/) for details. diff --git a/docs/configuration/inbound/tproxy.zh.md b/docs/configuration/inbound/tproxy.zh.md index 6e35ad5e0c..a368b963cf 100644 --- a/docs/configuration/inbound/tproxy.zh.md +++ b/docs/configuration/inbound/tproxy.zh.md @@ -1,3 +1,9 @@ +!!! quote "sing-box 1.14.0 中的更改" + + :material-plus: [udp_mapping](/zh/configuration/shared/udp-nat/#udp_mapping) + :material-plus: [udp_filtering](/zh/configuration/shared/udp-nat/#udp_filtering) + :material-plus: [udp_nat_max](/zh/configuration/shared/udp-nat/#udp_nat_max) + !!! quote "" 仅支持 Linux。 @@ -11,7 +17,9 @@ ... // 监听字段 - "network": "udp" + "network": "udp", + + ... // UDP NAT 字段 } ``` @@ -26,3 +34,7 @@ 监听的网络协议,`tcp` `udp` 之一。 默认所有。 + +### UDP NAT 字段 + +参阅 [UDP NAT 字段](/zh/configuration/shared/udp-nat/)。 diff --git a/docs/configuration/inbound/trusttunnel.md b/docs/configuration/inbound/trusttunnel.md new file mode 100644 index 0000000000..9daa661d87 --- /dev/null +++ b/docs/configuration/inbound/trusttunnel.md @@ -0,0 +1,80 @@ +--- +icon: material/new-box +--- + +!!! question "Since sing-box 1.14.0" + +### Structure + +```json +{ + "type": "trusttunnel", + "tag": "trusttunnel-in", + + ... // Listen Fields + + "users": [ + { + "username": "trust", + "password": "tunnel" + } + ], + "quic_congestion_control": "bbr", + "network": "tcp,udp", + "tls": {} +} +``` + +### Listen Fields + +See [Listen Fields](/configuration/shared/listen/) for details. + +### Fields + +#### users + +==Required== + +TrustTunnel user list. + +#### users.username + +==Required== + +TrustTunnel username. + +#### users.password + +==Required== + +TrustTunnel user password. + +#### quic_congestion_control + +QUIC congestion control algorithm. + +| Algorithm | Description | +|-----------|-------------| +| `bbr` | BBR | +| `bbr_standard` | BBR (Standard version) | +| `bbr2` | BBRv2 | +| `bbr_variant` | BBRv2 (An experimental variant) | +| `cubic` | CUBIC | +| `reno` | New Reno | + +`bbr` is used by default. + +#### network + +Network list. + +Available values: + +- `tcp` (HTTP/2) +- `udp` (HTTP/3) + +When `udp` is enabled, `tls` must be enabled. + +#### tls + +Inbound TLS configuration, see [TLS](/configuration/shared/tls/#inbound). diff --git a/docs/configuration/inbound/trusttunnel.zh.md b/docs/configuration/inbound/trusttunnel.zh.md new file mode 100644 index 0000000000..c007503eee --- /dev/null +++ b/docs/configuration/inbound/trusttunnel.zh.md @@ -0,0 +1,80 @@ +--- +icon: material/new-box +--- + +!!! question "自 sing-box 1.14.0 起" + +### 结构 + +```json +{ + "type": "trusttunnel", + "tag": "trusttunnel-in", + + ... // 监听字段 + + "users": [ + { + "username": "trust", + "password": "tunnel" + } + ], + "quic_congestion_control": "bbr", + "network": "tcp,udp", + "tls": {} +} +``` + +### 监听字段 + +监听字段参阅 [监听字段](/zh/configuration/shared/listen/)。 + +### 字段 + +#### users + +==必填== + +TrustTunnel 用户列表。 + +#### users.username + +==必填== + +TrustTunnel 用户名。 + +#### users.password + +==必填== + +TrustTunnel 用户密码。 + +#### quic_congestion_control + +QUIC 拥塞控制算法。 + +| 算法 | 描述 | +|------|------| +| `bbr` | BBR | +| `bbr_standard` | BBR (标准版) | +| `bbr2` | BBRv2 | +| `bbr_variant` | BBRv2 (一种试验变体) | +| `cubic` | CUBIC | +| `reno` | New Reno | + +默认使用 `bbr`。 + +#### network + +网络列表。 + +可选值: + +- `tcp` (HTTP/2) +- `udp` (HTTP/3) + +当启用 `udp` 时,必须启用 `tls`。 + +#### tls + +入站 TLS 配置,参阅 [TLS](/zh/configuration/shared/tls/#inbound)。 diff --git a/docs/configuration/inbound/tun.md b/docs/configuration/inbound/tun.md index 9af118b68a..838295ab04 100644 --- a/docs/configuration/inbound/tun.md +++ b/docs/configuration/inbound/tun.md @@ -7,7 +7,11 @@ icon: material/new-box :material-plus: [include_mac_address](#include_mac_address) :material-plus: [exclude_mac_address](#exclude_mac_address) :material-plus: [dns_mode](#dns_mode) - :material-plus: [dns_address](#dns_address) + :material-plus: [dns_address](#dns_address) + :material-plus: [netns](#netns) + :material-plus: [udp_mapping](/configuration/shared/udp-nat/#udp_mapping) + :material-plus: [udp_filtering](/configuration/shared/udp-nat/#udp_filtering) + :material-plus: [udp_nat_max](/configuration/shared/udp-nat/#udp_nat_max) !!! quote "Changes in sing-box 1.13.3" @@ -111,7 +115,9 @@ icon: material/new-box "geoip-cn" ], "endpoint_independent_nat": false, - "udp_timeout": "5m", + + ... // UDP NAT Fields + "stack": "system", "include_interface": [ "lan0" @@ -536,12 +542,6 @@ Enable endpoint-independent NAT. Performance may degrade slightly, so it is not recommended to enable on when it is not needed. -#### udp_timeout - -UDP NAT expiration time. - -`5m` will be used by default. - #### stack !!! quote "Changes in sing-box 1.8.0" @@ -683,6 +683,10 @@ Hostnames that bypass the HTTP proxy. Hostnames that use the HTTP proxy. +### UDP NAT Fields + +See [UDP NAT Fields](/configuration/shared/udp-nat/) for details. + ### Listen Fields See [Listen Fields](/configuration/shared/listen/) for details. diff --git a/docs/configuration/inbound/tun.zh.md b/docs/configuration/inbound/tun.zh.md index 7471771eec..7aeb90205e 100644 --- a/docs/configuration/inbound/tun.zh.md +++ b/docs/configuration/inbound/tun.zh.md @@ -7,7 +7,11 @@ icon: material/new-box :material-plus: [include_mac_address](#include_mac_address) :material-plus: [exclude_mac_address](#exclude_mac_address) :material-plus: [dns_mode](#dns_mode) - :material-plus: [dns_address](#dns_address) + :material-plus: [dns_address](#dns_address) + :material-plus: [netns](#netns) + :material-plus: [udp_mapping](/zh/configuration/shared/udp-nat/#udp_mapping) + :material-plus: [udp_filtering](/zh/configuration/shared/udp-nat/#udp_filtering) + :material-plus: [udp_nat_max](/zh/configuration/shared/udp-nat/#udp_nat_max) !!! quote "sing-box 1.13.3 中的更改" @@ -112,7 +116,9 @@ icon: material/new-box "geoip-cn" ], "endpoint_independent_nat": false, - "udp_timeout": "5m", + + ... // UDP NAT 字段 + "stack": "system", "include_interface": [ "lan0" @@ -518,12 +524,6 @@ sing-box DNS 模块,等价于一条 性能可能会略有下降,所以不建议在不需要的时候开启。 -#### udp_timeout - -UDP NAT 过期时间。 - -默认使用 `5m`。 - #### stack !!! quote "sing-box 1.8.0 中的更改" @@ -665,6 +665,10 @@ TCP/IP 栈。 代理的主机名列表。 +### UDP NAT 字段 + +参阅 [UDP NAT 字段](/zh/configuration/shared/udp-nat/)。 + ### 监听字段 参阅 [监听字段](/zh/configuration/shared/listen/)。 diff --git a/docs/configuration/index.md b/docs/configuration/index.md index 311161c1cc..ac02c46c30 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -5,6 +5,7 @@ sing-box uses JSON for configuration files. ```json { + "$schema": "https://sing-box.sagernet.org/schema.json", "log": {}, "dns": {}, "ntp": {}, @@ -24,6 +25,7 @@ sing-box uses JSON for configuration files. | Key | Format | |----------------|---------------------------------| +| `$schema` | [JSON Schema](./schema/) | | `log` | [Log](./log/) | | `dns` | [DNS](./dns/) | | `ntp` | [NTP](./ntp/) | diff --git a/docs/configuration/index.zh.md b/docs/configuration/index.zh.md index fbb44c79e1..d421f156fd 100644 --- a/docs/configuration/index.zh.md +++ b/docs/configuration/index.zh.md @@ -5,6 +5,7 @@ sing-box 使用 JSON 作为配置文件格式。 ```json { + "$schema": "https://sing-box.sagernet.org/schema.json", "log": {}, "dns": {}, "ntp": {}, @@ -24,6 +25,7 @@ sing-box 使用 JSON 作为配置文件格式。 | Key | Format | |----------------|------------------------| +| `$schema` | [JSON Schema](./schema/) | | `log` | [日志](./log/) | | `dns` | [DNS](./dns/) | | `ntp` | [NTP](./ntp/) | diff --git a/docs/configuration/outbound/index.md b/docs/configuration/outbound/index.md index 5ef0476ef5..b69f404c45 100644 --- a/docs/configuration/outbound/index.md +++ b/docs/configuration/outbound/index.md @@ -41,6 +41,7 @@ | `selector` | [Selector](./selector/) | | `urltest` | [URLTest](./urltest/) | | `naive` | [NaiveProxy](./naive/) | +| `trusttunnel` | [TrustTunnel](./trusttunnel/) | #### tag diff --git a/docs/configuration/outbound/index.zh.md b/docs/configuration/outbound/index.zh.md index a4bcbc8a83..db75cce2b7 100644 --- a/docs/configuration/outbound/index.zh.md +++ b/docs/configuration/outbound/index.zh.md @@ -41,6 +41,7 @@ | `selector` | [Selector](./selector/) | | `urltest` | [URLTest](./urltest/) | | `naive` | [NaiveProxy](./naive/) | +| `trusttunnel` | [TrustTunnel](./trusttunnel/) | #### tag diff --git a/docs/configuration/outbound/trusttunnel.md b/docs/configuration/outbound/trusttunnel.md new file mode 100644 index 0000000000..ba18894fa0 --- /dev/null +++ b/docs/configuration/outbound/trusttunnel.md @@ -0,0 +1,85 @@ +--- +icon: material/new-box +--- + +!!! question "Since sing-box 1.14.0" + +### Structure + +```json +{ + "type": "trusttunnel", + "tag": "trusttunnel-out", + + "server": "127.0.0.1", + "server_port": 443, + "username": "trust", + "password": "tunnel", + "health_check": true, + "quic": false, + "quic_congestion_control": "bbr", + "tls": {}, + + ... // Dial Fields +} +``` + +### Fields + +#### server + +==Required== + +The server address. + +#### server_port + +==Required== + +The server port. + +#### username + +==Required== + +Authentication username. + +#### password + +Authentication password. + +#### health_check + +Enable periodic health check. + +#### quic + +Use QUIC transport. + +- `false`: Use HTTP/2 over TCP. +- `true`: Use HTTP/3 over UDP. + +#### quic_congestion_control + +QUIC congestion control algorithm. + +| Algorithm | Description | +|-----------|-------------| +| `bbr` | BBR | +| `bbr_standard` | BBR (Standard version) | +| `bbr2` | BBRv2 | +| `bbr_variant` | BBRv2 (An experimental variant) | +| `cubic` | CUBIC | +| `reno` | New Reno | + +`bbr` is used by default. + +#### tls + +==Required== + +Outbound TLS configuration, see [TLS](/configuration/shared/tls/#outbound). + +### Dial Fields + +See [Dial Fields](/configuration/shared/dial/) for details. diff --git a/docs/configuration/outbound/trusttunnel.zh.md b/docs/configuration/outbound/trusttunnel.zh.md new file mode 100644 index 0000000000..d779855db7 --- /dev/null +++ b/docs/configuration/outbound/trusttunnel.zh.md @@ -0,0 +1,85 @@ +--- +icon: material/new-box +--- + +!!! question "自 sing-box 1.14.0 起" + +### 结构 + +```json +{ + "type": "trusttunnel", + "tag": "trusttunnel-out", + + "server": "127.0.0.1", + "server_port": 443, + "username": "trust", + "password": "tunnel", + "health_check": true, + "quic": false, + "quic_congestion_control": "bbr", + "tls": {}, + + ... // 拨号字段 +} +``` + +### 字段 + +#### server + +==必填== + +服务器地址。 + +#### server_port + +==必填== + +服务器端口。 + +#### username + +==必填== + +认证用户名。 + +#### password + +认证密码。 + +#### health_check + +启用周期性健康检查。 + +#### quic + +使用 QUIC 传输。 + +- `false`:使用基于 TCP 的 HTTP/2。 +- `true`:使用基于 UDP 的 HTTP/3。 + +#### quic_congestion_control + +QUIC 拥塞控制算法。 + +| 算法 | 描述 | +|------|------| +| `bbr` | BBR | +| `bbr_standard` | BBR (标准版) | +| `bbr2` | BBRv2 | +| `bbr_variant` | BBRv2 (一种试验变体) | +| `cubic` | CUBIC | +| `reno` | New Reno | + +默认使用 `bbr`。 + +#### tls + +==必填== + +出站 TLS 配置,参阅 [TLS](/zh/configuration/shared/tls/#outbound)。 + +### 拨号字段 + +拨号字段参阅 [拨号字段](/zh/configuration/shared/dial/)。 diff --git a/docs/configuration/route/rule.md b/docs/configuration/route/rule.md index 038132670a..32b72f37e6 100644 --- a/docs/configuration/route/rule.md +++ b/docs/configuration/route/rule.md @@ -214,7 +214,7 @@ icon: material/new-box (`source_port` || `source_port_range`) && `other fields` - Additionally, each branch inside an included rule-set can be considered merged into the outer rule, while different branches keep OR semantics. + When a rule-set contains only a single default rule without `invert`, its fields are considered merged into the outer rule per the logic above; otherwise, it is matched as an `other field`; different rule-sets always keep OR semantics. #### inbound diff --git a/docs/configuration/route/rule.zh.md b/docs/configuration/route/rule.zh.md index 188748d14b..a93fa1a9c4 100644 --- a/docs/configuration/route/rule.zh.md +++ b/docs/configuration/route/rule.zh.md @@ -210,9 +210,9 @@ icon: material/new-box (`port` || `port_range`) && (`source_geoip` || `source_ip_cidr` || `source_ip_is_private`) && (`source_port` || `source_port_range`) && - `other fields` + `其他字段` - 另外,引用规则集中的每个分支都可视为与外层规则合并,不同分支之间仍保持 OR 语义。 + 当规则集仅包含一条默认规则且非 invert 时,其中字段视为按以上规则与外层规则合并;否则,作为一条 `其他字段` 匹配;不同规则集之间始终保持 or。 #### inbound diff --git a/docs/configuration/rule-set/index.md b/docs/configuration/rule-set/index.md index c6d4a17c08..4ce23b94fb 100644 --- a/docs/configuration/rule-set/index.md +++ b/docs/configuration/rule-set/index.md @@ -1,7 +1,9 @@ !!! quote "Changes in sing-box 1.14.0" :material-plus: [http_client](#http_client) - :material-delete-clock: [download_detour](#download_detour) + :material-delete-clock: [download_detour](#download_detour) + :material-alert: [tag](#tag) + :material-plus: [initial_path](#initial_path) !!! quote "Changes in sing-box 1.10.0" @@ -30,7 +32,7 @@ ```json { "type": "local", - "tag": "", + "tag": "", // or [] "format": "source", // or binary "path": "" } @@ -45,9 +47,10 @@ ```json { "type": "remote", - "tag": "", + "tag": "", // or [] "format": "source", // or binary "url": "", + "initial_path": "", "http_client": "", // or {} "update_interval": "", @@ -71,6 +74,15 @@ Type of rule-set, `local` or `remote`. Tag of rule-set. +!!! question "Since sing-box 1.14.0" + + `tag` also accepts a list of tags to define multiple rule-sets sharing other options at once. + + The `{tag}` placeholder in `path`, `url` or `initial_path` is replaced by each tag, + and is required when multiple tags are set. + + Multiple tags conflict with `type: inline`. + ### Inline Fields !!! question "Since sing-box 1.10.0" @@ -111,6 +123,16 @@ File path of rule-set. Download URL of rule-set. +#### initial_path + +!!! question "Since sing-box 1.14.0" + +File path of the initial rule-set content. + +Read once at startup when no cached rule-set is available, so startup is not +blocked by the initial download. The rule-set is still updated in the background +immediately after startup. + #### http_client !!! question "Since sing-box 1.14.0" diff --git a/docs/configuration/rule-set/index.zh.md b/docs/configuration/rule-set/index.zh.md index 2cd6f93793..812648ade0 100644 --- a/docs/configuration/rule-set/index.zh.md +++ b/docs/configuration/rule-set/index.zh.md @@ -1,7 +1,9 @@ !!! quote "sing-box 1.14.0 中的更改" :material-plus: [http_client](#http_client) - :material-delete-clock: [download_detour](#download_detour) + :material-delete-clock: [download_detour](#download_detour) + :material-alert: [tag](#tag) + :material-plus: [initial_path](#initial_path) !!! quote "sing-box 1.10.0 中的更改" @@ -30,7 +32,7 @@ ```json { "type": "local", - "tag": "", + "tag": "", // 或 [] "format": "source", // or binary "path": "" } @@ -45,9 +47,10 @@ ```json { "type": "remote", - "tag": "", + "tag": "", // 或 [] "format": "source", // or binary "url": "", + "initial_path": "", "http_client": "", // 或 {} "update_interval": "", @@ -71,6 +74,14 @@ 规则集的标签。 +!!! question "自 sing-box 1.14.0 起" + + `tag` 也接受一组标签,用于一次定义多个共享其他选项的规则集。 + + `path`、`url` 或 `initial_path` 中的 `{tag}` 占位符将被替换为每个标签,设置多个标签时必填。 + + 多个标签与 `type: inline` 冲突。 + ### 内联字段 !!! question "自 sing-box 1.10.0 起" @@ -111,6 +122,15 @@ 规则集的下载 URL。 +#### initial_path + +!!! question "自 sing-box 1.14.0 起" + +规则集初始内容的文件路径。 + +仅在启动时没有可用的规则集缓存时读取一次,使启动不被初始下载阻塞。 +启动后规则集仍会立即在后台更新。 + #### http_client !!! question "自 sing-box 1.14.0 起" diff --git a/docs/configuration/schema.md b/docs/configuration/schema.md new file mode 100644 index 0000000000..210e2bf52b --- /dev/null +++ b/docs/configuration/schema.md @@ -0,0 +1,47 @@ +--- +icon: material/new-box +--- + +!!! question "Since sing-box 1.14.0" + +# JSON Schema + +sing-box provides a JSON Schema Draft 2020-12 for configuration files. +Compatible editors can use it for completion and validation. + +### Structure + +```json +{ + "$schema": "https://sing-box.sagernet.org/schema.json" +} +``` + +### Fields + +#### $schema + +The schema URI used by compatible editors. +This field does not affect sing-box runtime behavior. + +The schema published with this documentation is available at +[sing-box.sagernet.org/schema.json](https://sing-box.sagernet.org/schema.json). + +### Generate + +Use the following command to generate a schema matching the installed binary: + +```bash +sing-box schema -o schema.json +``` + +Without `--output`, the schema is written to standard output. +The generated schema reflects the features included in the current build. + +You can then reference the local schema from a configuration file: + +```json +{ + "$schema": "./schema.json" +} +``` diff --git a/docs/configuration/schema.zh.md b/docs/configuration/schema.zh.md new file mode 100644 index 0000000000..26c85264f8 --- /dev/null +++ b/docs/configuration/schema.zh.md @@ -0,0 +1,47 @@ +--- +icon: material/new-box +--- + +!!! question "自 sing-box 1.14.0 起" + +# JSON Schema + +sing-box 为配置文件提供 JSON Schema Draft 2020-12。 +兼容的编辑器可使用它提供补全和校验。 + +### 结构 + +```json +{ + "$schema": "https://sing-box.sagernet.org/schema.json" +} +``` + +### 字段 + +#### $schema + +兼容编辑器使用的 Schema URI。 +该字段不影响 sing-box 的运行行为。 + +随本文档发布的 Schema 位于 +[sing-box.sagernet.org/schema.json](https://sing-box.sagernet.org/schema.json)。 + +### 生成 + +使用以下命令生成与已安装的二进制文件匹配的 Schema: + +```bash +sing-box schema -o schema.json +``` + +未指定 `--output` 时,Schema 将写入标准输出。 +生成的 Schema 会反映当前构建中包含的功能。 + +之后可从配置文件中引用本地 Schema: + +```json +{ + "$schema": "./schema.json" +} +``` diff --git a/docs/configuration/shared/udp-nat.md b/docs/configuration/shared/udp-nat.md new file mode 100644 index 0000000000..3a3a15cabf --- /dev/null +++ b/docs/configuration/shared/udp-nat.md @@ -0,0 +1,67 @@ +--- +icon: material/new-box +--- + +!!! quote "Changes in sing-box 1.14.0" + + :material-plus: [udp_mapping](#udp_mapping) + :material-plus: [udp_filtering](#udp_filtering) + :material-plus: [udp_nat_max](#udp_nat_max) + +### Structure + +```json +{ + "udp_timeout": "5m", + "udp_mapping": "endpoint_independent", + "udp_filtering": "endpoint_independent", + "udp_nat_max": 0 +} +``` + +### Fields + +#### udp_timeout + +UDP NAT expiration time. + +`5m` will be used by default. + +#### udp_mapping + +!!! question "Since sing-box 1.14.0" + +UDP NAT mapping behavior. + +| Value | Behavior | +|------------------------------|-------------------------------------------------------------------------------| +| `endpoint_independent` | Reuse the same mapping for the same source address and port for all destinations. | +| `address_dependent` | Use a separate mapping for each destination address. | +| `address_and_port_dependent` | Use a separate mapping for each destination address and port. | + +`endpoint_independent` is used by default. + +#### udp_filtering + +!!! question "Since sing-box 1.14.0" + +UDP NAT filtering behavior. + +| Value | Behavior | +|------------------------------|-----------------------------------------------------------------------------| +| `endpoint_independent` | Accept packets from any remote endpoint. | +| `address_dependent` | Accept packets only from remote addresses to which packets have been sent. | +| `address_and_port_dependent` | Accept packets only from remote addresses and ports to which packets have been sent. | + +`endpoint_independent` is used by default. + +#### udp_nat_max + +!!! question "Since sing-box 1.14.0" + +Maximum number of UDP NAT sessions. + +When the limit is reached, the least recently used session is closed. + +When unset or set to `0`, `4096` is used on iOS. On other platforms, a value from `4096` to `16384` is selected based on total memory; +`16384` is used if total memory cannot be detected. diff --git a/docs/configuration/shared/udp-nat.zh.md b/docs/configuration/shared/udp-nat.zh.md new file mode 100644 index 0000000000..d6490241f5 --- /dev/null +++ b/docs/configuration/shared/udp-nat.zh.md @@ -0,0 +1,67 @@ +--- +icon: material/new-box +--- + +!!! quote "sing-box 1.14.0 中的更改" + + :material-plus: [udp_mapping](#udp_mapping) + :material-plus: [udp_filtering](#udp_filtering) + :material-plus: [udp_nat_max](#udp_nat_max) + +### 结构 + +```json +{ + "udp_timeout": "5m", + "udp_mapping": "endpoint_independent", + "udp_filtering": "endpoint_independent", + "udp_nat_max": 0 +} +``` + +### 字段 + +#### udp_timeout + +UDP NAT 过期时间。 + +默认使用 `5m`。 + +#### udp_mapping + +!!! question "自 sing-box 1.14.0 起" + +UDP NAT 映射行为。 + +| 值 | 行为 | +|------------------------------|----------------------------------------------------------| +| `endpoint_independent` | 对相同的源地址和端口,所有目标复用同一映射。 | +| `address_dependent` | 每个目标地址使用单独的映射。 | +| `address_and_port_dependent` | 每个目标地址和端口使用单独的映射。 | + +默认使用 `endpoint_independent`。 + +#### udp_filtering + +!!! question "自 sing-box 1.14.0 起" + +UDP NAT 过滤行为。 + +| 值 | 行为 | +|------------------------------|----------------------------------------------------------| +| `endpoint_independent` | 接受来自任意远程端点的数据包。 | +| `address_dependent` | 仅接受来自已向其发送过数据包的远程地址的数据包。 | +| `address_and_port_dependent` | 仅接受来自已向其发送过数据包的远程地址和端口的数据包。 | + +默认使用 `endpoint_independent`。 + +#### udp_nat_max + +!!! question "自 sing-box 1.14.0 起" + +UDP NAT 会话的最大数量。 + +达到限制时,将关闭最近最少使用的会话。 + +未设置或设置为 `0` 时,iOS 使用 `4096`。其他平台根据总内存在 `4096` 到 `16384` 之间选择; +无法检测总内存时使用 `16384`。 diff --git a/docs/installation/build-from-source.md b/docs/installation/build-from-source.md index 48b53b178d..ea799863a5 100644 --- a/docs/installation/build-from-source.md +++ b/docs/installation/build-from-source.md @@ -69,11 +69,10 @@ It is not recommended to change the default build tag list unless you really kno ## :material-wrench: Linker Flags -The following `-ldflags` are used in official builds: +The required linker flags for official builds are maintained in `release/LDFLAGS`. Downstream builds should use that file unchanged. | Flag | Description | |-------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `-X 'internal/godebug.defaultGODEBUG=multipathtcp=0'` | Go 1.24 enabled Multipath TCP for listeners by default (`multipathtcp=2`). This may cause errors on low-level sockets, and sing-box has its own MPTCP control (`tcp_multi_path` option). This flag disables the Go default. | | `-checklinkname=0` | Go 1.23+ linker rejects unauthorized `go:linkname` usage. This flag disables the check, required together with the `badlinkname` build tag. | ## :material-package-variant: For Downstream Packagers diff --git a/docs/installation/build-from-source.zh.md b/docs/installation/build-from-source.zh.md index 4ffebdc65d..cfd8ac76c3 100644 --- a/docs/installation/build-from-source.zh.md +++ b/docs/installation/build-from-source.zh.md @@ -73,11 +73,10 @@ go build -tags "tag_a tag_b" ./cmd/sing-box ## :material-wrench: 链接器标志 -以下 `-ldflags` 在官方构建中使用: +官方构建所需的链接器标志维护在 `release/LDFLAGS` 中。下游构建应原样使用该文件。 | 标志 | 说明 | |-------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `-X 'internal/godebug.defaultGODEBUG=multipathtcp=0'` | Go 1.24 默认为监听器启用 Multipath TCP(`multipathtcp=2`)。这可能在底层 socket 上导致错误,且 sing-box 有自己的 MPTCP 控制(`tcp_multi_path` 选项)。此标志禁用 Go 的默认行为。 | | `-checklinkname=0` | Go 1.23+ 链接器拒绝未授权的 `go:linkname` 使用。此标志禁用该检查,需要与 `badlinkname` 构建标记一起使用。 | ## :material-package-variant: 下游打包者 diff --git a/docs/schema.json b/docs/schema.json new file mode 100644 index 0000000000..e11edb7e09 --- /dev/null +++ b/docs/schema.json @@ -0,0 +1,17320 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://sing-box.sagernet.org/schema.json", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "examples": [ + "https://sing-box.sagernet.org/schema.json" + ] + }, + "log": { + "$ref": "#/$defs/LogOptions" + }, + "dns": { + "$ref": "#/$defs/DNS" + }, + "ntp": { + "$ref": "#/$defs/NTPOptions" + }, + "certificate": { + "type": "object", + "properties": { + "store": { + "type": "string", + "enum": [ + "system", + "mozilla", + "chrome", + "none" + ] + }, + "certificate": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "certificate_path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "certificate_directory_path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "additionalProperties": false + }, + "certificate_providers": { + "type": "array", + "items": { + "$ref": "#/$defs/CertificateProvider" + } + }, + "http_clients": { + "type": "array", + "items": { + "$ref": "#/$defs/HTTPClient" + } + }, + "network_namespaces": { + "type": "array", + "items": { + "$ref": "#/$defs/NetworkNamespace" + } + }, + "endpoints": { + "type": "array", + "items": { + "$ref": "#/$defs/Endpoint" + } + }, + "inbounds": { + "type": "array", + "items": { + "$ref": "#/$defs/Inbound" + } + }, + "outbounds": { + "type": "array", + "items": { + "$ref": "#/$defs/Outbound" + } + }, + "route": { + "$ref": "#/$defs/RouteOptions" + }, + "services": { + "type": "array", + "items": { + "$ref": "#/$defs/Service" + } + }, + "experimental": { + "$ref": "#/$defs/ExperimentalOptions" + } + }, + "additionalProperties": false, + "$defs": { + "ACMEExternalAccountOptions": { + "type": "object", + "properties": { + "key_id": { + "type": "string" + }, + "mac_key": { + "type": "string" + } + }, + "additionalProperties": false + }, + "ACMEProviderDNS01Challenge": { + "oneOf": [ + { + "type": "object", + "properties": { + "provider": { + "const": "alidns" + }, + "ttl": { + "$ref": "#/$defs/Duration" + }, + "propagation_delay": { + "$ref": "#/$defs/Duration" + }, + "propagation_timeout": { + "$ref": "#/$defs/Duration" + }, + "resolvers": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "override_domain": { + "type": "string" + }, + "access_key_id": { + "type": "string" + }, + "access_key_secret": { + "type": "string" + }, + "region_id": { + "type": "string" + }, + "security_token": { + "type": "string" + } + }, + "required": [ + "provider" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "provider": { + "const": "cloudflare" + }, + "ttl": { + "$ref": "#/$defs/Duration" + }, + "propagation_delay": { + "$ref": "#/$defs/Duration" + }, + "propagation_timeout": { + "$ref": "#/$defs/Duration" + }, + "resolvers": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "override_domain": { + "type": "string" + }, + "api_token": { + "type": "string" + }, + "zone_token": { + "type": "string" + } + }, + "required": [ + "provider" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "provider": { + "const": "acmedns" + }, + "ttl": { + "$ref": "#/$defs/Duration" + }, + "propagation_delay": { + "$ref": "#/$defs/Duration" + }, + "propagation_timeout": { + "$ref": "#/$defs/Duration" + }, + "resolvers": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "override_domain": { + "type": "string" + }, + "username": { + "type": "string" + }, + "password": { + "type": "string" + }, + "subdomain": { + "type": "string" + }, + "server_url": { + "type": "string" + } + }, + "required": [ + "provider" + ], + "additionalProperties": false + } + ] + }, + "AnyTLSUser": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "password": { + "type": "string" + } + }, + "additionalProperties": false + }, + "BrutalOptions": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "up_mbps": { + "type": "integer" + }, + "down_mbps": { + "type": "integer" + } + }, + "additionalProperties": false + }, + "CCMUser": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "token": { + "type": "string" + } + }, + "additionalProperties": false + }, + "CacheFileOptions": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "path": { + "type": "string" + }, + "cache_id": { + "type": "string" + }, + "store_fakeip": { + "type": "boolean" + }, + "rdrc_timeout": { + "$ref": "#/$defs/Duration" + }, + "store_dns": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "CertificateProvider": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "acme" + }, + "tag": { + "type": "string" + }, + "domain": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "data_directory": { + "type": "string" + }, + "default_server_name": { + "type": "string" + }, + "email": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "account_key": { + "type": "string" + }, + "disable_http_challenge": { + "type": "boolean" + }, + "disable_tls_alpn_challenge": { + "type": "boolean" + }, + "alternative_http_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "alternative_tls_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "external_account": { + "$ref": "#/$defs/ACMEExternalAccountOptions" + }, + "dns01_challenge": { + "$ref": "#/$defs/ACMEProviderDNS01Challenge" + }, + "key_type": { + "type": "string", + "enum": [ + "ed25519", + "p256", + "p384", + "rsa2048", + "rsa4096" + ] + }, + "profile": { + "type": "string" + }, + "http_client": { + "$ref": "#/$defs/HTTPClientReference" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "cloudflare-origin-ca" + }, + "tag": { + "type": "string" + }, + "domain": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "data_directory": { + "type": "string" + }, + "api_token": { + "type": "string" + }, + "origin_ca_key": { + "type": "string" + }, + "request_type": { + "type": "string", + "enum": [ + "origin-rsa", + "origin-ecc" + ] + }, + "requested_validity": { + "type": "integer", + "enum": [ + 0, + 7, + 30, + 90, + 365, + 730, + 1095, + 5475 + ], + "minimum": 0, + "maximum": 65535 + }, + "http_client": { + "$ref": "#/$defs/HTTPClientReference" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "tailscale" + }, + "tag": { + "type": "string" + }, + "endpoint": { + "type": "string" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + ] + }, + "CertificateProviderReference": { + "anyOf": [ + { + "type": "string", + "x-tag-reference": "certificate_provider" + }, + { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "acme" + }, + "domain": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "data_directory": { + "type": "string" + }, + "default_server_name": { + "type": "string" + }, + "email": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "account_key": { + "type": "string" + }, + "disable_http_challenge": { + "type": "boolean" + }, + "disable_tls_alpn_challenge": { + "type": "boolean" + }, + "alternative_http_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "alternative_tls_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "external_account": { + "$ref": "#/$defs/ACMEExternalAccountOptions" + }, + "dns01_challenge": { + "$ref": "#/$defs/ACMEProviderDNS01Challenge" + }, + "key_type": { + "type": "string", + "enum": [ + "ed25519", + "p256", + "p384", + "rsa2048", + "rsa4096" + ] + }, + "profile": { + "type": "string" + }, + "http_client": { + "$ref": "#/$defs/HTTPClientReference" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "cloudflare-origin-ca" + }, + "domain": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "data_directory": { + "type": "string" + }, + "api_token": { + "type": "string" + }, + "origin_ca_key": { + "type": "string" + }, + "request_type": { + "type": "string", + "enum": [ + "origin-rsa", + "origin-ecc" + ] + }, + "requested_validity": { + "type": "integer", + "enum": [ + 0, + 7, + 30, + 90, + 365, + 730, + 1095, + 5475 + ], + "minimum": 0, + "maximum": 65535 + }, + "http_client": { + "$ref": "#/$defs/HTTPClientReference" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "tailscale" + }, + "endpoint": { + "type": "string" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + ] + } + ] + }, + "ClashAPIOptions": { + "type": "object", + "properties": { + "external_controller": { + "type": "string" + }, + "external_ui": { + "type": "string" + }, + "external_ui_download_url": { + "type": "string" + }, + "external_ui_download_detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "secret": { + "type": "string" + }, + "default_mode": { + "type": "string" + }, + "access_control_allow_origin": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "access_control_allow_private_network": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "DERPMeshOptions": { + "type": "object", + "properties": { + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "host": { + "type": "string" + }, + "tls": { + "$ref": "#/$defs/OutboundTLSOptions" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + } + }, + "additionalProperties": false + }, + "DNS": { + "type": "object", + "properties": { + "servers": { + "type": "array", + "items": { + "$ref": "#/$defs/DNSServer" + } + }, + "rules": { + "type": "array", + "items": { + "$ref": "#/$defs/DNSRule" + } + }, + "final": { + "type": "string", + "x-tag-reference": "dns_server" + }, + "reverse_mapping": { + "type": "boolean" + }, + "strategy": { + "$ref": "#/$defs/DomainStrategy" + }, + "timeout": { + "$ref": "#/$defs/Duration" + }, + "disable_cache": { + "type": "boolean" + }, + "disable_expire": { + "type": "boolean" + }, + "cache_capacity": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "optimistic": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "timeout": { + "$ref": "#/$defs/Duration" + } + }, + "additionalProperties": false + } + ] + }, + "client_subnet": { + "type": "string" + } + }, + "additionalProperties": false + }, + "DNSQueryType": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + { + "type": "string", + "enum": [ + "A", + "AAAA", + "AFSDB", + "AMTRELAY", + "ANY", + "APL", + "ATMA", + "AVC", + "AXFR", + "CAA", + "CDNSKEY", + "CDS", + "CERT", + "CNAME", + "CSYNC", + "DHCID", + "DLV", + "DNAME", + "DNSKEY", + "DS", + "EID", + "EUI48", + "EUI64", + "GID", + "GPOS", + "HINFO", + "HIP", + "HTTPS", + "IPSECKEY", + "ISDN", + "IXFR", + "KEY", + "KX", + "L32", + "L64", + "LOC", + "LP", + "MAILA", + "MAILB", + "MB", + "MD", + "MF", + "MG", + "MINFO", + "MR", + "MX", + "NAPTR", + "NID", + "NIMLOC", + "NINFO", + "NS", + "NSAP-PTR", + "NSEC", + "NSEC3", + "NSEC3PARAM", + "NULL", + "NXNAME", + "NXT", + "None", + "OPENPGPKEY", + "OPT", + "PTR", + "PX", + "RESINFO", + "RKEY", + "RP", + "RRSIG", + "RT", + "Reserved", + "SIG", + "SMIMEA", + "SOA", + "SPF", + "SRV", + "SSHFP", + "SVCB", + "TA", + "TALINK", + "TKEY", + "TLSA", + "TSIG", + "TXT", + "UID", + "UINFO", + "UNSPEC", + "URI", + "X25", + "ZONEMD" + ] + } + ] + }, + "DNSRCode": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string", + "enum": [ + "NOERROR", + "FORMERR", + "SERVFAIL", + "NXDOMAIN", + "NOTIMP", + "NOTIMPL", + "REFUSED", + "YXDOMAIN", + "YXRRSET", + "NXRRSET", + "NOTAUTH", + "NOTZONE", + "DSOTYPENI", + "BADSIG", + "BADKEY", + "BADTIME", + "BADMODE", + "BADNAME", + "BADALG", + "BADTRUNC", + "BADCOOKIE" + ] + } + ] + }, + "DNSRule": { + "oneOf": [ + { + "type": "object", + "unevaluatedProperties": false, + "allOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "default", + "" + ] + }, + "inbound": { + "anyOf": [ + { + "type": "string", + "x-tag-reference": "inbound" + }, + { + "type": "array", + "items": { + "type": "string", + "x-tag-reference": "inbound" + } + } + ] + }, + "ip_version": { + "type": "integer", + "enum": [ + 4, + 6 + ] + }, + "query_type": { + "anyOf": [ + { + "$ref": "#/$defs/DNSQueryType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/DNSQueryType" + } + } + ] + }, + "network": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + } + } + ] + }, + "auth_user": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "protocol": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tls", + "http", + "quic", + "dns", + "stun", + "bittorrent", + "dtls", + "ssh", + "rdp", + "ntp" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "tls", + "http", + "quic", + "dns", + "stun", + "bittorrent", + "dtls", + "ssh", + "rdp", + "ntp" + ] + } + } + ] + }, + "domain": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "domain_suffix": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "domain_keyword": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "domain_regex": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "source_ip_cidr": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "source_ip_is_private": { + "type": "boolean" + }, + "source_port": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + } + } + ] + }, + "source_port_range": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "port": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + } + } + ] + }, + "port_range": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "process_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "process_path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "process_path_regex": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "package_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "package_name_regex": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "user": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "user_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "array", + "items": { + "type": "integer" + } + } + ] + }, + "clash_mode": { + "type": "string" + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "network_is_expensive": { + "type": "boolean" + }, + "network_is_constrained": { + "type": "boolean" + }, + "wifi_ssid": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "wifi_bssid": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "interface_address": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "network_interface_address": { + "type": "object", + "propertyNames": { + "$ref": "#/$defs/InterfaceType" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "default_interface_address": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "source_mac_address": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "source_hostname": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "preferred_by": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "rule_set": { + "anyOf": [ + { + "type": "string", + "x-tag-reference": "rule_set" + }, + { + "type": "array", + "items": { + "type": "string", + "x-tag-reference": "rule_set" + } + } + ] + }, + "rule_set_ip_cidr_match_source": { + "type": "boolean" + }, + "match_response": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + } + ] + }, + "ip_cidr": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "ip_is_private": { + "type": "boolean" + }, + "ip_accept_any": { + "type": "boolean" + }, + "response_rcode": { + "$ref": "#/$defs/DNSRCode" + }, + "response_answer": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "response_ns": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "response_extra": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "invert": { + "type": "boolean" + } + } + }, + { + "$ref": "#/$defs/DNSRuleAction" + } + ] + }, + { + "type": "object", + "unevaluatedProperties": false, + "allOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "logical" + }, + "mode": { + "type": "string", + "enum": [ + "and", + "or" + ] + }, + "rules": { + "type": "array", + "items": { + "$ref": "#/$defs/NestedDNSRule" + } + }, + "invert": { + "type": "boolean" + } + }, + "required": [ + "type", + "mode", + "rules" + ] + }, + { + "$ref": "#/$defs/DNSRuleAction" + } + ] + } + ] + }, + "DNSRuleAction": { + "oneOf": [ + { + "type": "object", + "properties": { + "action": { + "const": "route" + }, + "race": { + "type": "boolean" + }, + "server": { + "type": "string", + "x-tag-reference": "dns_server" + }, + "speculative": { + "type": "boolean" + }, + "timeout": { + "$ref": "#/$defs/Duration" + }, + "disable_cache": { + "type": "boolean" + }, + "disable_optimistic_cache": { + "type": "boolean" + }, + "rewrite_ttl": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "client_subnet": { + "type": "string" + } + } + }, + { + "type": "object", + "properties": { + "action": { + "const": "evaluate" + }, + "race": { + "type": "boolean" + }, + "server": { + "type": "string", + "x-tag-reference": "dns_server" + }, + "tag": { + "type": "string" + }, + "speculative": { + "type": "boolean" + }, + "timeout": { + "$ref": "#/$defs/Duration" + }, + "disable_cache": { + "type": "boolean" + }, + "disable_optimistic_cache": { + "type": "boolean" + }, + "rewrite_ttl": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "client_subnet": { + "type": "string" + } + }, + "required": [ + "action" + ] + }, + { + "type": "object", + "properties": { + "action": { + "const": "respond" + }, + "race": { + "type": "boolean" + } + }, + "required": [ + "action" + ] + }, + { + "type": "object", + "properties": { + "action": { + "const": "route-options" + }, + "race": { + "type": "boolean" + }, + "timeout": { + "$ref": "#/$defs/Duration" + }, + "disable_cache": { + "type": "boolean" + }, + "disable_optimistic_cache": { + "type": "boolean" + }, + "rewrite_ttl": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "client_subnet": { + "type": "string" + } + }, + "required": [ + "action" + ] + }, + { + "type": "object", + "properties": { + "action": { + "const": "reject" + }, + "race": { + "type": "boolean" + }, + "method": { + "type": "string", + "enum": [ + "", + "default", + "drop", + "reply" + ] + }, + "no_drop": { + "type": "boolean" + } + }, + "required": [ + "action" + ] + }, + { + "type": "object", + "properties": { + "action": { + "const": "predefined" + }, + "race": { + "type": "boolean" + }, + "rcode": { + "$ref": "#/$defs/DNSRCode" + }, + "answer": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "ns": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "extra": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "required": [ + "action" + ] + } + ] + }, + "DNSServer": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "dhcp" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "prefer_go": { + "type": "boolean" + }, + "neighbor_domain": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "interface": { + "type": "string" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "fakeip" + }, + "tag": { + "type": "string" + }, + "inet4_range": { + "type": "string", + "examples": [ + "198.18.0.0/15" + ] + }, + "inet6_range": { + "type": "string", + "examples": [ + "fc00::/18" + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "h3" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "tls": { + "$ref": "#/$defs/OutboundTLSOptions" + }, + "path": { + "type": "string" + }, + "method": { + "type": "string" + }, + "headers": { + "$ref": "#/$defs/HTTPHeader" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "hosts" + }, + "tag": { + "type": "string" + }, + "path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "predefined": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "https" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "tls": { + "$ref": "#/$defs/OutboundTLSOptions" + }, + "path": { + "type": "string" + }, + "method": { + "type": "string" + }, + "headers": { + "$ref": "#/$defs/HTTPHeader" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "local" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "prefer_go": { + "type": "boolean" + }, + "neighbor_domain": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "mdns" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "prefer_go": { + "type": "boolean" + }, + "neighbor_domain": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "interface": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "openconnect" + }, + "tag": { + "type": "string" + }, + "endpoint": { + "type": "string" + }, + "accept_default_resolvers": { + "type": "boolean" + }, + "accept_search_domain": { + "type": "boolean" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "openvpn" + }, + "tag": { + "type": "string" + }, + "endpoint": { + "type": "string" + }, + "accept_default_resolvers": { + "type": "boolean" + }, + "accept_search_domain": { + "type": "boolean" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "quic" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "tls": { + "$ref": "#/$defs/OutboundTLSOptions" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "resolved" + }, + "tag": { + "type": "string" + }, + "service": { + "type": "string" + }, + "accept_default_resolvers": { + "type": "boolean" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "tailscale" + }, + "tag": { + "type": "string" + }, + "endpoint": { + "type": "string" + }, + "accept_default_resolvers": { + "type": "boolean" + }, + "accept_search_domain": { + "type": "boolean" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "tcp" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "tls" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "tls": { + "$ref": "#/$defs/OutboundTLSOptions" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "udp" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + ] + }, + "DebugOptions": { + "type": "object", + "properties": { + "listen": { + "type": "string" + }, + "gc_percent": { + "type": "integer" + }, + "max_stack": { + "type": "integer" + }, + "max_threads": { + "type": "integer" + }, + "panic_on_fault": { + "type": "boolean" + }, + "trace_back": { + "type": "string" + }, + "memory_limit": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "oom_killer": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "DialerOptions": { + "type": "object", + "properties": { + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + } + }, + "additionalProperties": false + }, + "DomainResolver": { + "anyOf": [ + { + "type": "string", + "x-tag-reference": "dns_server" + }, + { + "type": "object", + "properties": { + "server": { + "type": "string", + "x-tag-reference": "dns_server" + }, + "timeout": { + "$ref": "#/$defs/Duration" + }, + "strategy": { + "$ref": "#/$defs/DomainStrategy" + }, + "disable_cache": { + "type": "boolean" + }, + "disable_optimistic_cache": { + "type": "boolean" + }, + "rewrite_ttl": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "client_subnet": { + "type": "string" + } + }, + "required": [ + "server" + ], + "additionalProperties": false + } + ] + }, + "DomainStrategy": { + "type": "string", + "enum": [ + "", + "as_is", + "prefer_ipv4", + "prefer_ipv6", + "ipv4_only", + "ipv6_only" + ] + }, + "Duration": { + "type": "string", + "pattern": "^[-+]?(((\\d+(\\.\\d*)?|\\.\\d+)(ns|us|µs|μs|ms|s|m|h|d))+|0)$" + }, + "Endpoint": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "openconnect" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "system": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "udp_timeout": { + "$ref": "#/$defs/Duration" + }, + "udp_mapping": { + "type": "string", + "enum": [ + "", + "endpoint_independent", + "address_dependent", + "address_and_port_dependent" + ] + }, + "udp_filtering": { + "type": "string", + "enum": [ + "", + "endpoint_independent", + "address_dependent", + "address_and_port_dependent" + ] + }, + "udp_nat_max": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "server": { + "type": "string" + }, + "flavor": { + "type": "string", + "enum": [ + "anyconnect", + "gp", + "fortinet", + "f5", + "pulse", + "nc" + ] + }, + "username": { + "type": "string" + }, + "password": { + "type": "string" + }, + "auth_group": { + "type": "string" + }, + "cookie": { + "type": "string" + }, + "token": { + "$ref": "#/$defs/OpenConnectTokenOptions" + }, + "reported_os": { + "type": "string" + }, + "user_agent": { + "type": "string" + }, + "version": { + "type": "string" + }, + "local_hostname": { + "type": "string" + }, + "mobile": { + "$ref": "#/$defs/OpenConnectMobileOptions" + }, + "csd": { + "$ref": "#/$defs/OpenConnectCSDOptions" + }, + "hip": { + "$ref": "#/$defs/OpenConnectHIPOptions" + }, + "tncc": { + "$ref": "#/$defs/OpenConnectTNCCOptions" + }, + "fortinet_host_check": { + "$ref": "#/$defs/OpenConnectFortinetHostCheckOptions" + }, + "no_udp": { + "type": "boolean" + }, + "dtls_local_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "compression_disabled": { + "type": "boolean" + }, + "compression_mode": { + "type": "string", + "enum": [ + "stateless", + "all" + ] + }, + "ipv6_disabled": { + "type": "boolean" + }, + "http_keepalive_disabled": { + "type": "boolean" + }, + "xml_post_disabled": { + "type": "boolean" + }, + "external_auth_disabled": { + "type": "boolean" + }, + "password_authentication_disabled": { + "type": "boolean" + }, + "tcp_keep_alive_enabled": { + "type": "boolean" + }, + "pfs": { + "type": "boolean" + }, + "mtu": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "base_mtu": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "dpd_interval": { + "$ref": "#/$defs/Duration" + }, + "reconnect_timeout": { + "$ref": "#/$defs/Duration" + }, + "trojan_interval": { + "$ref": "#/$defs/Duration" + }, + "queue_length": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "allow_insecure_crypto": { + "type": "boolean" + }, + "tls": { + "$ref": "#/$defs/OpenConnectTLSOptions" + }, + "form_entries": { + "type": "array", + "items": { + "$ref": "#/$defs/OpenConnectFormEntryOptions" + } + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "openvpn-client" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "system": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "mtu": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "udp_mapping": { + "type": "string", + "enum": [ + "", + "endpoint_independent", + "address_dependent", + "address_and_port_dependent" + ] + }, + "udp_filtering": { + "type": "string", + "enum": [ + "", + "endpoint_independent", + "address_dependent", + "address_and_port_dependent" + ] + }, + "udp_nat_max": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "mode": { + "type": "string", + "enum": [ + "tls", + "static_key" + ] + }, + "network": { + "type": "string", + "enum": [ + "udp", + "udp4", + "udp6", + "tcp", + "tcp4", + "tcp6" + ] + }, + "servers": { + "type": "array", + "items": { + "$ref": "#/$defs/OpenVPNRemoteOptions" + } + }, + "remote_random": { + "type": "boolean" + }, + "address": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "peer_address": { + "type": "string" + }, + "peer_address_ipv6": { + "type": "string" + }, + "topology": { + "type": "string", + "enum": [ + "net30", + "p2p", + "subnet" + ] + }, + "username": { + "type": "string" + }, + "password": { + "type": "string" + }, + "auth_retry": { + "type": "string", + "enum": [ + "none", + "nointeract", + "interact" + ] + }, + "static_challenge": { + "type": "string" + }, + "static_challenge_echo": { + "type": "boolean" + }, + "static_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "static_key_path": { + "type": "string" + }, + "key_direction": { + "type": "string", + "enum": [ + "server", + "client" + ] + }, + "tls": { + "$ref": "#/$defs/OpenVPNOutboundTLSOptions" + }, + "cipher": { + "type": "string" + }, + "data_ciphers": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "data_ciphers_fallback": { + "type": "string" + }, + "auth": { + "type": "string" + }, + "mss_fix": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "mss_fix_disabled": { + "type": "boolean" + }, + "mss_fix_mode": { + "type": "string", + "enum": [ + "mtu", + "fixed" + ] + }, + "fragment": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "replay_window": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "replay_window_time": { + "$ref": "#/$defs/Duration" + }, + "compression": { + "type": "string", + "enum": [ + "none", + "no", + "lz4", + "lz4-v2", + "stub", + "stub-v2", + "disabled", + "off" + ] + }, + "compression_lzo": { + "type": "string", + "enum": [ + "none", + "no", + "yes", + "adaptive", + "asym", + "disabled", + "off" + ] + }, + "allow_compression": { + "type": "string", + "enum": [ + "no", + "asym", + "yes" + ] + }, + "route_no_pull": { + "type": "boolean" + }, + "pull_filters": { + "type": "array", + "items": { + "$ref": "#/$defs/OpenVPNPullFilterOptions" + } + }, + "routes": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "route_gateway": { + "type": "string" + }, + "route_metric": { + "type": "integer" + }, + "redirect_gateway": { + "type": "boolean" + }, + "redirect_gateway_flags": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "redirect_private": { + "type": "boolean" + }, + "block_ipv6": { + "type": "boolean" + }, + "ping_interval": { + "$ref": "#/$defs/Duration" + }, + "ping_restart": { + "$ref": "#/$defs/Duration" + }, + "ping_restart_disabled": { + "type": "boolean" + }, + "renegotiate_interval": { + "$ref": "#/$defs/Duration" + }, + "renegotiate_disabled": { + "type": "boolean" + }, + "renegotiate_bytes": { + "type": "integer", + "minimum": 0 + }, + "renegotiate_packets": { + "type": "integer", + "minimum": 0 + }, + "tls_timeout": { + "$ref": "#/$defs/Duration" + }, + "handshake_window": { + "$ref": "#/$defs/Duration" + }, + "explicit_exit_notify": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "openvpn-server" + }, + "tag": { + "type": "string" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + }, + "system": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "mtu": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "udp_mapping": { + "type": "string", + "enum": [ + "", + "endpoint_independent", + "address_dependent", + "address_and_port_dependent" + ] + }, + "udp_filtering": { + "type": "string", + "enum": [ + "", + "endpoint_independent", + "address_dependent", + "address_and_port_dependent" + ] + }, + "udp_nat_max": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "mode": { + "type": "string", + "enum": [ + "tls", + "static_key" + ] + }, + "network": { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + }, + "remote": { + "type": "string" + }, + "remote_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "max_clients": { + "type": "integer" + }, + "address": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "peer_address": { + "type": "string" + }, + "peer_address_ipv6": { + "type": "string" + }, + "topology": { + "type": "string", + "enum": [ + "net30", + "p2p", + "subnet" + ] + }, + "duplicate_cn": { + "type": "boolean" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/$defs/User" + } + }, + "static_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "static_key_path": { + "type": "string" + }, + "key_direction": { + "type": "string", + "enum": [ + "server", + "client" + ] + }, + "tls": { + "$ref": "#/$defs/OpenVPNInboundTLSOptions" + }, + "cipher": { + "type": "string" + }, + "data_ciphers": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "data_ciphers_fallback": { + "type": "string" + }, + "auth": { + "type": "string" + }, + "mss_fix": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "mss_fix_disabled": { + "type": "boolean" + }, + "mss_fix_mode": { + "type": "string", + "enum": [ + "mtu", + "fixed" + ] + }, + "replay_window": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "replay_window_time": { + "$ref": "#/$defs/Duration" + }, + "push": { + "$ref": "#/$defs/OpenVPNPushOptions" + }, + "ping_interval": { + "$ref": "#/$defs/Duration" + }, + "ping_restart": { + "$ref": "#/$defs/Duration" + }, + "renegotiate_interval": { + "$ref": "#/$defs/Duration" + }, + "renegotiate_disabled": { + "type": "boolean" + }, + "renegotiate_bytes": { + "type": "integer", + "minimum": 0 + }, + "renegotiate_packets": { + "type": "integer", + "minimum": 0 + }, + "handshake_window": { + "$ref": "#/$defs/Duration" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "tailscale" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "state_directory": { + "type": "string" + }, + "auth_key": { + "type": "string" + }, + "control_url": { + "type": "string" + }, + "ephemeral": { + "type": "boolean" + }, + "hostname": { + "type": "string" + }, + "accept_routes": { + "type": "boolean" + }, + "exit_node": { + "type": "string" + }, + "exit_node_allow_lan_access": { + "type": "boolean" + }, + "advertise_routes": { + "type": "array", + "items": { + "type": "string" + } + }, + "advertise_exit_node": { + "type": "boolean" + }, + "advertise_tags": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "relay_server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "relay_server_static_endpoints": { + "type": "array", + "items": { + "type": "string" + } + }, + "system_interface": { + "type": "boolean" + }, + "system_interface_name": { + "type": "string" + }, + "system_interface_mtu": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "ssh_server": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "disable_pty": { + "type": "boolean" + }, + "disable_sftp": { + "type": "boolean" + }, + "disable_forwarding": { + "type": "boolean" + } + }, + "additionalProperties": false + } + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "wireguard" + }, + "tag": { + "type": "string" + }, + "system": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "mtu": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "address": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "private_key": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "peers": { + "type": "array", + "items": { + "$ref": "#/$defs/WireGuardPeer" + } + }, + "udp_timeout": { + "$ref": "#/$defs/Duration" + }, + "udp_mapping": { + "type": "string", + "enum": [ + "", + "endpoint_independent", + "address_dependent", + "address_and_port_dependent" + ] + }, + "udp_filtering": { + "type": "string", + "enum": [ + "", + "endpoint_independent", + "address_dependent", + "address_and_port_dependent" + ] + }, + "udp_nat_max": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "workers": { + "type": "integer" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + ] + }, + "ExperimentalOptions": { + "type": "object", + "properties": { + "cache_file": { + "$ref": "#/$defs/CacheFileOptions" + }, + "clash_api": { + "$ref": "#/$defs/ClashAPIOptions" + }, + "v2ray_api": { + "$ref": "#/$defs/V2RayAPIOptions" + }, + "debug": { + "$ref": "#/$defs/DebugOptions" + } + }, + "additionalProperties": false + }, + "HTTPClient": { + "type": "object", + "properties": { + "tag": { + "type": "string" + }, + "engine": { + "type": "string", + "enum": [ + "go", + "apple" + ] + }, + "version": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3 + ] + }, + "disable_version_fallback": { + "type": "boolean" + }, + "headers": { + "$ref": "#/$defs/HTTPHeader" + }, + "tls": { + "$ref": "#/$defs/OutboundTLSOptions" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "idle_timeout": { + "$ref": "#/$defs/Duration" + }, + "keep_alive_period": { + "$ref": "#/$defs/Duration" + }, + "stream_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "connection_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "max_concurrent_streams": { + "type": "integer" + }, + "initial_packet_size": { + "type": "integer" + }, + "disable_path_mtu_discovery": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "HTTPClientReference": { + "anyOf": [ + { + "type": "string", + "x-tag-reference": "http_client" + }, + { + "type": "object", + "properties": { + "engine": { + "type": "string", + "enum": [ + "go", + "apple" + ] + }, + "version": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3 + ] + }, + "disable_version_fallback": { + "type": "boolean" + }, + "headers": { + "$ref": "#/$defs/HTTPHeader" + }, + "tls": { + "$ref": "#/$defs/OutboundTLSOptions" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "idle_timeout": { + "$ref": "#/$defs/Duration" + }, + "keep_alive_period": { + "$ref": "#/$defs/Duration" + }, + "stream_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "connection_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "max_concurrent_streams": { + "type": "integer" + }, + "initial_packet_size": { + "type": "integer" + }, + "disable_path_mtu_discovery": { + "type": "boolean" + } + }, + "additionalProperties": false + } + ] + }, + "HTTPHeader": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "HTTPProxyOptions": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bypass_domain": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "match_domain": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "additionalProperties": false + }, + "HeadlessRule": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "default", + "" + ] + }, + "query_type": { + "anyOf": [ + { + "$ref": "#/$defs/DNSQueryType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/DNSQueryType" + } + } + ] + }, + "network": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tcp", + "udp", + "icmp" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "tcp", + "udp", + "icmp" + ] + } + } + ] + }, + "domain": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "domain_suffix": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "domain_keyword": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "domain_regex": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "source_ip_cidr": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "ip_cidr": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "source_port": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + } + } + ] + }, + "source_port_range": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "port": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + } + } + ] + }, + "port_range": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "process_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "process_path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "process_path_regex": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "package_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "package_name_regex": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "network_is_expensive": { + "type": "boolean" + }, + "network_is_constrained": { + "type": "boolean" + }, + "wifi_ssid": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "wifi_bssid": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "network_interface_address": { + "type": "object", + "propertyNames": { + "$ref": "#/$defs/InterfaceType" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "default_interface_address": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "invert": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "logical" + }, + "mode": { + "type": "string", + "enum": [ + "and", + "or" + ] + }, + "rules": { + "type": "array", + "items": { + "$ref": "#/$defs/HeadlessRule" + } + }, + "invert": { + "type": "boolean" + } + }, + "required": [ + "type", + "mode", + "rules" + ], + "additionalProperties": false + } + ] + }, + "Hysteria2InboundRealm": { + "type": "object", + "properties": { + "server_url": { + "type": "string" + }, + "token": { + "type": "string" + }, + "realm_id": { + "type": "string" + }, + "stun_servers": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "ip_version": { + "type": "integer", + "enum": [ + 0, + 4, + 6 + ] + }, + "port_mapping": { + "$ref": "#/$defs/Hysteria2RealmPortMapping" + }, + "http_client": { + "$ref": "#/$defs/HTTPClientReference" + }, + "stun_domain_resolver": { + "$ref": "#/$defs/DomainResolver" + } + }, + "additionalProperties": false + }, + "Hysteria2Realm": { + "type": "object", + "properties": { + "server_url": { + "type": "string" + }, + "token": { + "type": "string" + }, + "realm_id": { + "type": "string" + }, + "stun_servers": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "ip_version": { + "type": "integer", + "enum": [ + 0, + 4, + 6 + ] + }, + "port_mapping": { + "$ref": "#/$defs/Hysteria2RealmPortMapping" + }, + "http_client": { + "$ref": "#/$defs/HTTPClientReference" + } + }, + "additionalProperties": false + }, + "Hysteria2RealmPortMapping": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "timeout": { + "$ref": "#/$defs/Duration" + }, + "lifetime": { + "$ref": "#/$defs/Duration" + } + }, + "additionalProperties": false + }, + "Hysteria2User": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "password": { + "type": "string" + } + }, + "additionalProperties": false + }, + "HysteriaRealmUser": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "token": { + "type": "string" + }, + "max_realms": { + "type": "integer" + } + }, + "additionalProperties": false + }, + "HysteriaUser": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "auth": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + } + } + ] + }, + "auth_str": { + "type": "string" + } + }, + "additionalProperties": false + }, + "Inbound": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "anytls" + }, + "tag": { + "type": "string" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + }, + "tls": { + "$ref": "#/$defs/InboundTLSOptions" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/$defs/AnyTLSUser" + } + }, + "padding_scheme": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "cloudflared" + }, + "tag": { + "type": "string" + }, + "token": { + "type": "string" + }, + "ha_connections": { + "type": "integer" + }, + "protocol": { + "type": "string", + "enum": [ + "auto", + "quic", + "http2", + "h2mux" + ] + }, + "post_quantum": { + "type": "boolean" + }, + "edge_ip_version": { + "type": "integer", + "enum": [ + 0, + 4, + 6 + ] + }, + "datagram_version": { + "type": "string", + "enum": [ + "v2", + "v3" + ] + }, + "grace_period": { + "$ref": "#/$defs/Duration" + }, + "region": { + "type": "string" + }, + "control_dialer": { + "$ref": "#/$defs/DialerOptions" + }, + "tunnel_dialer": { + "$ref": "#/$defs/DialerOptions" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "direct" + }, + "tag": { + "type": "string" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + }, + "network": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + } + } + ] + }, + "override_address": { + "type": "string" + }, + "override_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "http" + }, + "tag": { + "type": "string" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/$defs/User" + } + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "set_system_proxy": { + "type": "boolean" + }, + "tls": { + "$ref": "#/$defs/InboundTLSOptions" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "hysteria" + }, + "tag": { + "type": "string" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + }, + "up": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "up_mbps": { + "type": "integer" + }, + "down": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "down_mbps": { + "type": "integer" + }, + "obfs": { + "type": "string" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/$defs/HysteriaUser" + } + }, + "tls": { + "$ref": "#/$defs/InboundTLSOptions" + }, + "idle_timeout": { + "$ref": "#/$defs/Duration" + }, + "keep_alive_period": { + "$ref": "#/$defs/Duration" + }, + "stream_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "connection_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "max_concurrent_streams": { + "type": "integer" + }, + "initial_packet_size": { + "type": "integer" + }, + "disable_path_mtu_discovery": { + "type": "boolean" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "hysteria2" + }, + "tag": { + "type": "string" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + }, + "up_mbps": { + "type": "integer" + }, + "down_mbps": { + "type": "integer" + }, + "obfs": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "salamander" + }, + "password": { + "type": "string" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "gecko" + }, + "password": { + "type": "string" + }, + "min_packet_size": { + "type": "integer" + }, + "max_packet_size": { + "type": "integer" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + ] + }, + "users": { + "type": "array", + "items": { + "$ref": "#/$defs/Hysteria2User" + } + }, + "ignore_client_bandwidth": { + "type": "boolean" + }, + "tls": { + "$ref": "#/$defs/InboundTLSOptions" + }, + "idle_timeout": { + "$ref": "#/$defs/Duration" + }, + "keep_alive_period": { + "$ref": "#/$defs/Duration" + }, + "stream_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "connection_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "max_concurrent_streams": { + "type": "integer" + }, + "initial_packet_size": { + "type": "integer" + }, + "disable_path_mtu_discovery": { + "type": "boolean" + }, + "masquerade": { + "anyOf": [ + { + "type": "string" + }, + { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "file" + }, + "directory": { + "type": "string" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "proxy" + }, + "url": { + "type": "string" + }, + "rewrite_host": { + "type": "boolean" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "string" + }, + "status_code": { + "type": "integer" + }, + "headers": { + "$ref": "#/$defs/HTTPHeader" + }, + "content": { + "type": "string" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + ] + } + ] + }, + "bbr_profile": { + "type": "string", + "enum": [ + "standard", + "conservative", + "aggressive" + ] + }, + "brutal_debug": { + "type": "boolean" + }, + "realm": { + "$ref": "#/$defs/Hysteria2InboundRealm" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "mixed" + }, + "tag": { + "type": "string" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/$defs/User" + } + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "set_system_proxy": { + "type": "boolean" + }, + "tls": { + "$ref": "#/$defs/InboundTLSOptions" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "naive" + }, + "tag": { + "type": "string" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/$defs/User" + } + }, + "network": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + } + } + ] + }, + "quic_congestion_control": { + "type": "string", + "enum": [ + "bbr", + "bbr_standard", + "bbr2", + "bbr2_variant", + "cubic", + "reno" + ] + }, + "tls": { + "$ref": "#/$defs/InboundTLSOptions" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "redirect" + }, + "tag": { + "type": "string" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "shadowsocks" + }, + "tag": { + "type": "string" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + }, + "network": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + } + } + ] + }, + "method": { + "type": "string", + "enum": [ + "none", + "aes-128-gcm", + "aes-192-gcm", + "aes-256-gcm", + "chacha20-ietf-poly1305", + "xchacha20-ietf-poly1305", + "2022-blake3-aes-128-gcm", + "2022-blake3-aes-256-gcm", + "2022-blake3-chacha20-poly1305" + ] + }, + "password": { + "type": "string" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/$defs/ShadowsocksUser" + } + }, + "destinations": { + "type": "array", + "items": { + "$ref": "#/$defs/ShadowsocksDestination" + } + }, + "multiplex": { + "$ref": "#/$defs/InboundMultiplexOptions" + }, + "managed": { + "type": "boolean" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "shadowtls" + }, + "tag": { + "type": "string" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + }, + "version": { + "type": "integer", + "enum": [ + 1, + 2, + 3 + ] + }, + "password": { + "type": "string" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/$defs/ShadowTLSUser" + } + }, + "handshake": { + "$ref": "#/$defs/ShadowTLSHandshakeOptions" + }, + "handshake_for_server_name": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/ShadowTLSHandshakeOptions" + } + }, + "strict_mode": { + "type": "boolean" + }, + "wildcard_sni": { + "type": "string", + "enum": [ + "", + "off", + "authed", + "all" + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "snell" + }, + "tag": { + "type": "string" + }, + "version": { + "const": 5 + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + }, + "psk": { + "type": "string" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/$defs/SnellUser" + } + }, + "obfs_mode": { + "type": "string", + "enum": [ + "none", + "http", + "tls" + ] + } + }, + "required": [ + "type", + "version" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "snell" + }, + "tag": { + "type": "string" + }, + "version": { + "const": 6 + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + }, + "psk": { + "type": "string" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/$defs/SnellUser" + } + }, + "mode": { + "type": "string", + "enum": [ + "default", + "unshaped", + "unsafe-raw" + ] + } + }, + "required": [ + "type", + "version" + ], + "additionalProperties": false + } + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "socks" + }, + "tag": { + "type": "string" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/$defs/User" + } + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "tproxy" + }, + "tag": { + "type": "string" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + }, + "network": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + } + } + ] + }, + "udp_mapping": { + "type": "string", + "enum": [ + "", + "endpoint_independent", + "address_dependent", + "address_and_port_dependent" + ] + }, + "udp_filtering": { + "type": "string", + "enum": [ + "", + "endpoint_independent", + "address_dependent", + "address_and_port_dependent" + ] + }, + "udp_nat_max": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "trojan" + }, + "tag": { + "type": "string" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/$defs/TrojanUser" + } + }, + "tls": { + "$ref": "#/$defs/InboundTLSOptions" + }, + "fallback": { + "$ref": "#/$defs/ServerOptions" + }, + "fallback_for_alpn": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/ServerOptions" + } + }, + "multiplex": { + "$ref": "#/$defs/InboundMultiplexOptions" + }, + "transport": { + "$ref": "#/$defs/V2RayTransport" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "tuic" + }, + "tag": { + "type": "string" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/$defs/TUICUser" + } + }, + "congestion_control": { + "type": "string", + "enum": [ + "cubic", + "new_reno", + "bbr" + ] + }, + "auth_timeout": { + "$ref": "#/$defs/Duration" + }, + "zero_rtt_handshake": { + "type": "boolean" + }, + "heartbeat": { + "$ref": "#/$defs/Duration" + }, + "tls": { + "$ref": "#/$defs/InboundTLSOptions" + }, + "idle_timeout": { + "$ref": "#/$defs/Duration" + }, + "keep_alive_period": { + "$ref": "#/$defs/Duration" + }, + "stream_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "connection_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "max_concurrent_streams": { + "type": "integer" + }, + "initial_packet_size": { + "type": "integer" + }, + "disable_path_mtu_discovery": { + "type": "boolean" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "tun" + }, + "tag": { + "type": "string" + }, + "interface_name": { + "type": "string" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "mtu": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "address": { + "anyOf": [ + { + "type": "string", + "examples": [ + "172.19.0.1/30", + "fdfe:dcba:9876::1/126" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "examples": [ + "172.19.0.1/30", + "fdfe:dcba:9876::1/126" + ] + } + } + ] + }, + "dns_mode": { + "type": "string", + "enum": [ + "disabled", + "native", + "hijack" + ] + }, + "dns_address": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "auto_route": { + "type": "boolean" + }, + "iproute2_table_index": { + "type": "integer" + }, + "iproute2_rule_index": { + "type": "integer" + }, + "auto_redirect": { + "type": "boolean" + }, + "auto_redirect_input_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "auto_redirect_output_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "auto_redirect_reset_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "auto_redirect_nfqueue": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "auto_redirect_iproute2_fallback_rule_index": { + "type": "integer" + }, + "exclude_mptcp": { + "type": "boolean" + }, + "loopback_address": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "strict_route": { + "type": "boolean" + }, + "route_address": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "route_address_set": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "route_exclude_address": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "route_exclude_address_set": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "include_interface": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "exclude_interface": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "include_uid": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + } + } + ] + }, + "include_uid_range": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "exclude_uid": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + } + } + ] + }, + "exclude_uid_range": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "include_android_user": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "array", + "items": { + "type": "integer" + } + } + ] + }, + "include_package": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "exclude_package": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "include_mac_address": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "exclude_mac_address": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "udp_mapping": { + "type": "string", + "enum": [ + "", + "endpoint_independent", + "address_dependent", + "address_and_port_dependent" + ] + }, + "udp_filtering": { + "type": "string", + "enum": [ + "", + "endpoint_independent", + "address_dependent", + "address_and_port_dependent" + ] + }, + "udp_nat_max": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "stack": { + "type": "string", + "enum": [ + "system", + "gvisor", + "mixed" + ] + }, + "platform": { + "$ref": "#/$defs/TunPlatformOptions" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "vless" + }, + "tag": { + "type": "string" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/$defs/VLESSUser" + } + }, + "tls": { + "$ref": "#/$defs/InboundTLSOptions" + }, + "multiplex": { + "$ref": "#/$defs/InboundMultiplexOptions" + }, + "transport": { + "$ref": "#/$defs/V2RayTransport" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "vmess" + }, + "tag": { + "type": "string" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/$defs/VMessUser" + } + }, + "tls": { + "$ref": "#/$defs/InboundTLSOptions" + }, + "multiplex": { + "$ref": "#/$defs/InboundMultiplexOptions" + }, + "transport": { + "$ref": "#/$defs/V2RayTransport" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + ] + }, + "InboundECHOptions": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "key_path": { + "type": "string" + } + }, + "additionalProperties": false + }, + "InboundMultiplexOptions": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "padding": { + "type": "boolean" + }, + "brutal": { + "$ref": "#/$defs/BrutalOptions" + } + }, + "additionalProperties": false + }, + "InboundRealityHandshakeOptions": { + "type": "object", + "properties": { + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + } + }, + "additionalProperties": false + }, + "InboundRealityOptions": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "handshake": { + "$ref": "#/$defs/InboundRealityHandshakeOptions" + }, + "private_key": { + "type": "string" + }, + "short_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "max_time_difference": { + "$ref": "#/$defs/Duration" + } + }, + "additionalProperties": false + }, + "InboundTLSOptions": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "server_name": { + "type": "string" + }, + "insecure": { + "type": "boolean" + }, + "alpn": { + "anyOf": [ + { + "type": "string", + "examples": [ + "http/1.1", + "h2", + "h3" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "examples": [ + "http/1.1", + "h2", + "h3" + ] + } + } + ] + }, + "min_version": { + "type": "string", + "enum": [ + "1.0", + "1.1", + "1.2", + "1.3" + ] + }, + "max_version": { + "type": "string", + "enum": [ + "1.0", + "1.1", + "1.2", + "1.3" + ] + }, + "cipher_suites": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "curve_preferences": { + "anyOf": [ + { + "type": "string", + "enum": [ + "P256", + "P384", + "P521", + "X25519", + "X25519MLKEM768" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "P256", + "P384", + "P521", + "X25519", + "X25519MLKEM768" + ] + } + } + ] + }, + "certificate": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "certificate_path": { + "type": "string" + }, + "client_authentication": { + "type": "string", + "enum": [ + "no", + "request", + "require-any", + "verify-if-given", + "require-and-verify" + ] + }, + "client_certificate": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "client_certificate_path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "client_certificate_public_key_sha256": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + } + } + ] + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + } + } + ] + } + } + ] + }, + "key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "key_path": { + "type": "string" + }, + "kernel_tx": { + "type": "boolean" + }, + "kernel_rx": { + "type": "boolean" + }, + "handshake_timeout": { + "$ref": "#/$defs/Duration" + }, + "certificate_provider": { + "$ref": "#/$defs/CertificateProviderReference" + }, + "ech": { + "$ref": "#/$defs/InboundECHOptions" + }, + "reality": { + "$ref": "#/$defs/InboundRealityOptions" + } + }, + "additionalProperties": false + }, + "InterfaceType": { + "type": "string", + "enum": [ + "cellular", + "ethernet", + "other", + "wifi" + ] + }, + "LogOptions": { + "type": "object", + "properties": { + "disabled": { + "type": "boolean" + }, + "level": { + "type": "string", + "enum": [ + "trace", + "debug", + "info", + "warn", + "warning", + "error", + "fatal", + "panic" + ] + }, + "output": { + "type": "string" + }, + "timestamp": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "NTPOptions": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "interval": { + "$ref": "#/$defs/Duration" + }, + "write_to_system": { + "type": "boolean" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + } + }, + "additionalProperties": false + }, + "NestedDNSRule": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "default", + "" + ] + }, + "inbound": { + "anyOf": [ + { + "type": "string", + "x-tag-reference": "inbound" + }, + { + "type": "array", + "items": { + "type": "string", + "x-tag-reference": "inbound" + } + } + ] + }, + "ip_version": { + "type": "integer", + "enum": [ + 4, + 6 + ] + }, + "query_type": { + "anyOf": [ + { + "$ref": "#/$defs/DNSQueryType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/DNSQueryType" + } + } + ] + }, + "network": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + } + } + ] + }, + "auth_user": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "protocol": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tls", + "http", + "quic", + "dns", + "stun", + "bittorrent", + "dtls", + "ssh", + "rdp", + "ntp" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "tls", + "http", + "quic", + "dns", + "stun", + "bittorrent", + "dtls", + "ssh", + "rdp", + "ntp" + ] + } + } + ] + }, + "domain": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "domain_suffix": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "domain_keyword": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "domain_regex": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "source_ip_cidr": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "source_ip_is_private": { + "type": "boolean" + }, + "source_port": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + } + } + ] + }, + "source_port_range": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "port": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + } + } + ] + }, + "port_range": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "process_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "process_path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "process_path_regex": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "package_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "package_name_regex": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "user": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "user_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "array", + "items": { + "type": "integer" + } + } + ] + }, + "clash_mode": { + "type": "string" + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "network_is_expensive": { + "type": "boolean" + }, + "network_is_constrained": { + "type": "boolean" + }, + "wifi_ssid": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "wifi_bssid": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "interface_address": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "network_interface_address": { + "type": "object", + "propertyNames": { + "$ref": "#/$defs/InterfaceType" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "default_interface_address": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "source_mac_address": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "source_hostname": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "preferred_by": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "rule_set": { + "anyOf": [ + { + "type": "string", + "x-tag-reference": "rule_set" + }, + { + "type": "array", + "items": { + "type": "string", + "x-tag-reference": "rule_set" + } + } + ] + }, + "rule_set_ip_cidr_match_source": { + "type": "boolean" + }, + "match_response": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + } + ] + }, + "ip_cidr": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "ip_is_private": { + "type": "boolean" + }, + "ip_accept_any": { + "type": "boolean" + }, + "response_rcode": { + "$ref": "#/$defs/DNSRCode" + }, + "response_answer": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "response_ns": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "response_extra": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "invert": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "logical" + }, + "mode": { + "type": "string", + "enum": [ + "and", + "or" + ] + }, + "rules": { + "type": "array", + "items": { + "$ref": "#/$defs/NestedDNSRule" + } + }, + "invert": { + "type": "boolean" + } + }, + "required": [ + "type", + "mode", + "rules" + ], + "additionalProperties": false + } + ] + }, + "NestedRule": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "default", + "" + ] + }, + "inbound": { + "anyOf": [ + { + "type": "string", + "x-tag-reference": "inbound" + }, + { + "type": "array", + "items": { + "type": "string", + "x-tag-reference": "inbound" + } + } + ] + }, + "ip_version": { + "type": "integer", + "enum": [ + 4, + 6 + ] + }, + "network": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tcp", + "udp", + "icmp" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "tcp", + "udp", + "icmp" + ] + } + } + ] + }, + "auth_user": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "protocol": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tls", + "http", + "quic", + "dns", + "stun", + "bittorrent", + "dtls", + "ssh", + "rdp", + "ntp" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "tls", + "http", + "quic", + "dns", + "stun", + "bittorrent", + "dtls", + "ssh", + "rdp", + "ntp" + ] + } + } + ] + }, + "client": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "domain": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "domain_suffix": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "domain_keyword": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "domain_regex": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "source_ip_cidr": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "source_ip_is_private": { + "type": "boolean" + }, + "ip_cidr": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "ip_is_private": { + "type": "boolean" + }, + "source_port": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + } + } + ] + }, + "source_port_range": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "port": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + } + } + ] + }, + "port_range": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "process_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "process_path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "process_path_regex": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "package_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "package_name_regex": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "user": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "user_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "array", + "items": { + "type": "integer" + } + } + ] + }, + "clash_mode": { + "type": "string" + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "network_is_expensive": { + "type": "boolean" + }, + "network_is_constrained": { + "type": "boolean" + }, + "wifi_ssid": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "wifi_bssid": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "interface_address": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "network_interface_address": { + "type": "object", + "propertyNames": { + "$ref": "#/$defs/InterfaceType" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "default_interface_address": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "source_mac_address": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "source_hostname": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "preferred_by": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "rule_set": { + "anyOf": [ + { + "type": "string", + "x-tag-reference": "rule_set" + }, + { + "type": "array", + "items": { + "type": "string", + "x-tag-reference": "rule_set" + } + } + ] + }, + "rule_set_ip_cidr_match_source": { + "type": "boolean" + }, + "invert": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "logical" + }, + "mode": { + "type": "string", + "enum": [ + "and", + "or" + ] + }, + "rules": { + "type": "array", + "items": { + "$ref": "#/$defs/NestedRule" + } + }, + "invert": { + "type": "boolean" + } + }, + "required": [ + "type", + "mode", + "rules" + ], + "additionalProperties": false + } + ] + }, + "NetworkNamespace": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "default", + "" + ] + }, + "tag": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "tag" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "unshare" + }, + "tag": { + "type": "string" + }, + "pid_file": { + "type": "string" + } + }, + "required": [ + "type", + "tag" + ], + "additionalProperties": false + } + ] + }, + "OCMUser": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "token": { + "type": "string" + } + }, + "additionalProperties": false + }, + "OpenConnectCSDOptions": { + "type": "object", + "properties": { + "wrapper_path": { + "type": "string" + } + }, + "additionalProperties": false + }, + "OpenConnectFormEntryOptions": { + "type": "object", + "properties": { + "form_id": { + "type": "string" + }, + "submission_key": { + "type": "string" + }, + "name": { + "type": "string" + }, + "value": { + "type": "string" + }, + "promote": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "OpenConnectFortinetHostCheckOptions": { + "type": "object", + "properties": { + "hostcheck": { + "type": "string" + }, + "check_virtual_desktop": { + "type": "string" + } + }, + "additionalProperties": false + }, + "OpenConnectHIPOptions": { + "type": "object", + "properties": { + "wrapper_path": { + "type": "string" + } + }, + "additionalProperties": false + }, + "OpenConnectMobileOptions": { + "type": "object", + "properties": { + "platform_version": { + "type": "string" + }, + "device_type": { + "type": "string" + }, + "device_unique_id": { + "type": "string" + } + }, + "additionalProperties": false + }, + "OpenConnectTLSOptions": { + "type": "object", + "properties": { + "insecure": { + "type": "boolean" + }, + "server_name": { + "type": "string" + }, + "peer_fingerprint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "system_trust_disabled": { + "type": "boolean" + }, + "certificate_authority": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "certificate_authority_path": { + "type": "string" + }, + "client_certificate": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "client_certificate_path": { + "type": "string" + }, + "client_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "client_key_path": { + "type": "string" + }, + "client_key_password": { + "type": "string" + }, + "mca_certificate": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "mca_certificate_path": { + "type": "string" + }, + "mca_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "mca_key_path": { + "type": "string" + }, + "mca_key_password": { + "type": "string" + } + }, + "additionalProperties": false + }, + "OpenConnectTNCCCertificateOptions": { + "type": "object", + "properties": { + "certificate": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "certificate_path": { + "type": "string" + } + }, + "additionalProperties": false + }, + "OpenConnectTNCCOptions": { + "type": "object", + "properties": { + "wrapper_path": { + "type": "string" + }, + "device_id": { + "type": "string" + }, + "user_agent": { + "type": "string" + }, + "machine_identification_enabled": { + "type": "boolean" + }, + "certificates": { + "type": "array", + "items": { + "$ref": "#/$defs/OpenConnectTNCCCertificateOptions" + } + } + }, + "additionalProperties": false + }, + "OpenConnectTokenOptions": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": [ + "totp", + "hotp", + "stoken", + "oidc" + ] + }, + "secret": { + "type": "string" + }, + "secret_path": { + "type": "string" + }, + "pin": { + "type": "string" + }, + "password": { + "type": "string" + }, + "device_id": { + "type": "string" + }, + "counter": { + "type": "integer", + "minimum": 0 + } + }, + "additionalProperties": false + }, + "OpenVPNControlWrapOptions": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "tls_auth", + "tls_crypt", + "tls_crypt_v2" + ] + }, + "key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "key_path": { + "type": "string" + }, + "direction": { + "type": "string", + "enum": [ + "server", + "client" + ] + } + }, + "additionalProperties": false + }, + "OpenVPNInboundControlWrapOptions": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "tls_auth", + "tls_crypt", + "tls_crypt_v2" + ] + }, + "key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "key_path": { + "type": "string" + }, + "direction": { + "type": "string", + "enum": [ + "server", + "client" + ] + }, + "force_cookie": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "OpenVPNInboundTLSOptions": { + "type": "object", + "properties": { + "certificate": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "certificate_path": { + "type": "string" + }, + "key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "key_path": { + "type": "string" + }, + "client_certificate": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "client_certificate_path": { + "type": "string" + }, + "verify_client_certificate": { + "type": "string", + "enum": [ + "require", + "optional", + "none" + ] + }, + "client_name": { + "type": "string" + }, + "client_name_type": { + "type": "string", + "enum": [ + "subject", + "name", + "name-prefix" + ] + }, + "peer_fingerprint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "crl_path": { + "type": "string" + }, + "remote_certificate_ku": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "remote_certificate_eku": { + "type": "string" + }, + "remote_certificate_tls": { + "type": "string", + "enum": [ + "server", + "client", + "none" + ] + }, + "certificate_profile": { + "type": "string", + "enum": [ + "legacy", + "preferred", + "insecure", + "suiteb" + ] + }, + "ns_certificate_type": { + "type": "string", + "enum": [ + "server", + "client" + ] + }, + "version_min": { + "type": "string", + "enum": [ + "1.0", + "1.1", + "1.2", + "1.3" + ] + }, + "version_max": { + "type": "string", + "enum": [ + "1.0", + "1.1", + "1.2", + "1.3" + ] + }, + "cipher": { + "type": "string" + }, + "groups": { + "type": "string" + }, + "control_wrap": { + "$ref": "#/$defs/OpenVPNInboundControlWrapOptions" + } + }, + "additionalProperties": false + }, + "OpenVPNOutboundTLSOptions": { + "type": "object", + "properties": { + "server_name": { + "type": "string" + }, + "server_name_type": { + "type": "string", + "enum": [ + "subject", + "name", + "name-prefix" + ] + }, + "certificate": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "certificate_path": { + "type": "string" + }, + "client_certificate": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "client_certificate_path": { + "type": "string" + }, + "client_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "client_key_path": { + "type": "string" + }, + "peer_fingerprint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "crl_path": { + "type": "string" + }, + "remote_certificate_ku": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "remote_certificate_eku": { + "type": "string" + }, + "remote_certificate_tls": { + "type": "string", + "enum": [ + "server", + "client", + "none" + ] + }, + "certificate_profile": { + "type": "string", + "enum": [ + "legacy", + "preferred", + "insecure", + "suiteb" + ] + }, + "ns_certificate_type": { + "type": "string", + "enum": [ + "server", + "client" + ] + }, + "version_min": { + "type": "string", + "enum": [ + "1.0", + "1.1", + "1.2", + "1.3" + ] + }, + "version_max": { + "type": "string", + "enum": [ + "1.0", + "1.1", + "1.2", + "1.3" + ] + }, + "cipher": { + "type": "string" + }, + "groups": { + "type": "string" + }, + "control_wrap": { + "$ref": "#/$defs/OpenVPNControlWrapOptions" + } + }, + "additionalProperties": false + }, + "OpenVPNPullFilterOptions": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "accept", + "ignore", + "reject" + ] + }, + "text": { + "type": "string" + } + }, + "additionalProperties": false + }, + "OpenVPNPushDNSServerOptions": { + "type": "object", + "properties": { + "priority": { + "type": "integer" + }, + "addresses": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "resolve_domains": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "dnssec": { + "type": "string", + "enum": [ + "yes", + "optional", + "no" + ] + }, + "transport": { + "type": "string", + "enum": [ + "plain", + "dot", + "doh" + ] + }, + "sni": { + "type": "string" + } + }, + "additionalProperties": false + }, + "OpenVPNPushOptions": { + "type": "object", + "properties": { + "routes": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "dns": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "dns_servers": { + "type": "array", + "items": { + "$ref": "#/$defs/OpenVPNPushDNSServerOptions" + } + }, + "search_domains": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "dhcp_options": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "redirect_gateway": { + "type": "boolean" + }, + "redirect_gateway_flags": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "block_outside_dns": { + "type": "boolean" + }, + "ping_interval": { + "$ref": "#/$defs/Duration" + }, + "ping_restart": { + "$ref": "#/$defs/Duration" + } + }, + "additionalProperties": false + }, + "OpenVPNRemoteOptions": { + "type": "object", + "properties": { + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "network": { + "type": "string", + "enum": [ + "udp", + "udp4", + "udp6", + "tcp", + "tcp4", + "tcp6" + ] + } + }, + "additionalProperties": false + }, + "Outbound": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "anytls" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "tls": { + "$ref": "#/$defs/OutboundTLSOptions" + }, + "password": { + "type": "string" + }, + "idle_session_check_interval": { + "$ref": "#/$defs/Duration" + }, + "idle_session_timeout": { + "$ref": "#/$defs/Duration" + }, + "min_idle_session": { + "type": "integer" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "block" + }, + "tag": { + "type": "string" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "bridge" + }, + "tag": { + "type": "string" + }, + "interface": { + "type": "string" + }, + "bridge_name": { + "type": "string" + }, + "iproute2_table_index": { + "type": "integer" + }, + "iproute2_rule_index": { + "type": "integer" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "direct" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "http" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "username": { + "type": "string" + }, + "password": { + "type": "string" + }, + "tls": { + "$ref": "#/$defs/OutboundTLSOptions" + }, + "path": { + "type": "string" + }, + "headers": { + "$ref": "#/$defs/HTTPHeader" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "hysteria" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "server_ports": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "hop_interval": { + "$ref": "#/$defs/Duration" + }, + "up": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "up_mbps": { + "type": "integer" + }, + "down": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "down_mbps": { + "type": "integer" + }, + "obfs": { + "type": "string" + }, + "auth": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + } + } + ] + }, + "auth_str": { + "type": "string" + }, + "network": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + } + } + ] + }, + "tls": { + "$ref": "#/$defs/OutboundTLSOptions" + }, + "idle_timeout": { + "$ref": "#/$defs/Duration" + }, + "keep_alive_period": { + "$ref": "#/$defs/Duration" + }, + "stream_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "connection_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "max_concurrent_streams": { + "type": "integer" + }, + "initial_packet_size": { + "type": "integer" + }, + "disable_path_mtu_discovery": { + "type": "boolean" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "hysteria2" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "server_ports": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "hop_interval": { + "$ref": "#/$defs/Duration" + }, + "hop_interval_max": { + "$ref": "#/$defs/Duration" + }, + "up_mbps": { + "type": "integer" + }, + "down_mbps": { + "type": "integer" + }, + "obfs": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "salamander" + }, + "password": { + "type": "string" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "gecko" + }, + "password": { + "type": "string" + }, + "min_packet_size": { + "type": "integer" + }, + "max_packet_size": { + "type": "integer" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + ] + }, + "password": { + "type": "string" + }, + "network": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + } + } + ] + }, + "tls": { + "$ref": "#/$defs/OutboundTLSOptions" + }, + "idle_timeout": { + "$ref": "#/$defs/Duration" + }, + "keep_alive_period": { + "$ref": "#/$defs/Duration" + }, + "stream_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "connection_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "max_concurrent_streams": { + "type": "integer" + }, + "initial_packet_size": { + "type": "integer" + }, + "disable_path_mtu_discovery": { + "type": "boolean" + }, + "bbr_profile": { + "type": "string", + "enum": [ + "standard", + "conservative", + "aggressive" + ] + }, + "brutal_debug": { + "type": "boolean" + }, + "realm": { + "$ref": "#/$defs/Hysteria2Realm" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "naive" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "username": { + "type": "string" + }, + "password": { + "type": "string" + }, + "insecure_concurrency": { + "type": "integer" + }, + "extra_headers": { + "$ref": "#/$defs/HTTPHeader" + }, + "stream_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "udp_over_tcp": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "version": { + "type": "integer", + "enum": [ + 1, + 2 + ], + "minimum": 0, + "maximum": 255 + } + }, + "additionalProperties": false + } + ] + }, + "quic": { + "type": "boolean" + }, + "quic_congestion_control": { + "type": "string", + "enum": [ + "bbr", + "bbr2", + "cubic", + "reno" + ] + }, + "quic_session_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "tls": { + "$ref": "#/$defs/OutboundTLSOptions" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "selector" + }, + "tag": { + "type": "string" + }, + "outbounds": { + "type": "array", + "items": { + "type": "string", + "x-tag-reference": "outbound" + } + }, + "default": { + "type": "string", + "x-tag-reference": "outbound" + }, + "interrupt_exist_connections": { + "type": "boolean" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "shadowsocks" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "method": { + "type": "string", + "enum": [ + "none", + "aes-128-gcm", + "aes-192-gcm", + "aes-256-gcm", + "chacha20-ietf-poly1305", + "xchacha20-ietf-poly1305", + "2022-blake3-aes-128-gcm", + "2022-blake3-aes-256-gcm", + "2022-blake3-chacha20-poly1305", + "aes-128-ctr", + "aes-192-ctr", + "aes-256-ctr", + "aes-128-cfb", + "aes-192-cfb", + "aes-256-cfb", + "rc4-md5", + "chacha20-ietf", + "xchacha20" + ] + }, + "password": { + "type": "string" + }, + "plugin": { + "type": "string" + }, + "plugin_opts": { + "type": "string" + }, + "network": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + } + } + ] + }, + "udp_over_tcp": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "version": { + "type": "integer", + "enum": [ + 1, + 2 + ], + "minimum": 0, + "maximum": 255 + } + }, + "additionalProperties": false + } + ] + }, + "multiplex": { + "$ref": "#/$defs/OutboundMultiplexOptions" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "shadowtls" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "version": { + "type": "integer", + "enum": [ + 1, + 2, + 3 + ] + }, + "password": { + "type": "string" + }, + "tls": { + "$ref": "#/$defs/OutboundTLSOptions" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "snell" + }, + "tag": { + "type": "string" + }, + "version": { + "const": 4 + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "psk": { + "type": "string" + }, + "userkey": { + "type": "string" + }, + "reuse": { + "type": "boolean" + }, + "network": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + } + } + ] + }, + "obfs_mode": { + "type": "string", + "enum": [ + "none", + "http", + "tls" + ] + }, + "obfs_host": { + "type": "string" + } + }, + "required": [ + "type", + "version" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "snell" + }, + "tag": { + "type": "string" + }, + "version": { + "const": 6 + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "psk": { + "type": "string" + }, + "userkey": { + "type": "string" + }, + "reuse": { + "type": "boolean" + }, + "network": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + } + } + ] + }, + "mode": { + "type": "string", + "enum": [ + "default", + "unshaped", + "unsafe-raw" + ] + } + }, + "required": [ + "type", + "version" + ], + "additionalProperties": false + } + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "socks" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "version": { + "type": "string", + "enum": [ + "4", + "4a", + "5" + ] + }, + "username": { + "type": "string" + }, + "password": { + "type": "string" + }, + "network": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + } + } + ] + }, + "udp_over_tcp": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "version": { + "type": "integer", + "enum": [ + 1, + 2 + ], + "minimum": 0, + "maximum": 255 + } + }, + "additionalProperties": false + } + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "ssh" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "user": { + "type": "string" + }, + "password": { + "type": "string" + }, + "private_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "private_key_path": { + "type": "string" + }, + "private_key_passphrase": { + "type": "string" + }, + "host_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "host_key_algorithms": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "client_version": { + "type": "string" + }, + "cipher": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "mac": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "kex_algorithm": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "tor" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "executable_path": { + "type": "string" + }, + "extra_args": { + "type": "array", + "items": { + "type": "string" + } + }, + "data_directory": { + "type": "string" + }, + "torrc": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "trojan" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "password": { + "type": "string" + }, + "network": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + } + } + ] + }, + "tls": { + "$ref": "#/$defs/OutboundTLSOptions" + }, + "multiplex": { + "$ref": "#/$defs/OutboundMultiplexOptions" + }, + "transport": { + "$ref": "#/$defs/V2RayTransport" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "tuic" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "uuid": { + "type": "string" + }, + "password": { + "type": "string" + }, + "congestion_control": { + "type": "string", + "enum": [ + "cubic", + "new_reno", + "bbr" + ] + }, + "udp_relay_mode": { + "type": "string", + "enum": [ + "native", + "quic" + ] + }, + "udp_over_stream": { + "type": "boolean" + }, + "zero_rtt_handshake": { + "type": "boolean" + }, + "heartbeat": { + "$ref": "#/$defs/Duration" + }, + "network": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + } + } + ] + }, + "tls": { + "$ref": "#/$defs/OutboundTLSOptions" + }, + "idle_timeout": { + "$ref": "#/$defs/Duration" + }, + "keep_alive_period": { + "$ref": "#/$defs/Duration" + }, + "stream_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "connection_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "max_concurrent_streams": { + "type": "integer" + }, + "initial_packet_size": { + "type": "integer" + }, + "disable_path_mtu_discovery": { + "type": "boolean" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "urltest" + }, + "tag": { + "type": "string" + }, + "outbounds": { + "type": "array", + "items": { + "type": "string", + "x-tag-reference": "outbound" + } + }, + "url": { + "type": "string" + }, + "interval": { + "$ref": "#/$defs/Duration" + }, + "tolerance": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "idle_timeout": { + "$ref": "#/$defs/Duration" + }, + "interrupt_exist_connections": { + "type": "boolean" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "vless" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "uuid": { + "type": "string" + }, + "flow": { + "type": "string" + }, + "network": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + } + } + ] + }, + "tls": { + "$ref": "#/$defs/OutboundTLSOptions" + }, + "multiplex": { + "$ref": "#/$defs/OutboundMultiplexOptions" + }, + "transport": { + "$ref": "#/$defs/V2RayTransport" + }, + "packet_encoding": { + "type": "string" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "vmess" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "uuid": { + "type": "string" + }, + "security": { + "type": "string", + "enum": [ + "auto", + "none", + "zero", + "aes-128-cfb", + "aes-128-gcm", + "chacha20-poly1305" + ] + }, + "alter_id": { + "type": "integer" + }, + "global_padding": { + "type": "boolean" + }, + "authenticated_length": { + "type": "boolean" + }, + "network": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + } + } + ] + }, + "tls": { + "$ref": "#/$defs/OutboundTLSOptions" + }, + "packet_encoding": { + "type": "string", + "enum": [ + "packetaddr", + "xudp" + ] + }, + "multiplex": { + "$ref": "#/$defs/OutboundMultiplexOptions" + }, + "transport": { + "$ref": "#/$defs/V2RayTransport" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + ] + }, + "OutboundECHOptions": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "config": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "config_path": { + "type": "string" + }, + "query_server_name": { + "type": "string" + } + }, + "additionalProperties": false + }, + "OutboundMultiplexOptions": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "protocol": { + "type": "string", + "enum": [ + "h2mux", + "smux", + "yamux" + ] + }, + "max_connections": { + "type": "integer" + }, + "min_streams": { + "type": "integer" + }, + "max_streams": { + "type": "integer" + }, + "padding": { + "type": "boolean" + }, + "brutal": { + "$ref": "#/$defs/BrutalOptions" + } + }, + "additionalProperties": false + }, + "OutboundRealityOptions": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "public_key": { + "type": "string" + }, + "short_id": { + "type": "string" + } + }, + "additionalProperties": false + }, + "OutboundTLSOptions": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "engine": { + "type": "string", + "enum": [ + "go", + "apple", + "windows" + ] + }, + "disable_sni": { + "type": "boolean" + }, + "server_name": { + "type": "string" + }, + "insecure": { + "type": "boolean" + }, + "alpn": { + "anyOf": [ + { + "type": "string", + "examples": [ + "http/1.1", + "h2", + "h3" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "examples": [ + "http/1.1", + "h2", + "h3" + ] + } + } + ] + }, + "min_version": { + "type": "string", + "enum": [ + "1.0", + "1.1", + "1.2", + "1.3" + ] + }, + "max_version": { + "type": "string", + "enum": [ + "1.0", + "1.1", + "1.2", + "1.3" + ] + }, + "cipher_suites": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "curve_preferences": { + "anyOf": [ + { + "type": "string", + "enum": [ + "P256", + "P384", + "P521", + "X25519", + "X25519MLKEM768" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "P256", + "P384", + "P521", + "X25519", + "X25519MLKEM768" + ] + } + } + ] + }, + "certificate": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "certificate_path": { + "type": "string" + }, + "certificate_public_key_sha256": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + } + } + ] + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + } + } + ] + } + } + ] + }, + "client_certificate": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "client_certificate_path": { + "type": "string" + }, + "client_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "client_key_path": { + "type": "string" + }, + "fragment": { + "type": "boolean" + }, + "fragment_fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "record_fragment": { + "type": "boolean" + }, + "spoof": { + "type": "string" + }, + "spoof_method": { + "type": "string", + "enum": [ + "wrong-sequence", + "wrong-checksum", + "wrong-ack", + "wrong-md5", + "wrong-timestamp" + ] + }, + "kernel_tx": { + "type": "boolean" + }, + "kernel_rx": { + "type": "boolean" + }, + "handshake_timeout": { + "$ref": "#/$defs/Duration" + }, + "ech": { + "$ref": "#/$defs/OutboundECHOptions" + }, + "utls": { + "$ref": "#/$defs/OutboundUTLSOptions" + }, + "reality": { + "$ref": "#/$defs/OutboundRealityOptions" + } + }, + "additionalProperties": false + }, + "OutboundUTLSOptions": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "fingerprint": { + "type": "string", + "enum": [ + "chrome_psk", + "chrome_psk_shuffle", + "chrome_padding_psk_shuffle", + "chrome_pq", + "chrome_pq_psk", + "chrome", + "firefox", + "edge", + "safari", + "360", + "qq", + "ios", + "android", + "random", + "randomized" + ] + } + }, + "additionalProperties": false + }, + "RouteOptions": { + "type": "object", + "properties": { + "rules": { + "type": "array", + "items": { + "$ref": "#/$defs/Rule" + } + }, + "rule_set": { + "type": "array", + "items": { + "$ref": "#/$defs/RuleSet" + } + }, + "final": { + "type": "string", + "x-tag-reference": "outbound" + }, + "find_process": { + "type": "boolean" + }, + "find_neighbor": { + "type": "boolean" + }, + "dhcp_lease_files": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "auto_detect_interface": { + "type": "boolean" + }, + "override_android_vpn": { + "type": "boolean" + }, + "default_interface": { + "type": "string" + }, + "default_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "default_domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "default_network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "default_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "default_fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "default_fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "default_http_client": { + "type": "string" + } + }, + "additionalProperties": false + }, + "Rule": { + "oneOf": [ + { + "type": "object", + "unevaluatedProperties": false, + "allOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "default", + "" + ] + }, + "inbound": { + "anyOf": [ + { + "type": "string", + "x-tag-reference": "inbound" + }, + { + "type": "array", + "items": { + "type": "string", + "x-tag-reference": "inbound" + } + } + ] + }, + "ip_version": { + "type": "integer", + "enum": [ + 4, + 6 + ] + }, + "network": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tcp", + "udp", + "icmp" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "tcp", + "udp", + "icmp" + ] + } + } + ] + }, + "auth_user": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "protocol": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tls", + "http", + "quic", + "dns", + "stun", + "bittorrent", + "dtls", + "ssh", + "rdp", + "ntp" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "tls", + "http", + "quic", + "dns", + "stun", + "bittorrent", + "dtls", + "ssh", + "rdp", + "ntp" + ] + } + } + ] + }, + "client": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "domain": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "domain_suffix": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "domain_keyword": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "domain_regex": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "source_ip_cidr": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "source_ip_is_private": { + "type": "boolean" + }, + "ip_cidr": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "ip_is_private": { + "type": "boolean" + }, + "source_port": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + } + } + ] + }, + "source_port_range": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "port": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + } + } + ] + }, + "port_range": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "process_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "process_path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "process_path_regex": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "package_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "package_name_regex": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "user": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "user_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "array", + "items": { + "type": "integer" + } + } + ] + }, + "clash_mode": { + "type": "string" + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "network_is_expensive": { + "type": "boolean" + }, + "network_is_constrained": { + "type": "boolean" + }, + "wifi_ssid": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "wifi_bssid": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "interface_address": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "network_interface_address": { + "type": "object", + "propertyNames": { + "$ref": "#/$defs/InterfaceType" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "default_interface_address": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "source_mac_address": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "source_hostname": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "preferred_by": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "rule_set": { + "anyOf": [ + { + "type": "string", + "x-tag-reference": "rule_set" + }, + { + "type": "array", + "items": { + "type": "string", + "x-tag-reference": "rule_set" + } + } + ] + }, + "rule_set_ip_cidr_match_source": { + "type": "boolean" + }, + "invert": { + "type": "boolean" + } + } + }, + { + "$ref": "#/$defs/RuleAction" + } + ] + }, + { + "type": "object", + "unevaluatedProperties": false, + "allOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "logical" + }, + "mode": { + "type": "string", + "enum": [ + "and", + "or" + ] + }, + "rules": { + "type": "array", + "items": { + "$ref": "#/$defs/NestedRule" + } + }, + "invert": { + "type": "boolean" + } + }, + "required": [ + "type", + "mode", + "rules" + ] + }, + { + "$ref": "#/$defs/RuleAction" + } + ] + } + ] + }, + "RuleAction": { + "oneOf": [ + { + "type": "object", + "properties": { + "action": { + "const": "route" + }, + "outbound": { + "type": "string", + "x-tag-reference": "outbound" + }, + "override_address": { + "type": "string" + }, + "override_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "fallback_delay": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "udp_disable_domain_unmapping": { + "type": "boolean" + }, + "udp_connect": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/Duration" + }, + "tls_fragment": { + "type": "boolean" + }, + "tls_fragment_fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "tls_record_fragment": { + "type": "boolean" + }, + "tls_spoof": { + "type": "string" + }, + "tls_spoof_method": { + "type": "string", + "enum": [ + "wrong-sequence", + "wrong-checksum", + "wrong-ack", + "wrong-md5", + "wrong-timestamp" + ] + } + } + }, + { + "type": "object", + "properties": { + "action": { + "const": "route-options" + }, + "override_address": { + "type": "string" + }, + "override_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "fallback_delay": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "udp_disable_domain_unmapping": { + "type": "boolean" + }, + "udp_connect": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/Duration" + }, + "tls_fragment": { + "type": "boolean" + }, + "tls_fragment_fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "tls_record_fragment": { + "type": "boolean" + }, + "tls_spoof": { + "type": "string" + }, + "tls_spoof_method": { + "type": "string", + "enum": [ + "wrong-sequence", + "wrong-checksum", + "wrong-ack", + "wrong-md5", + "wrong-timestamp" + ] + } + }, + "required": [ + "action" + ] + }, + { + "type": "object", + "properties": { + "action": { + "const": "direct" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + } + }, + "required": [ + "action" + ] + }, + { + "type": "object", + "properties": { + "action": { + "const": "bypass" + }, + "outbound": { + "type": "string", + "x-tag-reference": "outbound" + }, + "override_address": { + "type": "string" + }, + "override_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "fallback_delay": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "udp_disable_domain_unmapping": { + "type": "boolean" + }, + "udp_connect": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/Duration" + }, + "tls_fragment": { + "type": "boolean" + }, + "tls_fragment_fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "tls_record_fragment": { + "type": "boolean" + }, + "tls_spoof": { + "type": "string" + }, + "tls_spoof_method": { + "type": "string", + "enum": [ + "wrong-sequence", + "wrong-checksum", + "wrong-ack", + "wrong-md5", + "wrong-timestamp" + ] + } + }, + "required": [ + "action" + ] + }, + { + "type": "object", + "properties": { + "action": { + "const": "reject" + }, + "method": { + "type": "string", + "enum": [ + "", + "default", + "drop", + "reply" + ] + }, + "no_drop": { + "type": "boolean" + } + }, + "required": [ + "action" + ] + }, + { + "type": "object", + "properties": { + "action": { + "const": "hijack-dns" + } + }, + "required": [ + "action" + ] + }, + { + "type": "object", + "properties": { + "action": { + "const": "sniff" + }, + "sniffer": { + "anyOf": [ + { + "type": "string", + "enum": [ + "tls", + "http", + "quic", + "dns", + "stun", + "bittorrent", + "dtls", + "ssh", + "rdp", + "ntp" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "tls", + "http", + "quic", + "dns", + "stun", + "bittorrent", + "dtls", + "ssh", + "rdp", + "ntp" + ] + } + } + ] + }, + "timeout": { + "$ref": "#/$defs/Duration" + } + }, + "required": [ + "action" + ] + }, + { + "type": "object", + "properties": { + "action": { + "const": "resolve" + }, + "server": { + "type": "string", + "x-tag-reference": "dns_server" + }, + "timeout": { + "$ref": "#/$defs/Duration" + }, + "strategy": { + "$ref": "#/$defs/DomainStrategy" + }, + "disable_cache": { + "type": "boolean" + }, + "disable_optimistic_cache": { + "type": "boolean" + }, + "rewrite_ttl": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "client_subnet": { + "type": "string" + } + }, + "required": [ + "action" + ] + } + ] + }, + "RuleSet": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "inline", + "" + ] + }, + "tag": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "rules": { + "type": "array", + "items": { + "$ref": "#/$defs/HeadlessRule" + } + } + }, + "required": [ + "tag" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "local" + }, + "tag": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "format": { + "type": "string", + "enum": [ + "source", + "binary" + ] + }, + "path": { + "type": "string" + } + }, + "required": [ + "type", + "tag" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "remote" + }, + "tag": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "format": { + "type": "string", + "enum": [ + "source", + "binary" + ] + }, + "url": { + "type": "string" + }, + "initial_path": { + "type": "string" + }, + "http_client": { + "$ref": "#/$defs/HTTPClientReference" + }, + "update_interval": { + "$ref": "#/$defs/Duration" + } + }, + "required": [ + "type", + "tag" + ], + "additionalProperties": false + } + ] + }, + "ServerOptions": { + "type": "object", + "properties": { + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + } + }, + "additionalProperties": false + }, + "Service": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "api" + }, + "tag": { + "type": "string" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + }, + "secret": { + "type": "string" + }, + "access_control_allow_origin": { + "anyOf": [ + { + "type": "string", + "examples": [ + "http://sing-box-dashboard.sagernet.org/", + "https://sing-box-dashboard.sagernet.org/" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "examples": [ + "http://sing-box-dashboard.sagernet.org/", + "https://sing-box-dashboard.sagernet.org/" + ] + } + } + ] + }, + "access_control_allow_private_network": { + "type": "boolean" + }, + "dashboard": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + }, + { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "path": { + "type": "string" + }, + "download_url": { + "type": "string" + }, + "http_client": { + "$ref": "#/$defs/HTTPClientReference" + }, + "update_interval": { + "$ref": "#/$defs/Duration" + } + }, + "additionalProperties": false + } + ] + }, + "tls": { + "$ref": "#/$defs/InboundTLSOptions" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "ccm" + }, + "tag": { + "type": "string" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "tls": { + "$ref": "#/$defs/InboundTLSOptions" + }, + "credential_path": { + "type": "string" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/$defs/CCMUser" + } + }, + "headers": { + "$ref": "#/$defs/HTTPHeader" + }, + "usages_path": { + "type": "string" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "derp" + }, + "tag": { + "type": "string" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + }, + "tls": { + "$ref": "#/$defs/InboundTLSOptions" + }, + "config_path": { + "type": "string" + }, + "verify_client_endpoint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "verify_client_url": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "tag": { + "type": "string" + }, + "engine": { + "type": "string", + "enum": [ + "go", + "apple" + ] + }, + "version": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3 + ] + }, + "disable_version_fallback": { + "type": "boolean" + }, + "headers": { + "$ref": "#/$defs/HTTPHeader" + }, + "tls": { + "$ref": "#/$defs/OutboundTLSOptions" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "idle_timeout": { + "$ref": "#/$defs/Duration" + }, + "keep_alive_period": { + "$ref": "#/$defs/Duration" + }, + "stream_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "connection_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "max_concurrent_streams": { + "type": "integer" + }, + "initial_packet_size": { + "type": "integer" + }, + "disable_path_mtu_discovery": { + "type": "boolean" + }, + "url": { + "type": "string" + } + }, + "additionalProperties": false + } + ] + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "tag": { + "type": "string" + }, + "engine": { + "type": "string", + "enum": [ + "go", + "apple" + ] + }, + "version": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3 + ] + }, + "disable_version_fallback": { + "type": "boolean" + }, + "headers": { + "$ref": "#/$defs/HTTPHeader" + }, + "tls": { + "$ref": "#/$defs/OutboundTLSOptions" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "idle_timeout": { + "$ref": "#/$defs/Duration" + }, + "keep_alive_period": { + "$ref": "#/$defs/Duration" + }, + "stream_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "connection_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "max_concurrent_streams": { + "type": "integer" + }, + "initial_packet_size": { + "type": "integer" + }, + "disable_path_mtu_discovery": { + "type": "boolean" + }, + "url": { + "type": "string" + } + }, + "additionalProperties": false + } + ] + } + } + ] + }, + "home": { + "type": "string" + }, + "mesh_with": { + "anyOf": [ + { + "$ref": "#/$defs/DERPMeshOptions" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/DERPMeshOptions" + } + } + ] + }, + "mesh_psk": { + "type": "string" + }, + "mesh_psk_file": { + "type": "string" + }, + "stun": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + } + }, + "additionalProperties": false + } + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "hysteria-realm" + }, + "tag": { + "type": "string" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + }, + "tls": { + "$ref": "#/$defs/InboundTLSOptions" + }, + "idle_timeout": { + "$ref": "#/$defs/Duration" + }, + "keep_alive_period": { + "$ref": "#/$defs/Duration" + }, + "stream_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "connection_receive_window": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "max_concurrent_streams": { + "type": "integer" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/$defs/HysteriaRealmUser" + } + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "ocm" + }, + "tag": { + "type": "string" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "tls": { + "$ref": "#/$defs/InboundTLSOptions" + }, + "credential_path": { + "type": "string" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/$defs/OCMUser" + } + }, + "headers": { + "$ref": "#/$defs/HTTPHeader" + }, + "usages_path": { + "type": "string" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "oom-killer" + }, + "tag": { + "type": "string" + }, + "memory_limit": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "safety_margin": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "min_interval": { + "$ref": "#/$defs/Duration" + }, + "max_interval": { + "$ref": "#/$defs/Duration" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "resolved" + }, + "tag": { + "type": "string" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "ssm-api" + }, + "tag": { + "type": "string" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + }, + "servers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "cache_path": { + "type": "string" + }, + "tls": { + "$ref": "#/$defs/InboundTLSOptions" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "usbip-client" + }, + "tag": { + "type": "string" + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "devices": { + "type": "array", + "items": { + "$ref": "#/$defs/USBIPDeviceMatch" + } + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "usbip-server" + }, + "tag": { + "type": "string" + }, + "provider": { + "type": "string", + "enum": [ + "default", + "" + ] + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + }, + "devices": { + "type": "array", + "items": { + "$ref": "#/$defs/USBIPDeviceMatch" + } + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "usbip-server" + }, + "tag": { + "type": "string" + }, + "provider": { + "const": "dynamic" + }, + "listen": { + "type": "string" + }, + "listen_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "bind_interface": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "udp_fragment": { + "type": "boolean" + }, + "udp_timeout": { + "$ref": "#/$defs/UDPTimeout" + }, + "detour": { + "type": "string", + "x-tag-reference": "inbound" + } + }, + "required": [ + "type", + "provider" + ], + "additionalProperties": false + } + ] + } + ] + }, + "ShadowTLSHandshakeOptions": { + "type": "object", + "properties": { + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "detour": { + "type": "string", + "x-tag-reference": "outbound" + }, + "bind_interface": { + "type": "string" + }, + "inet4_bind_address": { + "type": "string" + }, + "inet6_bind_address": { + "type": "string" + }, + "bind_address_no_port": { + "type": "boolean" + }, + "protect_path": { + "type": "string" + }, + "routing_mark": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string" + } + ] + }, + "reuse_addr": { + "type": "boolean" + }, + "netns": { + "type": "string", + "x-tag-reference": "network_namespace" + }, + "connect_timeout": { + "$ref": "#/$defs/Duration" + }, + "tcp_fast_open": { + "type": "boolean" + }, + "tcp_multi_path": { + "type": "boolean" + }, + "disable_tcp_keep_alive": { + "type": "boolean" + }, + "tcp_keep_alive": { + "$ref": "#/$defs/Duration" + }, + "tcp_keep_alive_interval": { + "$ref": "#/$defs/Duration" + }, + "udp_fragment": { + "type": "boolean" + }, + "domain_resolver": { + "$ref": "#/$defs/DomainResolver" + }, + "network_strategy": { + "type": "string", + "enum": [ + "default", + "fallback", + "hybrid" + ] + }, + "network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_network_type": { + "anyOf": [ + { + "$ref": "#/$defs/InterfaceType" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/InterfaceType" + } + } + ] + }, + "fallback_delay": { + "$ref": "#/$defs/Duration" + } + }, + "additionalProperties": false + }, + "ShadowTLSUser": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "password": { + "type": "string" + } + }, + "additionalProperties": false + }, + "ShadowsocksDestination": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "password": { + "type": "string" + }, + "server": { + "type": "string" + }, + "server_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + } + }, + "additionalProperties": false + }, + "ShadowsocksUser": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "password": { + "type": "string" + } + }, + "additionalProperties": false + }, + "SnellUser": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "userkey": { + "type": "string" + } + }, + "additionalProperties": false + }, + "TUICUser": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "uuid": { + "type": "string" + }, + "password": { + "type": "string" + } + }, + "additionalProperties": false + }, + "TrojanUser": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "password": { + "type": "string" + } + }, + "additionalProperties": false + }, + "TunPlatformOptions": { + "type": "object", + "properties": { + "http_proxy": { + "$ref": "#/$defs/HTTPProxyOptions" + } + }, + "additionalProperties": false + }, + "UDPTimeout": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + { + "type": "string", + "pattern": "^[-+]?(((\\d+(\\.\\d*)?|\\.\\d+)(ns|us|µs|μs|ms|s|m|h|d))+|0)$" + } + ] + }, + "USBIPDeviceMatch": { + "type": "object", + "properties": { + "bus_id": { + "type": "string" + }, + "vendor_id": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "product_id": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "serial": { + "type": "string" + } + }, + "additionalProperties": false + }, + "User": { + "type": "object", + "properties": { + "Username": { + "type": "string" + }, + "Password": { + "type": "string" + } + }, + "additionalProperties": false + }, + "V2RayAPIOptions": { + "type": "object", + "properties": { + "listen": { + "type": "string" + }, + "stats": { + "$ref": "#/$defs/V2RayStatsServiceOptions" + } + }, + "additionalProperties": false + }, + "V2RayStatsServiceOptions": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "inbounds": { + "type": "array", + "items": { + "type": "string" + } + }, + "outbounds": { + "type": "array", + "items": { + "type": "string" + } + }, + "users": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "V2RayTransport": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "http" + }, + "host": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "path": { + "type": "string" + }, + "method": { + "type": "string" + }, + "headers": { + "$ref": "#/$defs/HTTPHeader" + }, + "idle_timeout": { + "$ref": "#/$defs/Duration" + }, + "ping_timeout": { + "$ref": "#/$defs/Duration" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "ws" + }, + "path": { + "type": "string" + }, + "headers": { + "$ref": "#/$defs/HTTPHeader" + }, + "max_early_data": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "early_data_header_name": { + "type": "string" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "quic" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "grpc" + }, + "service_name": { + "type": "string" + }, + "idle_timeout": { + "$ref": "#/$defs/Duration" + }, + "ping_timeout": { + "$ref": "#/$defs/Duration" + }, + "permit_without_stream": { + "type": "boolean" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "httpupgrade" + }, + "host": { + "type": "string" + }, + "path": { + "type": "string" + }, + "headers": { + "$ref": "#/$defs/HTTPHeader" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + ] + }, + "VLESSUser": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "uuid": { + "type": "string" + }, + "flow": { + "type": "string" + } + }, + "additionalProperties": false + }, + "VMessUser": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "uuid": { + "type": "string" + }, + "alterId": { + "type": "integer" + } + }, + "additionalProperties": false + }, + "WireGuardPeer": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "public_key": { + "type": "string" + }, + "pre_shared_key": { + "type": "string" + }, + "allowed_ips": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "persistent_keepalive_interval": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "reserved": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 255 + } + } + ] + } + }, + "additionalProperties": false + } + } +} diff --git a/experimental/boxdd/application_service.go b/experimental/boxdd/application_service.go new file mode 100644 index 0000000000..ed986a3079 --- /dev/null +++ b/experimental/boxdd/application_service.go @@ -0,0 +1,140 @@ +package main + +import ( + "context" + "reflect" + "time" + + "github.com/sagernet/sing-box/common/networkquality" + "github.com/sagernet/sing-box/common/stun" + "github.com/sagernet/sing-box/daemon" + "github.com/sagernet/sing-box/experimental/libbox" + "github.com/sagernet/sing-box/include" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/schema" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/emptypb" +) + +var _ ApplicationServiceServer = (*applicationService)(nil) + +type applicationService struct { + UnimplementedApplicationServiceServer + startedService *daemon.StartedService +} + +func (s *applicationService) CheckConfig(ctx context.Context, request *ConfigContent) (*emptypb.Empty, error) { + err := s.startedService.CheckConfig(ctx, request.Content) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + return &emptypb.Empty{}, nil +} + +func (s *applicationService) FormatConfig(ctx context.Context, request *ConfigContent) (*ConfigContent, error) { + content, err := s.startedService.FormatConfig(ctx, request.Content) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + return &ConfigContent{Content: content}, nil +} + +func (s *applicationService) GenerateConfigSchema(ctx context.Context, request *emptypb.Empty) (*ConfigContent, error) { + content, err := schema.Generate(include.Context(context.Background()), reflect.TypeFor[option.Options]()) + if err != nil { + return nil, status.Error(codes.Internal, err.Error()) + } + return &ConfigContent{Content: string(content)}, nil +} + +func (s *applicationService) EncodeProfile(ctx context.Context, request *ProfileContent) (*ProfileData, error) { + content := libbox.ProfileContent{ + Name: request.Name, + Type: int32(request.Type), + Config: request.Config, + RemotePath: request.RemotePath, + AutoUpdate: request.AutoUpdate, + AutoUpdateInterval: request.AutoUpdateInterval, + LastUpdated: request.LastUpdated, + } + return &ProfileData{Data: content.Encode()}, nil +} + +func (s *applicationService) DecodeProfile(ctx context.Context, request *ProfileData) (*ProfileContent, error) { + content, err := libbox.DecodeProfileContent(request.Data) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + return &ProfileContent{ + Type: ProfileContent_Type(content.Type), + Name: content.Name, + Config: content.Config, + RemotePath: content.RemotePath, + AutoUpdate: content.AutoUpdate, + AutoUpdateInterval: content.AutoUpdateInterval, + LastUpdated: content.LastUpdated, + }, nil +} + +func (s *applicationService) ArchiveReport(ctx context.Context, request *ArchiveReportRequest) (*emptypb.Empty, error) { + err := libbox.CreateZipArchive(request.SourcePath, request.DestinationPath, request.Encrypt) + if err != nil { + return nil, err + } + return &emptypb.Empty{}, nil +} + +func (s *applicationService) StartStandaloneNetworkQualityTest( + request *StandaloneNetworkQualityTestRequest, + server grpc.ServerStreamingServer[daemon.NetworkQualityTestProgress], +) error { + httpClient := networkquality.NewHTTPClient(nil) + defer httpClient.CloseIdleConnections() + + measurementClientFactory, err := networkquality.NewOptionalHTTP3Factory(nil, request.Http3) + if err != nil { + return err + } + + result, err := networkquality.Run(networkquality.Options{ + ConfigURL: request.ConfigUrl, + HTTPClient: httpClient, + NewMeasurementClient: measurementClientFactory, + Serial: request.Serial, + MaxRuntime: time.Duration(request.MaxRuntimeSeconds) * time.Second, + Context: server.Context(), + OnProgress: func(progress networkquality.Progress) { + _ = server.Send(daemon.NewNetworkQualityTestProgress(progress)) + }, + }) + if err != nil { + return server.Send(&daemon.NetworkQualityTestProgress{ + IsFinal: true, + Error: err.Error(), + }) + } + return server.Send(daemon.NewNetworkQualityTestResult(result)) +} + +func (s *applicationService) StartStandaloneSTUNTest( + request *StandaloneSTUNTestRequest, + server grpc.ServerStreamingServer[daemon.STUNTestProgress], +) error { + result, err := stun.Run(stun.Options{ + Server: request.Server, + Context: server.Context(), + OnProgress: func(progress stun.Progress) { + _ = server.Send(daemon.NewSTUNTestProgress(progress)) + }, + }) + if err != nil { + return server.Send(&daemon.STUNTestProgress{ + IsFinal: true, + Error: err.Error(), + }) + } + return server.Send(daemon.NewSTUNTestResult(result)) +} diff --git a/experimental/boxdd/authenticode_windows.go b/experimental/boxdd/authenticode_windows.go new file mode 100644 index 0000000000..c29dc54308 --- /dev/null +++ b/experimental/boxdd/authenticode_windows.go @@ -0,0 +1,131 @@ +//go:build windows + +package main + +import ( + "bytes" + "crypto/x509" + "time" + "unsafe" + + E "github.com/sagernet/sing/common/exceptions" + + "golang.org/x/sys/windows" +) + +var ( + winTrustLibrary = windows.NewLazySystemDLL("wintrust.dll") + winTrustProviderDataFromStateDataProcedure = winTrustLibrary.NewProc("WTHelperProvDataFromStateData") + winTrustProviderSignerFromChainProcedure = winTrustLibrary.NewProc("WTHelperGetProvSignerFromChain") + winTrustProviderCertificateFromChainProcedure = winTrustLibrary.NewProc("WTHelperGetProvCertFromChain") +) + +type cryptProviderCertificate struct { + structureSize uint32 + certificateContext *windows.CertContext +} + +func authenticodeSigner(path string, file windows.Handle) ([]byte, error) { + pathPointer, err := windows.UTF16PtrFromString(path) + if err != nil { + return nil, err + } + fileInformation := windows.WinTrustFileInfo{ + Size: uint32(unsafe.Sizeof(windows.WinTrustFileInfo{})), + FilePath: pathPointer, + File: file, + } + trustData := windows.WinTrustData{ + Size: uint32(unsafe.Sizeof(windows.WinTrustData{})), + UIChoice: windows.WTD_UI_NONE, + RevocationChecks: windows.WTD_REVOKE_NONE, + UnionChoice: windows.WTD_CHOICE_FILE, + StateAction: windows.WTD_STATEACTION_VERIFY, + FileOrCatalogOrBlobOrSgnrOrCert: unsafe.Pointer(&fileInformation), + ProvFlags: windows.WTD_CACHE_ONLY_URL_RETRIEVAL | + windows.WTD_REVOCATION_CHECK_NONE | + windows.WTD_DISABLE_MD2_MD4, + UIContext: windows.WTD_UICONTEXT_EXECUTE, + } + trustError := windows.WinVerifyTrustEx(windows.InvalidHWND, &windows.WINTRUST_ACTION_GENERIC_VERIFY_V2, &trustData) + if trustError != nil && !E.IsMulti( + trustError, + windows.Errno(windows.CERT_E_UNTRUSTEDROOT), + windows.Errno(windows.CERT_E_CHAINING), + ) { + trustData.StateAction = windows.WTD_STATEACTION_CLOSE + windows.WinVerifyTrustEx(windows.InvalidHWND, &windows.WINTRUST_ACTION_GENERIC_VERIFY_V2, &trustData) + return nil, E.Cause(trustError, "verify Authenticode signature") + } + signer, signerError := verifiedSignerCertificate(trustData.StateData) + trustData.StateAction = windows.WTD_STATEACTION_CLOSE + closeError := windows.WinVerifyTrustEx(windows.InvalidHWND, &windows.WINTRUST_ACTION_GENERIC_VERIFY_V2, &trustData) + if signerError != nil { + return nil, signerError + } + if closeError != nil { + return nil, E.Cause(closeError, "close Authenticode verification") + } + certificate, err := validateCodeSigningCertificate(signer) + if err != nil { + return nil, err + } + if trustError != nil { + err = validateUntrustedSelfSignedCertificate(certificate, time.Now()) + if err != nil { + return nil, err + } + } + return signer, nil +} + +func verifiedSignerCertificate(stateData windows.Handle) ([]byte, error) { + providerData, _, _ := winTrustProviderDataFromStateDataProcedure.Call(uintptr(stateData)) + if providerData == 0 { + return nil, E.New("missing Authenticode provider data") + } + providerSigner, _, _ := winTrustProviderSignerFromChainProcedure.Call(providerData, 0, 0, 0) + if providerSigner == 0 { + return nil, E.New("missing Authenticode provider signer") + } + providerCertificate, _, _ := winTrustProviderCertificateFromChainProcedure.Call(providerSigner, 0) + if providerCertificate == 0 { + return nil, E.New("missing Authenticode provider certificate") + } + certificateContext := (*cryptProviderCertificate)(unsafe.Pointer(providerCertificate)).certificateContext + if certificateContext == nil { + return nil, E.New("empty Authenticode signer certificate context") + } + if certificateContext.Length == 0 || certificateContext.EncodedCert == nil { + return nil, E.New("empty Authenticode signer certificate") + } + encodedCertificate := unsafe.Slice(certificateContext.EncodedCert, int(certificateContext.Length)) + return append([]byte(nil), encodedCertificate...), nil +} + +func validateCodeSigningCertificate(encodedCertificate []byte) (*x509.Certificate, error) { + certificate, err := x509.ParseCertificate(encodedCertificate) + if err != nil { + return nil, E.Cause(err, "parse Authenticode signer certificate") + } + for _, usage := range certificate.ExtKeyUsage { + if usage == x509.ExtKeyUsageCodeSigning || usage == x509.ExtKeyUsageAny { + return certificate, nil + } + } + return nil, E.New("Authenticode signer certificate is not valid for code signing") +} + +func validateUntrustedSelfSignedCertificate(certificate *x509.Certificate, currentTime time.Time) error { + if !bytes.Equal(certificate.RawSubject, certificate.RawIssuer) { + return E.New("untrusted Authenticode signer certificate is not self-signed") + } + err := certificate.CheckSignature(certificate.SignatureAlgorithm, certificate.RawTBSCertificate, certificate.Signature) + if err != nil { + return E.Cause(err, "verify untrusted Authenticode signer self-signature") + } + if currentTime.Before(certificate.NotBefore) || currentTime.After(certificate.NotAfter) { + return E.New("untrusted Authenticode signer certificate is not currently valid") + } + return nil +} diff --git a/experimental/boxdd/authorize_takeover_linux.go b/experimental/boxdd/authorize_takeover_linux.go new file mode 100644 index 0000000000..8fd1912cf1 --- /dev/null +++ b/experimental/boxdd/authorize_takeover_linux.go @@ -0,0 +1,102 @@ +//go:build linux + +package main + +import ( + "context" + "crypto/rand" + "encoding/hex" + "math" + "strconv" + + E "github.com/sagernet/sing/common/exceptions" + + "github.com/godbus/dbus/v5" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const ( + policyKitService = "org.freedesktop.PolicyKit1" + policyKitAuthorityPath = dbus.ObjectPath("/org/freedesktop/PolicyKit1/Authority") + policyKitAuthorityInterface = "org.freedesktop.PolicyKit1.Authority" + policyKitTakeOverAction = "io.nekohasekai.sfl.take-over-service" + policyKitAllowUserInteraction = uint32(1) +) + +type policyKitSubject struct { + Kind string + Details map[string]dbus.Variant +} + +type policyKitAuthorizationResult struct { + Authorized bool + Challenge bool + Details map[string]string +} + +func authorizeTakeOver(ctx context.Context, identity peerIdentity) error { + if listenAddress != "" { + return nil + } + userID, err := strconv.ParseUint(identity.UserID, 10, 32) + if err != nil || userID > math.MaxInt32 { + return status.Error(codes.Unauthenticated, "daemon peer has an invalid Linux user ID") + } + if identity.ProcessID == 0 || identity.ProcessStartTime == 0 { + return status.Error(codes.Unauthenticated, "daemon peer has an invalid Linux process identity") + } + cancellationContent := make([]byte, 16) + _, err = rand.Read(cancellationContent) + if err != nil { + return E.Cause(err, "create PolicyKit cancellation ID") + } + cancellationID := "sing-box-" + hex.EncodeToString(cancellationContent) + connection, err := dbus.ConnectSystemBus() + if err != nil { + return E.Cause(err, "connect to system bus") + } + defer connection.Close() + authority := connection.Object(policyKitService, policyKitAuthorityPath) + subject := policyKitSubject{ + Kind: "unix-process", + Details: map[string]dbus.Variant{ + "pid": dbus.MakeVariant(identity.ProcessID), + "start-time": dbus.MakeVariant(identity.ProcessStartTime), + "uid": dbus.MakeVariant(int32(userID)), + }, + } + resultChannel := make(chan *dbus.Call, 1) + authority.Go( + policyKitAuthorityInterface+".CheckAuthorization", + 0, + resultChannel, + subject, + policyKitTakeOverAction, + map[string]string{}, + policyKitAllowUserInteraction, + cancellationID, + ) + select { + case call := <-resultChannel: + if call.Err != nil { + return E.Cause(call.Err, "check PolicyKit authorization") + } + var result policyKitAuthorizationResult + err = call.Store(&result) + if err != nil { + return E.Cause(err, "read PolicyKit authorization result") + } + if !result.Authorized { + return status.Error(codes.PermissionDenied, "take over authorization was denied") + } + return nil + case <-ctx.Done(): + _ = authority.Call( + policyKitAuthorityInterface+".CancelCheckAuthorization", + 0, + cancellationID, + ).Err + return status.Error(codes.Canceled, "take over authorization was canceled") + } +} diff --git a/experimental/boxdd/authorize_takeover_stub.go b/experimental/boxdd/authorize_takeover_stub.go new file mode 100644 index 0000000000..c62b68d5cd --- /dev/null +++ b/experimental/boxdd/authorize_takeover_stub.go @@ -0,0 +1,9 @@ +//go:build !linux + +package main + +import "context" + +func authorizeTakeOver(ctx context.Context, identity peerIdentity) error { + return nil +} diff --git a/experimental/boxdd/cmd_run.go b/experimental/boxdd/cmd_run.go new file mode 100644 index 0000000000..b054dc1f27 --- /dev/null +++ b/experimental/boxdd/cmd_run.go @@ -0,0 +1,93 @@ +package main + +import ( + "os" + "os/signal" + "path/filepath" + "syscall" + + "github.com/sagernet/sing-box/experimental/libbox" + "github.com/sagernet/sing-box/log" + E "github.com/sagernet/sing/common/exceptions" + + "github.com/spf13/cobra" +) + +var ( + workingDirectory string + socketPath string + listenAddress string +) + +var commandRun = &cobra.Command{ + Use: "run", + Short: "Run the daemon", + Args: cobra.NoArgs, + Run: func(command *cobra.Command, args []string) { + err := run() + if err != nil { + log.Fatal(E.Cause(err, "run daemon")) + } + }, +} + +func init() { + commandRun.Flags().StringVarP(&workingDirectory, "working-directory", "D", "", "working directory") + commandRun.Flags().StringVar(&socketPath, "socket", "", "listen on the specified unix domain socket path, or named pipe path on Windows") + commandRun.Flags().StringVar(&listenAddress, "listen", "", "listen on the specified TCP address (development only)") + mainCommand.AddCommand(commandRun) +} + +func prepareWorkingDirectory() error { + if workingDirectory == "" { + return E.New("missing working directory") + } + absoluteWorkingDirectory, err := filepath.Abs(workingDirectory) + if err != nil { + return err + } + workingDirectory = absoluteWorkingDirectory + err = preparePlatformWorkingDirectory() + if err != nil { + return err + } + err = os.Chdir(workingDirectory) + if err != nil { + return err + } + err = libbox.Setup(&libbox.SetupOptions{ + BasePath: workingDirectory, + WorkingPath: workingDirectory, + TempPath: workingDirectory, + CrashReportSource: "Daemon", + }) + if err != nil { + return err + } + libbox.PromoteOOMDraft() + return nil +} + +func run() error { + handled, err := runService() + if handled { + return err + } + err = prepareWorkingDirectory() + if err != nil { + return err + } + d, err := newDaemon() + if err != nil { + return err + } + err = d.Start() + if err != nil { + return err + } + signalChannel := make(chan os.Signal, 1) + signal.Notify(signalChannel, os.Interrupt, syscall.SIGTERM) + <-signalChannel + d.Close() + return nil +} diff --git a/experimental/boxdd/cmd_run_linux.go b/experimental/boxdd/cmd_run_linux.go new file mode 100644 index 0000000000..96b8ceb048 --- /dev/null +++ b/experimental/boxdd/cmd_run_linux.go @@ -0,0 +1,18 @@ +package main + +import ( + "os" + + E "github.com/sagernet/sing/common/exceptions" +) + +func runService() (bool, error) { + if os.Getenv("INVOCATION_ID") != "" && listenAddress != "" { + return true, E.New("--listen is not allowed in service mode") + } + return false, nil +} + +func preparePlatformWorkingDirectory() error { + return os.MkdirAll(workingDirectory, 0o700) +} diff --git a/experimental/boxdd/cmd_run_stub.go b/experimental/boxdd/cmd_run_stub.go new file mode 100644 index 0000000000..71c0916bb5 --- /dev/null +++ b/experimental/boxdd/cmd_run_stub.go @@ -0,0 +1,13 @@ +//go:build !windows && !linux + +package main + +import "os" + +func runService() (bool, error) { + return false, nil +} + +func preparePlatformWorkingDirectory() error { + return os.MkdirAll(workingDirectory, 0o700) +} diff --git a/experimental/boxdd/cmd_run_windows.go b/experimental/boxdd/cmd_run_windows.go new file mode 100644 index 0000000000..7ed25fbc25 --- /dev/null +++ b/experimental/boxdd/cmd_run_windows.go @@ -0,0 +1,122 @@ +package main + +import ( + "os" + "runtime" + "time" + + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + + "golang.org/x/sys/windows/svc" + "golang.org/x/sys/windows/svc/eventlog" +) + +var commandRunFlagAllowUnsafeInstallation bool + +func init() { + commandRun.Flags().BoolVar( + &commandRunFlagAllowUnsafeInstallation, + "allow-unsafe-installation-directory-permissions", + false, + "allow unsafe daemon working directory ancestor permissions", + ) +} + +func runService() (bool, error) { + isWindowsService, err := svc.IsWindowsService() + if err != nil { + return true, E.Cause(err, "check windows service") + } + if !isWindowsService { + return false, nil + } + return true, svc.Run(serviceName, &windowsService{}) +} + +func preparePlatformWorkingDirectory() error { + if listenAddress != "" { + return os.MkdirAll(workingDirectory, 0o700) + } + serviceWorkingDirectory, err := resolveWindowsServiceWorkingDirectory( + workingDirectory, + commandRunFlagAllowUnsafeInstallation, + ) + if err != nil { + return err + } + workingDirectory = serviceWorkingDirectory + return ensureWindowsWorkingDirectory(serviceWorkingDirectory) +} + +type windowsService struct{} + +func (s *windowsService) Execute(arguments []string, requests <-chan svc.ChangeRequest, statuses chan<- svc.Status) (serviceSpecific bool, exitCode uint32) { + statuses <- svc.Status{State: svc.StartPending} + if listenAddress != "" { + exitCode = 1 + serviceLogError(E.New("--listen is not allowed in service mode")) + return + } + err := allowAuthenticatedUsersToQueryCurrentProcess() + if err != nil { + exitCode = 1 + serviceLogError(E.Cause(err, "secure daemon process")) + return + } + err = prepareWorkingDirectory() + if err != nil { + exitCode = 1 + serviceLogError(err) + return + } + d, err := newDaemon() + if err != nil { + exitCode = 1 + serviceLogError(err) + return + } + err = d.Start() + if err != nil { + exitCode = 1 + serviceLogError(err) + return + } + statuses <- svc.Status{State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown | svc.AcceptSessionChange} + runtime.GC() + for request := range requests { + if request.Cmd == svc.Interrogate { + statuses <- request.CurrentStatus + continue + } + if request.Cmd == svc.SessionChange { + err = d.handlePlatformSessionChange(request.EventType, uint32(request.EventData)) + if err != nil { + serviceLogError(E.Cause(err, "handle session change")) + } + continue + } + if request.Cmd == svc.Stop || request.Cmd == svc.Shutdown { + break + } + serviceLogError(E.New("unexpected service command: ", uint32(request.Cmd))) + } + statuses <- svc.Status{State: svc.StopPending} + watchdog := time.AfterFunc(3*time.Second, func() { + serviceLogError(E.New("daemon did not close")) + os.Exit(1) + }) + d.Close() + watchdog.Stop() + statuses <- svc.Status{State: svc.Stopped} + return +} + +func serviceLogError(err error) { + eventLog, openError := eventlog.Open(serviceName) + if openError != nil { + return + } + eventLog.Error(1, F.ToString(err)) + eventLog.Close() +} diff --git a/experimental/boxdd/cmd_service.go b/experimental/boxdd/cmd_service.go new file mode 100644 index 0000000000..6451aabdac --- /dev/null +++ b/experimental/boxdd/cmd_service.go @@ -0,0 +1,69 @@ +package main + +import ( + "os" + + "github.com/sagernet/sing-box/log" + E "github.com/sagernet/sing/common/exceptions" + + "github.com/spf13/cobra" +) + +var commandServiceFlagWorkingDirectory string + +var commandService = &cobra.Command{ + Use: "service", + Short: "Manage the system service", +} + +var commandServiceStart = &cobra.Command{ + Use: "start", + Short: "Start the system service", + Args: cobra.NoArgs, + Run: func(command *cobra.Command, args []string) { + err := serviceStart() + if err != nil { + log.Fatal(E.Cause(err, "start service")) + } + }, +} + +var commandServiceStop = &cobra.Command{ + Use: "stop", + Short: "Stop the system service", + Args: cobra.NoArgs, + Run: func(command *cobra.Command, args []string) { + err := serviceStop() + if err != nil { + log.Fatal(E.Cause(err, "stop service")) + } + }, +} + +var commandServiceStatus = &cobra.Command{ + Use: "status", + Short: "Print the system service status", + Args: cobra.NoArgs, + Run: func(command *cobra.Command, args []string) { + status, err := serviceStatus() + if err != nil { + log.Fatal(E.Cause(err, "query service status")) + } + os.Stdout.WriteString(status.description + "\n") + os.Exit(status.exitCode) + }, +} + +type serviceStatusResult struct { + exitCode int + description string +} + +func init() { + commandService.PersistentFlags().StringVarP(&commandServiceFlagWorkingDirectory, "working-directory", "D", defaultServiceWorkingDirectory, "daemon working directory") + commandService.AddCommand(commandServiceStart) + commandService.AddCommand(commandServiceStop) + commandService.AddCommand(commandServiceStatus) + addPlatformServiceCommands() + mainCommand.AddCommand(commandService) +} diff --git a/experimental/boxdd/cmd_service_linux.go b/experimental/boxdd/cmd_service_linux.go new file mode 100644 index 0000000000..1161c10532 --- /dev/null +++ b/experimental/boxdd/cmd_service_linux.go @@ -0,0 +1,163 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + + "github.com/sagernet/sing-box/log" + E "github.com/sagernet/sing/common/exceptions" + + "github.com/spf13/cobra" +) + +const ( + defaultServiceWorkingDirectory = "/var/lib/sing-box-daemon" + serviceUnitName = serviceName + ".service" +) + +var commandServiceRestart = &cobra.Command{ + Use: "restart", + Short: "Restart the system service", + Args: cobra.NoArgs, + Run: func(command *cobra.Command, args []string) { + err := serviceRestart() + if err != nil { + log.Fatal(E.Cause(err, "restart service")) + } + }, +} + +var commandServiceSetInsecureMode = &cobra.Command{ + Use: "set-insecure-mode ", + Short: "Set whether configurations may use privileges unrelated to networking", + Args: cobra.ExactArgs(1), + Run: func(command *cobra.Command, args []string) { + err := serviceSetInsecureMode(args[0]) + if err != nil { + log.Fatal(E.Cause(err, "set insecure mode")) + } + }, +} + +func addPlatformServiceCommands() { + commandService.AddCommand(commandServiceRestart) + commandService.AddCommand(commandServiceSetInsecureMode) +} + +func serviceSetInsecureMode(value string) error { + enabled, err := strconv.ParseBool(value) + if err != nil { + return E.Cause(err, "parse value") + } + if os.Geteuid() != 0 { + return E.New("setting insecure mode requires an elevated process") + } + directory, err := filepath.Abs(commandServiceFlagWorkingDirectory) + if err != nil { + return E.Cause(err, "resolve working directory") + } + err = validateProtectedLinuxDirectory(directory) + if err != nil { + return E.Cause(err, "validate working directory") + } + return saveSecuritySettings(directory, securitySettings{InsecureModeEnabled: enabled}) +} + +func validateProtectedLinuxDirectory(directory string) error { + currentPath := directory + for { + info, err := os.Lstat(currentPath) + if err != nil { + return err + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return E.New("protected path is not a directory: ", currentPath) + } + fileStatus, loaded := info.Sys().(*syscall.Stat_t) + if !loaded || fileStatus.Uid != 0 { + return E.New("protected path is not owned by root: ", currentPath) + } + if info.Mode().Perm()&0o022 != 0 { + return E.New("protected path is writable by non-root users: ", currentPath) + } + parentPath := filepath.Dir(currentPath) + if parentPath == currentPath { + return nil + } + currentPath = parentPath + } +} + +func runSystemctl(arguments ...string) error { + output, err := exec.Command("systemctl", arguments...).CombinedOutput() + if err != nil { + message := strings.TrimSpace(string(output)) + if message == "" { + return E.Cause(err, "systemctl ", strings.Join(arguments, " ")) + } + return E.New("systemctl ", strings.Join(arguments, " "), ": ", message) + } + return nil +} + +func serviceStart() error { + return runSystemctl("start", serviceUnitName) +} + +func serviceStop() error { + installed, err := serviceInstalled() + if err != nil { + return err + } + if !installed { + log.Info("service not installed") + return nil + } + return runSystemctl("stop", serviceUnitName) +} + +func serviceRestart() error { + return runSystemctl("restart", serviceUnitName) +} + +func serviceInstalled() (bool, error) { + loadState, err := systemctlProperty("LoadState") + if err != nil { + return false, err + } + return loadState != "" && loadState != "not-found", nil +} + +func systemctlProperty(property string) (string, error) { + output, err := exec.Command("systemctl", "show", "--property="+property, "--value", serviceUnitName).CombinedOutput() + if err != nil { + message := strings.TrimSpace(string(output)) + if message == "" { + return "", E.Cause(err, "query system service") + } + return "", E.New("query system service: ", message) + } + return strings.TrimSpace(string(output)), nil +} + +func serviceStatus() (*serviceStatusResult, error) { + installed, err := serviceInstalled() + if err != nil { + return nil, err + } + if !installed { + return &serviceStatusResult{exitCode: 3, description: "not installed"}, nil + } + activeState, err := systemctlProperty("ActiveState") + if err != nil { + return nil, err + } + if activeState == "active" { + return &serviceStatusResult{exitCode: 0, description: "running"}, nil + } + return &serviceStatusResult{exitCode: 2, description: "stopped"}, nil +} diff --git a/experimental/boxdd/cmd_service_stub.go b/experimental/boxdd/cmd_service_stub.go new file mode 100644 index 0000000000..58730bdc36 --- /dev/null +++ b/experimental/boxdd/cmd_service_stub.go @@ -0,0 +1,24 @@ +//go:build !windows && !linux + +package main + +import ( + E "github.com/sagernet/sing/common/exceptions" +) + +const defaultServiceWorkingDirectory = "" + +func addPlatformServiceCommands() { +} + +func serviceStart() error { + return E.New("service management is not supported on this platform") +} + +func serviceStop() error { + return E.New("service management is not supported on this platform") +} + +func serviceStatus() (*serviceStatusResult, error) { + return nil, E.New("service management is not supported on this platform") +} diff --git a/experimental/boxdd/cmd_service_windows.go b/experimental/boxdd/cmd_service_windows.go new file mode 100644 index 0000000000..c890321fc1 --- /dev/null +++ b/experimental/boxdd/cmd_service_windows.go @@ -0,0 +1,398 @@ +package main + +import ( + "errors" + "os" + "strconv" + "strings" + "time" + + "github.com/sagernet/sing-box/log" + E "github.com/sagernet/sing/common/exceptions" + + "github.com/spf13/cobra" + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/svc" + "golang.org/x/sys/windows/svc/eventlog" + "golang.org/x/sys/windows/svc/mgr" +) + +const ( + serviceDisplayName = "sing-box Service" + serviceDescriptionText = "Privileged service for sing-box" + defaultServiceWorkingDirectory = `C:\ProgramData\sing-box-daemon` +) + +var commandServiceFlagAllowUnsafeInstallation bool + +var commandServiceInstall = &cobra.Command{ + Use: "install", + Short: "Install or update the system service", + Args: cobra.NoArgs, + Run: func(command *cobra.Command, args []string) { + err := serviceInstall() + if err != nil { + log.Fatal(E.Cause(err, "install service")) + } + }, +} + +var commandServiceUninstall = &cobra.Command{ + Use: "uninstall", + Short: "Uninstall the system service", + Args: cobra.NoArgs, + Run: func(command *cobra.Command, args []string) { + err := serviceUninstall() + if err != nil { + log.Fatal(E.Cause(err, "uninstall service")) + } + }, +} + +var commandServiceSetInsecureMode = &cobra.Command{ + Use: "set-insecure-mode ", + Short: "Set whether configurations may use privileges unrelated to networking", + Args: cobra.ExactArgs(1), + Run: func(command *cobra.Command, args []string) { + err := serviceSetInsecureMode(args[0]) + if err != nil { + log.Fatal(E.Cause(err, "set insecure mode")) + } + }, +} + +func addPlatformServiceCommands() { + commandServiceInstall.Flags().BoolVar( + &commandServiceFlagAllowUnsafeInstallation, + "allow-unsafe-installation-directory-permissions", + false, + "allow unsafe installation path security and daemon working directory ancestor permissions", + ) + commandService.AddCommand(commandServiceInstall) + commandService.AddCommand(commandServiceUninstall) + commandService.AddCommand(commandServiceSetInsecureMode) +} + +func serviceSetInsecureMode(value string) error { + enabled, err := strconv.ParseBool(value) + if err != nil { + return E.Cause(err, "parse value") + } + if !windows.GetCurrentProcessToken().IsElevated() { + return E.New("setting insecure mode requires an elevated process") + } + directory, err := installedServiceWorkingDirectory() + if err != nil { + return err + } + serviceUserID, err := windowsServiceSID() + if err != nil { + return E.Cause(err, "create daemon service SID") + } + err = validateProtectedWindowsWorkingDirectory(directory, serviceUserID) + if err != nil { + return E.Cause(err, "validate working directory") + } + return saveSecuritySettings(directory, securitySettings{InsecureModeEnabled: enabled}) +} + +func installedServiceWorkingDirectory() (string, error) { + manager, err := mgr.Connect() + if err != nil { + return "", E.Cause(err, "connect to service manager") + } + defer manager.Disconnect() + service, err := manager.OpenService(serviceName) + if err != nil { + return "", E.Cause(err, "open service") + } + defer service.Close() + config, err := service.Config() + if err != nil { + return "", E.Cause(err, "query service config") + } + arguments, err := windows.DecomposeCommandLine(config.BinaryPathName) + if err != nil { + return "", E.Cause(err, "parse service command line") + } + for index, argument := range arguments { + if argument == "--working-directory" && index+1 < len(arguments) { + return arguments[index+1], nil + } + } + return "", E.New("missing working directory in the service configuration") +} + +func serviceInstall() error { + executablePath, err := os.Executable() + if err != nil { + return E.Cause(err, "get executable path") + } + serviceWorkingDirectory, err := resolveWindowsServiceWorkingDirectory( + commandServiceFlagWorkingDirectory, + commandServiceFlagAllowUnsafeInstallation, + ) + if err != nil { + return E.Cause(err, "validate working directory") + } + executablePath, err = secureWindowsInstallation(executablePath, commandServiceFlagAllowUnsafeInstallation) + if err != nil { + return E.Cause(err, "secure installation") + } + err = ensureWindowsWorkingDirectory(serviceWorkingDirectory) + if err != nil { + return E.Cause(err, "secure working directory") + } + manager, err := mgr.Connect() + if err != nil { + return E.Cause(err, "connect to service manager") + } + defer manager.Disconnect() + arguments := []string{"run", "--working-directory", serviceWorkingDirectory} + if commandServiceFlagAllowUnsafeInstallation { + arguments = append(arguments, "--allow-unsafe-installation-directory-permissions") + } + config := mgr.Config{ + DisplayName: serviceDisplayName, + Description: serviceDescriptionText, + StartType: mgr.StartAutomatic, + Dependencies: []string{"Tcpip"}, + SidType: windows.SERVICE_SID_TYPE_UNRESTRICTED, + } + created := false + service, err := manager.OpenService(serviceName) + if err != nil { + if !errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST) { + return E.Cause(err, "open service") + } + service, err = manager.CreateService(serviceName, executablePath, config, arguments...) + if err != nil { + return E.Cause(err, "create service") + } + created = true + } else { + err = updateServiceConfig(service, config, executablePath, arguments) + if err != nil { + service.Close() + return err + } + } + defer service.Close() + rollback := func() { + if created { + _ = service.Delete() + } + } + installedConfig, err := service.Config() + if err != nil { + rollback() + return E.Cause(err, "query installed service config") + } + if installedConfig.SidType != windows.SERVICE_SID_TYPE_UNRESTRICTED { + rollback() + return E.New("unexpected installed service SID type: ", installedConfig.SidType) + } + err = service.SetRecoveryActions([]mgr.RecoveryAction{ + {Type: mgr.ServiceRestart, Delay: 5 * time.Second}, + {Type: mgr.ServiceRestart, Delay: 5 * time.Second}, + {Type: mgr.ServiceRestart, Delay: 5 * time.Second}, + }, 86400) + if err != nil { + rollback() + return E.Cause(err, "set recovery actions") + } + err = applyProtectedServiceSecurity(service) + if err != nil { + rollback() + return E.Cause(err, "secure service") + } + err = eventlog.InstallAsEventCreate(serviceName, eventlog.Error|eventlog.Warning|eventlog.Info) + if err != nil && !strings.Contains(err.Error(), "already exists") { + rollback() + return E.Cause(err, "install event log source") + } + if !created { + err = stopServiceAndWait(service) + if err != nil { + return E.Cause(err, "stop service") + } + } + err = startServiceAndWait(service) + if err != nil { + return E.Cause(err, "start service") + } + return nil +} + +func updateServiceConfig(service *mgr.Service, config mgr.Config, executablePath string, arguments []string) error { + binaryPathName := windows.ComposeCommandLine(append([]string{executablePath}, arguments...)) + currentConfig, err := service.Config() + if err != nil { + return E.Cause(err, "query service config") + } + currentConfig.DisplayName = config.DisplayName + currentConfig.Description = config.Description + currentConfig.StartType = config.StartType + currentConfig.Dependencies = config.Dependencies + currentConfig.SidType = config.SidType + currentConfig.BinaryPathName = binaryPathName + err = service.UpdateConfig(currentConfig) + if err != nil { + return E.Cause(err, "update service config") + } + return nil +} + +func serviceUninstall() error { + manager, err := mgr.Connect() + if err != nil { + return E.Cause(err, "connect to service manager") + } + defer manager.Disconnect() + service, err := manager.OpenService(serviceName) + if err != nil { + if errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST) { + log.Info("service not installed") + return nil + } + return E.Cause(err, "open service") + } + defer service.Close() + err = stopServiceAndWait(service) + if err != nil { + log.Warn("stop service: ", err) + } + err = service.Delete() + if err != nil { + return E.Cause(err, "delete service") + } + err = eventlog.Remove(serviceName) + if err != nil { + log.Warn("remove event log source: ", err) + } + return nil +} + +func serviceStart() error { + manager, err := mgr.Connect() + if err != nil { + return E.Cause(err, "connect to service manager") + } + defer manager.Disconnect() + service, err := manager.OpenService(serviceName) + if err != nil { + return E.Cause(err, "open service") + } + defer service.Close() + return startServiceAndWait(service) +} + +func serviceStop() error { + manager, err := mgr.Connect() + if err != nil { + return E.Cause(err, "connect to service manager") + } + defer manager.Disconnect() + service, err := manager.OpenService(serviceName) + if err != nil { + if errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST) { + log.Info("service not installed") + return nil + } + return E.Cause(err, "open service") + } + defer service.Close() + return stopServiceAndWait(service) +} + +func startServiceAndWait(service *mgr.Service) error { + status, err := service.Query() + if err != nil { + return E.Cause(err, "query service status") + } + if status.State == svc.Running { + return nil + } + if status.State == svc.Stopped { + err = service.Start() + if err != nil { + return E.Cause(err, "start service") + } + } + return waitServiceState(service, svc.Running) +} + +func stopServiceAndWait(service *mgr.Service) error { + status, err := service.Query() + if err != nil { + return E.Cause(err, "query service status") + } + if status.State == svc.Stopped { + return nil + } + if status.State != svc.StopPending { + _, err = service.Control(svc.Stop) + if err != nil { + return E.Cause(err, "stop service") + } + } + return waitServiceState(service, svc.Stopped) +} + +func waitServiceState(service *mgr.Service, state svc.State) error { + timeout := time.Now().Add(30 * time.Second) + var currentStatus svc.Status + for time.Now().Before(timeout) { + status, err := service.Query() + if err != nil { + return E.Cause(err, "query service status") + } + currentStatus = status + if status.State == state { + return nil + } + if state == svc.Running && status.State == svc.Stopped { + return E.New( + "service stopped while starting, Windows exit code ", status.Win32ExitCode, + ", service exit code ", status.ServiceSpecificExitCode, + ) + } + time.Sleep(500 * time.Millisecond) + } + return E.New( + "timeout waiting for service state ", uint32(state), + ", current state ", uint32(currentStatus.State), + ", process ID ", currentStatus.ProcessId, + ", Windows exit code ", currentStatus.Win32ExitCode, + ", service exit code ", currentStatus.ServiceSpecificExitCode, + ) +} + +func serviceStatus() (*serviceStatusResult, error) { + manager, err := windows.OpenSCManager(nil, nil, windows.SC_MANAGER_CONNECT) + if err != nil { + return nil, E.Cause(err, "connect to service manager") + } + defer windows.CloseServiceHandle(manager) + namePointer, err := windows.UTF16PtrFromString(serviceName) + if err != nil { + return nil, err + } + service, err := windows.OpenService(manager, namePointer, windows.SERVICE_QUERY_STATUS) + if err != nil { + if errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST) { + return &serviceStatusResult{exitCode: 3, description: "not installed"}, nil + } + return nil, E.Cause(err, "open service") + } + defer windows.CloseServiceHandle(service) + var status windows.SERVICE_STATUS + err = windows.QueryServiceStatus(service, &status) + if err != nil { + return nil, E.Cause(err, "query service status") + } + if status.CurrentState == windows.SERVICE_RUNNING { + return &serviceStatusResult{exitCode: 0, description: "running"}, nil + } + return &serviceStatusResult{exitCode: 2, description: "stopped"}, nil +} diff --git a/experimental/boxdd/cmd_worker.go b/experimental/boxdd/cmd_worker.go new file mode 100644 index 0000000000..ce43d9fa85 --- /dev/null +++ b/experimental/boxdd/cmd_worker.go @@ -0,0 +1,104 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io" + "os" + + "github.com/sagernet/sing-box/daemon" + "github.com/sagernet/sing-box/include" + "github.com/sagernet/sing-box/log" + E "github.com/sagernet/sing/common/exceptions" + + "github.com/spf13/cobra" + "google.golang.org/grpc" +) + +var ( + workerSocketPath string + workerDaemonRelaySocketPath string + workerParentProcessID uint32 +) + +type workerParent interface { + Close() error +} + +var commandWorker = &cobra.Command{ + Use: "worker", + Short: "Serve the non-privileged application worker for the application process", + Args: cobra.NoArgs, + Run: func(command *cobra.Command, args []string) { + err := runWorker() + if err != nil { + log.Fatal(E.Cause(err, "run application worker")) + } + }, +} + +func init() { + commandWorker.Flags().StringVar(&workerSocketPath, "socket", "", "listen on the specified unix domain socket path, or named pipe path on Windows") + commandWorker.Flags().StringVar(&workerDaemonRelaySocketPath, "daemon-relay-socket", "", "relay the authenticated Windows daemon connection on the specified named pipe path") + commandWorker.Flags().Uint32Var(&workerParentProcessID, "parent-pid", 0, "expected application parent process ID") + mainCommand.AddCommand(commandWorker) +} + +func runWorker() error { + if workerSocketPath == "" { + return E.New("missing --socket") + } + if workerParentProcessID == 0 { + return E.New("missing --parent-pid") + } + parent, err := prepareWorkerParent(workerParentProcessID) + if err != nil { + return err + } + defer parent.Close() + listener, err := listenWorkerEndpoint(workerSocketPath, parent) + if err != nil { + return err + } + defer listener.Close() + server := grpc.NewServer( + grpc.ChainUnaryInterceptor(daemon.UnaryLocaleInterceptor), + grpc.ChainStreamInterceptor(daemon.StreamLocaleInterceptor), + ) + RegisterApplicationServiceServer(server, &applicationService{ + startedService: daemon.NewStartedService(daemon.ServiceOptions{Context: include.Context(context.Background())}), + }) + relayErrorChannel := make(chan error, 1) + relay, err := startWorkerDaemonRelay(workerDaemonRelaySocketPath, parent, func(relayError error) { + select { + case relayErrorChannel <- relayError: + default: + } + server.Stop() + }) + if err != nil { + return err + } + if relay != nil { + defer relay.Close() + } + go func() { + _, _ = io.Copy(io.Discard, os.Stdin) + if relay != nil { + relay.Close() + } + server.Stop() + }() + fmt.Println("READY") + err = server.Serve(listener) + select { + case relayError := <-relayErrorChannel: + return relayError + default: + } + if err != nil && !errors.Is(err, grpc.ErrServerStopped) { + return err + } + return nil +} diff --git a/experimental/boxdd/cmd_worker_unix.go b/experimental/boxdd/cmd_worker_unix.go new file mode 100644 index 0000000000..55abc76ac3 --- /dev/null +++ b/experimental/boxdd/cmd_worker_unix.go @@ -0,0 +1,48 @@ +//go:build !windows + +package main + +import ( + "io" + "net" + "os" + + E "github.com/sagernet/sing/common/exceptions" +) + +type unixWorkerParent struct{} + +func (unixWorkerParent) Close() error { + return nil +} + +func prepareWorkerParent(parentProcessID uint32) (workerParent, error) { + if uint32(os.Getppid()) != parentProcessID { + return nil, E.New("worker was not started by the expected application process") + } + return unixWorkerParent{}, nil +} + +func listenWorkerEndpoint(path string, parent workerParent) (net.Listener, error) { + err := os.Remove(path) + if err != nil && !os.IsNotExist(err) { + return nil, err + } + listener, err := net.Listen("unix", path) + if err != nil { + return nil, err + } + err = os.Chmod(path, 0o600) + if err != nil { + listener.Close() + return nil, err + } + return listener, nil +} + +func startWorkerDaemonRelay(path string, parent workerParent, onFailure func(error)) (io.Closer, error) { + if path != "" { + return nil, E.New("daemon relay is only supported on Windows") + } + return nil, nil +} diff --git a/experimental/boxdd/cmd_worker_windows.go b/experimental/boxdd/cmd_worker_windows.go new file mode 100644 index 0000000000..fddec6f4fc --- /dev/null +++ b/experimental/boxdd/cmd_worker_windows.go @@ -0,0 +1,500 @@ +package main + +import ( + "bytes" + "errors" + "fmt" + "io" + "net" + "os" + "strings" + "sync" + "sync/atomic" + + E "github.com/sagernet/sing/common/exceptions" + + "github.com/tailscale/go-winio" + winioProcess "github.com/tailscale/go-winio/pkg/process" + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/svc" + "golang.org/x/sys/windows/svc/mgr" +) + +type windowsWorkerParent struct { + process windows.Handle + processImage windows.Handle + executable windows.Handle + executablePath string + signer []byte + userID string + sessionID uint32 + pid uint32 + exited chan struct{} + close sync.Once + closeError error +} + +type authenticatedWorkerListener struct { + net.Listener + parent *windowsWorkerParent +} + +type windowsWorkerDaemonRelay struct { + listener net.Listener + parent *windowsWorkerParent + onFailure func(error) + connections map[net.Conn]struct{} + connectionAccess sync.Mutex + connectionWaitGroup sync.WaitGroup + closing atomic.Bool + close sync.Once + closeError error +} + +type windowsAuthenticatedDaemonConnection struct { + net.Conn + process windows.Handle + processImage windows.Handle + close sync.Once + closeError error +} + +func prepareWorkerParent(parentProcessID uint32) (workerParent, error) { + if os.Getppid() != int(parentProcessID) { + return nil, E.New("worker was not started by the expected application process") + } + parentProcess, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.SYNCHRONIZE, false, parentProcessID) + if err != nil { + return nil, err + } + keepProcess := false + defer func() { + if !keepProcess { + windows.CloseHandle(parentProcess) + } + }() + identity, err := processIdentity(parentProcess, parentProcessID) + if err != nil { + return nil, err + } + parentImagePath, err := winioProcess.QueryFullProcessImageName(parentProcess, winioProcess.ImageNameFormatWin32Path) + if err != nil { + return nil, E.Cause(err, "query worker parent executable") + } + parentImage, err := openLockedExecutable(parentImagePath) + if err != nil { + return nil, err + } + keepParentImage := false + defer func() { + if !keepParentImage { + windows.CloseHandle(parentImage) + } + }() + workerExecutablePath, err := os.Executable() + if err != nil { + return nil, err + } + workerExecutable, err := openLockedExecutable(workerExecutablePath) + if err != nil { + return nil, err + } + keepWorkerExecutable := false + defer func() { + if !keepWorkerExecutable { + windows.CloseHandle(workerExecutable) + } + }() + workerFinalPath, err := finalWindowsPath(workerExecutable) + if err != nil { + return nil, err + } + _, expectedApplicationPath, err := installedApplicationPath(workerFinalPath) + if err != nil { + return nil, err + } + expectedApplication, err := openLockedExecutable(expectedApplicationPath) + if err != nil { + return nil, err + } + defer windows.CloseHandle(expectedApplication) + parentFinalPath, err := finalWindowsPath(parentImage) + if err != nil { + return nil, err + } + expectedApplicationFinalPath, err := finalWindowsPath(expectedApplication) + if err != nil { + return nil, err + } + if !strings.EqualFold(parentFinalPath, expectedApplicationFinalPath) { + return nil, E.New("worker parent is not the installed sing-box application") + } + sameApplication, err := sameWindowsFile(parentImage, expectedApplication) + if err != nil { + return nil, err + } + if !sameApplication { + return nil, E.New("worker parent executable was replaced") + } + err = validateApplicationProcessRole(parentProcess, expectedApplication) + if err != nil { + return nil, err + } + workerSigner, err := authenticodeSigner(workerFinalPath, workerExecutable) + if err != nil { + return nil, err + } + parentSigner, err := authenticodeSigner(parentFinalPath, parentImage) + if err != nil { + return nil, err + } + if !bytes.Equal(workerSigner, parentSigner) { + return nil, E.New("worker and application have different signing certificates") + } + parentCreationTime, err := processCreationTime(parentProcess) + if err != nil { + return nil, err + } + workerCreationTime, err := processCreationTime(windows.CurrentProcess()) + if err != nil { + return nil, err + } + if parentCreationTime >= workerCreationTime { + return nil, E.New("worker parent was created after the worker process") + } + waitResult, err := windows.WaitForSingleObject(parentProcess, 0) + if err != nil { + return nil, err + } + if waitResult != uint32(windows.WAIT_TIMEOUT) { + return nil, E.New("worker application parent exited during authentication") + } + parent := &windowsWorkerParent{ + process: parentProcess, + processImage: parentImage, + executable: workerExecutable, + executablePath: workerFinalPath, + signer: workerSigner, + userID: identity.UserID, + sessionID: identity.SessionID, + pid: parentProcessID, + exited: make(chan struct{}), + } + go func() { + _, _ = windows.WaitForSingleObject(parent.process, windows.INFINITE) + close(parent.exited) + }() + keepProcess = true + keepParentImage = true + keepWorkerExecutable = true + return parent, nil +} + +func listenWorkerEndpoint(path string, parent workerParent) (net.Listener, error) { + windowsParent := parent.(*windowsWorkerParent) + securityDescriptor := fmt.Sprintf( + "D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GA;;;%s)", + windowsParent.userID, + ) + listener, err := winio.ListenPipe(path, &winio.PipeConfig{ + SecurityDescriptor: securityDescriptor, + InputBufferSize: pipeBufferSize, + OutputBufferSize: pipeBufferSize, + }) + if err != nil { + return nil, err + } + authenticatedListener := &authenticatedWorkerListener{Listener: listener, parent: windowsParent} + go func() { + <-windowsParent.exited + authenticatedListener.Close() + }() + return authenticatedListener, nil +} + +func (l *authenticatedWorkerListener) Accept() (net.Conn, error) { + for { + waitResult, err := windows.WaitForSingleObject(l.parent.process, 0) + if err != nil { + return nil, err + } + if waitResult != uint32(windows.WAIT_TIMEOUT) { + return nil, E.New("worker application parent exited") + } + connection, err := l.Listener.Accept() + if err != nil { + return nil, err + } + descriptorConnection, loaded := connection.(fileDescriptorConnection) + if !loaded { + connection.Close() + continue + } + var clientProcessID uint32 + err = windows.GetNamedPipeClientProcessId(windows.Handle(descriptorConnection.Fd()), &clientProcessID) + if err != nil || clientProcessID != l.parent.pid { + connection.Close() + continue + } + waitResult, err = windows.WaitForSingleObject(l.parent.process, 0) + if err != nil || waitResult != uint32(windows.WAIT_TIMEOUT) { + connection.Close() + return nil, E.New("worker application parent exited") + } + return connection, nil + } +} + +func (p *windowsWorkerParent) Close() error { + p.close.Do(func() { + p.closeError = E.Errors( + windows.CloseHandle(p.executable), + windows.CloseHandle(p.processImage), + windows.CloseHandle(p.process), + ) + }) + return p.closeError +} + +func startWorkerDaemonRelay(path string, parent workerParent, onFailure func(error)) (io.Closer, error) { + if path == "" { + return nil, E.New("missing --daemon-relay-socket") + } + windowsParent := parent.(*windowsWorkerParent) + listener, err := listenWorkerEndpoint(path, parent) + if err != nil { + return nil, err + } + relay := &windowsWorkerDaemonRelay{ + listener: listener, + parent: windowsParent, + onFailure: onFailure, + connections: make(map[net.Conn]struct{}), + } + go relay.serve() + return relay, nil +} + +func (r *windowsWorkerDaemonRelay) serve() { + for { + connection, err := r.listener.Accept() + if err != nil { + if !r.closing.Load() && !errors.Is(err, net.ErrClosed) { + r.onFailure(E.Cause(err, "accept daemon relay connection")) + } + return + } + r.connectionAccess.Lock() + if r.closing.Load() { + r.connectionAccess.Unlock() + connection.Close() + return + } + r.connections[connection] = struct{}{} + r.connectionWaitGroup.Add(1) + r.connectionAccess.Unlock() + go func() { + r.relay(connection) + r.connectionAccess.Lock() + delete(r.connections, connection) + r.connectionAccess.Unlock() + r.connectionWaitGroup.Done() + }() + } +} + +func (r *windowsWorkerDaemonRelay) relay(applicationConnection net.Conn) { + daemonConnection, err := r.connectDaemon() + if err != nil { + applicationConnection.Close() + return + } + copyCompleted := make(chan struct{}, 2) + firstCopyCompleted := make(chan struct{}) + var firstCopy sync.Once + copyConnection := func(destination io.Writer, source io.Reader) { + _, _ = io.Copy(destination, source) + firstCopy.Do(func() { + close(firstCopyCompleted) + }) + copyCompleted <- struct{}{} + } + go copyConnection(daemonConnection, applicationConnection) + go copyConnection(applicationConnection, daemonConnection) + select { + case <-firstCopyCompleted: + case <-r.parent.exited: + } + applicationConnection.Close() + daemonConnection.Close() + <-copyCompleted + <-copyCompleted +} + +func (r *windowsWorkerDaemonRelay) connectDaemon() (net.Conn, error) { + connection, err := winio.DialPipe(daemonPipePath, nil) + if err != nil { + return nil, err + } + keepConnection := false + defer func() { + if !keepConnection { + connection.Close() + } + }() + descriptorConnection, loaded := connection.(fileDescriptorConnection) + if !loaded { + return nil, E.New("daemon endpoint is not a Windows named pipe") + } + var processID uint32 + err = windows.GetNamedPipeServerProcessId(windows.Handle(descriptorConnection.Fd()), &processID) + if err != nil { + return nil, E.Cause(err, "identify daemon named pipe server") + } + process, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.SYNCHRONIZE, false, processID) + if err != nil { + return nil, E.Cause(err, "open daemon named pipe server process") + } + keepProcess := false + defer func() { + if !keepProcess { + windows.CloseHandle(process) + } + }() + err = validateDaemonProcessIdentity(processID) + if err != nil { + return nil, err + } + processImagePath, err := winioProcess.QueryFullProcessImageName(process, winioProcess.ImageNameFormatWin32Path) + if err != nil { + return nil, E.Cause(err, "query daemon named pipe server executable") + } + processImage, err := openLockedExecutable(processImagePath) + if err != nil { + return nil, E.Cause(err, "open daemon named pipe server executable") + } + keepProcessImage := false + defer func() { + if !keepProcessImage { + windows.CloseHandle(processImage) + } + }() + processImageFinalPath, err := finalWindowsPath(processImage) + if err != nil { + return nil, err + } + if !strings.EqualFold(processImageFinalPath, r.parent.executablePath) { + return nil, E.New("named pipe server is not the installed daemon") + } + sameExecutable, err := sameWindowsFile(processImage, r.parent.executable) + if err != nil { + return nil, err + } + if !sameExecutable { + return nil, E.New("named pipe server daemon executable was replaced") + } + signer, err := authenticodeSigner(processImageFinalPath, processImage) + if err != nil { + return nil, E.Cause(err, "authenticate daemon named pipe server") + } + if !bytes.Equal(signer, r.parent.signer) { + return nil, E.New("daemon server and worker have different signing certificates") + } + waitResult, err := windows.WaitForSingleObject(process, 0) + if err != nil { + return nil, err + } + if waitResult != uint32(windows.WAIT_TIMEOUT) { + return nil, E.New("daemon named pipe server exited during authentication") + } + keepConnection = true + keepProcess = true + keepProcessImage = true + return &windowsAuthenticatedDaemonConnection{ + Conn: connection, + process: process, + processImage: processImage, + }, nil +} + +func (r *windowsWorkerDaemonRelay) Close() error { + r.close.Do(func() { + r.closing.Store(true) + r.closeError = r.listener.Close() + r.connectionAccess.Lock() + for connection := range r.connections { + connection.Close() + } + r.connectionAccess.Unlock() + r.connectionWaitGroup.Wait() + }) + return r.closeError +} + +func (c *windowsAuthenticatedDaemonConnection) Close() error { + c.close.Do(func() { + c.closeError = E.Errors( + c.Conn.Close(), + windows.CloseHandle(c.processImage), + windows.CloseHandle(c.process), + ) + }) + return c.closeError +} + +func validateDaemonProcessIdentity(processID uint32) error { + var sessionID uint32 + err := windows.ProcessIdToSessionId(processID, &sessionID) + if err != nil { + return E.Cause(err, "query daemon named pipe server session") + } + if sessionID != 0 { + return E.New("daemon named pipe server is not in session zero") + } + managerHandle, err := windows.OpenSCManager(nil, nil, windows.SC_MANAGER_CONNECT) + if err != nil { + return E.Cause(err, "connect to service manager") + } + defer windows.CloseServiceHandle(managerHandle) + serviceNamePointer, err := windows.UTF16PtrFromString(serviceName) + if err != nil { + return err + } + serviceHandle, err := windows.OpenService( + managerHandle, + serviceNamePointer, + windows.SERVICE_QUERY_STATUS|windows.SERVICE_QUERY_CONFIG, + ) + if err != nil { + return E.Cause(err, "open daemon service") + } + service := &mgr.Service{Name: serviceName, Handle: serviceHandle} + defer service.Close() + status, err := service.Query() + if err != nil { + return E.Cause(err, "query daemon service status") + } + if status.State != svc.Running || status.ProcessId != processID { + return E.New("named pipe server is not the running daemon service") + } + configuration, err := service.Config() + if err != nil { + return E.Cause(err, "query daemon service configuration") + } + if !strings.EqualFold(configuration.ServiceStartName, "LocalSystem") { + return E.New("daemon service does not run as LocalSystem") + } + return nil +} + +func processCreationTime(process windows.Handle) (int64, error) { + var creationTime windows.Filetime + var exitTime windows.Filetime + var kernelTime windows.Filetime + var userTime windows.Filetime + err := windows.GetProcessTimes(process, &creationTime, &exitTime, &kernelTime, &userTime) + if err != nil { + return 0, err + } + return creationTime.Nanoseconds(), nil +} diff --git a/experimental/boxdd/crash_report.go b/experimental/boxdd/crash_report.go new file mode 100644 index 0000000000..390dee1d5d --- /dev/null +++ b/experimental/boxdd/crash_report.go @@ -0,0 +1,125 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "sort" + + "google.golang.org/protobuf/types/known/emptypb" +) + +const crashReportsDirectoryName = "crash_reports" + +var crashReportFileOrder = []string{metadataFileName, nativeLogFileName, goLogFileName, configSnapshotFileName} + +func (s *desktopService) ListCrashReports(ctx context.Context, empty *emptypb.Empty) (*CrashReportList, error) { + reportsDirectory, userID, err := s.daemon.reportCaller(ctx, crashReportsDirectoryName) + if err != nil { + return nil, err + } + entries, err := os.ReadDir(reportsDirectory) + if err != nil { + if os.IsNotExist(err) { + return &CrashReportList{}, nil + } + return nil, err + } + reports := make([]*CrashReportEntry, 0, len(entries)) + for _, entry := range entries { + if !entry.IsDir() { + continue + } + fullPath := filepath.Join(reportsDirectory, entry.Name()) + if !reportOwnedBy(fullPath, userID) { + continue + } + reports = append(reports, &CrashReportEntry{ + Name: entry.Name(), + CrashedAt: reportTime(fullPath, "crashedAt").UnixMilli(), + IsRead: reportIsRead(fullPath), + }) + } + sort.Slice(reports, func(i, j int) bool { + return reports[i].CrashedAt > reports[j].CrashedAt + }) + return &CrashReportList{Reports: reports}, nil +} + +func (s *desktopService) ReadCrashReport(ctx context.Context, request *CrashReportRequest) (*CrashReportContent, error) { + reportsDirectory, userID, err := s.daemon.reportCaller(ctx, crashReportsDirectoryName) + if err != nil { + return nil, err + } + fullPath, err := reportPathForUser(reportsDirectory, request.Name, userID) + if err != nil { + return nil, err + } + files := make([]*CrashReportFile, 0, len(crashReportFileOrder)) + for _, fileName := range crashReportFileOrder { + content, readError := os.ReadFile(filepath.Join(fullPath, fileName)) + if readError != nil { + if os.IsNotExist(readError) { + continue + } + return nil, readError + } + files = append(files, &CrashReportFile{ + Name: fileName, + Content: string(content), + }) + } + return &CrashReportContent{Files: files}, nil +} + +func (s *desktopService) MarkCrashReportRead(ctx context.Context, request *CrashReportRequest) (*emptypb.Empty, error) { + reportsDirectory, userID, err := s.daemon.reportCaller(ctx, crashReportsDirectoryName) + if err != nil { + return nil, err + } + fullPath, err := reportPathForUser(reportsDirectory, request.Name, userID) + if err != nil { + return nil, err + } + err = os.WriteFile(filepath.Join(fullPath, readMarkerFileName), nil, 0o600) + if err != nil { + return nil, err + } + return &emptypb.Empty{}, nil +} + +func (s *desktopService) ExportCrashReport(ctx context.Context, request *CrashReportExportRequest) (*CrashReportArchive, error) { + reportsDirectory, userID, err := s.daemon.reportCaller(ctx, crashReportsDirectoryName) + if err != nil { + return nil, err + } + return exportReportArchive(reportsDirectory, request.Name, userID, request.WithConfiguration, request.WithLog, request.Encrypt) +} + +func (s *desktopService) DeleteCrashReport(ctx context.Context, request *CrashReportRequest) (*emptypb.Empty, error) { + reportsDirectory, userID, err := s.daemon.reportCaller(ctx, crashReportsDirectoryName) + if err != nil { + return nil, err + } + fullPath, err := reportPathForUser(reportsDirectory, request.Name, userID) + if err != nil { + return nil, err + } + err = os.RemoveAll(fullPath) + if err != nil { + return nil, err + } + return &emptypb.Empty{}, nil +} + +func (s *desktopService) DeleteAllCrashReports(ctx context.Context, empty *emptypb.Empty) (*emptypb.Empty, error) { + reportsDirectory, userID, err := s.daemon.reportCaller(ctx, crashReportsDirectoryName) + if err != nil { + return nil, err + } + err = deleteReportsForUser(reportsDirectory, userID) + if err != nil { + return nil, err + } + return &emptypb.Empty{}, nil +} diff --git a/experimental/boxdd/debug.go b/experimental/boxdd/debug.go new file mode 100644 index 0000000000..e98a49432e --- /dev/null +++ b/experimental/boxdd/debug.go @@ -0,0 +1,5 @@ +//go:build debug + +package main + +const debugEnabled = true diff --git a/experimental/boxdd/debug_stub.go b/experimental/boxdd/debug_stub.go new file mode 100644 index 0000000000..f011fd9b4a --- /dev/null +++ b/experimental/boxdd/debug_stub.go @@ -0,0 +1,5 @@ +//go:build !debug + +package main + +const debugEnabled = false diff --git a/experimental/boxdd/desktop_service.go b/experimental/boxdd/desktop_service.go new file mode 100644 index 0000000000..ef92deba76 --- /dev/null +++ b/experimental/boxdd/desktop_service.go @@ -0,0 +1,380 @@ +package main + +import ( + "context" + "io/fs" + "os" + "path/filepath" + + C "github.com/sagernet/sing-box/constant" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/tailscale/atomicfile" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/emptypb" +) + +var _ DesktopServiceServer = (*desktopService)(nil) + +type desktopService struct { + UnimplementedDesktopServiceServer + daemon *Daemon +} + +func (s *desktopService) GetDaemonInfo(ctx context.Context, empty *emptypb.Empty) (*DaemonInfo, error) { + identity, err := peerIdentityFromContext(ctx) + if err != nil { + return nil, err + } + ownership := DaemonOwnership_DAEMON_OWNERSHIP_AVAILABLE + ownerUserID, err := loadOwner() + if err != nil && !os.IsNotExist(err) { + return nil, err + } + if ownerUserID == identity.UserID { + ownership = DaemonOwnership_DAEMON_OWNERSHIP_CALLER + } else if ownerUserID != "" { + ownership = DaemonOwnership_DAEMON_OWNERSHIP_OTHER + } + return &DaemonInfo{ + Version: C.Version, + Ownership: ownership, + }, nil +} + +func (s *desktopService) InstallUpdate(ctx context.Context, request *InstallUpdateRequest) (*InstallUpdateResponse, error) { + identity, err := peerIdentityFromContext(ctx) + if err != nil { + return nil, err + } + return s.daemon.installUpdate(identity, request.InstallerPath) +} + +func (s *desktopService) StartService(ctx context.Context, request *StartServiceRequest) (*emptypb.Empty, error) { + identity, err := peerIdentityFromContext(ctx) + if err != nil { + return nil, err + } + s.daemon.lifecycleAccess.Lock() + defer s.daemon.lifecycleAccess.Unlock() + if s.daemon.closed { + return nil, os.ErrClosed + } + ownerUserID, err := loadOwner() + if err != nil && !os.IsNotExist(err) { + return nil, err + } + if ownerUserID != "" && ownerUserID != identity.UserID { + return nil, status.Error(codes.PermissionDenied, "the service is owned by another user") + } + err = s.daemon.preparePlatformOwnerLocked(identity) + if err != nil { + return nil, err + } + err = saveOwner(identity.UserID, identity.SessionID) + if err != nil { + return nil, err + } + currentOptions, err := loadStartOptions(identity.UserID) + if err != nil && !os.IsNotExist(err) { + return nil, err + } + mergedOptions := currentOptions + mergedOptions.WasRunning = true + if request.Options != nil { + mergedOptions.OOMKillerEnabled = request.Options.OomKillerEnabled + mergedOptions.OOMKillerDisabled = request.Options.OomKillerDisabled + mergedOptions.OOMMemoryLimit = request.Options.OomMemoryLimit + } + err = s.daemon.startServiceLocked(ctx, identity.UserID, request.ConfigContent, mergedOptions) + if err != nil { + return nil, s.daemon.cleanFailedStartLocked(identity.UserID, mergedOptions, err) + } + directory := userWorkingDirectory(identity.UserID) + configError := atomicfile.WriteFile(filepath.Join(directory, serviceConfigFileName), []byte(request.ConfigContent), 0o600) + optionsError := saveStartOptions(identity.UserID, mergedOptions) + if configError != nil || optionsError != nil { + return nil, s.daemon.cleanFailedStartLocked(identity.UserID, mergedOptions, E.Errors(configError, optionsError)) + } + return &emptypb.Empty{}, nil +} + +func (s *desktopService) ClaimService(ctx context.Context, empty *emptypb.Empty) (*emptypb.Empty, error) { + identity, err := peerIdentityFromContext(ctx) + if err != nil { + return nil, err + } + s.daemon.lifecycleAccess.Lock() + defer s.daemon.lifecycleAccess.Unlock() + if s.daemon.closed { + return nil, os.ErrClosed + } + ownerUserID, err := loadOwner() + if err != nil && !os.IsNotExist(err) { + return nil, err + } + if ownerUserID == identity.UserID { + err = s.daemon.preparePlatformOwnerLocked(identity) + if err != nil { + return nil, err + } + err = saveOwner(identity.UserID, identity.SessionID) + if err != nil { + return nil, err + } + return &emptypb.Empty{}, nil + } + if ownerUserID != "" { + return nil, status.Error(codes.Aborted, "the service was claimed by another user") + } + err = s.daemon.configureWorkingDirectoryLocked(userWorkingDirectory(identity.UserID)) + if err != nil { + return nil, err + } + err = s.daemon.preparePlatformOwnerLocked(identity) + if err != nil { + return nil, err + } + err = saveOwner(identity.UserID, identity.SessionID) + if err != nil { + return nil, err + } + return &emptypb.Empty{}, nil +} + +func (s *desktopService) TakeOverService(ctx context.Context, empty *emptypb.Empty) (*emptypb.Empty, error) { + identity, err := peerIdentityFromContext(ctx) + if err != nil { + return nil, err + } + s.daemon.lifecycleAccess.Lock() + if s.daemon.closed { + s.daemon.lifecycleAccess.Unlock() + return nil, os.ErrClosed + } + ownerUserID, err := loadOwner() + if err != nil && !os.IsNotExist(err) { + s.daemon.lifecycleAccess.Unlock() + return nil, err + } + if ownerUserID == "" || ownerUserID == identity.UserID { + err = s.takeOverServiceLocked(identity, ownerUserID) + s.daemon.lifecycleAccess.Unlock() + if err != nil { + return nil, err + } + return &emptypb.Empty{}, nil + } + s.daemon.lifecycleAccess.Unlock() + err = authorizeTakeOver(ctx, identity) + if err != nil { + return nil, err + } + s.daemon.lifecycleAccess.Lock() + defer s.daemon.lifecycleAccess.Unlock() + if s.daemon.closed { + return nil, os.ErrClosed + } + ownerUserID, err = loadOwner() + if err != nil && !os.IsNotExist(err) { + return nil, err + } + err = s.takeOverServiceLocked(identity, ownerUserID) + if err != nil { + return nil, err + } + return &emptypb.Empty{}, nil +} + +func (s *desktopService) takeOverServiceLocked(identity peerIdentity, ownerUserID string) error { + if ownerUserID == identity.UserID { + err := s.daemon.preparePlatformOwnerLocked(identity) + if err != nil { + return err + } + return saveOwner(identity.UserID, identity.SessionID) + } + if ownerUserID != "" { + err := s.daemon.stopServiceLocked(ownerUserID) + if err != nil { + return err + } + if s.daemon.platform != nil { + err = s.daemon.platform.ReleaseOwner() + if err != nil { + return err + } + } + } + err := s.daemon.configureWorkingDirectoryLocked(userWorkingDirectory(identity.UserID)) + if err != nil { + return err + } + err = s.daemon.preparePlatformOwnerLocked(identity) + if err != nil { + return err + } + err = saveOwner(identity.UserID, identity.SessionID) + if err != nil { + return err + } + s.daemon.disconnectPeerConnectionsExcept(identity.UserID) + return nil +} + +func (s *desktopService) GetSecuritySettings(ctx context.Context, empty *emptypb.Empty) (*SecuritySettings, error) { + _, err := peerIdentityFromContext(ctx) + if err != nil { + return nil, err + } + if !insecureModeAvailable() { + return &SecuritySettings{}, nil + } + return &SecuritySettings{ + Available: true, + InsecureModeEnabled: s.daemon.insecureModeEnabled(), + }, nil +} + +func (s *desktopService) SetInsecureModeEnabled(ctx context.Context, request *SetInsecureModeEnabledRequest) (*emptypb.Empty, error) { + identity, err := peerIdentityFromContext(ctx) + if err != nil { + return nil, err + } + if !insecureModeAvailable() { + return nil, status.Error(codes.FailedPrecondition, "insecure mode is not available on this platform") + } + if request.Enabled { + return nil, status.Error(codes.PermissionDenied, "enabling insecure mode requires an elevated service command") + } + s.daemon.lifecycleAccess.Lock() + defer s.daemon.lifecycleAccess.Unlock() + if s.daemon.closed { + return nil, os.ErrClosed + } + err = authorizeDisableInsecureMode(identity) + if err != nil { + return nil, err + } + wasEnabled := s.daemon.insecureModeEnabled() + err = saveSecuritySettings(workingDirectory, securitySettings{InsecureModeEnabled: false}) + if err != nil { + return nil, err + } + if wasEnabled && s.daemon.startedService.Instance() != nil { + var ownerUserID string + ownerUserID, err = loadOwner() + if err != nil { + return nil, err + } + err = s.daemon.stopServiceLocked(ownerUserID) + if err != nil { + return nil, err + } + } + return &emptypb.Empty{}, nil +} + +func (d *Daemon) cleanFailedStartLocked(ownerUserID string, options startOptions, startError error) error { + var platformError error + if d.platform != nil { + platformError = d.platform.ResetPlatformOptions() + } + if d.startedService.Instance() != nil { + _ = d.startedService.CloseService() + } + directory := userWorkingDirectory(ownerUserID) + crashReportError := tagUnownedReports(filepath.Join(directory, crashReportsDirectoryName), ownerUserID) + oomReportError := tagUnownedReports(filepath.Join(directory, oomReportsDirectoryName), ownerUserID) + options.WasRunning = false + snapshotError := saveStartOptions(ownerUserID, options) + return E.Errors(startError, platformError, crashReportError, oomReportError, snapshotError) +} + +func (s *desktopService) GetWorkingDirectory(ctx context.Context, empty *emptypb.Empty) (*WorkingDirectoryInfo, error) { + identity, err := peerIdentityFromContext(ctx) + if err != nil { + return nil, err + } + s.daemon.lifecycleAccess.Lock() + defer s.daemon.lifecycleAccess.Unlock() + ownerUserID, err := loadOwner() + if err != nil { + return nil, err + } + if ownerUserID != identity.UserID { + return nil, status.Error(codes.PermissionDenied, "the service is owned by another user") + } + directory := userWorkingDirectory(identity.UserID) + size, err := directorySize(directory) + if err != nil { + return nil, err + } + return &WorkingDirectoryInfo{ + Path: directory, + Size: size, + }, nil +} + +func (s *desktopService) DestroyWorkingDirectory(ctx context.Context, empty *emptypb.Empty) (*emptypb.Empty, error) { + identity, err := peerIdentityFromContext(ctx) + if err != nil { + return nil, err + } + s.daemon.lifecycleAccess.Lock() + defer s.daemon.lifecycleAccess.Unlock() + if s.daemon.closed { + return nil, os.ErrClosed + } + if s.daemon.startedService.Instance() != nil { + return nil, status.Error(codes.FailedPrecondition, "the service must be stopped before destroying the working directory") + } + ownerUserID, err := loadOwner() + if err != nil { + return nil, err + } + if ownerUserID != identity.UserID { + return nil, status.Error(codes.PermissionDenied, "the service is owned by another user") + } + directory := userWorkingDirectory(identity.UserID) + err = s.daemon.configureWorkingDirectoryLocked(workingDirectory) + if err != nil { + return nil, err + } + err = os.RemoveAll(directory) + if err != nil { + restoreError := s.daemon.configureWorkingDirectoryLocked(directory) + return nil, E.Errors(err, restoreError) + } + err = s.daemon.configureWorkingDirectoryLocked(directory) + if err != nil { + return nil, err + } + return &emptypb.Empty{}, nil +} + +func directorySize(root string) (int64, error) { + var size int64 + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + return nil + } + info, err := entry.Info() + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + size += info.Size() + return nil + }) + if err != nil { + return 0, err + } + return size, nil +} diff --git a/experimental/boxdd/desktop_service.pb.go b/experimental/boxdd/desktop_service.pb.go new file mode 100644 index 0000000000..c9eaf014a3 --- /dev/null +++ b/experimental/boxdd/desktop_service.pb.go @@ -0,0 +1,1905 @@ +package main + +import ( + reflect "reflect" + sync "sync" + unsafe "unsafe" + + daemon "github.com/sagernet/sing-box/daemon" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + emptypb "google.golang.org/protobuf/types/known/emptypb" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type DaemonOwnership int32 + +const ( + DaemonOwnership_DAEMON_OWNERSHIP_UNSPECIFIED DaemonOwnership = 0 + DaemonOwnership_DAEMON_OWNERSHIP_AVAILABLE DaemonOwnership = 1 + DaemonOwnership_DAEMON_OWNERSHIP_CALLER DaemonOwnership = 2 + DaemonOwnership_DAEMON_OWNERSHIP_OTHER DaemonOwnership = 3 +) + +// Enum value maps for DaemonOwnership. +var ( + DaemonOwnership_name = map[int32]string{ + 0: "DAEMON_OWNERSHIP_UNSPECIFIED", + 1: "DAEMON_OWNERSHIP_AVAILABLE", + 2: "DAEMON_OWNERSHIP_CALLER", + 3: "DAEMON_OWNERSHIP_OTHER", + } + DaemonOwnership_value = map[string]int32{ + "DAEMON_OWNERSHIP_UNSPECIFIED": 0, + "DAEMON_OWNERSHIP_AVAILABLE": 1, + "DAEMON_OWNERSHIP_CALLER": 2, + "DAEMON_OWNERSHIP_OTHER": 3, + } +) + +func (x DaemonOwnership) Enum() *DaemonOwnership { + p := new(DaemonOwnership) + *p = x + return p +} + +func (x DaemonOwnership) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (DaemonOwnership) Descriptor() protoreflect.EnumDescriptor { + return file_experimental_boxdd_desktop_service_proto_enumTypes[0].Descriptor() +} + +func (DaemonOwnership) Type() protoreflect.EnumType { + return &file_experimental_boxdd_desktop_service_proto_enumTypes[0] +} + +func (x DaemonOwnership) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use DaemonOwnership.Descriptor instead. +func (DaemonOwnership) EnumDescriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{0} +} + +type InstallUpdateResult int32 + +const ( + InstallUpdateResult_INSTALL_UPDATE_RESULT_UNSPECIFIED InstallUpdateResult = 0 + InstallUpdateResult_INSTALL_UPDATE_RESULT_STARTED InstallUpdateResult = 1 + InstallUpdateResult_INSTALL_UPDATE_RESULT_SIGNER_MISMATCH InstallUpdateResult = 2 + InstallUpdateResult_INSTALL_UPDATE_RESULT_NOT_NEWER InstallUpdateResult = 3 +) + +// Enum value maps for InstallUpdateResult. +var ( + InstallUpdateResult_name = map[int32]string{ + 0: "INSTALL_UPDATE_RESULT_UNSPECIFIED", + 1: "INSTALL_UPDATE_RESULT_STARTED", + 2: "INSTALL_UPDATE_RESULT_SIGNER_MISMATCH", + 3: "INSTALL_UPDATE_RESULT_NOT_NEWER", + } + InstallUpdateResult_value = map[string]int32{ + "INSTALL_UPDATE_RESULT_UNSPECIFIED": 0, + "INSTALL_UPDATE_RESULT_STARTED": 1, + "INSTALL_UPDATE_RESULT_SIGNER_MISMATCH": 2, + "INSTALL_UPDATE_RESULT_NOT_NEWER": 3, + } +) + +func (x InstallUpdateResult) Enum() *InstallUpdateResult { + p := new(InstallUpdateResult) + *p = x + return p +} + +func (x InstallUpdateResult) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (InstallUpdateResult) Descriptor() protoreflect.EnumDescriptor { + return file_experimental_boxdd_desktop_service_proto_enumTypes[1].Descriptor() +} + +func (InstallUpdateResult) Type() protoreflect.EnumType { + return &file_experimental_boxdd_desktop_service_proto_enumTypes[1] +} + +func (x InstallUpdateResult) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use InstallUpdateResult.Descriptor instead. +func (InstallUpdateResult) EnumDescriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{1} +} + +type ProfileContent_Type int32 + +const ( + ProfileContent_LOCAL ProfileContent_Type = 0 + ProfileContent_ICLOUD ProfileContent_Type = 1 + ProfileContent_REMOTE ProfileContent_Type = 2 +) + +// Enum value maps for ProfileContent_Type. +var ( + ProfileContent_Type_name = map[int32]string{ + 0: "LOCAL", + 1: "ICLOUD", + 2: "REMOTE", + } + ProfileContent_Type_value = map[string]int32{ + "LOCAL": 0, + "ICLOUD": 1, + "REMOTE": 2, + } +) + +func (x ProfileContent_Type) Enum() *ProfileContent_Type { + p := new(ProfileContent_Type) + *p = x + return p +} + +func (x ProfileContent_Type) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ProfileContent_Type) Descriptor() protoreflect.EnumDescriptor { + return file_experimental_boxdd_desktop_service_proto_enumTypes[2].Descriptor() +} + +func (ProfileContent_Type) Type() protoreflect.EnumType { + return &file_experimental_boxdd_desktop_service_proto_enumTypes[2] +} + +func (x ProfileContent_Type) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ProfileContent_Type.Descriptor instead. +func (ProfileContent_Type) EnumDescriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{7, 0} +} + +type ArchiveReportRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SourcePath string `protobuf:"bytes,1,opt,name=source_path,json=sourcePath,proto3" json:"source_path,omitempty"` + DestinationPath string `protobuf:"bytes,2,opt,name=destination_path,json=destinationPath,proto3" json:"destination_path,omitempty"` + Encrypt bool `protobuf:"varint,3,opt,name=encrypt,proto3" json:"encrypt,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ArchiveReportRequest) Reset() { + *x = ArchiveReportRequest{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ArchiveReportRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ArchiveReportRequest) ProtoMessage() {} + +func (x *ArchiveReportRequest) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ArchiveReportRequest.ProtoReflect.Descriptor instead. +func (*ArchiveReportRequest) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{0} +} + +func (x *ArchiveReportRequest) GetSourcePath() string { + if x != nil { + return x.SourcePath + } + return "" +} + +func (x *ArchiveReportRequest) GetDestinationPath() string { + if x != nil { + return x.DestinationPath + } + return "" +} + +func (x *ArchiveReportRequest) GetEncrypt() bool { + if x != nil { + return x.Encrypt + } + return false +} + +type StandaloneNetworkQualityTestRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ConfigUrl string `protobuf:"bytes,1,opt,name=config_url,json=configUrl,proto3" json:"config_url,omitempty"` + Serial bool `protobuf:"varint,2,opt,name=serial,proto3" json:"serial,omitempty"` + MaxRuntimeSeconds int32 `protobuf:"varint,3,opt,name=max_runtime_seconds,json=maxRuntimeSeconds,proto3" json:"max_runtime_seconds,omitempty"` + Http3 bool `protobuf:"varint,4,opt,name=http3,proto3" json:"http3,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StandaloneNetworkQualityTestRequest) Reset() { + *x = StandaloneNetworkQualityTestRequest{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StandaloneNetworkQualityTestRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StandaloneNetworkQualityTestRequest) ProtoMessage() {} + +func (x *StandaloneNetworkQualityTestRequest) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StandaloneNetworkQualityTestRequest.ProtoReflect.Descriptor instead. +func (*StandaloneNetworkQualityTestRequest) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{1} +} + +func (x *StandaloneNetworkQualityTestRequest) GetConfigUrl() string { + if x != nil { + return x.ConfigUrl + } + return "" +} + +func (x *StandaloneNetworkQualityTestRequest) GetSerial() bool { + if x != nil { + return x.Serial + } + return false +} + +func (x *StandaloneNetworkQualityTestRequest) GetMaxRuntimeSeconds() int32 { + if x != nil { + return x.MaxRuntimeSeconds + } + return 0 +} + +func (x *StandaloneNetworkQualityTestRequest) GetHttp3() bool { + if x != nil { + return x.Http3 + } + return false +} + +type StandaloneSTUNTestRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Server string `protobuf:"bytes,1,opt,name=server,proto3" json:"server,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StandaloneSTUNTestRequest) Reset() { + *x = StandaloneSTUNTestRequest{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StandaloneSTUNTestRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StandaloneSTUNTestRequest) ProtoMessage() {} + +func (x *StandaloneSTUNTestRequest) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StandaloneSTUNTestRequest.ProtoReflect.Descriptor instead. +func (*StandaloneSTUNTestRequest) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{2} +} + +func (x *StandaloneSTUNTestRequest) GetServer() string { + if x != nil { + return x.Server + } + return "" +} + +type DaemonInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + Version string `protobuf:"bytes,1,opt,name=version,proto3" json:"version,omitempty"` + Ownership DaemonOwnership `protobuf:"varint,2,opt,name=ownership,proto3,enum=desktop.DaemonOwnership" json:"ownership,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DaemonInfo) Reset() { + *x = DaemonInfo{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DaemonInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DaemonInfo) ProtoMessage() {} + +func (x *DaemonInfo) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DaemonInfo.ProtoReflect.Descriptor instead. +func (*DaemonInfo) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{3} +} + +func (x *DaemonInfo) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +func (x *DaemonInfo) GetOwnership() DaemonOwnership { + if x != nil { + return x.Ownership + } + return DaemonOwnership_DAEMON_OWNERSHIP_UNSPECIFIED +} + +type StartServiceRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ConfigContent string `protobuf:"bytes,1,opt,name=config_content,json=configContent,proto3" json:"config_content,omitempty"` + Options *StartOptions `protobuf:"bytes,2,opt,name=options,proto3" json:"options,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StartServiceRequest) Reset() { + *x = StartServiceRequest{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StartServiceRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StartServiceRequest) ProtoMessage() {} + +func (x *StartServiceRequest) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StartServiceRequest.ProtoReflect.Descriptor instead. +func (*StartServiceRequest) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{4} +} + +func (x *StartServiceRequest) GetConfigContent() string { + if x != nil { + return x.ConfigContent + } + return "" +} + +func (x *StartServiceRequest) GetOptions() *StartOptions { + if x != nil { + return x.Options + } + return nil +} + +type StartOptions struct { + state protoimpl.MessageState `protogen:"open.v1"` + OomKillerEnabled bool `protobuf:"varint,1,opt,name=oom_killer_enabled,json=oomKillerEnabled,proto3" json:"oom_killer_enabled,omitempty"` + OomKillerDisabled bool `protobuf:"varint,2,opt,name=oom_killer_disabled,json=oomKillerDisabled,proto3" json:"oom_killer_disabled,omitempty"` + OomMemoryLimit int64 `protobuf:"varint,3,opt,name=oom_memory_limit,json=oomMemoryLimit,proto3" json:"oom_memory_limit,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StartOptions) Reset() { + *x = StartOptions{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StartOptions) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StartOptions) ProtoMessage() {} + +func (x *StartOptions) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StartOptions.ProtoReflect.Descriptor instead. +func (*StartOptions) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{5} +} + +func (x *StartOptions) GetOomKillerEnabled() bool { + if x != nil { + return x.OomKillerEnabled + } + return false +} + +func (x *StartOptions) GetOomKillerDisabled() bool { + if x != nil { + return x.OomKillerDisabled + } + return false +} + +func (x *StartOptions) GetOomMemoryLimit() int64 { + if x != nil { + return x.OomMemoryLimit + } + return 0 +} + +type ConfigContent struct { + state protoimpl.MessageState `protogen:"open.v1"` + Content string `protobuf:"bytes,1,opt,name=content,proto3" json:"content,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConfigContent) Reset() { + *x = ConfigContent{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConfigContent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConfigContent) ProtoMessage() {} + +func (x *ConfigContent) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ConfigContent.ProtoReflect.Descriptor instead. +func (*ConfigContent) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{6} +} + +func (x *ConfigContent) GetContent() string { + if x != nil { + return x.Content + } + return "" +} + +type ProfileContent struct { + state protoimpl.MessageState `protogen:"open.v1"` + Type ProfileContent_Type `protobuf:"varint,1,opt,name=type,proto3,enum=desktop.ProfileContent_Type" json:"type,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Config string `protobuf:"bytes,3,opt,name=config,proto3" json:"config,omitempty"` + RemotePath string `protobuf:"bytes,4,opt,name=remote_path,json=remotePath,proto3" json:"remote_path,omitempty"` + AutoUpdate bool `protobuf:"varint,5,opt,name=auto_update,json=autoUpdate,proto3" json:"auto_update,omitempty"` + AutoUpdateInterval int32 `protobuf:"varint,6,opt,name=auto_update_interval,json=autoUpdateInterval,proto3" json:"auto_update_interval,omitempty"` + LastUpdated int64 `protobuf:"varint,7,opt,name=last_updated,json=lastUpdated,proto3" json:"last_updated,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProfileContent) Reset() { + *x = ProfileContent{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProfileContent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProfileContent) ProtoMessage() {} + +func (x *ProfileContent) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProfileContent.ProtoReflect.Descriptor instead. +func (*ProfileContent) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{7} +} + +func (x *ProfileContent) GetType() ProfileContent_Type { + if x != nil { + return x.Type + } + return ProfileContent_LOCAL +} + +func (x *ProfileContent) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ProfileContent) GetConfig() string { + if x != nil { + return x.Config + } + return "" +} + +func (x *ProfileContent) GetRemotePath() string { + if x != nil { + return x.RemotePath + } + return "" +} + +func (x *ProfileContent) GetAutoUpdate() bool { + if x != nil { + return x.AutoUpdate + } + return false +} + +func (x *ProfileContent) GetAutoUpdateInterval() int32 { + if x != nil { + return x.AutoUpdateInterval + } + return 0 +} + +func (x *ProfileContent) GetLastUpdated() int64 { + if x != nil { + return x.LastUpdated + } + return 0 +} + +type ProfileData struct { + state protoimpl.MessageState `protogen:"open.v1"` + Data []byte `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProfileData) Reset() { + *x = ProfileData{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProfileData) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProfileData) ProtoMessage() {} + +func (x *ProfileData) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProfileData.ProtoReflect.Descriptor instead. +func (*ProfileData) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{8} +} + +func (x *ProfileData) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +type WorkingDirectoryInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + Size int64 `protobuf:"varint,2,opt,name=size,proto3" json:"size,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WorkingDirectoryInfo) Reset() { + *x = WorkingDirectoryInfo{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WorkingDirectoryInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WorkingDirectoryInfo) ProtoMessage() {} + +func (x *WorkingDirectoryInfo) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WorkingDirectoryInfo.ProtoReflect.Descriptor instead. +func (*WorkingDirectoryInfo) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{9} +} + +func (x *WorkingDirectoryInfo) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *WorkingDirectoryInfo) GetSize() int64 { + if x != nil { + return x.Size + } + return 0 +} + +type CrashReportList struct { + state protoimpl.MessageState `protogen:"open.v1"` + Reports []*CrashReportEntry `protobuf:"bytes,1,rep,name=reports,proto3" json:"reports,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CrashReportList) Reset() { + *x = CrashReportList{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CrashReportList) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CrashReportList) ProtoMessage() {} + +func (x *CrashReportList) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CrashReportList.ProtoReflect.Descriptor instead. +func (*CrashReportList) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{10} +} + +func (x *CrashReportList) GetReports() []*CrashReportEntry { + if x != nil { + return x.Reports + } + return nil +} + +type CrashReportEntry struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + CrashedAt int64 `protobuf:"varint,2,opt,name=crashed_at,json=crashedAt,proto3" json:"crashed_at,omitempty"` + IsRead bool `protobuf:"varint,3,opt,name=is_read,json=isRead,proto3" json:"is_read,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CrashReportEntry) Reset() { + *x = CrashReportEntry{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CrashReportEntry) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CrashReportEntry) ProtoMessage() {} + +func (x *CrashReportEntry) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CrashReportEntry.ProtoReflect.Descriptor instead. +func (*CrashReportEntry) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{11} +} + +func (x *CrashReportEntry) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *CrashReportEntry) GetCrashedAt() int64 { + if x != nil { + return x.CrashedAt + } + return 0 +} + +func (x *CrashReportEntry) GetIsRead() bool { + if x != nil { + return x.IsRead + } + return false +} + +type CrashReportRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CrashReportRequest) Reset() { + *x = CrashReportRequest{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CrashReportRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CrashReportRequest) ProtoMessage() {} + +func (x *CrashReportRequest) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CrashReportRequest.ProtoReflect.Descriptor instead. +func (*CrashReportRequest) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{12} +} + +func (x *CrashReportRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +type CrashReportExportRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + WithConfiguration bool `protobuf:"varint,2,opt,name=with_configuration,json=withConfiguration,proto3" json:"with_configuration,omitempty"` + WithLog bool `protobuf:"varint,3,opt,name=with_log,json=withLog,proto3" json:"with_log,omitempty"` + Encrypt bool `protobuf:"varint,4,opt,name=encrypt,proto3" json:"encrypt,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CrashReportExportRequest) Reset() { + *x = CrashReportExportRequest{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CrashReportExportRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CrashReportExportRequest) ProtoMessage() {} + +func (x *CrashReportExportRequest) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CrashReportExportRequest.ProtoReflect.Descriptor instead. +func (*CrashReportExportRequest) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{13} +} + +func (x *CrashReportExportRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *CrashReportExportRequest) GetWithConfiguration() bool { + if x != nil { + return x.WithConfiguration + } + return false +} + +func (x *CrashReportExportRequest) GetWithLog() bool { + if x != nil { + return x.WithLog + } + return false +} + +func (x *CrashReportExportRequest) GetEncrypt() bool { + if x != nil { + return x.Encrypt + } + return false +} + +type CrashReportContent struct { + state protoimpl.MessageState `protogen:"open.v1"` + Files []*CrashReportFile `protobuf:"bytes,1,rep,name=files,proto3" json:"files,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CrashReportContent) Reset() { + *x = CrashReportContent{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CrashReportContent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CrashReportContent) ProtoMessage() {} + +func (x *CrashReportContent) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CrashReportContent.ProtoReflect.Descriptor instead. +func (*CrashReportContent) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{14} +} + +func (x *CrashReportContent) GetFiles() []*CrashReportFile { + if x != nil { + return x.Files + } + return nil +} + +type CrashReportFile struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Content string `protobuf:"bytes,2,opt,name=content,proto3" json:"content,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CrashReportFile) Reset() { + *x = CrashReportFile{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CrashReportFile) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CrashReportFile) ProtoMessage() {} + +func (x *CrashReportFile) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CrashReportFile.ProtoReflect.Descriptor instead. +func (*CrashReportFile) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{15} +} + +func (x *CrashReportFile) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *CrashReportFile) GetContent() string { + if x != nil { + return x.Content + } + return "" +} + +type CrashReportArchive struct { + state protoimpl.MessageState `protogen:"open.v1"` + FileName string `protobuf:"bytes,1,opt,name=file_name,json=fileName,proto3" json:"file_name,omitempty"` + Data []byte `protobuf:"bytes,2,opt,name=data,proto3" json:"data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CrashReportArchive) Reset() { + *x = CrashReportArchive{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CrashReportArchive) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CrashReportArchive) ProtoMessage() {} + +func (x *CrashReportArchive) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CrashReportArchive.ProtoReflect.Descriptor instead. +func (*CrashReportArchive) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{16} +} + +func (x *CrashReportArchive) GetFileName() string { + if x != nil { + return x.FileName + } + return "" +} + +func (x *CrashReportArchive) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +type OOMReportList struct { + state protoimpl.MessageState `protogen:"open.v1"` + Reports []*OOMReportEntry `protobuf:"bytes,1,rep,name=reports,proto3" json:"reports,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OOMReportList) Reset() { + *x = OOMReportList{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OOMReportList) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OOMReportList) ProtoMessage() {} + +func (x *OOMReportList) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OOMReportList.ProtoReflect.Descriptor instead. +func (*OOMReportList) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{17} +} + +func (x *OOMReportList) GetReports() []*OOMReportEntry { + if x != nil { + return x.Reports + } + return nil +} + +type OOMReportEntry struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + RecordedAt int64 `protobuf:"varint,2,opt,name=recorded_at,json=recordedAt,proto3" json:"recorded_at,omitempty"` + IsRead bool `protobuf:"varint,3,opt,name=is_read,json=isRead,proto3" json:"is_read,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OOMReportEntry) Reset() { + *x = OOMReportEntry{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OOMReportEntry) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OOMReportEntry) ProtoMessage() {} + +func (x *OOMReportEntry) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OOMReportEntry.ProtoReflect.Descriptor instead. +func (*OOMReportEntry) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{18} +} + +func (x *OOMReportEntry) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *OOMReportEntry) GetRecordedAt() int64 { + if x != nil { + return x.RecordedAt + } + return 0 +} + +func (x *OOMReportEntry) GetIsRead() bool { + if x != nil { + return x.IsRead + } + return false +} + +type OOMReportRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OOMReportRequest) Reset() { + *x = OOMReportRequest{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OOMReportRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OOMReportRequest) ProtoMessage() {} + +func (x *OOMReportRequest) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OOMReportRequest.ProtoReflect.Descriptor instead. +func (*OOMReportRequest) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{19} +} + +func (x *OOMReportRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +type OOMReportExportRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + WithConfiguration bool `protobuf:"varint,2,opt,name=with_configuration,json=withConfiguration,proto3" json:"with_configuration,omitempty"` + WithLog bool `protobuf:"varint,3,opt,name=with_log,json=withLog,proto3" json:"with_log,omitempty"` + Encrypt bool `protobuf:"varint,4,opt,name=encrypt,proto3" json:"encrypt,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OOMReportExportRequest) Reset() { + *x = OOMReportExportRequest{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OOMReportExportRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OOMReportExportRequest) ProtoMessage() {} + +func (x *OOMReportExportRequest) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[20] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OOMReportExportRequest.ProtoReflect.Descriptor instead. +func (*OOMReportExportRequest) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{20} +} + +func (x *OOMReportExportRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *OOMReportExportRequest) GetWithConfiguration() bool { + if x != nil { + return x.WithConfiguration + } + return false +} + +func (x *OOMReportExportRequest) GetWithLog() bool { + if x != nil { + return x.WithLog + } + return false +} + +func (x *OOMReportExportRequest) GetEncrypt() bool { + if x != nil { + return x.Encrypt + } + return false +} + +type OOMReportContent struct { + state protoimpl.MessageState `protogen:"open.v1"` + Files []*OOMReportFile `protobuf:"bytes,1,rep,name=files,proto3" json:"files,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OOMReportContent) Reset() { + *x = OOMReportContent{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OOMReportContent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OOMReportContent) ProtoMessage() {} + +func (x *OOMReportContent) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OOMReportContent.ProtoReflect.Descriptor instead. +func (*OOMReportContent) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{21} +} + +func (x *OOMReportContent) GetFiles() []*OOMReportFile { + if x != nil { + return x.Files + } + return nil +} + +type OOMReportFile struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Content []byte `protobuf:"bytes,2,opt,name=content,proto3" json:"content,omitempty"` + IsProfile bool `protobuf:"varint,3,opt,name=is_profile,json=isProfile,proto3" json:"is_profile,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OOMReportFile) Reset() { + *x = OOMReportFile{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OOMReportFile) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OOMReportFile) ProtoMessage() {} + +func (x *OOMReportFile) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[22] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OOMReportFile.ProtoReflect.Descriptor instead. +func (*OOMReportFile) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{22} +} + +func (x *OOMReportFile) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *OOMReportFile) GetContent() []byte { + if x != nil { + return x.Content + } + return nil +} + +func (x *OOMReportFile) GetIsProfile() bool { + if x != nil { + return x.IsProfile + } + return false +} + +type SecuritySettings struct { + state protoimpl.MessageState `protogen:"open.v1"` + Available bool `protobuf:"varint,1,opt,name=available,proto3" json:"available,omitempty"` + InsecureModeEnabled bool `protobuf:"varint,2,opt,name=insecure_mode_enabled,json=insecureModeEnabled,proto3" json:"insecure_mode_enabled,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SecuritySettings) Reset() { + *x = SecuritySettings{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SecuritySettings) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SecuritySettings) ProtoMessage() {} + +func (x *SecuritySettings) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[23] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SecuritySettings.ProtoReflect.Descriptor instead. +func (*SecuritySettings) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{23} +} + +func (x *SecuritySettings) GetAvailable() bool { + if x != nil { + return x.Available + } + return false +} + +func (x *SecuritySettings) GetInsecureModeEnabled() bool { + if x != nil { + return x.InsecureModeEnabled + } + return false +} + +type SetInsecureModeEnabledRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Enabled bool `protobuf:"varint,1,opt,name=enabled,proto3" json:"enabled,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetInsecureModeEnabledRequest) Reset() { + *x = SetInsecureModeEnabledRequest{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetInsecureModeEnabledRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetInsecureModeEnabledRequest) ProtoMessage() {} + +func (x *SetInsecureModeEnabledRequest) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[24] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SetInsecureModeEnabledRequest.ProtoReflect.Descriptor instead. +func (*SetInsecureModeEnabledRequest) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{24} +} + +func (x *SetInsecureModeEnabledRequest) GetEnabled() bool { + if x != nil { + return x.Enabled + } + return false +} + +type InstallUpdateRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + InstallerPath string `protobuf:"bytes,1,opt,name=installer_path,json=installerPath,proto3" json:"installer_path,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InstallUpdateRequest) Reset() { + *x = InstallUpdateRequest{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[25] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InstallUpdateRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InstallUpdateRequest) ProtoMessage() {} + +func (x *InstallUpdateRequest) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[25] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InstallUpdateRequest.ProtoReflect.Descriptor instead. +func (*InstallUpdateRequest) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{25} +} + +func (x *InstallUpdateRequest) GetInstallerPath() string { + if x != nil { + return x.InstallerPath + } + return "" +} + +type InstallUpdateResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Result InstallUpdateResult `protobuf:"varint,1,opt,name=result,proto3,enum=desktop.InstallUpdateResult" json:"result,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InstallUpdateResponse) Reset() { + *x = InstallUpdateResponse{} + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[26] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InstallUpdateResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InstallUpdateResponse) ProtoMessage() {} + +func (x *InstallUpdateResponse) ProtoReflect() protoreflect.Message { + mi := &file_experimental_boxdd_desktop_service_proto_msgTypes[26] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InstallUpdateResponse.ProtoReflect.Descriptor instead. +func (*InstallUpdateResponse) Descriptor() ([]byte, []int) { + return file_experimental_boxdd_desktop_service_proto_rawDescGZIP(), []int{26} +} + +func (x *InstallUpdateResponse) GetResult() InstallUpdateResult { + if x != nil { + return x.Result + } + return InstallUpdateResult_INSTALL_UPDATE_RESULT_UNSPECIFIED +} + +var File_experimental_boxdd_desktop_service_proto protoreflect.FileDescriptor + +const file_experimental_boxdd_desktop_service_proto_rawDesc = "" + + "\n" + + "(experimental/boxdd/desktop_service.proto\x12\adesktop\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1cdaemon/started_service.proto\"|\n" + + "\x14ArchiveReportRequest\x12\x1f\n" + + "\vsource_path\x18\x01 \x01(\tR\n" + + "sourcePath\x12)\n" + + "\x10destination_path\x18\x02 \x01(\tR\x0fdestinationPath\x12\x18\n" + + "\aencrypt\x18\x03 \x01(\bR\aencrypt\"\xa2\x01\n" + + "#StandaloneNetworkQualityTestRequest\x12\x1d\n" + + "\n" + + "config_url\x18\x01 \x01(\tR\tconfigUrl\x12\x16\n" + + "\x06serial\x18\x02 \x01(\bR\x06serial\x12.\n" + + "\x13max_runtime_seconds\x18\x03 \x01(\x05R\x11maxRuntimeSeconds\x12\x14\n" + + "\x05http3\x18\x04 \x01(\bR\x05http3\"3\n" + + "\x19StandaloneSTUNTestRequest\x12\x16\n" + + "\x06server\x18\x01 \x01(\tR\x06server\"^\n" + + "\n" + + "DaemonInfo\x12\x18\n" + + "\aversion\x18\x01 \x01(\tR\aversion\x126\n" + + "\townership\x18\x02 \x01(\x0e2\x18.desktop.DaemonOwnershipR\townership\"m\n" + + "\x13StartServiceRequest\x12%\n" + + "\x0econfig_content\x18\x01 \x01(\tR\rconfigContent\x12/\n" + + "\aoptions\x18\x02 \x01(\v2\x15.desktop.StartOptionsR\aoptions\"\x96\x01\n" + + "\fStartOptions\x12,\n" + + "\x12oom_killer_enabled\x18\x01 \x01(\bR\x10oomKillerEnabled\x12.\n" + + "\x13oom_killer_disabled\x18\x02 \x01(\bR\x11oomKillerDisabled\x12(\n" + + "\x10oom_memory_limit\x18\x03 \x01(\x03R\x0eoomMemoryLimit\")\n" + + "\rConfigContent\x12\x18\n" + + "\acontent\x18\x01 \x01(\tR\acontent\"\xb0\x02\n" + + "\x0eProfileContent\x120\n" + + "\x04type\x18\x01 \x01(\x0e2\x1c.desktop.ProfileContent.TypeR\x04type\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\x12\x16\n" + + "\x06config\x18\x03 \x01(\tR\x06config\x12\x1f\n" + + "\vremote_path\x18\x04 \x01(\tR\n" + + "remotePath\x12\x1f\n" + + "\vauto_update\x18\x05 \x01(\bR\n" + + "autoUpdate\x120\n" + + "\x14auto_update_interval\x18\x06 \x01(\x05R\x12autoUpdateInterval\x12!\n" + + "\flast_updated\x18\a \x01(\x03R\vlastUpdated\")\n" + + "\x04Type\x12\t\n" + + "\x05LOCAL\x10\x00\x12\n" + + "\n" + + "\x06ICLOUD\x10\x01\x12\n" + + "\n" + + "\x06REMOTE\x10\x02\"!\n" + + "\vProfileData\x12\x12\n" + + "\x04data\x18\x01 \x01(\fR\x04data\">\n" + + "\x14WorkingDirectoryInfo\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\x12\x12\n" + + "\x04size\x18\x02 \x01(\x03R\x04size\"F\n" + + "\x0fCrashReportList\x123\n" + + "\areports\x18\x01 \x03(\v2\x19.desktop.CrashReportEntryR\areports\"^\n" + + "\x10CrashReportEntry\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x1d\n" + + "\n" + + "crashed_at\x18\x02 \x01(\x03R\tcrashedAt\x12\x17\n" + + "\ais_read\x18\x03 \x01(\bR\x06isRead\"(\n" + + "\x12CrashReportRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\"\x92\x01\n" + + "\x18CrashReportExportRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12-\n" + + "\x12with_configuration\x18\x02 \x01(\bR\x11withConfiguration\x12\x19\n" + + "\bwith_log\x18\x03 \x01(\bR\awithLog\x12\x18\n" + + "\aencrypt\x18\x04 \x01(\bR\aencrypt\"D\n" + + "\x12CrashReportContent\x12.\n" + + "\x05files\x18\x01 \x03(\v2\x18.desktop.CrashReportFileR\x05files\"?\n" + + "\x0fCrashReportFile\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x18\n" + + "\acontent\x18\x02 \x01(\tR\acontent\"E\n" + + "\x12CrashReportArchive\x12\x1b\n" + + "\tfile_name\x18\x01 \x01(\tR\bfileName\x12\x12\n" + + "\x04data\x18\x02 \x01(\fR\x04data\"B\n" + + "\rOOMReportList\x121\n" + + "\areports\x18\x01 \x03(\v2\x17.desktop.OOMReportEntryR\areports\"^\n" + + "\x0eOOMReportEntry\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x1f\n" + + "\vrecorded_at\x18\x02 \x01(\x03R\n" + + "recordedAt\x12\x17\n" + + "\ais_read\x18\x03 \x01(\bR\x06isRead\"&\n" + + "\x10OOMReportRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\"\x90\x01\n" + + "\x16OOMReportExportRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12-\n" + + "\x12with_configuration\x18\x02 \x01(\bR\x11withConfiguration\x12\x19\n" + + "\bwith_log\x18\x03 \x01(\bR\awithLog\x12\x18\n" + + "\aencrypt\x18\x04 \x01(\bR\aencrypt\"@\n" + + "\x10OOMReportContent\x12,\n" + + "\x05files\x18\x01 \x03(\v2\x16.desktop.OOMReportFileR\x05files\"\\\n" + + "\rOOMReportFile\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x18\n" + + "\acontent\x18\x02 \x01(\fR\acontent\x12\x1d\n" + + "\n" + + "is_profile\x18\x03 \x01(\bR\tisProfile\"d\n" + + "\x10SecuritySettings\x12\x1c\n" + + "\tavailable\x18\x01 \x01(\bR\tavailable\x122\n" + + "\x15insecure_mode_enabled\x18\x02 \x01(\bR\x13insecureModeEnabled\"9\n" + + "\x1dSetInsecureModeEnabledRequest\x12\x18\n" + + "\aenabled\x18\x01 \x01(\bR\aenabled\"=\n" + + "\x14InstallUpdateRequest\x12%\n" + + "\x0einstaller_path\x18\x01 \x01(\tR\rinstallerPath\"M\n" + + "\x15InstallUpdateResponse\x124\n" + + "\x06result\x18\x01 \x01(\x0e2\x1c.desktop.InstallUpdateResultR\x06result*\x8c\x01\n" + + "\x0fDaemonOwnership\x12 \n" + + "\x1cDAEMON_OWNERSHIP_UNSPECIFIED\x10\x00\x12\x1e\n" + + "\x1aDAEMON_OWNERSHIP_AVAILABLE\x10\x01\x12\x1b\n" + + "\x17DAEMON_OWNERSHIP_CALLER\x10\x02\x12\x1a\n" + + "\x16DAEMON_OWNERSHIP_OTHER\x10\x03*\xaf\x01\n" + + "\x13InstallUpdateResult\x12%\n" + + "!INSTALL_UPDATE_RESULT_UNSPECIFIED\x10\x00\x12!\n" + + "\x1dINSTALL_UPDATE_RESULT_STARTED\x10\x01\x12)\n" + + "%INSTALL_UPDATE_RESULT_SIGNER_MISMATCH\x10\x02\x12#\n" + + "\x1fINSTALL_UPDATE_RESULT_NOT_NEWER\x10\x032\xc4\f\n" + + "\x0eDesktopService\x12>\n" + + "\rGetDaemonInfo\x12\x16.google.protobuf.Empty\x1a\x13.desktop.DaemonInfo\"\x00\x12@\n" + + "\fClaimService\x12\x16.google.protobuf.Empty\x1a\x16.google.protobuf.Empty\"\x00\x12C\n" + + "\x0fTakeOverService\x12\x16.google.protobuf.Empty\x1a\x16.google.protobuf.Empty\"\x00\x12F\n" + + "\fStartService\x12\x1c.desktop.StartServiceRequest\x1a\x16.google.protobuf.Empty\"\x00\x12N\n" + + "\x13GetWorkingDirectory\x12\x16.google.protobuf.Empty\x1a\x1d.desktop.WorkingDirectoryInfo\"\x00\x12K\n" + + "\x17DestroyWorkingDirectory\x12\x16.google.protobuf.Empty\x1a\x16.google.protobuf.Empty\"\x00\x12F\n" + + "\x10ListCrashReports\x12\x16.google.protobuf.Empty\x1a\x18.desktop.CrashReportList\"\x00\x12M\n" + + "\x0fReadCrashReport\x12\x1b.desktop.CrashReportRequest\x1a\x1b.desktop.CrashReportContent\"\x00\x12L\n" + + "\x13MarkCrashReportRead\x12\x1b.desktop.CrashReportRequest\x1a\x16.google.protobuf.Empty\"\x00\x12U\n" + + "\x11ExportCrashReport\x12!.desktop.CrashReportExportRequest\x1a\x1b.desktop.CrashReportArchive\"\x00\x12J\n" + + "\x11DeleteCrashReport\x12\x1b.desktop.CrashReportRequest\x1a\x16.google.protobuf.Empty\"\x00\x12I\n" + + "\x15DeleteAllCrashReports\x12\x16.google.protobuf.Empty\x1a\x16.google.protobuf.Empty\"\x00\x12B\n" + + "\x0eListOOMReports\x12\x16.google.protobuf.Empty\x1a\x16.desktop.OOMReportList\"\x00\x12G\n" + + "\rReadOOMReport\x12\x19.desktop.OOMReportRequest\x1a\x19.desktop.OOMReportContent\"\x00\x12H\n" + + "\x11MarkOOMReportRead\x12\x19.desktop.OOMReportRequest\x1a\x16.google.protobuf.Empty\"\x00\x12Q\n" + + "\x0fExportOOMReport\x12\x1f.desktop.OOMReportExportRequest\x1a\x1b.desktop.CrashReportArchive\"\x00\x12F\n" + + "\x0fDeleteOOMReport\x12\x19.desktop.OOMReportRequest\x1a\x16.google.protobuf.Empty\"\x00\x12G\n" + + "\x13DeleteAllOOMReports\x12\x16.google.protobuf.Empty\x1a\x16.google.protobuf.Empty\"\x00\x12P\n" + + "\rInstallUpdate\x12\x1d.desktop.InstallUpdateRequest\x1a\x1e.desktop.InstallUpdateResponse\"\x00\x12J\n" + + "\x13GetSecuritySettings\x12\x16.google.protobuf.Empty\x1a\x19.desktop.SecuritySettings\"\x00\x12Z\n" + + "\x16SetInsecureModeEnabled\x12&.desktop.SetInsecureModeEnabledRequest\x1a\x16.google.protobuf.Empty\"\x002\x87\x05\n" + + "\x12ApplicationService\x12?\n" + + "\vCheckConfig\x12\x16.desktop.ConfigContent\x1a\x16.google.protobuf.Empty\"\x00\x12@\n" + + "\fFormatConfig\x12\x16.desktop.ConfigContent\x1a\x16.desktop.ConfigContent\"\x00\x12H\n" + + "\x14GenerateConfigSchema\x12\x16.google.protobuf.Empty\x1a\x16.desktop.ConfigContent\"\x00\x12@\n" + + "\rEncodeProfile\x12\x17.desktop.ProfileContent\x1a\x14.desktop.ProfileData\"\x00\x12@\n" + + "\rDecodeProfile\x12\x14.desktop.ProfileData\x1a\x17.desktop.ProfileContent\"\x00\x12H\n" + + "\rArchiveReport\x12\x1d.desktop.ArchiveReportRequest\x1a\x16.google.protobuf.Empty\"\x00\x12y\n" + + "!StartStandaloneNetworkQualityTest\x12,.desktop.StandaloneNetworkQualityTestRequest\x1a\".daemon.NetworkQualityTestProgress\"\x000\x01\x12[\n" + + "\x17StartStandaloneSTUNTest\x12\".desktop.StandaloneSTUNTestRequest\x1a\x18.daemon.STUNTestProgress\"\x000\x01B6Z4github.com/sagernet/sing-box/experimental/boxdd;mainb\x06proto3" + +var ( + file_experimental_boxdd_desktop_service_proto_rawDescOnce sync.Once + file_experimental_boxdd_desktop_service_proto_rawDescData []byte +) + +func file_experimental_boxdd_desktop_service_proto_rawDescGZIP() []byte { + file_experimental_boxdd_desktop_service_proto_rawDescOnce.Do(func() { + file_experimental_boxdd_desktop_service_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_experimental_boxdd_desktop_service_proto_rawDesc), len(file_experimental_boxdd_desktop_service_proto_rawDesc))) + }) + return file_experimental_boxdd_desktop_service_proto_rawDescData +} + +var ( + file_experimental_boxdd_desktop_service_proto_enumTypes = make([]protoimpl.EnumInfo, 3) + file_experimental_boxdd_desktop_service_proto_msgTypes = make([]protoimpl.MessageInfo, 27) + file_experimental_boxdd_desktop_service_proto_goTypes = []any{ + (DaemonOwnership)(0), // 0: desktop.DaemonOwnership + (InstallUpdateResult)(0), // 1: desktop.InstallUpdateResult + (ProfileContent_Type)(0), // 2: desktop.ProfileContent.Type + (*ArchiveReportRequest)(nil), // 3: desktop.ArchiveReportRequest + (*StandaloneNetworkQualityTestRequest)(nil), // 4: desktop.StandaloneNetworkQualityTestRequest + (*StandaloneSTUNTestRequest)(nil), // 5: desktop.StandaloneSTUNTestRequest + (*DaemonInfo)(nil), // 6: desktop.DaemonInfo + (*StartServiceRequest)(nil), // 7: desktop.StartServiceRequest + (*StartOptions)(nil), // 8: desktop.StartOptions + (*ConfigContent)(nil), // 9: desktop.ConfigContent + (*ProfileContent)(nil), // 10: desktop.ProfileContent + (*ProfileData)(nil), // 11: desktop.ProfileData + (*WorkingDirectoryInfo)(nil), // 12: desktop.WorkingDirectoryInfo + (*CrashReportList)(nil), // 13: desktop.CrashReportList + (*CrashReportEntry)(nil), // 14: desktop.CrashReportEntry + (*CrashReportRequest)(nil), // 15: desktop.CrashReportRequest + (*CrashReportExportRequest)(nil), // 16: desktop.CrashReportExportRequest + (*CrashReportContent)(nil), // 17: desktop.CrashReportContent + (*CrashReportFile)(nil), // 18: desktop.CrashReportFile + (*CrashReportArchive)(nil), // 19: desktop.CrashReportArchive + (*OOMReportList)(nil), // 20: desktop.OOMReportList + (*OOMReportEntry)(nil), // 21: desktop.OOMReportEntry + (*OOMReportRequest)(nil), // 22: desktop.OOMReportRequest + (*OOMReportExportRequest)(nil), // 23: desktop.OOMReportExportRequest + (*OOMReportContent)(nil), // 24: desktop.OOMReportContent + (*OOMReportFile)(nil), // 25: desktop.OOMReportFile + (*SecuritySettings)(nil), // 26: desktop.SecuritySettings + (*SetInsecureModeEnabledRequest)(nil), // 27: desktop.SetInsecureModeEnabledRequest + (*InstallUpdateRequest)(nil), // 28: desktop.InstallUpdateRequest + (*InstallUpdateResponse)(nil), // 29: desktop.InstallUpdateResponse + (*emptypb.Empty)(nil), // 30: google.protobuf.Empty + (*daemon.NetworkQualityTestProgress)(nil), // 31: daemon.NetworkQualityTestProgress + (*daemon.STUNTestProgress)(nil), // 32: daemon.STUNTestProgress + } +) + +var file_experimental_boxdd_desktop_service_proto_depIdxs = []int32{ + 0, // 0: desktop.DaemonInfo.ownership:type_name -> desktop.DaemonOwnership + 8, // 1: desktop.StartServiceRequest.options:type_name -> desktop.StartOptions + 2, // 2: desktop.ProfileContent.type:type_name -> desktop.ProfileContent.Type + 14, // 3: desktop.CrashReportList.reports:type_name -> desktop.CrashReportEntry + 18, // 4: desktop.CrashReportContent.files:type_name -> desktop.CrashReportFile + 21, // 5: desktop.OOMReportList.reports:type_name -> desktop.OOMReportEntry + 25, // 6: desktop.OOMReportContent.files:type_name -> desktop.OOMReportFile + 1, // 7: desktop.InstallUpdateResponse.result:type_name -> desktop.InstallUpdateResult + 30, // 8: desktop.DesktopService.GetDaemonInfo:input_type -> google.protobuf.Empty + 30, // 9: desktop.DesktopService.ClaimService:input_type -> google.protobuf.Empty + 30, // 10: desktop.DesktopService.TakeOverService:input_type -> google.protobuf.Empty + 7, // 11: desktop.DesktopService.StartService:input_type -> desktop.StartServiceRequest + 30, // 12: desktop.DesktopService.GetWorkingDirectory:input_type -> google.protobuf.Empty + 30, // 13: desktop.DesktopService.DestroyWorkingDirectory:input_type -> google.protobuf.Empty + 30, // 14: desktop.DesktopService.ListCrashReports:input_type -> google.protobuf.Empty + 15, // 15: desktop.DesktopService.ReadCrashReport:input_type -> desktop.CrashReportRequest + 15, // 16: desktop.DesktopService.MarkCrashReportRead:input_type -> desktop.CrashReportRequest + 16, // 17: desktop.DesktopService.ExportCrashReport:input_type -> desktop.CrashReportExportRequest + 15, // 18: desktop.DesktopService.DeleteCrashReport:input_type -> desktop.CrashReportRequest + 30, // 19: desktop.DesktopService.DeleteAllCrashReports:input_type -> google.protobuf.Empty + 30, // 20: desktop.DesktopService.ListOOMReports:input_type -> google.protobuf.Empty + 22, // 21: desktop.DesktopService.ReadOOMReport:input_type -> desktop.OOMReportRequest + 22, // 22: desktop.DesktopService.MarkOOMReportRead:input_type -> desktop.OOMReportRequest + 23, // 23: desktop.DesktopService.ExportOOMReport:input_type -> desktop.OOMReportExportRequest + 22, // 24: desktop.DesktopService.DeleteOOMReport:input_type -> desktop.OOMReportRequest + 30, // 25: desktop.DesktopService.DeleteAllOOMReports:input_type -> google.protobuf.Empty + 28, // 26: desktop.DesktopService.InstallUpdate:input_type -> desktop.InstallUpdateRequest + 30, // 27: desktop.DesktopService.GetSecuritySettings:input_type -> google.protobuf.Empty + 27, // 28: desktop.DesktopService.SetInsecureModeEnabled:input_type -> desktop.SetInsecureModeEnabledRequest + 9, // 29: desktop.ApplicationService.CheckConfig:input_type -> desktop.ConfigContent + 9, // 30: desktop.ApplicationService.FormatConfig:input_type -> desktop.ConfigContent + 30, // 31: desktop.ApplicationService.GenerateConfigSchema:input_type -> google.protobuf.Empty + 10, // 32: desktop.ApplicationService.EncodeProfile:input_type -> desktop.ProfileContent + 11, // 33: desktop.ApplicationService.DecodeProfile:input_type -> desktop.ProfileData + 3, // 34: desktop.ApplicationService.ArchiveReport:input_type -> desktop.ArchiveReportRequest + 4, // 35: desktop.ApplicationService.StartStandaloneNetworkQualityTest:input_type -> desktop.StandaloneNetworkQualityTestRequest + 5, // 36: desktop.ApplicationService.StartStandaloneSTUNTest:input_type -> desktop.StandaloneSTUNTestRequest + 6, // 37: desktop.DesktopService.GetDaemonInfo:output_type -> desktop.DaemonInfo + 30, // 38: desktop.DesktopService.ClaimService:output_type -> google.protobuf.Empty + 30, // 39: desktop.DesktopService.TakeOverService:output_type -> google.protobuf.Empty + 30, // 40: desktop.DesktopService.StartService:output_type -> google.protobuf.Empty + 12, // 41: desktop.DesktopService.GetWorkingDirectory:output_type -> desktop.WorkingDirectoryInfo + 30, // 42: desktop.DesktopService.DestroyWorkingDirectory:output_type -> google.protobuf.Empty + 13, // 43: desktop.DesktopService.ListCrashReports:output_type -> desktop.CrashReportList + 17, // 44: desktop.DesktopService.ReadCrashReport:output_type -> desktop.CrashReportContent + 30, // 45: desktop.DesktopService.MarkCrashReportRead:output_type -> google.protobuf.Empty + 19, // 46: desktop.DesktopService.ExportCrashReport:output_type -> desktop.CrashReportArchive + 30, // 47: desktop.DesktopService.DeleteCrashReport:output_type -> google.protobuf.Empty + 30, // 48: desktop.DesktopService.DeleteAllCrashReports:output_type -> google.protobuf.Empty + 20, // 49: desktop.DesktopService.ListOOMReports:output_type -> desktop.OOMReportList + 24, // 50: desktop.DesktopService.ReadOOMReport:output_type -> desktop.OOMReportContent + 30, // 51: desktop.DesktopService.MarkOOMReportRead:output_type -> google.protobuf.Empty + 19, // 52: desktop.DesktopService.ExportOOMReport:output_type -> desktop.CrashReportArchive + 30, // 53: desktop.DesktopService.DeleteOOMReport:output_type -> google.protobuf.Empty + 30, // 54: desktop.DesktopService.DeleteAllOOMReports:output_type -> google.protobuf.Empty + 29, // 55: desktop.DesktopService.InstallUpdate:output_type -> desktop.InstallUpdateResponse + 26, // 56: desktop.DesktopService.GetSecuritySettings:output_type -> desktop.SecuritySettings + 30, // 57: desktop.DesktopService.SetInsecureModeEnabled:output_type -> google.protobuf.Empty + 30, // 58: desktop.ApplicationService.CheckConfig:output_type -> google.protobuf.Empty + 9, // 59: desktop.ApplicationService.FormatConfig:output_type -> desktop.ConfigContent + 9, // 60: desktop.ApplicationService.GenerateConfigSchema:output_type -> desktop.ConfigContent + 11, // 61: desktop.ApplicationService.EncodeProfile:output_type -> desktop.ProfileData + 10, // 62: desktop.ApplicationService.DecodeProfile:output_type -> desktop.ProfileContent + 30, // 63: desktop.ApplicationService.ArchiveReport:output_type -> google.protobuf.Empty + 31, // 64: desktop.ApplicationService.StartStandaloneNetworkQualityTest:output_type -> daemon.NetworkQualityTestProgress + 32, // 65: desktop.ApplicationService.StartStandaloneSTUNTest:output_type -> daemon.STUNTestProgress + 37, // [37:66] is the sub-list for method output_type + 8, // [8:37] is the sub-list for method input_type + 8, // [8:8] is the sub-list for extension type_name + 8, // [8:8] is the sub-list for extension extendee + 0, // [0:8] is the sub-list for field type_name +} + +func init() { file_experimental_boxdd_desktop_service_proto_init() } +func file_experimental_boxdd_desktop_service_proto_init() { + if File_experimental_boxdd_desktop_service_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_experimental_boxdd_desktop_service_proto_rawDesc), len(file_experimental_boxdd_desktop_service_proto_rawDesc)), + NumEnums: 3, + NumMessages: 27, + NumExtensions: 0, + NumServices: 2, + }, + GoTypes: file_experimental_boxdd_desktop_service_proto_goTypes, + DependencyIndexes: file_experimental_boxdd_desktop_service_proto_depIdxs, + EnumInfos: file_experimental_boxdd_desktop_service_proto_enumTypes, + MessageInfos: file_experimental_boxdd_desktop_service_proto_msgTypes, + }.Build() + File_experimental_boxdd_desktop_service_proto = out.File + file_experimental_boxdd_desktop_service_proto_goTypes = nil + file_experimental_boxdd_desktop_service_proto_depIdxs = nil +} diff --git a/experimental/boxdd/desktop_service.proto b/experimental/boxdd/desktop_service.proto new file mode 100644 index 0000000000..4365c625cc --- /dev/null +++ b/experimental/boxdd/desktop_service.proto @@ -0,0 +1,201 @@ +syntax = "proto3"; + +package desktop; +option go_package = "github.com/sagernet/sing-box/experimental/boxdd;main"; + +import "google/protobuf/empty.proto"; +import "daemon/started_service.proto"; + +service DesktopService { + rpc GetDaemonInfo(google.protobuf.Empty) returns (DaemonInfo) {} + rpc ClaimService(google.protobuf.Empty) returns (google.protobuf.Empty) {} + rpc TakeOverService(google.protobuf.Empty) returns (google.protobuf.Empty) {} + rpc StartService(StartServiceRequest) returns (google.protobuf.Empty) {} + rpc GetWorkingDirectory(google.protobuf.Empty) returns (WorkingDirectoryInfo) {} + rpc DestroyWorkingDirectory(google.protobuf.Empty) returns (google.protobuf.Empty) {} + rpc ListCrashReports(google.protobuf.Empty) returns (CrashReportList) {} + rpc ReadCrashReport(CrashReportRequest) returns (CrashReportContent) {} + rpc MarkCrashReportRead(CrashReportRequest) returns (google.protobuf.Empty) {} + rpc ExportCrashReport(CrashReportExportRequest) returns (CrashReportArchive) {} + rpc DeleteCrashReport(CrashReportRequest) returns (google.protobuf.Empty) {} + rpc DeleteAllCrashReports(google.protobuf.Empty) returns (google.protobuf.Empty) {} + rpc ListOOMReports(google.protobuf.Empty) returns (OOMReportList) {} + rpc ReadOOMReport(OOMReportRequest) returns (OOMReportContent) {} + rpc MarkOOMReportRead(OOMReportRequest) returns (google.protobuf.Empty) {} + rpc ExportOOMReport(OOMReportExportRequest) returns (CrashReportArchive) {} + rpc DeleteOOMReport(OOMReportRequest) returns (google.protobuf.Empty) {} + rpc DeleteAllOOMReports(google.protobuf.Empty) returns (google.protobuf.Empty) {} + rpc InstallUpdate(InstallUpdateRequest) returns (InstallUpdateResponse) {} + rpc GetSecuritySettings(google.protobuf.Empty) returns (SecuritySettings) {} + rpc SetInsecureModeEnabled(SetInsecureModeEnabledRequest) returns (google.protobuf.Empty) {} +} + +service ApplicationService { + rpc CheckConfig(ConfigContent) returns (google.protobuf.Empty) {} + rpc FormatConfig(ConfigContent) returns (ConfigContent) {} + rpc GenerateConfigSchema(google.protobuf.Empty) returns (ConfigContent) {} + rpc EncodeProfile(ProfileContent) returns (ProfileData) {} + rpc DecodeProfile(ProfileData) returns (ProfileContent) {} + rpc ArchiveReport(ArchiveReportRequest) returns (google.protobuf.Empty) {} + rpc StartStandaloneNetworkQualityTest(StandaloneNetworkQualityTestRequest) returns (stream daemon.NetworkQualityTestProgress) {} + rpc StartStandaloneSTUNTest(StandaloneSTUNTestRequest) returns (stream daemon.STUNTestProgress) {} +} + +message ArchiveReportRequest { + string source_path = 1; + string destination_path = 2; + bool encrypt = 3; +} + +message StandaloneNetworkQualityTestRequest { + string config_url = 1; + bool serial = 2; + int32 max_runtime_seconds = 3; + bool http3 = 4; +} + +message StandaloneSTUNTestRequest { + string server = 1; +} + +message DaemonInfo { + string version = 1; + DaemonOwnership ownership = 2; +} + +enum DaemonOwnership { + DAEMON_OWNERSHIP_UNSPECIFIED = 0; + DAEMON_OWNERSHIP_AVAILABLE = 1; + DAEMON_OWNERSHIP_CALLER = 2; + DAEMON_OWNERSHIP_OTHER = 3; +} + +message StartServiceRequest { + string config_content = 1; + StartOptions options = 2; +} + +message StartOptions { + bool oom_killer_enabled = 1; + bool oom_killer_disabled = 2; + int64 oom_memory_limit = 3; +} + +message ConfigContent { + string content = 1; +} + +message ProfileContent { + enum Type { + LOCAL = 0; + ICLOUD = 1; + REMOTE = 2; + } + + Type type = 1; + string name = 2; + string config = 3; + string remote_path = 4; + bool auto_update = 5; + int32 auto_update_interval = 6; + int64 last_updated = 7; +} + +message ProfileData { + bytes data = 1; +} + +message WorkingDirectoryInfo { + string path = 1; + int64 size = 2; +} + +message CrashReportList { + repeated CrashReportEntry reports = 1; +} + +message CrashReportEntry { + string name = 1; + int64 crashed_at = 2; + bool is_read = 3; +} + +message CrashReportRequest { + string name = 1; +} + +message CrashReportExportRequest { + string name = 1; + bool with_configuration = 2; + bool with_log = 3; + bool encrypt = 4; +} + +message CrashReportContent { + repeated CrashReportFile files = 1; +} + +message CrashReportFile { + string name = 1; + string content = 2; +} + +message CrashReportArchive { + string file_name = 1; + bytes data = 2; +} + +message OOMReportList { + repeated OOMReportEntry reports = 1; +} + +message OOMReportEntry { + string name = 1; + int64 recorded_at = 2; + bool is_read = 3; +} + +message OOMReportRequest { + string name = 1; +} + +message OOMReportExportRequest { + string name = 1; + bool with_configuration = 2; + bool with_log = 3; + bool encrypt = 4; +} + +message OOMReportContent { + repeated OOMReportFile files = 1; +} + +message OOMReportFile { + string name = 1; + bytes content = 2; + bool is_profile = 3; +} + +message SecuritySettings { + bool available = 1; + bool insecure_mode_enabled = 2; +} + +message SetInsecureModeEnabledRequest { + bool enabled = 1; +} + +message InstallUpdateRequest { + string installer_path = 1; +} + +message InstallUpdateResponse { + InstallUpdateResult result = 1; +} + +enum InstallUpdateResult { + INSTALL_UPDATE_RESULT_UNSPECIFIED = 0; + INSTALL_UPDATE_RESULT_STARTED = 1; + INSTALL_UPDATE_RESULT_SIGNER_MISMATCH = 2; + INSTALL_UPDATE_RESULT_NOT_NEWER = 3; +} diff --git a/experimental/boxdd/desktop_service_grpc.pb.go b/experimental/boxdd/desktop_service_grpc.pb.go new file mode 100644 index 0000000000..5ad6af211c --- /dev/null +++ b/experimental/boxdd/desktop_service_grpc.pb.go @@ -0,0 +1,1280 @@ +package main + +import ( + context "context" + + daemon "github.com/sagernet/sing-box/daemon" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" + emptypb "google.golang.org/protobuf/types/known/emptypb" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + DesktopService_GetDaemonInfo_FullMethodName = "/desktop.DesktopService/GetDaemonInfo" + DesktopService_ClaimService_FullMethodName = "/desktop.DesktopService/ClaimService" + DesktopService_TakeOverService_FullMethodName = "/desktop.DesktopService/TakeOverService" + DesktopService_StartService_FullMethodName = "/desktop.DesktopService/StartService" + DesktopService_GetWorkingDirectory_FullMethodName = "/desktop.DesktopService/GetWorkingDirectory" + DesktopService_DestroyWorkingDirectory_FullMethodName = "/desktop.DesktopService/DestroyWorkingDirectory" + DesktopService_ListCrashReports_FullMethodName = "/desktop.DesktopService/ListCrashReports" + DesktopService_ReadCrashReport_FullMethodName = "/desktop.DesktopService/ReadCrashReport" + DesktopService_MarkCrashReportRead_FullMethodName = "/desktop.DesktopService/MarkCrashReportRead" + DesktopService_ExportCrashReport_FullMethodName = "/desktop.DesktopService/ExportCrashReport" + DesktopService_DeleteCrashReport_FullMethodName = "/desktop.DesktopService/DeleteCrashReport" + DesktopService_DeleteAllCrashReports_FullMethodName = "/desktop.DesktopService/DeleteAllCrashReports" + DesktopService_ListOOMReports_FullMethodName = "/desktop.DesktopService/ListOOMReports" + DesktopService_ReadOOMReport_FullMethodName = "/desktop.DesktopService/ReadOOMReport" + DesktopService_MarkOOMReportRead_FullMethodName = "/desktop.DesktopService/MarkOOMReportRead" + DesktopService_ExportOOMReport_FullMethodName = "/desktop.DesktopService/ExportOOMReport" + DesktopService_DeleteOOMReport_FullMethodName = "/desktop.DesktopService/DeleteOOMReport" + DesktopService_DeleteAllOOMReports_FullMethodName = "/desktop.DesktopService/DeleteAllOOMReports" + DesktopService_InstallUpdate_FullMethodName = "/desktop.DesktopService/InstallUpdate" + DesktopService_GetSecuritySettings_FullMethodName = "/desktop.DesktopService/GetSecuritySettings" + DesktopService_SetInsecureModeEnabled_FullMethodName = "/desktop.DesktopService/SetInsecureModeEnabled" +) + +// DesktopServiceClient is the client API for DesktopService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type DesktopServiceClient interface { + GetDaemonInfo(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*DaemonInfo, error) + ClaimService(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) + TakeOverService(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) + StartService(ctx context.Context, in *StartServiceRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + GetWorkingDirectory(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*WorkingDirectoryInfo, error) + DestroyWorkingDirectory(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) + ListCrashReports(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*CrashReportList, error) + ReadCrashReport(ctx context.Context, in *CrashReportRequest, opts ...grpc.CallOption) (*CrashReportContent, error) + MarkCrashReportRead(ctx context.Context, in *CrashReportRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + ExportCrashReport(ctx context.Context, in *CrashReportExportRequest, opts ...grpc.CallOption) (*CrashReportArchive, error) + DeleteCrashReport(ctx context.Context, in *CrashReportRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + DeleteAllCrashReports(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) + ListOOMReports(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*OOMReportList, error) + ReadOOMReport(ctx context.Context, in *OOMReportRequest, opts ...grpc.CallOption) (*OOMReportContent, error) + MarkOOMReportRead(ctx context.Context, in *OOMReportRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + ExportOOMReport(ctx context.Context, in *OOMReportExportRequest, opts ...grpc.CallOption) (*CrashReportArchive, error) + DeleteOOMReport(ctx context.Context, in *OOMReportRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + DeleteAllOOMReports(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) + InstallUpdate(ctx context.Context, in *InstallUpdateRequest, opts ...grpc.CallOption) (*InstallUpdateResponse, error) + GetSecuritySettings(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*SecuritySettings, error) + SetInsecureModeEnabled(ctx context.Context, in *SetInsecureModeEnabledRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) +} + +type desktopServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewDesktopServiceClient(cc grpc.ClientConnInterface) DesktopServiceClient { + return &desktopServiceClient{cc} +} + +func (c *desktopServiceClient) GetDaemonInfo(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*DaemonInfo, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(DaemonInfo) + err := c.cc.Invoke(ctx, DesktopService_GetDaemonInfo_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *desktopServiceClient) ClaimService(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, DesktopService_ClaimService_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *desktopServiceClient) TakeOverService(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, DesktopService_TakeOverService_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *desktopServiceClient) StartService(ctx context.Context, in *StartServiceRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, DesktopService_StartService_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *desktopServiceClient) GetWorkingDirectory(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*WorkingDirectoryInfo, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(WorkingDirectoryInfo) + err := c.cc.Invoke(ctx, DesktopService_GetWorkingDirectory_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *desktopServiceClient) DestroyWorkingDirectory(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, DesktopService_DestroyWorkingDirectory_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *desktopServiceClient) ListCrashReports(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*CrashReportList, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CrashReportList) + err := c.cc.Invoke(ctx, DesktopService_ListCrashReports_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *desktopServiceClient) ReadCrashReport(ctx context.Context, in *CrashReportRequest, opts ...grpc.CallOption) (*CrashReportContent, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CrashReportContent) + err := c.cc.Invoke(ctx, DesktopService_ReadCrashReport_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *desktopServiceClient) MarkCrashReportRead(ctx context.Context, in *CrashReportRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, DesktopService_MarkCrashReportRead_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *desktopServiceClient) ExportCrashReport(ctx context.Context, in *CrashReportExportRequest, opts ...grpc.CallOption) (*CrashReportArchive, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CrashReportArchive) + err := c.cc.Invoke(ctx, DesktopService_ExportCrashReport_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *desktopServiceClient) DeleteCrashReport(ctx context.Context, in *CrashReportRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, DesktopService_DeleteCrashReport_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *desktopServiceClient) DeleteAllCrashReports(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, DesktopService_DeleteAllCrashReports_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *desktopServiceClient) ListOOMReports(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*OOMReportList, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(OOMReportList) + err := c.cc.Invoke(ctx, DesktopService_ListOOMReports_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *desktopServiceClient) ReadOOMReport(ctx context.Context, in *OOMReportRequest, opts ...grpc.CallOption) (*OOMReportContent, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(OOMReportContent) + err := c.cc.Invoke(ctx, DesktopService_ReadOOMReport_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *desktopServiceClient) MarkOOMReportRead(ctx context.Context, in *OOMReportRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, DesktopService_MarkOOMReportRead_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *desktopServiceClient) ExportOOMReport(ctx context.Context, in *OOMReportExportRequest, opts ...grpc.CallOption) (*CrashReportArchive, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CrashReportArchive) + err := c.cc.Invoke(ctx, DesktopService_ExportOOMReport_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *desktopServiceClient) DeleteOOMReport(ctx context.Context, in *OOMReportRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, DesktopService_DeleteOOMReport_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *desktopServiceClient) DeleteAllOOMReports(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, DesktopService_DeleteAllOOMReports_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *desktopServiceClient) InstallUpdate(ctx context.Context, in *InstallUpdateRequest, opts ...grpc.CallOption) (*InstallUpdateResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(InstallUpdateResponse) + err := c.cc.Invoke(ctx, DesktopService_InstallUpdate_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *desktopServiceClient) GetSecuritySettings(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*SecuritySettings, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SecuritySettings) + err := c.cc.Invoke(ctx, DesktopService_GetSecuritySettings_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *desktopServiceClient) SetInsecureModeEnabled(ctx context.Context, in *SetInsecureModeEnabledRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, DesktopService_SetInsecureModeEnabled_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// DesktopServiceServer is the server API for DesktopService service. +// All implementations must embed UnimplementedDesktopServiceServer +// for forward compatibility. +type DesktopServiceServer interface { + GetDaemonInfo(context.Context, *emptypb.Empty) (*DaemonInfo, error) + ClaimService(context.Context, *emptypb.Empty) (*emptypb.Empty, error) + TakeOverService(context.Context, *emptypb.Empty) (*emptypb.Empty, error) + StartService(context.Context, *StartServiceRequest) (*emptypb.Empty, error) + GetWorkingDirectory(context.Context, *emptypb.Empty) (*WorkingDirectoryInfo, error) + DestroyWorkingDirectory(context.Context, *emptypb.Empty) (*emptypb.Empty, error) + ListCrashReports(context.Context, *emptypb.Empty) (*CrashReportList, error) + ReadCrashReport(context.Context, *CrashReportRequest) (*CrashReportContent, error) + MarkCrashReportRead(context.Context, *CrashReportRequest) (*emptypb.Empty, error) + ExportCrashReport(context.Context, *CrashReportExportRequest) (*CrashReportArchive, error) + DeleteCrashReport(context.Context, *CrashReportRequest) (*emptypb.Empty, error) + DeleteAllCrashReports(context.Context, *emptypb.Empty) (*emptypb.Empty, error) + ListOOMReports(context.Context, *emptypb.Empty) (*OOMReportList, error) + ReadOOMReport(context.Context, *OOMReportRequest) (*OOMReportContent, error) + MarkOOMReportRead(context.Context, *OOMReportRequest) (*emptypb.Empty, error) + ExportOOMReport(context.Context, *OOMReportExportRequest) (*CrashReportArchive, error) + DeleteOOMReport(context.Context, *OOMReportRequest) (*emptypb.Empty, error) + DeleteAllOOMReports(context.Context, *emptypb.Empty) (*emptypb.Empty, error) + InstallUpdate(context.Context, *InstallUpdateRequest) (*InstallUpdateResponse, error) + GetSecuritySettings(context.Context, *emptypb.Empty) (*SecuritySettings, error) + SetInsecureModeEnabled(context.Context, *SetInsecureModeEnabledRequest) (*emptypb.Empty, error) + mustEmbedUnimplementedDesktopServiceServer() +} + +// UnimplementedDesktopServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedDesktopServiceServer struct{} + +func (UnimplementedDesktopServiceServer) GetDaemonInfo(context.Context, *emptypb.Empty) (*DaemonInfo, error) { + return nil, status.Error(codes.Unimplemented, "method GetDaemonInfo not implemented") +} + +func (UnimplementedDesktopServiceServer) ClaimService(context.Context, *emptypb.Empty) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method ClaimService not implemented") +} + +func (UnimplementedDesktopServiceServer) TakeOverService(context.Context, *emptypb.Empty) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method TakeOverService not implemented") +} + +func (UnimplementedDesktopServiceServer) StartService(context.Context, *StartServiceRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method StartService not implemented") +} + +func (UnimplementedDesktopServiceServer) GetWorkingDirectory(context.Context, *emptypb.Empty) (*WorkingDirectoryInfo, error) { + return nil, status.Error(codes.Unimplemented, "method GetWorkingDirectory not implemented") +} + +func (UnimplementedDesktopServiceServer) DestroyWorkingDirectory(context.Context, *emptypb.Empty) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method DestroyWorkingDirectory not implemented") +} + +func (UnimplementedDesktopServiceServer) ListCrashReports(context.Context, *emptypb.Empty) (*CrashReportList, error) { + return nil, status.Error(codes.Unimplemented, "method ListCrashReports not implemented") +} + +func (UnimplementedDesktopServiceServer) ReadCrashReport(context.Context, *CrashReportRequest) (*CrashReportContent, error) { + return nil, status.Error(codes.Unimplemented, "method ReadCrashReport not implemented") +} + +func (UnimplementedDesktopServiceServer) MarkCrashReportRead(context.Context, *CrashReportRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method MarkCrashReportRead not implemented") +} + +func (UnimplementedDesktopServiceServer) ExportCrashReport(context.Context, *CrashReportExportRequest) (*CrashReportArchive, error) { + return nil, status.Error(codes.Unimplemented, "method ExportCrashReport not implemented") +} + +func (UnimplementedDesktopServiceServer) DeleteCrashReport(context.Context, *CrashReportRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method DeleteCrashReport not implemented") +} + +func (UnimplementedDesktopServiceServer) DeleteAllCrashReports(context.Context, *emptypb.Empty) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method DeleteAllCrashReports not implemented") +} + +func (UnimplementedDesktopServiceServer) ListOOMReports(context.Context, *emptypb.Empty) (*OOMReportList, error) { + return nil, status.Error(codes.Unimplemented, "method ListOOMReports not implemented") +} + +func (UnimplementedDesktopServiceServer) ReadOOMReport(context.Context, *OOMReportRequest) (*OOMReportContent, error) { + return nil, status.Error(codes.Unimplemented, "method ReadOOMReport not implemented") +} + +func (UnimplementedDesktopServiceServer) MarkOOMReportRead(context.Context, *OOMReportRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method MarkOOMReportRead not implemented") +} + +func (UnimplementedDesktopServiceServer) ExportOOMReport(context.Context, *OOMReportExportRequest) (*CrashReportArchive, error) { + return nil, status.Error(codes.Unimplemented, "method ExportOOMReport not implemented") +} + +func (UnimplementedDesktopServiceServer) DeleteOOMReport(context.Context, *OOMReportRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method DeleteOOMReport not implemented") +} + +func (UnimplementedDesktopServiceServer) DeleteAllOOMReports(context.Context, *emptypb.Empty) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method DeleteAllOOMReports not implemented") +} + +func (UnimplementedDesktopServiceServer) InstallUpdate(context.Context, *InstallUpdateRequest) (*InstallUpdateResponse, error) { + return nil, status.Error(codes.Unimplemented, "method InstallUpdate not implemented") +} + +func (UnimplementedDesktopServiceServer) GetSecuritySettings(context.Context, *emptypb.Empty) (*SecuritySettings, error) { + return nil, status.Error(codes.Unimplemented, "method GetSecuritySettings not implemented") +} + +func (UnimplementedDesktopServiceServer) SetInsecureModeEnabled(context.Context, *SetInsecureModeEnabledRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method SetInsecureModeEnabled not implemented") +} +func (UnimplementedDesktopServiceServer) mustEmbedUnimplementedDesktopServiceServer() {} +func (UnimplementedDesktopServiceServer) testEmbeddedByValue() {} + +// UnsafeDesktopServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to DesktopServiceServer will +// result in compilation errors. +type UnsafeDesktopServiceServer interface { + mustEmbedUnimplementedDesktopServiceServer() +} + +func RegisterDesktopServiceServer(s grpc.ServiceRegistrar, srv DesktopServiceServer) { + // If the following call panics, it indicates UnimplementedDesktopServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&DesktopService_ServiceDesc, srv) +} + +func _DesktopService_GetDaemonInfo_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(emptypb.Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DesktopServiceServer).GetDaemonInfo(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DesktopService_GetDaemonInfo_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DesktopServiceServer).GetDaemonInfo(ctx, req.(*emptypb.Empty)) + } + return interceptor(ctx, in, info, handler) +} + +func _DesktopService_ClaimService_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(emptypb.Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DesktopServiceServer).ClaimService(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DesktopService_ClaimService_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DesktopServiceServer).ClaimService(ctx, req.(*emptypb.Empty)) + } + return interceptor(ctx, in, info, handler) +} + +func _DesktopService_TakeOverService_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(emptypb.Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DesktopServiceServer).TakeOverService(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DesktopService_TakeOverService_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DesktopServiceServer).TakeOverService(ctx, req.(*emptypb.Empty)) + } + return interceptor(ctx, in, info, handler) +} + +func _DesktopService_StartService_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(StartServiceRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DesktopServiceServer).StartService(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DesktopService_StartService_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DesktopServiceServer).StartService(ctx, req.(*StartServiceRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DesktopService_GetWorkingDirectory_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(emptypb.Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DesktopServiceServer).GetWorkingDirectory(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DesktopService_GetWorkingDirectory_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DesktopServiceServer).GetWorkingDirectory(ctx, req.(*emptypb.Empty)) + } + return interceptor(ctx, in, info, handler) +} + +func _DesktopService_DestroyWorkingDirectory_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(emptypb.Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DesktopServiceServer).DestroyWorkingDirectory(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DesktopService_DestroyWorkingDirectory_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DesktopServiceServer).DestroyWorkingDirectory(ctx, req.(*emptypb.Empty)) + } + return interceptor(ctx, in, info, handler) +} + +func _DesktopService_ListCrashReports_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(emptypb.Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DesktopServiceServer).ListCrashReports(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DesktopService_ListCrashReports_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DesktopServiceServer).ListCrashReports(ctx, req.(*emptypb.Empty)) + } + return interceptor(ctx, in, info, handler) +} + +func _DesktopService_ReadCrashReport_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CrashReportRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DesktopServiceServer).ReadCrashReport(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DesktopService_ReadCrashReport_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DesktopServiceServer).ReadCrashReport(ctx, req.(*CrashReportRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DesktopService_MarkCrashReportRead_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CrashReportRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DesktopServiceServer).MarkCrashReportRead(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DesktopService_MarkCrashReportRead_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DesktopServiceServer).MarkCrashReportRead(ctx, req.(*CrashReportRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DesktopService_ExportCrashReport_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CrashReportExportRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DesktopServiceServer).ExportCrashReport(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DesktopService_ExportCrashReport_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DesktopServiceServer).ExportCrashReport(ctx, req.(*CrashReportExportRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DesktopService_DeleteCrashReport_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CrashReportRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DesktopServiceServer).DeleteCrashReport(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DesktopService_DeleteCrashReport_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DesktopServiceServer).DeleteCrashReport(ctx, req.(*CrashReportRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DesktopService_DeleteAllCrashReports_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(emptypb.Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DesktopServiceServer).DeleteAllCrashReports(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DesktopService_DeleteAllCrashReports_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DesktopServiceServer).DeleteAllCrashReports(ctx, req.(*emptypb.Empty)) + } + return interceptor(ctx, in, info, handler) +} + +func _DesktopService_ListOOMReports_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(emptypb.Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DesktopServiceServer).ListOOMReports(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DesktopService_ListOOMReports_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DesktopServiceServer).ListOOMReports(ctx, req.(*emptypb.Empty)) + } + return interceptor(ctx, in, info, handler) +} + +func _DesktopService_ReadOOMReport_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(OOMReportRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DesktopServiceServer).ReadOOMReport(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DesktopService_ReadOOMReport_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DesktopServiceServer).ReadOOMReport(ctx, req.(*OOMReportRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DesktopService_MarkOOMReportRead_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(OOMReportRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DesktopServiceServer).MarkOOMReportRead(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DesktopService_MarkOOMReportRead_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DesktopServiceServer).MarkOOMReportRead(ctx, req.(*OOMReportRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DesktopService_ExportOOMReport_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(OOMReportExportRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DesktopServiceServer).ExportOOMReport(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DesktopService_ExportOOMReport_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DesktopServiceServer).ExportOOMReport(ctx, req.(*OOMReportExportRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DesktopService_DeleteOOMReport_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(OOMReportRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DesktopServiceServer).DeleteOOMReport(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DesktopService_DeleteOOMReport_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DesktopServiceServer).DeleteOOMReport(ctx, req.(*OOMReportRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DesktopService_DeleteAllOOMReports_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(emptypb.Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DesktopServiceServer).DeleteAllOOMReports(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DesktopService_DeleteAllOOMReports_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DesktopServiceServer).DeleteAllOOMReports(ctx, req.(*emptypb.Empty)) + } + return interceptor(ctx, in, info, handler) +} + +func _DesktopService_InstallUpdate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(InstallUpdateRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DesktopServiceServer).InstallUpdate(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DesktopService_InstallUpdate_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DesktopServiceServer).InstallUpdate(ctx, req.(*InstallUpdateRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DesktopService_GetSecuritySettings_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(emptypb.Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DesktopServiceServer).GetSecuritySettings(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DesktopService_GetSecuritySettings_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DesktopServiceServer).GetSecuritySettings(ctx, req.(*emptypb.Empty)) + } + return interceptor(ctx, in, info, handler) +} + +func _DesktopService_SetInsecureModeEnabled_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SetInsecureModeEnabledRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DesktopServiceServer).SetInsecureModeEnabled(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DesktopService_SetInsecureModeEnabled_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DesktopServiceServer).SetInsecureModeEnabled(ctx, req.(*SetInsecureModeEnabledRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// DesktopService_ServiceDesc is the grpc.ServiceDesc for DesktopService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var DesktopService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "desktop.DesktopService", + HandlerType: (*DesktopServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "GetDaemonInfo", + Handler: _DesktopService_GetDaemonInfo_Handler, + }, + { + MethodName: "ClaimService", + Handler: _DesktopService_ClaimService_Handler, + }, + { + MethodName: "TakeOverService", + Handler: _DesktopService_TakeOverService_Handler, + }, + { + MethodName: "StartService", + Handler: _DesktopService_StartService_Handler, + }, + { + MethodName: "GetWorkingDirectory", + Handler: _DesktopService_GetWorkingDirectory_Handler, + }, + { + MethodName: "DestroyWorkingDirectory", + Handler: _DesktopService_DestroyWorkingDirectory_Handler, + }, + { + MethodName: "ListCrashReports", + Handler: _DesktopService_ListCrashReports_Handler, + }, + { + MethodName: "ReadCrashReport", + Handler: _DesktopService_ReadCrashReport_Handler, + }, + { + MethodName: "MarkCrashReportRead", + Handler: _DesktopService_MarkCrashReportRead_Handler, + }, + { + MethodName: "ExportCrashReport", + Handler: _DesktopService_ExportCrashReport_Handler, + }, + { + MethodName: "DeleteCrashReport", + Handler: _DesktopService_DeleteCrashReport_Handler, + }, + { + MethodName: "DeleteAllCrashReports", + Handler: _DesktopService_DeleteAllCrashReports_Handler, + }, + { + MethodName: "ListOOMReports", + Handler: _DesktopService_ListOOMReports_Handler, + }, + { + MethodName: "ReadOOMReport", + Handler: _DesktopService_ReadOOMReport_Handler, + }, + { + MethodName: "MarkOOMReportRead", + Handler: _DesktopService_MarkOOMReportRead_Handler, + }, + { + MethodName: "ExportOOMReport", + Handler: _DesktopService_ExportOOMReport_Handler, + }, + { + MethodName: "DeleteOOMReport", + Handler: _DesktopService_DeleteOOMReport_Handler, + }, + { + MethodName: "DeleteAllOOMReports", + Handler: _DesktopService_DeleteAllOOMReports_Handler, + }, + { + MethodName: "InstallUpdate", + Handler: _DesktopService_InstallUpdate_Handler, + }, + { + MethodName: "GetSecuritySettings", + Handler: _DesktopService_GetSecuritySettings_Handler, + }, + { + MethodName: "SetInsecureModeEnabled", + Handler: _DesktopService_SetInsecureModeEnabled_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "experimental/boxdd/desktop_service.proto", +} + +const ( + ApplicationService_CheckConfig_FullMethodName = "/desktop.ApplicationService/CheckConfig" + ApplicationService_FormatConfig_FullMethodName = "/desktop.ApplicationService/FormatConfig" + ApplicationService_GenerateConfigSchema_FullMethodName = "/desktop.ApplicationService/GenerateConfigSchema" + ApplicationService_EncodeProfile_FullMethodName = "/desktop.ApplicationService/EncodeProfile" + ApplicationService_DecodeProfile_FullMethodName = "/desktop.ApplicationService/DecodeProfile" + ApplicationService_ArchiveReport_FullMethodName = "/desktop.ApplicationService/ArchiveReport" + ApplicationService_StartStandaloneNetworkQualityTest_FullMethodName = "/desktop.ApplicationService/StartStandaloneNetworkQualityTest" + ApplicationService_StartStandaloneSTUNTest_FullMethodName = "/desktop.ApplicationService/StartStandaloneSTUNTest" +) + +// ApplicationServiceClient is the client API for ApplicationService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type ApplicationServiceClient interface { + CheckConfig(ctx context.Context, in *ConfigContent, opts ...grpc.CallOption) (*emptypb.Empty, error) + FormatConfig(ctx context.Context, in *ConfigContent, opts ...grpc.CallOption) (*ConfigContent, error) + GenerateConfigSchema(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*ConfigContent, error) + EncodeProfile(ctx context.Context, in *ProfileContent, opts ...grpc.CallOption) (*ProfileData, error) + DecodeProfile(ctx context.Context, in *ProfileData, opts ...grpc.CallOption) (*ProfileContent, error) + ArchiveReport(ctx context.Context, in *ArchiveReportRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + StartStandaloneNetworkQualityTest(ctx context.Context, in *StandaloneNetworkQualityTestRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[daemon.NetworkQualityTestProgress], error) + StartStandaloneSTUNTest(ctx context.Context, in *StandaloneSTUNTestRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[daemon.STUNTestProgress], error) +} + +type applicationServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewApplicationServiceClient(cc grpc.ClientConnInterface) ApplicationServiceClient { + return &applicationServiceClient{cc} +} + +func (c *applicationServiceClient) CheckConfig(ctx context.Context, in *ConfigContent, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, ApplicationService_CheckConfig_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *applicationServiceClient) FormatConfig(ctx context.Context, in *ConfigContent, opts ...grpc.CallOption) (*ConfigContent, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ConfigContent) + err := c.cc.Invoke(ctx, ApplicationService_FormatConfig_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *applicationServiceClient) GenerateConfigSchema(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*ConfigContent, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ConfigContent) + err := c.cc.Invoke(ctx, ApplicationService_GenerateConfigSchema_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *applicationServiceClient) EncodeProfile(ctx context.Context, in *ProfileContent, opts ...grpc.CallOption) (*ProfileData, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ProfileData) + err := c.cc.Invoke(ctx, ApplicationService_EncodeProfile_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *applicationServiceClient) DecodeProfile(ctx context.Context, in *ProfileData, opts ...grpc.CallOption) (*ProfileContent, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ProfileContent) + err := c.cc.Invoke(ctx, ApplicationService_DecodeProfile_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *applicationServiceClient) ArchiveReport(ctx context.Context, in *ArchiveReportRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, ApplicationService_ArchiveReport_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *applicationServiceClient) StartStandaloneNetworkQualityTest(ctx context.Context, in *StandaloneNetworkQualityTestRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[daemon.NetworkQualityTestProgress], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &ApplicationService_ServiceDesc.Streams[0], ApplicationService_StartStandaloneNetworkQualityTest_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[StandaloneNetworkQualityTestRequest, daemon.NetworkQualityTestProgress]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type ApplicationService_StartStandaloneNetworkQualityTestClient = grpc.ServerStreamingClient[daemon.NetworkQualityTestProgress] + +func (c *applicationServiceClient) StartStandaloneSTUNTest(ctx context.Context, in *StandaloneSTUNTestRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[daemon.STUNTestProgress], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &ApplicationService_ServiceDesc.Streams[1], ApplicationService_StartStandaloneSTUNTest_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[StandaloneSTUNTestRequest, daemon.STUNTestProgress]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type ApplicationService_StartStandaloneSTUNTestClient = grpc.ServerStreamingClient[daemon.STUNTestProgress] + +// ApplicationServiceServer is the server API for ApplicationService service. +// All implementations must embed UnimplementedApplicationServiceServer +// for forward compatibility. +type ApplicationServiceServer interface { + CheckConfig(context.Context, *ConfigContent) (*emptypb.Empty, error) + FormatConfig(context.Context, *ConfigContent) (*ConfigContent, error) + GenerateConfigSchema(context.Context, *emptypb.Empty) (*ConfigContent, error) + EncodeProfile(context.Context, *ProfileContent) (*ProfileData, error) + DecodeProfile(context.Context, *ProfileData) (*ProfileContent, error) + ArchiveReport(context.Context, *ArchiveReportRequest) (*emptypb.Empty, error) + StartStandaloneNetworkQualityTest(*StandaloneNetworkQualityTestRequest, grpc.ServerStreamingServer[daemon.NetworkQualityTestProgress]) error + StartStandaloneSTUNTest(*StandaloneSTUNTestRequest, grpc.ServerStreamingServer[daemon.STUNTestProgress]) error + mustEmbedUnimplementedApplicationServiceServer() +} + +// UnimplementedApplicationServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedApplicationServiceServer struct{} + +func (UnimplementedApplicationServiceServer) CheckConfig(context.Context, *ConfigContent) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method CheckConfig not implemented") +} + +func (UnimplementedApplicationServiceServer) FormatConfig(context.Context, *ConfigContent) (*ConfigContent, error) { + return nil, status.Error(codes.Unimplemented, "method FormatConfig not implemented") +} + +func (UnimplementedApplicationServiceServer) GenerateConfigSchema(context.Context, *emptypb.Empty) (*ConfigContent, error) { + return nil, status.Error(codes.Unimplemented, "method GenerateConfigSchema not implemented") +} + +func (UnimplementedApplicationServiceServer) EncodeProfile(context.Context, *ProfileContent) (*ProfileData, error) { + return nil, status.Error(codes.Unimplemented, "method EncodeProfile not implemented") +} + +func (UnimplementedApplicationServiceServer) DecodeProfile(context.Context, *ProfileData) (*ProfileContent, error) { + return nil, status.Error(codes.Unimplemented, "method DecodeProfile not implemented") +} + +func (UnimplementedApplicationServiceServer) ArchiveReport(context.Context, *ArchiveReportRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method ArchiveReport not implemented") +} + +func (UnimplementedApplicationServiceServer) StartStandaloneNetworkQualityTest(*StandaloneNetworkQualityTestRequest, grpc.ServerStreamingServer[daemon.NetworkQualityTestProgress]) error { + return status.Error(codes.Unimplemented, "method StartStandaloneNetworkQualityTest not implemented") +} + +func (UnimplementedApplicationServiceServer) StartStandaloneSTUNTest(*StandaloneSTUNTestRequest, grpc.ServerStreamingServer[daemon.STUNTestProgress]) error { + return status.Error(codes.Unimplemented, "method StartStandaloneSTUNTest not implemented") +} +func (UnimplementedApplicationServiceServer) mustEmbedUnimplementedApplicationServiceServer() {} +func (UnimplementedApplicationServiceServer) testEmbeddedByValue() {} + +// UnsafeApplicationServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to ApplicationServiceServer will +// result in compilation errors. +type UnsafeApplicationServiceServer interface { + mustEmbedUnimplementedApplicationServiceServer() +} + +func RegisterApplicationServiceServer(s grpc.ServiceRegistrar, srv ApplicationServiceServer) { + // If the following call panics, it indicates UnimplementedApplicationServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&ApplicationService_ServiceDesc, srv) +} + +func _ApplicationService_CheckConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ConfigContent) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ApplicationServiceServer).CheckConfig(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ApplicationService_CheckConfig_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ApplicationServiceServer).CheckConfig(ctx, req.(*ConfigContent)) + } + return interceptor(ctx, in, info, handler) +} + +func _ApplicationService_FormatConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ConfigContent) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ApplicationServiceServer).FormatConfig(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ApplicationService_FormatConfig_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ApplicationServiceServer).FormatConfig(ctx, req.(*ConfigContent)) + } + return interceptor(ctx, in, info, handler) +} + +func _ApplicationService_GenerateConfigSchema_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(emptypb.Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ApplicationServiceServer).GenerateConfigSchema(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ApplicationService_GenerateConfigSchema_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ApplicationServiceServer).GenerateConfigSchema(ctx, req.(*emptypb.Empty)) + } + return interceptor(ctx, in, info, handler) +} + +func _ApplicationService_EncodeProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ProfileContent) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ApplicationServiceServer).EncodeProfile(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ApplicationService_EncodeProfile_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ApplicationServiceServer).EncodeProfile(ctx, req.(*ProfileContent)) + } + return interceptor(ctx, in, info, handler) +} + +func _ApplicationService_DecodeProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ProfileData) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ApplicationServiceServer).DecodeProfile(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ApplicationService_DecodeProfile_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ApplicationServiceServer).DecodeProfile(ctx, req.(*ProfileData)) + } + return interceptor(ctx, in, info, handler) +} + +func _ApplicationService_ArchiveReport_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ArchiveReportRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ApplicationServiceServer).ArchiveReport(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ApplicationService_ArchiveReport_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ApplicationServiceServer).ArchiveReport(ctx, req.(*ArchiveReportRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ApplicationService_StartStandaloneNetworkQualityTest_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(StandaloneNetworkQualityTestRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(ApplicationServiceServer).StartStandaloneNetworkQualityTest(m, &grpc.GenericServerStream[StandaloneNetworkQualityTestRequest, daemon.NetworkQualityTestProgress]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type ApplicationService_StartStandaloneNetworkQualityTestServer = grpc.ServerStreamingServer[daemon.NetworkQualityTestProgress] + +func _ApplicationService_StartStandaloneSTUNTest_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(StandaloneSTUNTestRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(ApplicationServiceServer).StartStandaloneSTUNTest(m, &grpc.GenericServerStream[StandaloneSTUNTestRequest, daemon.STUNTestProgress]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type ApplicationService_StartStandaloneSTUNTestServer = grpc.ServerStreamingServer[daemon.STUNTestProgress] + +// ApplicationService_ServiceDesc is the grpc.ServiceDesc for ApplicationService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var ApplicationService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "desktop.ApplicationService", + HandlerType: (*ApplicationServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "CheckConfig", + Handler: _ApplicationService_CheckConfig_Handler, + }, + { + MethodName: "FormatConfig", + Handler: _ApplicationService_FormatConfig_Handler, + }, + { + MethodName: "GenerateConfigSchema", + Handler: _ApplicationService_GenerateConfigSchema_Handler, + }, + { + MethodName: "EncodeProfile", + Handler: _ApplicationService_EncodeProfile_Handler, + }, + { + MethodName: "DecodeProfile", + Handler: _ApplicationService_DecodeProfile_Handler, + }, + { + MethodName: "ArchiveReport", + Handler: _ApplicationService_ArchiveReport_Handler, + }, + }, + Streams: []grpc.StreamDesc{ + { + StreamName: "StartStandaloneNetworkQualityTest", + Handler: _ApplicationService_StartStandaloneNetworkQualityTest_Handler, + ServerStreams: true, + }, + { + StreamName: "StartStandaloneSTUNTest", + Handler: _ApplicationService_StartStandaloneSTUNTest_Handler, + ServerStreams: true, + }, + }, + Metadata: "experimental/boxdd/desktop_service.proto", +} diff --git a/experimental/boxdd/insecure_mode.go b/experimental/boxdd/insecure_mode.go new file mode 100644 index 0000000000..77965ce09f --- /dev/null +++ b/experimental/boxdd/insecure_mode.go @@ -0,0 +1,22 @@ +package main + +import ( + "path/filepath" + + "github.com/sagernet/sing/common/json" + "github.com/sagernet/tailscale/atomicfile" +) + +const securitySettingsFileName = "security.json" + +type securitySettings struct { + InsecureModeEnabled bool `json:"insecure_mode_enabled"` +} + +func saveSecuritySettings(directory string, settings securitySettings) error { + content, err := json.Marshal(settings) + if err != nil { + return err + } + return atomicfile.WriteFile(filepath.Join(directory, securitySettingsFileName), content, 0o600) +} diff --git a/experimental/boxdd/insecure_mode_authorize_linux.go b/experimental/boxdd/insecure_mode_authorize_linux.go new file mode 100644 index 0000000000..35769a0cee --- /dev/null +++ b/experimental/boxdd/insecure_mode_authorize_linux.go @@ -0,0 +1,24 @@ +//go:build linux + +package main + +import ( + "os" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func authorizeDisableInsecureMode(identity peerIdentity) error { + ownerUserID, err := loadOwner() + if err != nil { + if os.IsNotExist(err) { + return status.Error(codes.PermissionDenied, "the service has no owner") + } + return err + } + if ownerUserID != identity.UserID { + return status.Error(codes.PermissionDenied, "the service is owned by another user") + } + return nil +} diff --git a/experimental/boxdd/insecure_mode_authorize_stub.go b/experimental/boxdd/insecure_mode_authorize_stub.go new file mode 100644 index 0000000000..8040cbea6f --- /dev/null +++ b/experimental/boxdd/insecure_mode_authorize_stub.go @@ -0,0 +1,7 @@ +//go:build !linux + +package main + +func authorizeDisableInsecureMode(identity peerIdentity) error { + return nil +} diff --git a/experimental/boxdd/insecure_mode_linux.go b/experimental/boxdd/insecure_mode_linux.go new file mode 100644 index 0000000000..e43657bf02 --- /dev/null +++ b/experimental/boxdd/insecure_mode_linux.go @@ -0,0 +1,47 @@ +//go:build linux + +package main + +import ( + "context" + "os" + "path/filepath" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/service" + "github.com/sagernet/sing/service/filemanager" +) + +func registerSecurityPolicy(ctx context.Context, daemon *Daemon) { + service.MustRegister[adapter.SecurityPolicy](ctx, &daemonSecurityPolicy{daemon}) + service.MustRegister[filemanager.Manager](ctx, &restrictedFileManager{daemon}) +} + +func insecureModeAvailable() bool { + return true +} + +func insecureModePlatformName() string { + return "Linux" +} + +func loadSecuritySettings(directory string) (securitySettings, error) { + content, err := os.ReadFile(filepath.Join(directory, securitySettingsFileName)) + if err != nil { + return securitySettings{}, err + } + settings, err := json.UnmarshalExtended[securitySettings](content) + if err != nil { + return securitySettings{}, err + } + return settings, nil +} + +func (d *Daemon) insecureModeEnabled() bool { + settings, err := loadSecuritySettings(workingDirectory) + if err != nil { + return false + } + return settings.InsecureModeEnabled +} diff --git a/experimental/boxdd/insecure_mode_path_linux.go b/experimental/boxdd/insecure_mode_path_linux.go new file mode 100644 index 0000000000..f4336f37f9 --- /dev/null +++ b/experimental/boxdd/insecure_mode_path_linux.go @@ -0,0 +1,9 @@ +//go:build linux + +package main + +import "path/filepath" + +func normalizeRestrictedPath(path string) string { + return filepath.Clean(path) +} diff --git a/experimental/boxdd/insecure_mode_path_windows.go b/experimental/boxdd/insecure_mode_path_windows.go new file mode 100644 index 0000000000..0680b9e153 --- /dev/null +++ b/experimental/boxdd/insecure_mode_path_windows.go @@ -0,0 +1,12 @@ +//go:build windows + +package main + +import ( + "path/filepath" + "strings" +) + +func normalizeRestrictedPath(path string) string { + return strings.ToLower(filepath.Clean(path)) +} diff --git a/experimental/boxdd/insecure_mode_stub.go b/experimental/boxdd/insecure_mode_stub.go new file mode 100644 index 0000000000..6d02d961a4 --- /dev/null +++ b/experimental/boxdd/insecure_mode_stub.go @@ -0,0 +1,16 @@ +//go:build !windows && !linux + +package main + +import "context" + +func registerSecurityPolicy(ctx context.Context, daemon *Daemon) { +} + +func insecureModeAvailable() bool { + return false +} + +func (d *Daemon) insecureModeEnabled() bool { + return false +} diff --git a/experimental/boxdd/insecure_mode_supported.go b/experimental/boxdd/insecure_mode_supported.go new file mode 100644 index 0000000000..2c2e52737a --- /dev/null +++ b/experimental/boxdd/insecure_mode_supported.go @@ -0,0 +1,177 @@ +//go:build windows || linux + +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/sagernet/sing-box/experimental/locale" + E "github.com/sagernet/sing/common/exceptions" +) + +func insecureFeatureError(ctx context.Context, feature string) error { + return E.New(fmt.Sprintf(locale.FromContext(ctx).InsecureFeatureMessage, feature, insecureModePlatformName())) +} + +type daemonSecurityPolicy struct { + daemon *Daemon +} + +func (p *daemonSecurityPolicy) CheckFeature(ctx context.Context, feature string) error { + if p.daemon.insecureModeEnabled() { + return nil + } + return insecureFeatureError(ctx, feature) +} + +type restrictedFileManager struct { + daemon *Daemon +} + +func (m *restrictedFileManager) BasePath(name string) string { + if filepath.IsAbs(name) { + return name + } + currentDirectory, err := os.Getwd() + if err != nil { + return name + } + return filepath.Join(currentDirectory, name) +} + +func (m *restrictedFileManager) TempPath() string { + currentDirectory, err := os.Getwd() + if err != nil { + return "." + } + return currentDirectory +} + +func (m *restrictedFileManager) checkPath(name string) (string, error) { + path, err := filepath.Abs(m.BasePath(name)) + if err != nil { + return "", err + } + if m.daemon.insecureModeEnabled() { + return path, nil + } + currentDirectory, err := os.Getwd() + if err != nil { + return "", err + } + normalizedRoot := normalizeRestrictedPath(currentDirectory) + normalizedPath := normalizeRestrictedPath(path) + if normalizedPath != normalizedRoot && !strings.HasPrefix(normalizedPath, normalizedRoot+string(filepath.Separator)) { + return "", E.New(fmt.Sprintf(locale.Current().ExternalPathFeature, path, insecureModePlatformName())) + } + existingPath := path + for { + _, err = os.Lstat(existingPath) + if err == nil { + break + } + if !os.IsNotExist(err) { + return "", err + } + parentPath := filepath.Dir(existingPath) + if parentPath == existingPath { + return "", err + } + existingPath = parentPath + } + resolvedRoot, err := filepath.EvalSymlinks(currentDirectory) + if err != nil { + return "", err + } + resolvedExistingPath, err := filepath.EvalSymlinks(existingPath) + if err != nil { + return "", err + } + remainingPath, err := filepath.Rel(existingPath, path) + if err != nil { + return "", err + } + resolvedPath := filepath.Join(resolvedExistingPath, remainingPath) + normalizedResolvedRoot := normalizeRestrictedPath(resolvedRoot) + normalizedResolvedPath := normalizeRestrictedPath(resolvedPath) + if normalizedResolvedPath != normalizedResolvedRoot && !strings.HasPrefix(normalizedResolvedPath, normalizedResolvedRoot+string(filepath.Separator)) { + return "", E.New(fmt.Sprintf(locale.Current().ExternalPathFeature, path, insecureModePlatformName())) + } + return path, nil +} + +func (m *restrictedFileManager) OpenFile(name string, flag int, perm os.FileMode) (*os.File, error) { + path, err := m.checkPath(name) + if err != nil { + return nil, err + } + return os.OpenFile(path, flag, perm) +} + +func (m *restrictedFileManager) Create(name string) (*os.File, error) { + path, err := m.checkPath(name) + if err != nil { + return nil, err + } + return os.Create(path) +} + +func (m *restrictedFileManager) CreateTemp(pattern string) (*os.File, error) { + currentDirectory, err := os.Getwd() + if err != nil { + return nil, err + } + return os.CreateTemp(currentDirectory, pattern) +} + +func (m *restrictedFileManager) Chown(path string) error { + return nil +} + +func (m *restrictedFileManager) Mkdir(path string, perm os.FileMode) error { + checkedPath, err := m.checkPath(path) + if err != nil { + return err + } + return os.Mkdir(checkedPath, perm) +} + +func (m *restrictedFileManager) MkdirAll(path string, perm os.FileMode) error { + checkedPath, err := m.checkPath(path) + if err != nil { + return err + } + return os.MkdirAll(checkedPath, perm) +} + +func (m *restrictedFileManager) Remove(path string) error { + checkedPath, err := m.checkPath(path) + if err != nil { + return err + } + return os.Remove(checkedPath) +} + +func (m *restrictedFileManager) RemoveAll(path string) error { + checkedPath, err := m.checkPath(path) + if err != nil { + return err + } + return os.RemoveAll(checkedPath) +} + +func (m *restrictedFileManager) Rename(oldPath string, newPath string) error { + checkedOldPath, err := m.checkPath(oldPath) + if err != nil { + return err + } + checkedNewPath, err := m.checkPath(newPath) + if err != nil { + return err + } + return os.Rename(checkedOldPath, checkedNewPath) +} diff --git a/experimental/boxdd/insecure_mode_windows.go b/experimental/boxdd/insecure_mode_windows.go new file mode 100644 index 0000000000..40de43c2e6 --- /dev/null +++ b/experimental/boxdd/insecure_mode_windows.go @@ -0,0 +1,47 @@ +//go:build windows + +package main + +import ( + "context" + "os" + "path/filepath" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/service" + "github.com/sagernet/sing/service/filemanager" +) + +func registerSecurityPolicy(ctx context.Context, daemon *Daemon) { + service.MustRegister[adapter.SecurityPolicy](ctx, &daemonSecurityPolicy{daemon}) + service.MustRegister[filemanager.Manager](ctx, &restrictedFileManager{daemon}) +} + +func insecureModeAvailable() bool { + return true +} + +func insecureModePlatformName() string { + return "Windows" +} + +func loadSecuritySettings(directory string) (securitySettings, error) { + content, err := os.ReadFile(filepath.Join(directory, securitySettingsFileName)) + if err != nil { + return securitySettings{}, err + } + settings, err := json.UnmarshalExtended[securitySettings](content) + if err != nil { + return securitySettings{}, err + } + return settings, nil +} + +func (d *Daemon) insecureModeEnabled() bool { + settings, err := loadSecuritySettings(workingDirectory) + if err != nil { + return false + } + return settings.InsecureModeEnabled +} diff --git a/experimental/boxdd/main.go b/experimental/boxdd/main.go new file mode 100644 index 0000000000..7f79209f15 --- /dev/null +++ b/experimental/boxdd/main.go @@ -0,0 +1,46 @@ +package main + +import ( + "context" + "fmt" + "os" + "time" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/daemon" + "github.com/sagernet/sing-box/log" + + "github.com/spf13/cobra" +) + +const serviceName = "sing-box-daemon" + +var mainCommand = &cobra.Command{ + Use: serviceName, + Version: C.Version, +} + +var commandVersion = &cobra.Command{ + Use: "version", + Short: "Print the daemon version", + Args: cobra.NoArgs, + Run: func(command *cobra.Command, args []string) { + fmt.Println("sing-box-daemon version", C.Version) + fmt.Println("core api version", daemon.APIVersion) + }, +} + +func init() { + mainCommand.AddCommand(commandVersion) +} + +func main() { + log.SetStdLogger(log.NewDefaultFactory(context.Background(), log.Formatter{ + BaseTime: time.Now(), + DisableColors: true, + }, os.Stderr, "", nil, false).Logger()) + err := mainCommand.Execute() + if err != nil { + log.Fatal(err) + } +} diff --git a/experimental/boxdd/managed.go b/experimental/boxdd/managed.go new file mode 100644 index 0000000000..608decf27f --- /dev/null +++ b/experimental/boxdd/managed.go @@ -0,0 +1,93 @@ +package main + +import ( + "context" + "os" + + "github.com/sagernet/sing-box/daemon" + E "github.com/sagernet/sing/common/exceptions" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +var _ daemon.ManagedHandler = (*managedHandler)(nil) + +type managedHandler struct { + daemon *Daemon +} + +func (h *managedHandler) ServiceStop() error { + if h.daemon.closed { + return os.ErrClosed + } + ownerUserID, err := loadOwner() + if err != nil { + return err + } + return h.daemon.stopServiceLocked(ownerUserID) +} + +func (h *managedHandler) ServiceReload(ctx context.Context) error { + if h.daemon.closed { + return os.ErrClosed + } + ownerUserID, err := loadOwner() + if err != nil { + return err + } + configContent, err := loadServiceConfig(ownerUserID) + if err != nil { + return err + } + options, err := loadStartOptions(ownerUserID) + if err != nil { + return err + } + err = h.daemon.startServiceLocked(ctx, ownerUserID, configContent, options) + if err != nil { + return err + } + options.WasRunning = true + return saveStartOptions(ownerUserID, options) +} + +func (h *managedHandler) SystemProxyStatus() (*daemon.SystemProxyStatus, error) { + if h.daemon.platform == nil { + return &daemon.SystemProxyStatus{}, nil + } + return h.daemon.platform.SystemProxyStatus() +} + +func (h *managedHandler) SetSystemProxyEnabled(enabled bool) error { + if h.daemon.platform == nil { + if !enabled { + return nil + } + return status.Error(codes.FailedPrecondition, "the system proxy is not available") + } + ownerUserID, err := loadOwner() + if err != nil { + return err + } + options, err := loadStartOptions(ownerUserID) + if err != nil { + return err + } + previousEnabled := options.systemProxyEnabled() + err = h.daemon.platform.SetSystemProxyEnabled(enabled) + if err != nil { + return err + } + options.SystemProxyEnabled = &enabled + err = saveStartOptions(ownerUserID, options) + if err != nil { + rollbackError := h.daemon.platform.SetSystemProxyEnabled(previousEnabled) + return E.Errors(err, rollbackError) + } + return nil +} + +func (h *managedHandler) TriggerNativeCrash() error { + return E.New("native crash is not supported") +} diff --git a/experimental/boxdd/oom_report.go b/experimental/boxdd/oom_report.go new file mode 100644 index 0000000000..e976cee734 --- /dev/null +++ b/experimental/boxdd/oom_report.go @@ -0,0 +1,152 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "slices" + "sort" + "strings" + + "google.golang.org/protobuf/types/known/emptypb" +) + +const oomReportsDirectoryName = "oom_reports" + +// File order and the profile classification follow the client convention: +// sing-box-for-apple Library/Shared/OOMReportManager.swift (availableFiles) +// and Library/Shared/OOMReportArchive.swift (profileFiles). +var oomReportLeadingFileOrder = []string{metadataFileName, configSnapshotFileName, goLogFileName} + +func (s *desktopService) ListOOMReports(ctx context.Context, empty *emptypb.Empty) (*OOMReportList, error) { + reportsDirectory, userID, err := s.daemon.reportCaller(ctx, oomReportsDirectoryName) + if err != nil { + return nil, err + } + entries, err := os.ReadDir(reportsDirectory) + if err != nil { + if os.IsNotExist(err) { + return &OOMReportList{}, nil + } + return nil, err + } + reports := make([]*OOMReportEntry, 0, len(entries)) + for _, entry := range entries { + if !entry.IsDir() { + continue + } + fullPath := filepath.Join(reportsDirectory, entry.Name()) + if !reportOwnedBy(fullPath, userID) { + continue + } + reports = append(reports, &OOMReportEntry{ + Name: entry.Name(), + RecordedAt: reportTime(fullPath, "recordedAt").UnixMilli(), + IsRead: reportIsRead(fullPath), + }) + } + sort.Slice(reports, func(i, j int) bool { + return reports[i].RecordedAt > reports[j].RecordedAt + }) + return &OOMReportList{Reports: reports}, nil +} + +func (s *desktopService) ReadOOMReport(ctx context.Context, request *OOMReportRequest) (*OOMReportContent, error) { + reportsDirectory, userID, err := s.daemon.reportCaller(ctx, oomReportsDirectoryName) + if err != nil { + return nil, err + } + fullPath, err := reportPathForUser(reportsDirectory, request.Name, userID) + if err != nil { + return nil, err + } + files := make([]*OOMReportFile, 0, len(oomReportLeadingFileOrder)) + for _, fileName := range oomReportLeadingFileOrder { + content, readError := os.ReadFile(filepath.Join(fullPath, fileName)) + if readError != nil { + if os.IsNotExist(readError) { + continue + } + return nil, readError + } + files = append(files, &OOMReportFile{ + Name: fileName, + Content: content, + }) + } + entries, err := os.ReadDir(fullPath) + if err != nil { + return nil, err + } + profileNames := make([]string, 0, len(entries)) + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || strings.HasPrefix(name, ".") { + continue + } + if slices.Contains(oomReportLeadingFileOrder, name) { + continue + } + profileNames = append(profileNames, name) + } + sort.Strings(profileNames) + for _, name := range profileNames { + files = append(files, &OOMReportFile{ + Name: name, + IsProfile: true, + }) + } + return &OOMReportContent{Files: files}, nil +} + +func (s *desktopService) MarkOOMReportRead(ctx context.Context, request *OOMReportRequest) (*emptypb.Empty, error) { + reportsDirectory, userID, err := s.daemon.reportCaller(ctx, oomReportsDirectoryName) + if err != nil { + return nil, err + } + fullPath, err := reportPathForUser(reportsDirectory, request.Name, userID) + if err != nil { + return nil, err + } + err = os.WriteFile(filepath.Join(fullPath, readMarkerFileName), nil, 0o600) + if err != nil { + return nil, err + } + return &emptypb.Empty{}, nil +} + +func (s *desktopService) ExportOOMReport(ctx context.Context, request *OOMReportExportRequest) (*CrashReportArchive, error) { + reportsDirectory, userID, err := s.daemon.reportCaller(ctx, oomReportsDirectoryName) + if err != nil { + return nil, err + } + return exportReportArchive(reportsDirectory, request.Name, userID, request.WithConfiguration, request.WithLog, request.Encrypt) +} + +func (s *desktopService) DeleteOOMReport(ctx context.Context, request *OOMReportRequest) (*emptypb.Empty, error) { + reportsDirectory, userID, err := s.daemon.reportCaller(ctx, oomReportsDirectoryName) + if err != nil { + return nil, err + } + fullPath, err := reportPathForUser(reportsDirectory, request.Name, userID) + if err != nil { + return nil, err + } + err = os.RemoveAll(fullPath) + if err != nil { + return nil, err + } + return &emptypb.Empty{}, nil +} + +func (s *desktopService) DeleteAllOOMReports(ctx context.Context, empty *emptypb.Empty) (*emptypb.Empty, error) { + reportsDirectory, userID, err := s.daemon.reportCaller(ctx, oomReportsDirectoryName) + if err != nil { + return nil, err + } + err = deleteReportsForUser(reportsDirectory, userID) + if err != nil { + return nil, err + } + return &emptypb.Empty{}, nil +} diff --git a/experimental/boxdd/peer.go b/experimental/boxdd/peer.go new file mode 100644 index 0000000000..cae63b9cb8 --- /dev/null +++ b/experimental/boxdd/peer.go @@ -0,0 +1,62 @@ +//nolint:unused +package main + +import ( + "context" + "net" + + E "github.com/sagernet/sing/common/exceptions" + + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/peer" +) + +type peerIdentity struct { + UserID string + ProcessID uint32 + ProcessStartTime uint64 + SessionID uint32 +} + +type peerAuthInfo struct { + credentials.CommonAuthInfo + identity peerIdentity +} + +func (i *peerAuthInfo) AuthType() string { + return "local-process" +} + +func peerIdentityFromContext(ctx context.Context) (peerIdentity, error) { + peerInfo, loaded := peer.FromContext(ctx) + if !loaded || peerInfo.AuthInfo == nil { + return platformFallbackPeerIdentity(ctx) + } + authInfo, loaded := peerInfo.AuthInfo.(*peerAuthInfo) + if !loaded { + return peerIdentity{}, E.New("unexpected peer authentication type") + } + return authInfo.identity, nil +} + +var _ credentials.AuthInfo = (*peerAuthInfo)(nil) + +type peerConnection interface { + net.Conn + peerConnectionIdentity() peerIdentity +} + +func (d *Daemon) registerPeerConnection(connection peerConnection) { + d.peerAccess.Lock() + defer d.peerAccess.Unlock() + if d.peerConnections == nil { + d.peerConnections = make(map[peerConnection]peerIdentity) + } + d.peerConnections[connection] = connection.peerConnectionIdentity() +} + +func (d *Daemon) unregisterPeerConnection(connection peerConnection) { + d.peerAccess.Lock() + defer d.peerAccess.Unlock() + delete(d.peerConnections, connection) +} diff --git a/experimental/boxdd/peer_linux.go b/experimental/boxdd/peer_linux.go new file mode 100644 index 0000000000..10e3822f02 --- /dev/null +++ b/experimental/boxdd/peer_linux.go @@ -0,0 +1,155 @@ +//go:build linux + +package main + +import ( + "bytes" + "context" + "net" + "os" + "strconv" + "strings" + "sync" + "syscall" + + E "github.com/sagernet/sing/common/exceptions" + + "golang.org/x/sys/unix" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" +) + +type linuxTransportCredentials struct { + daemon *Daemon +} + +type linuxAuthenticatedConnection struct { + net.Conn + daemon *Daemon + identity peerIdentity + close sync.Once + closeError error +} + +func platformServerOptions(daemon *Daemon) ([]grpc.ServerOption, error) { + if listenAddress != "" { + return nil, nil + } + return []grpc.ServerOption{grpc.Creds(&linuxTransportCredentials{daemon: daemon})}, nil +} + +func platformFallbackPeerIdentity(ctx context.Context) (peerIdentity, error) { + if listenAddress != "" { + return peerIdentity{UserID: "local"}, nil + } + return peerIdentity{}, E.New("missing Linux peer authentication") +} + +func (c *linuxTransportCredentials) ClientHandshake(ctx context.Context, authority string, rawConnection net.Conn) (net.Conn, credentials.AuthInfo, error) { + return nil, nil, E.New("Linux local process credentials do not support client handshakes") +} + +func (c *linuxTransportCredentials) ServerHandshake(rawConnection net.Conn) (net.Conn, credentials.AuthInfo, error) { + identity, err := linuxPeerIdentity(rawConnection) + if err != nil { + return nil, nil, err + } + connection := &linuxAuthenticatedConnection{ + Conn: rawConnection, + daemon: c.daemon, + identity: identity, + } + c.daemon.registerPeerConnection(connection) + authenticationInformation := &peerAuthInfo{ + CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.PrivacyAndIntegrity}, + identity: identity, + } + return connection, authenticationInformation, nil +} + +func (c *linuxTransportCredentials) Info() credentials.ProtocolInfo { + return credentials.ProtocolInfo{ + SecurityProtocol: "linux-local-process", + SecurityVersion: "1", + } +} + +func (c *linuxTransportCredentials) Clone() credentials.TransportCredentials { + return &linuxTransportCredentials{daemon: c.daemon} +} + +func (c *linuxTransportCredentials) OverrideServerName(serverNameOverride string) error { + return nil +} + +func linuxPeerIdentity(connection net.Conn) (peerIdentity, error) { + syscallConnection, loaded := connection.(syscall.Conn) + if !loaded { + return peerIdentity{}, E.New("daemon endpoint does not expose a syscall connection") + } + rawConnection, err := syscallConnection.SyscallConn() + if err != nil { + return peerIdentity{}, E.Cause(err, "access daemon endpoint") + } + var peerCredentials *unix.Ucred + var credentialError error + err = rawConnection.Control(func(fileDescriptor uintptr) { + peerCredentials, credentialError = unix.GetsockoptUcred(int(fileDescriptor), unix.SOL_SOCKET, unix.SO_PEERCRED) + }) + if err != nil { + return peerIdentity{}, E.Cause(err, "inspect daemon endpoint") + } + if credentialError != nil { + return peerIdentity{}, E.Cause(credentialError, "identify daemon peer") + } + if peerCredentials == nil || peerCredentials.Pid <= 0 { + return peerIdentity{}, E.New("daemon peer has invalid credentials") + } + processID := uint32(peerCredentials.Pid) + processStartTime, err := linuxProcessStartTime(processID) + if err != nil { + return peerIdentity{}, E.Cause(err, "identify daemon peer process") + } + return peerIdentity{ + UserID: strconv.FormatUint(uint64(peerCredentials.Uid), 10), + ProcessID: processID, + ProcessStartTime: processStartTime, + }, nil +} + +func linuxProcessStartTime(processID uint32) (uint64, error) { + content, err := os.ReadFile("/proc/" + strconv.FormatUint(uint64(processID), 10) + "/stat") + if err != nil { + return 0, err + } + commandEnd := bytes.LastIndexByte(content, ')') + if commandEnd < 0 { + return 0, E.New("invalid process stat") + } + fields := strings.Fields(string(content[commandEnd+1:])) + if len(fields) <= 19 { + return 0, E.New("incomplete process stat") + } + startTime, err := strconv.ParseUint(fields[19], 10, 64) + if err != nil { + return 0, E.Cause(err, "parse process start time") + } + return startTime, nil +} + +func (c *linuxAuthenticatedConnection) peerConnectionIdentity() peerIdentity { + return c.identity +} + +func (c *linuxAuthenticatedConnection) Close() error { + c.close.Do(func() { + c.daemon.unregisterPeerConnection(c) + c.closeError = c.Conn.Close() + }) + return c.closeError +} + +var ( + _ credentials.TransportCredentials = (*linuxTransportCredentials)(nil) + _ peerConnection = (*linuxAuthenticatedConnection)(nil) +) diff --git a/experimental/boxdd/peer_linux_test.go b/experimental/boxdd/peer_linux_test.go new file mode 100644 index 0000000000..674f9ac28f --- /dev/null +++ b/experimental/boxdd/peer_linux_test.go @@ -0,0 +1,62 @@ +//go:build linux + +package main + +import ( + "net" + "os" + "path/filepath" + "strconv" + "testing" +) + +func TestLinuxPeerAuthentication(t *testing.T) { + socketPath := filepath.Join(t.TempDir(), "daemon.sock") + listener, err := net.Listen("unix", socketPath) + if err != nil { + t.Fatal(err) + } + defer listener.Close() + clientConnection, err := net.Dial("unix", socketPath) + if err != nil { + t.Fatal(err) + } + defer clientConnection.Close() + serverConnection, err := listener.Accept() + if err != nil { + t.Fatal(err) + } + daemon := &Daemon{} + authenticatedConnection, authenticationInformation, err := (&linuxTransportCredentials{daemon: daemon}).ServerHandshake(serverConnection) + if err != nil { + t.Fatal(err) + } + authentication, loaded := authenticationInformation.(*peerAuthInfo) + if !loaded { + t.Fatal("missing peer authentication information") + } + identity := authentication.identity + if identity.UserID != strconv.Itoa(os.Getuid()) { + t.Fatalf("unexpected peer user ID: %s", identity.UserID) + } + if identity.ProcessID != uint32(os.Getpid()) { + t.Fatalf("unexpected peer process ID: %d", identity.ProcessID) + } + expectedStartTime, err := linuxProcessStartTime(identity.ProcessID) + if err != nil { + t.Fatal(err) + } + if identity.ProcessStartTime != expectedStartTime { + t.Fatalf("unexpected peer process start time: %d", identity.ProcessStartTime) + } + if len(daemon.peerConnections) != 1 { + t.Fatalf("unexpected authenticated connection count: %d", len(daemon.peerConnections)) + } + err = authenticatedConnection.Close() + if err != nil { + t.Fatal(err) + } + if len(daemon.peerConnections) != 0 { + t.Fatalf("authenticated connection was not removed: %d", len(daemon.peerConnections)) + } +} diff --git a/experimental/boxdd/peer_stub.go b/experimental/boxdd/peer_stub.go new file mode 100644 index 0000000000..2d6deca941 --- /dev/null +++ b/experimental/boxdd/peer_stub.go @@ -0,0 +1,17 @@ +//go:build !windows && !linux + +package main + +import ( + "context" + + "google.golang.org/grpc" +) + +func platformServerOptions(daemon *Daemon) ([]grpc.ServerOption, error) { + return nil, nil +} + +func platformFallbackPeerIdentity(ctx context.Context) (peerIdentity, error) { + return peerIdentity{UserID: "local"}, nil +} diff --git a/experimental/boxdd/peer_windows.go b/experimental/boxdd/peer_windows.go new file mode 100644 index 0000000000..345e2b559f --- /dev/null +++ b/experimental/boxdd/peer_windows.go @@ -0,0 +1,567 @@ +//go:build windows + +package main + +import ( + "bytes" + "context" + "errors" + "net" + "os" + "strconv" + "strings" + "sync" + "unsafe" + + E "github.com/sagernet/sing/common/exceptions" + + winioProcess "github.com/tailscale/go-winio/pkg/process" + "golang.org/x/sys/windows" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" +) + +const ( + daemonExecutableName = "sing-box-daemon.exe" + applicationExecutableName = "sing-box.exe" + workerPipePrefix = `\\.\pipe\sing-box-worker.` +) + +type windowsTransportCredentials struct { + daemon *Daemon + daemonSigner []byte + daemonExecutable windows.Handle + expectedWorkerPath string + expectedApplicationPath string +} + +type windowsAuthenticatedConnection struct { + net.Conn + daemon *Daemon + identity peerIdentity + process windows.Handle + processImage windows.Handle + parentProcess windows.Handle + parentProcessImage windows.Handle + close sync.Once + closeError error +} + +type fileDescriptorConnection interface { + Fd() uintptr +} + +func platformServerOptions(daemon *Daemon) ([]grpc.ServerOption, error) { + if listenAddress != "" { + return nil, nil + } + transportCredentials := &windowsTransportCredentials{daemon: daemon} + err := transportCredentials.initializeServerIdentity() + if err != nil { + return nil, err + } + daemon.platform.(*windowsPlatformInterface).daemonSigner = transportCredentials.daemonSigner + return []grpc.ServerOption{grpc.Creds(transportCredentials)}, nil +} + +func platformFallbackPeerIdentity(ctx context.Context) (peerIdentity, error) { + return peerIdentity{}, E.New("missing Windows peer authentication") +} + +func (c *windowsTransportCredentials) ClientHandshake(ctx context.Context, authority string, rawConnection net.Conn) (net.Conn, credentials.AuthInfo, error) { + return nil, nil, E.New("Windows local process credentials do not support client handshakes") +} + +func (c *windowsTransportCredentials) ServerHandshake(rawConnection net.Conn) (net.Conn, credentials.AuthInfo, error) { + connection, authenticationInformation, err := c.serverHandshake(rawConnection) + if err != nil { + serviceLogError(E.Cause(err, "reject Windows daemon connection")) + } + return connection, authenticationInformation, err +} + +func (c *windowsTransportCredentials) serverHandshake(rawConnection net.Conn) (net.Conn, credentials.AuthInfo, error) { + descriptorConnection, loaded := rawConnection.(fileDescriptorConnection) + if !loaded { + return nil, nil, E.New("daemon endpoint is not a Windows named pipe") + } + var processID uint32 + err := windows.GetNamedPipeClientProcessId(windows.Handle(descriptorConnection.Fd()), &processID) + if err != nil { + return nil, nil, E.Cause(err, "identify named pipe client") + } + process, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.SYNCHRONIZE, false, processID) + if err != nil { + return nil, nil, E.Cause(err, "open named pipe client process") + } + keepProcess := false + defer func() { + if !keepProcess { + windows.CloseHandle(process) + } + }() + identity, err := processIdentity(process, processID) + if err != nil { + return nil, nil, err + } + workerImagePath, err := winioProcess.QueryFullProcessImageName(process, winioProcess.ImageNameFormatWin32Path) + if err != nil { + return nil, nil, E.Cause(err, "query named pipe client executable") + } + processImage, err := openLockedExecutable(workerImagePath) + if err != nil { + return nil, nil, E.Cause(err, "open named pipe client executable") + } + keepProcessImage := false + defer func() { + if !keepProcessImage { + windows.CloseHandle(processImage) + } + }() + processImageFinalPath, err := finalWindowsPath(processImage) + if err != nil { + return nil, nil, E.Cause(err, "resolve named pipe client executable") + } + if !strings.EqualFold(processImageFinalPath, c.expectedWorkerPath) { + return nil, nil, E.New("named pipe client is not the installed sing-box worker") + } + sameExecutable, err := sameWindowsFile(processImage, c.daemonExecutable) + if err != nil { + return nil, nil, err + } + if !sameExecutable { + return nil, nil, E.New("named pipe client worker executable was replaced") + } + workerSigner, err := authenticodeSigner(processImageFinalPath, processImage) + if err != nil { + return nil, nil, E.Cause(err, "authenticate sing-box worker") + } + if !bytes.Equal(workerSigner, c.daemonSigner) { + return nil, nil, E.New("sing-box worker and daemon have different signing certificates") + } + parentProcessID, err := processParentID(process) + if err != nil { + return nil, nil, err + } + err = validateWorkerProcessRole(process, parentProcessID) + if err != nil { + return nil, nil, err + } + parentProcess, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.SYNCHRONIZE, false, parentProcessID) + if err != nil { + return nil, nil, E.Cause(err, "open sing-box worker parent process") + } + keepParentProcess := false + defer func() { + if !keepParentProcess { + windows.CloseHandle(parentProcess) + } + }() + parentIdentity, err := processIdentity(parentProcess, parentProcessID) + if err != nil { + return nil, nil, err + } + if parentIdentity.UserID != identity.UserID || parentIdentity.SessionID != identity.SessionID { + return nil, nil, E.New("sing-box worker and application have different process identities") + } + parentCreationTime, err := processCreationTime(parentProcess) + if err != nil { + return nil, nil, err + } + workerCreationTime, err := processCreationTime(process) + if err != nil { + return nil, nil, err + } + if parentCreationTime >= workerCreationTime { + return nil, nil, E.New("sing-box worker parent was created after the worker") + } + parentImagePath, err := winioProcess.QueryFullProcessImageName(parentProcess, winioProcess.ImageNameFormatWin32Path) + if err != nil { + return nil, nil, E.Cause(err, "query sing-box worker parent executable") + } + parentProcessImage, err := openLockedExecutable(parentImagePath) + if err != nil { + return nil, nil, E.Cause(err, "open sing-box worker parent executable") + } + keepParentProcessImage := false + defer func() { + if !keepParentProcessImage { + windows.CloseHandle(parentProcessImage) + } + }() + expectedApplication, err := openLockedExecutable(c.expectedApplicationPath) + if err != nil { + return nil, nil, E.Cause(err, "open installed application executable") + } + defer windows.CloseHandle(expectedApplication) + parentImageFinalPath, err := finalWindowsPath(parentProcessImage) + if err != nil { + return nil, nil, E.Cause(err, "resolve sing-box worker parent executable") + } + expectedApplicationFinalPath, err := finalWindowsPath(expectedApplication) + if err != nil { + return nil, nil, E.Cause(err, "resolve installed application executable") + } + if !strings.EqualFold(parentImageFinalPath, expectedApplicationFinalPath) { + return nil, nil, E.New("sing-box worker parent is not the installed application") + } + sameApplication, err := sameWindowsFile(parentProcessImage, expectedApplication) + if err != nil { + return nil, nil, err + } + if !sameApplication { + return nil, nil, E.New("sing-box worker parent executable was replaced") + } + err = validateApplicationProcessRole(parentProcess, expectedApplication) + if err != nil { + return nil, nil, err + } + applicationSigner, err := authenticodeSigner(parentImageFinalPath, parentProcessImage) + if err != nil { + return nil, nil, E.Cause(err, "authenticate sing-box application") + } + if !bytes.Equal(applicationSigner, c.daemonSigner) { + return nil, nil, E.New("sing-box application and daemon have different signing certificates") + } + workerWaitResult, err := windows.WaitForSingleObject(process, 0) + if err != nil { + return nil, nil, err + } + parentWaitResult, err := windows.WaitForSingleObject(parentProcess, 0) + if err != nil { + return nil, nil, err + } + if workerWaitResult != uint32(windows.WAIT_TIMEOUT) || parentWaitResult != uint32(windows.WAIT_TIMEOUT) { + return nil, nil, E.New("sing-box worker or application exited during authentication") + } + parentIdentity.ProcessID = parentProcessID + connection := &windowsAuthenticatedConnection{ + Conn: rawConnection, + daemon: c.daemon, + identity: parentIdentity, + process: process, + processImage: processImage, + parentProcess: parentProcess, + parentProcessImage: parentProcessImage, + } + keepProcess = true + keepProcessImage = true + keepParentProcess = true + keepParentProcessImage = true + c.daemon.registerPeerConnection(connection) + authenticationInformation := &peerAuthInfo{ + CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.PrivacyAndIntegrity}, + identity: parentIdentity, + } + return connection, authenticationInformation, nil +} + +func (c *windowsTransportCredentials) Info() credentials.ProtocolInfo { + return credentials.ProtocolInfo{ + SecurityProtocol: "windows-local-process", + SecurityVersion: "1", + } +} + +func (c *windowsTransportCredentials) Clone() credentials.TransportCredentials { + return &windowsTransportCredentials{ + daemon: c.daemon, + daemonSigner: c.daemonSigner, + daemonExecutable: c.daemonExecutable, + expectedWorkerPath: c.expectedWorkerPath, + expectedApplicationPath: c.expectedApplicationPath, + } +} + +func (c *windowsTransportCredentials) OverrideServerName(serverNameOverride string) error { + return nil +} + +func (c *windowsTransportCredentials) initializeServerIdentity() error { + executablePath, err := os.Executable() + if err != nil { + return E.Cause(err, "locate daemon executable") + } + executable, err := openLockedExecutable(executablePath) + if err != nil { + return E.Cause(err, "open daemon executable") + } + keepExecutable := false + defer func() { + if !keepExecutable { + windows.CloseHandle(executable) + } + }() + finalPath, err := finalWindowsPath(executable) + if err != nil { + return E.Cause(err, "resolve daemon executable") + } + _, applicationPath, err := installedApplicationPath(finalPath) + if err != nil { + return err + } + c.daemonExecutable = executable + c.expectedWorkerPath = finalPath + c.expectedApplicationPath = applicationPath + c.daemonSigner, err = authenticodeSigner(finalPath, executable) + if err != nil { + return E.Cause(err, "authenticate daemon executable") + } + keepExecutable = true + return nil +} + +func processIdentity(process windows.Handle, processID uint32) (peerIdentity, error) { + var token windows.Token + err := windows.OpenProcessToken(process, windows.TOKEN_QUERY, &token) + if err != nil { + return peerIdentity{}, E.Cause(err, "open named pipe client token") + } + defer token.Close() + user, err := token.GetTokenUser() + if err != nil { + return peerIdentity{}, E.Cause(err, "query named pipe client user") + } + userID := user.User.Sid.String() + if userID == "" { + return peerIdentity{}, E.New("named pipe client has an invalid user SID") + } + var sessionID uint32 + err = windows.ProcessIdToSessionId(processID, &sessionID) + if err != nil { + return peerIdentity{}, E.Cause(err, "query named pipe client session") + } + if sessionID == 0 { + return peerIdentity{}, E.New("named pipe client is not in an interactive session") + } + return peerIdentity{UserID: userID, ProcessID: processID, SessionID: sessionID}, nil +} + +func validateApplicationProcessRole(process windows.Handle, expectedApplication windows.Handle) error { + arguments, err := processCommandLine(process) + if err != nil { + return E.Cause(err, "query named pipe client command line") + } + for _, argument := range arguments[1:] { + normalizedArgument := strings.ToLower(argument) + if normalizedArgument == "--type" || strings.HasPrefix(normalizedArgument, "--type=") { + return E.New("named pipe client is an Electron child process") + } + } + applicationParent, err := processParentIsApplication(process, expectedApplication) + if err != nil { + return err + } + if applicationParent { + return E.New("named pipe client is a child of the sing-box application") + } + return nil +} + +func validateWorkerProcessRole(process windows.Handle, parentProcessID uint32) error { + arguments, err := processCommandLine(process) + if err != nil { + return E.Cause(err, "query sing-box worker command line") + } + if len(arguments) != 8 || + arguments[1] != "worker" || + arguments[2] != "--socket" || + arguments[4] != "--parent-pid" || + arguments[6] != "--daemon-relay-socket" { + return E.New("named pipe client is not a sing-box worker process") + } + if !strings.HasPrefix(strings.ToLower(arguments[3]), strings.ToLower(workerPipePrefix)) || + !strings.HasPrefix(strings.ToLower(arguments[7]), strings.ToLower(workerPipePrefix)) || + strings.EqualFold(arguments[3], arguments[7]) { + return E.New("sing-box worker has invalid private pipe paths") + } + commandParentProcessID, err := strconv.ParseUint(arguments[5], 10, 32) + if err != nil || uint32(commandParentProcessID) != parentProcessID { + return E.New("sing-box worker has an invalid parent process ID") + } + return nil +} + +func processCommandLine(process windows.Handle) ([]string, error) { + var bufferLength uint32 + queryError := windows.NtQueryInformationProcess( + process, + windows.ProcessCommandLineInformation, + nil, + 0, + &bufferLength, + ) + if bufferLength == 0 { + if queryError != nil { + return nil, queryError + } + return nil, E.New("named pipe client has an empty command line buffer") + } + buffer := make([]byte, bufferLength) + queryError = windows.NtQueryInformationProcess( + process, + windows.ProcessCommandLineInformation, + unsafe.Pointer(&buffer[0]), + uint32(len(buffer)), + &bufferLength, + ) + if queryError != nil { + return nil, queryError + } + commandLine := (*windows.NTUnicodeString)(unsafe.Pointer(&buffer[0])) + if commandLine.Buffer == nil || commandLine.Length == 0 || commandLine.Length%2 != 0 { + return nil, E.New("named pipe client has an invalid command line") + } + commandLineString := windows.UTF16ToString(unsafe.Slice(commandLine.Buffer, int(commandLine.Length/2))) + return windows.DecomposeCommandLine(commandLineString) +} + +func processParentIsApplication(process windows.Handle, expectedApplication windows.Handle) (bool, error) { + parentProcessID, err := processParentID(process) + if err != nil { + return false, err + } + if parentProcessID == 0 { + return false, nil + } + parentProcess, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, parentProcessID) + if err != nil { + if errors.Is(err, windows.ERROR_INVALID_PARAMETER) { + return false, nil + } + return false, E.Cause(err, "open named pipe client parent") + } + defer windows.CloseHandle(parentProcess) + parentImagePath, err := winioProcess.QueryFullProcessImageName(parentProcess, winioProcess.ImageNameFormatWin32Path) + if err != nil { + return false, E.Cause(err, "query named pipe client parent executable") + } + parentImage, err := openLockedExecutable(parentImagePath) + if err != nil { + return false, err + } + defer windows.CloseHandle(parentImage) + return sameWindowsFile(parentImage, expectedApplication) +} + +func processParentID(process windows.Handle) (uint32, error) { + var processInformation windows.PROCESS_BASIC_INFORMATION + processInformationLength := uint32(unsafe.Sizeof(processInformation)) + err := windows.NtQueryInformationProcess( + process, + windows.ProcessBasicInformation, + unsafe.Pointer(&processInformation), + processInformationLength, + &processInformationLength, + ) + if err != nil { + return 0, E.Cause(err, "query named pipe client parent") + } + parentProcessID := uint32(processInformation.InheritedFromUniqueProcessId) + if parentProcessID == 0 || uintptr(parentProcessID) != processInformation.InheritedFromUniqueProcessId { + return 0, nil + } + return parentProcessID, nil +} + +func openLockedExecutable(path string) (windows.Handle, error) { + pathPointer, err := windows.UTF16PtrFromString(path) + if err != nil { + return 0, err + } + return windows.CreateFile( + pathPointer, + windows.GENERIC_READ, + windows.FILE_SHARE_READ, + nil, + windows.OPEN_EXISTING, + windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_SEQUENTIAL_SCAN, + 0, + ) +} + +func finalWindowsPath(file windows.Handle) (string, error) { + buffer := make([]uint16, windows.MAX_LONG_PATH) + for { + length, err := windows.GetFinalPathNameByHandle(file, &buffer[0], uint32(len(buffer)), 0) + if err != nil { + return "", err + } + if length < uint32(len(buffer)) { + return normalizeWindowsPath(windows.UTF16ToString(buffer[:length])), nil + } + buffer = make([]uint16, length+1) + } +} + +func normalizeWindowsPath(path string) string { + if strings.HasPrefix(path, `\\?\UNC\`) { + return `\\` + path[len(`\\?\UNC\`):] + } + return strings.TrimPrefix(path, `\\?\`) +} + +func sameWindowsFile(first windows.Handle, second windows.Handle) (bool, error) { + var firstInformation windows.ByHandleFileInformation + err := windows.GetFileInformationByHandle(first, &firstInformation) + if err != nil { + return false, E.Cause(err, "query named pipe client executable identity") + } + var secondInformation windows.ByHandleFileInformation + err = windows.GetFileInformationByHandle(second, &secondInformation) + if err != nil { + return false, E.Cause(err, "query installed application executable identity") + } + return firstInformation.VolumeSerialNumber == secondInformation.VolumeSerialNumber && + firstInformation.FileIndexHigh == secondInformation.FileIndexHigh && + firstInformation.FileIndexLow == secondInformation.FileIndexLow, nil +} + +func (c *windowsAuthenticatedConnection) peerConnectionIdentity() peerIdentity { + return c.identity +} + +func (c *windowsAuthenticatedConnection) duplicateImpersonationToken() (windows.Token, error) { + var processToken windows.Token + err := windows.OpenProcessToken(c.parentProcess, windows.TOKEN_QUERY|windows.TOKEN_DUPLICATE, &processToken) + if err != nil { + return 0, E.Cause(err, "open application token") + } + defer processToken.Close() + return duplicateImpersonationToken(processToken) +} + +func (d *Daemon) duplicatePeerImpersonationToken(identity peerIdentity) (windows.Token, error) { + d.peerAccess.Lock() + defer d.peerAccess.Unlock() + for connection, connectionIdentity := range d.peerConnections { + if connectionIdentity != identity { + continue + } + windowsConnection, loaded := connection.(*windowsAuthenticatedConnection) + if !loaded { + continue + } + return windowsConnection.duplicateImpersonationToken() + } + return 0, E.New("authenticated application connection is no longer available") +} + +func (c *windowsAuthenticatedConnection) Close() error { + c.close.Do(func() { + c.daemon.unregisterPeerConnection(c) + c.closeError = E.Errors( + c.Conn.Close(), + windows.CloseHandle(c.parentProcessImage), + windows.CloseHandle(c.parentProcess), + windows.CloseHandle(c.processImage), + windows.CloseHandle(c.process), + ) + }) + return c.closeError +} + +var ( + _ credentials.TransportCredentials = (*windowsTransportCredentials)(nil) + _ peerConnection = (*windowsAuthenticatedConnection)(nil) +) diff --git a/experimental/boxdd/platform.go b/experimental/boxdd/platform.go new file mode 100644 index 0000000000..d7681420d2 --- /dev/null +++ b/experimental/boxdd/platform.go @@ -0,0 +1,26 @@ +package main + +import ( + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/daemon" +) + +type daemonPlatform interface { + adapter.PlatformInterface + PrepareOwner(identity peerIdentity) error + RestoreOwner(state ownerState) error + ReleaseOwner() error + ResetPlatformOptions() error + SetSystemProxyPreference(enabled bool) + SystemProxyStatus() (*daemon.SystemProxyStatus, error) + SetSystemProxyEnabled(enabled bool) error + HandleSessionChange(eventType uint32, sessionID uint32, state ownerState) (uint32, bool, error) + Close() error +} + +func (d *Daemon) preparePlatformOwnerLocked(identity peerIdentity) error { + if d.platform == nil { + return nil + } + return d.platform.PrepareOwner(identity) +} diff --git a/experimental/boxdd/platform_linux.go b/experimental/boxdd/platform_linux.go new file mode 100644 index 0000000000..77dfedcd56 --- /dev/null +++ b/experimental/boxdd/platform_linux.go @@ -0,0 +1,394 @@ +//go:build linux && !android + +package main + +import ( + "net/netip" + "os" + "os/exec" + "os/user" + "strconv" + "strings" + "sync" + "syscall" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/settings" + "github.com/sagernet/sing-box/daemon" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-tun" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + "github.com/sagernet/sing/common/shell" +) + +type linuxPlatformInterface struct { + daemon *Daemon + access sync.Mutex + ownerUser *user.User + systemProxy *settings.LinuxSystemProxy + systemProxyEnabled bool +} + +func newPlatformInterface(daemonInstance *Daemon) (daemonPlatform, error) { + return &linuxPlatformInterface{ + daemon: daemonInstance, + systemProxyEnabled: true, + }, nil +} + +func (p *linuxPlatformInterface) Initialize(networkManager adapter.NetworkManager) error { + return nil +} + +func (p *linuxPlatformInterface) UsePlatformAutoDetectInterfaceControl() bool { + return false +} + +func (p *linuxPlatformInterface) AutoDetectInterfaceControl(fd int) error { + return os.ErrInvalid +} + +func (p *linuxPlatformInterface) UsePlatformInterface() bool { + return false +} + +func (p *linuxPlatformInterface) OpenInterface(options *tun.Options, platformOptions option.TunPlatformOptions) (tun.Tun, error) { + return nil, os.ErrInvalid +} + +func (p *linuxPlatformInterface) ProcessPlatformOptions(options option.TunPlatformOptions) error { + if options.HTTPProxy == nil || !options.HTTPProxy.Enabled { + return nil + } + httpProxyOptions := options.HTTPProxy + systemProxy, err := settings.NewLinuxSystemProxy( + M.ParseSocksaddrHostPort(httpProxyOptions.Server, httpProxyOptions.ServerPort), + false, + p.executeAsOwner, + ) + if err != nil { + p.daemon.logger.Warn("initialize system proxy: ", err) + return nil + } + p.access.Lock() + if p.systemProxy != nil { + p.access.Unlock() + return E.New("only one enabled `tun.platform.http_proxy` is supported") + } + p.systemProxy = systemProxy + err = p.applySystemProxyLocked() + if err != nil { + rollbackError := p.disableSystemProxyLocked() + p.systemProxy = nil + p.access.Unlock() + return E.Errors(E.Cause(err, "set system proxy"), rollbackError) + } + p.access.Unlock() + return nil +} + +func (p *linuxPlatformInterface) UsePlatformDefaultInterfaceMonitor() bool { + return false +} + +func (p *linuxPlatformInterface) CreateDefaultInterfaceMonitor(logger logger.Logger) tun.DefaultInterfaceMonitor { + return nil +} + +func (p *linuxPlatformInterface) UsePlatformNetworkInterfaces() bool { + return false +} + +func (p *linuxPlatformInterface) NetworkInterfaces() ([]adapter.NetworkInterface, error) { + return nil, os.ErrInvalid +} + +func (p *linuxPlatformInterface) UnderNetworkExtension() bool { + return false +} + +func (p *linuxPlatformInterface) NetworkExtensionIncludeAllNetworks() bool { + return false +} + +func (p *linuxPlatformInterface) ClearDNSCache() { +} + +func (p *linuxPlatformInterface) RequestPermissionForWIFIState() error { + return nil +} + +func (p *linuxPlatformInterface) ReadWIFIState() adapter.WIFIState { + return adapter.WIFIState{} +} + +func (p *linuxPlatformInterface) UsePlatformConnectionOwnerFinder() bool { + return false +} + +func (p *linuxPlatformInterface) FindConnectionOwner(request *adapter.FindConnectionOwnerRequest) (*adapter.ConnectionOwner, error) { + return nil, os.ErrInvalid +} + +func (p *linuxPlatformInterface) UsePlatformWIFIMonitor() bool { + return false +} + +func (p *linuxPlatformInterface) UsePlatformNotification() bool { + return false +} + +func (p *linuxPlatformInterface) SendNotification(notification *adapter.Notification) error { + return nil +} + +func (p *linuxPlatformInterface) MyInterfaceAddress() []netip.Addr { + return nil +} + +func (p *linuxPlatformInterface) UsePlatformNeighborResolver() bool { + return false +} + +func (p *linuxPlatformInterface) StartNeighborMonitor(listener adapter.NeighborUpdateListener) error { + return os.ErrInvalid +} + +func (p *linuxPlatformInterface) CloseNeighborMonitor(listener adapter.NeighborUpdateListener) error { + return nil +} + +func (p *linuxPlatformInterface) UsePlatformShell() bool { + return false +} + +func (p *linuxPlatformInterface) CheckPlatformShell() error { + return nil +} + +func (p *linuxPlatformInterface) OpenShellSession(user *adapter.PlatformUser, command string, environ []string, term string, rows int32, cols int32) (adapter.ShellSession, error) { + return nil, os.ErrInvalid +} + +func (p *linuxPlatformInterface) LookupUser(username string) (*adapter.PlatformUser, error) { + return nil, os.ErrInvalid +} + +func (p *linuxPlatformInterface) LookupSFTPServer() (string, error) { + return "", os.ErrInvalid +} + +func (p *linuxPlatformInterface) ReadSystemSSHHostKey() ([]byte, error) { + return nil, os.ErrInvalid +} + +func (p *linuxPlatformInterface) TailscaleHostname() string { + return "" +} + +func (p *linuxPlatformInterface) UsePlatformBridge() bool { + return false +} + +func (p *linuxPlatformInterface) CreateBridge(options adapter.BridgeOptions) (adapter.BridgeSession, error) { + return nil, os.ErrInvalid +} + +func (p *linuxPlatformInterface) PrepareOwner(identity peerIdentity) error { + p.access.Lock() + defer p.access.Unlock() + if listenAddress != "" { + return p.applySystemProxyLocked() + } + if p.ownerUser != nil && p.ownerUser.Uid == identity.UserID { + return p.applySystemProxyLocked() + } + ownerUser, err := user.LookupId(identity.UserID) + if err != nil { + return E.Cause(err, "lookup owner user") + } + return p.replaceOwnerLocked(ownerUser) +} + +func (p *linuxPlatformInterface) RestoreOwner(state ownerState) error { + p.access.Lock() + defer p.access.Unlock() + if listenAddress != "" { + return nil + } + ownerUser, err := user.LookupId(state.UserID) + if err != nil { + return E.Cause(err, "lookup owner user") + } + p.ownerUser = ownerUser + return nil +} + +func (p *linuxPlatformInterface) ReleaseOwner() error { + p.access.Lock() + defer p.access.Unlock() + err := p.disableSystemProxyLocked() + if err != nil { + return err + } + p.ownerUser = nil + return nil +} + +func (p *linuxPlatformInterface) ResetPlatformOptions() error { + p.access.Lock() + defer p.access.Unlock() + err := p.disableSystemProxyLocked() + if err == nil { + p.systemProxy = nil + } + return err +} + +func (p *linuxPlatformInterface) SetSystemProxyPreference(enabled bool) { + p.access.Lock() + p.systemProxyEnabled = enabled + p.access.Unlock() +} + +func (p *linuxPlatformInterface) SystemProxyStatus() (*daemon.SystemProxyStatus, error) { + p.access.Lock() + defer p.access.Unlock() + available := p.systemProxy != nil + return &daemon.SystemProxyStatus{ + Available: available, + Enabled: available && p.systemProxyEnabled, + }, nil +} + +func (p *linuxPlatformInterface) SetSystemProxyEnabled(enabled bool) error { + p.access.Lock() + defer p.access.Unlock() + if p.systemProxy == nil { + if !enabled { + p.systemProxyEnabled = false + return nil + } + return E.New("the system proxy is not available") + } + previousEnabled := p.systemProxyEnabled + p.systemProxyEnabled = enabled + err := p.applySystemProxyLocked() + if err != nil { + p.systemProxyEnabled = previousEnabled + rollbackError := p.applySystemProxyLocked() + return E.Errors(err, rollbackError) + } + return nil +} + +func (p *linuxPlatformInterface) HandleSessionChange(eventType uint32, sessionID uint32, state ownerState) (uint32, bool, error) { + return 0, false, nil +} + +func (p *linuxPlatformInterface) Close() error { + p.access.Lock() + defer p.access.Unlock() + err := p.disableSystemProxyLocked() + p.systemProxy = nil + p.ownerUser = nil + return err +} + +func (p *linuxPlatformInterface) applySystemProxyLocked() error { + if p.systemProxy == nil { + return nil + } + if p.systemProxyEnabled { + if p.systemProxy.IsEnabled() || !p.ownerSessionActiveLocked() { + return nil + } + return p.systemProxy.Enable() + } + return p.disableSystemProxyLocked() +} + +func (p *linuxPlatformInterface) disableSystemProxyLocked() error { + if p.systemProxy == nil || !p.systemProxy.IsEnabled() || !p.ownerSessionActiveLocked() { + return nil + } + return p.systemProxy.Disable() +} + +func (p *linuxPlatformInterface) ownerSessionActiveLocked() bool { + if listenAddress != "" { + return true + } + if p.ownerUser == nil { + return false + } + _, err := os.Stat(ownerSessionBusPath(p.ownerUser)) + return err == nil +} + +func (p *linuxPlatformInterface) executeAsOwner(name string, args ...string) error { + if listenAddress != "" { + return shell.Exec(name, args...).Attach().Run() + } + ownerUser := p.ownerUser + if ownerUser == nil { + return E.New("missing owner session") + } + uid, err := strconv.ParseUint(ownerUser.Uid, 10, 32) + if err != nil { + return E.Cause(err, "parse owner user ID") + } + gid, err := strconv.ParseUint(ownerUser.Gid, 10, 32) + if err != nil { + return E.Cause(err, "parse owner group ID") + } + searchPath := os.Getenv("PATH") + if searchPath == "" { + searchPath = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + } + command := exec.Command(name, args...) + command.SysProcAttr = &syscall.SysProcAttr{ + Credential: &syscall.Credential{ + Uid: uint32(uid), + Gid: uint32(gid), + }, + } + command.Env = []string{ + "HOME=" + ownerUser.HomeDir, + "USER=" + ownerUser.Username, + "LOGNAME=" + ownerUser.Username, + "PATH=" + searchPath, + "XDG_RUNTIME_DIR=" + ownerRuntimeDirectory(ownerUser), + "DBUS_SESSION_BUS_ADDRESS=unix:path=" + ownerSessionBusPath(ownerUser), + } + output, err := command.CombinedOutput() + if err != nil { + outputText := strings.TrimSpace(string(output)) + if outputText != "" { + return E.Cause(err, "execute (", name, ") ", strings.Join(args, " "), ": ", outputText) + } + return E.Cause(err, "execute (", name, ") ", strings.Join(args, " ")) + } + return nil +} + +func (p *linuxPlatformInterface) replaceOwnerLocked(ownerUser *user.User) error { + err := p.disableSystemProxyLocked() + if err != nil { + return err + } + p.ownerUser = ownerUser + return p.applySystemProxyLocked() +} + +func ownerRuntimeDirectory(ownerUser *user.User) string { + return "/run/user/" + ownerUser.Uid +} + +func ownerSessionBusPath(ownerUser *user.User) string { + return ownerRuntimeDirectory(ownerUser) + "/bus" +} + +var _ daemonPlatform = (*linuxPlatformInterface)(nil) diff --git a/experimental/boxdd/platform_stub.go b/experimental/boxdd/platform_stub.go new file mode 100644 index 0000000000..8567678813 --- /dev/null +++ b/experimental/boxdd/platform_stub.go @@ -0,0 +1,7 @@ +//go:build !windows && !(linux && !android) + +package main + +func newPlatformInterface(daemon *Daemon) (daemonPlatform, error) { + return nil, nil +} diff --git a/experimental/boxdd/platform_windows.go b/experimental/boxdd/platform_windows.go new file mode 100644 index 0000000000..964198f989 --- /dev/null +++ b/experimental/boxdd/platform_windows.go @@ -0,0 +1,565 @@ +//go:build windows + +package main + +import ( + "context" + "io" + "net/netip" + "os" + "os/user" + "path/filepath" + "runtime" + "sync" + "syscall" + "unsafe" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/settings" + "github.com/sagernet/sing-box/daemon" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-tun" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + + "github.com/tailscale/go-winio" + "golang.org/x/sys/windows" +) + +var regDisablePredefinedCacheEx = windows.NewLazySystemDLL("advapi32.dll").NewProc("RegDisablePredefinedCacheEx") + +type windowsPlatformInterface struct { + daemon *Daemon + access sync.Mutex + updateAccess sync.Mutex + updateInProgress bool + daemonSigner []byte + ownerUserID string + sessionID uint32 + token windows.Token + systemProxy *settings.WindowsSystemProxy + systemProxyEnabled bool +} + +func newPlatformInterface(daemonInstance *Daemon) (daemonPlatform, error) { + result, _, _ := regDisablePredefinedCacheEx.Call() + if result != 0 { + return nil, E.Cause(syscall.Errno(result), "disable predefined registry handle cache") + } + return &windowsPlatformInterface{ + daemon: daemonInstance, + systemProxyEnabled: true, + }, nil +} + +func (p *windowsPlatformInterface) Initialize(networkManager adapter.NetworkManager) error { + return nil +} + +func (p *windowsPlatformInterface) UsePlatformAutoDetectInterfaceControl() bool { + return false +} + +func (p *windowsPlatformInterface) AutoDetectInterfaceControl(fd int) error { + return os.ErrInvalid +} + +func (p *windowsPlatformInterface) UsePlatformInterface() bool { + return false +} + +func (p *windowsPlatformInterface) OpenInterface(options *tun.Options, platformOptions option.TunPlatformOptions) (tun.Tun, error) { + return nil, os.ErrInvalid +} + +func (p *windowsPlatformInterface) ProcessPlatformOptions(options option.TunPlatformOptions) error { + if options.HTTPProxy == nil || !options.HTTPProxy.Enabled { + return nil + } + httpProxyOptions := options.HTTPProxy + systemProxy, err := settings.NewSystemProxy( + context.Background(), + M.ParseSocksaddrHostPort(httpProxyOptions.Server, httpProxyOptions.ServerPort), + false, + []string(httpProxyOptions.BypassDomain), + ) + if err != nil { + return E.Cause(err, "initialize system proxy") + } + p.access.Lock() + if p.systemProxy != nil { + p.access.Unlock() + return E.New("only one enabled `tun.platform.http_proxy` is supported") + } + p.systemProxy = systemProxy + err = p.applySystemProxyLocked() + if err != nil { + rollbackError := p.disableSystemProxyLocked() + p.systemProxy = nil + p.access.Unlock() + return E.Errors(E.Cause(err, "set system proxy"), rollbackError) + } + p.access.Unlock() + return nil +} + +func (p *windowsPlatformInterface) UsePlatformDefaultInterfaceMonitor() bool { + return false +} + +func (p *windowsPlatformInterface) CreateDefaultInterfaceMonitor(logger logger.Logger) tun.DefaultInterfaceMonitor { + return nil +} + +func (p *windowsPlatformInterface) UsePlatformNetworkInterfaces() bool { + return false +} + +func (p *windowsPlatformInterface) NetworkInterfaces() ([]adapter.NetworkInterface, error) { + return nil, os.ErrInvalid +} + +func (p *windowsPlatformInterface) UnderNetworkExtension() bool { + return false +} + +func (p *windowsPlatformInterface) NetworkExtensionIncludeAllNetworks() bool { + return false +} + +func (p *windowsPlatformInterface) ClearDNSCache() { +} + +func (p *windowsPlatformInterface) RequestPermissionForWIFIState() error { + return nil +} + +func (p *windowsPlatformInterface) ReadWIFIState() adapter.WIFIState { + return adapter.WIFIState{} +} + +func (p *windowsPlatformInterface) UsePlatformConnectionOwnerFinder() bool { + return false +} + +func (p *windowsPlatformInterface) FindConnectionOwner(request *adapter.FindConnectionOwnerRequest) (*adapter.ConnectionOwner, error) { + return nil, os.ErrInvalid +} + +func (p *windowsPlatformInterface) UsePlatformWIFIMonitor() bool { + return false +} + +func (p *windowsPlatformInterface) UsePlatformNotification() bool { + return false +} + +func (p *windowsPlatformInterface) SendNotification(notification *adapter.Notification) error { + return nil +} + +func (p *windowsPlatformInterface) MyInterfaceAddress() []netip.Addr { + return nil +} + +func (p *windowsPlatformInterface) UsePlatformNeighborResolver() bool { + return false +} + +func (p *windowsPlatformInterface) StartNeighborMonitor(listener adapter.NeighborUpdateListener) error { + return os.ErrInvalid +} + +func (p *windowsPlatformInterface) CloseNeighborMonitor(listener adapter.NeighborUpdateListener) error { + return nil +} + +func (p *windowsPlatformInterface) UsePlatformShell() bool { + return listenAddress == "" +} + +func (p *windowsPlatformInterface) CheckPlatformShell() error { + return nil +} + +func (p *windowsPlatformInterface) OpenShellSession(user *adapter.PlatformUser, command string, environ []string, term string, rows int32, cols int32) (adapter.ShellSession, error) { + return nil, os.ErrInvalid +} + +func (p *windowsPlatformInterface) LookupUser(username string) (*adapter.PlatformUser, error) { + requestedUser, err := user.Lookup(username) + if err != nil { + return nil, E.Cause(err, "lookup Windows user") + } + return &adapter.PlatformUser{ + Username: requestedUser.Username, + Uid: os.Getuid(), + Gid: os.Getgid(), + HomeDir: requestedUser.HomeDir, + }, nil +} + +func (p *windowsPlatformInterface) LookupSFTPServer() (string, error) { + for _, sftpPath := range []string{ + filepath.Join(os.Getenv("SystemRoot"), "System32", "OpenSSH", "sftp-server.exe"), + filepath.Join(os.Getenv("ProgramFiles"), "OpenSSH", "sftp-server.exe"), + } { + _, err := os.Stat(sftpPath) + if err == nil { + return sftpPath, nil + } + } + return "", E.New("sftp-server not found") +} + +func (p *windowsPlatformInterface) ReadSystemSSHHostKey() ([]byte, error) { + return nil, os.ErrInvalid +} + +func (p *windowsPlatformInterface) TailscaleHostname() string { + return "" +} + +func (p *windowsPlatformInterface) AcquireWindowsUserToken(localUser *adapter.PlatformUser) (windows.Token, io.Closer, error) { + requestedUser, err := user.Lookup(localUser.Username) + if err != nil { + return 0, nil, E.Cause(err, "lookup Windows user") + } + return acquireWindowsUserSession(requestedUser) +} + +func (p *windowsPlatformInterface) UsePlatformBridge() bool { + return false +} + +func (p *windowsPlatformInterface) CreateBridge(options adapter.BridgeOptions) (adapter.BridgeSession, error) { + return nil, os.ErrInvalid +} + +func (p *windowsPlatformInterface) PrepareOwner(identity peerIdentity) error { + p.access.Lock() + defer p.access.Unlock() + if listenAddress != "" { + p.ownerUserID = identity.UserID + p.sessionID = identity.SessionID + return p.applySystemProxyLocked() + } + if p.token != 0 && p.ownerUserID == identity.UserID && p.sessionID == identity.SessionID { + return p.applySystemProxyLocked() + } + token, err := p.daemon.duplicatePeerImpersonationToken(identity) + if err != nil { + return err + } + err = validateImpersonationToken(token, identity.UserID, identity.SessionID) + if err != nil { + token.Close() + return err + } + err = p.replaceOwnerTokenLocked(identity.UserID, identity.SessionID, token) + if err != nil { + return err + } + return nil +} + +func (p *windowsPlatformInterface) RestoreOwner(state ownerState) error { + p.access.Lock() + defer p.access.Unlock() + p.ownerUserID = state.UserID + p.sessionID = state.SessionID + if listenAddress != "" { + return nil + } + if state.SessionID == 0 { + return E.New("missing owner session") + } + token, err := querySessionImpersonationToken(state.SessionID) + if err != nil { + return err + } + err = validateImpersonationToken(token, state.UserID, state.SessionID) + if err != nil { + token.Close() + return err + } + p.token = token + return nil +} + +func (p *windowsPlatformInterface) ReleaseOwner() error { + p.access.Lock() + defer p.access.Unlock() + return p.releaseOwnerLocked() +} + +func (p *windowsPlatformInterface) ResetPlatformOptions() error { + p.access.Lock() + defer p.access.Unlock() + err := p.disableSystemProxyLocked() + if err == nil { + p.systemProxy = nil + } + return err +} + +func (p *windowsPlatformInterface) SetSystemProxyPreference(enabled bool) { + p.access.Lock() + p.systemProxyEnabled = enabled + p.access.Unlock() +} + +func (p *windowsPlatformInterface) SystemProxyStatus() (*daemon.SystemProxyStatus, error) { + p.access.Lock() + defer p.access.Unlock() + available := p.systemProxy != nil + return &daemon.SystemProxyStatus{ + Available: available, + Enabled: available && p.systemProxyEnabled, + }, nil +} + +func (p *windowsPlatformInterface) SetSystemProxyEnabled(enabled bool) error { + p.access.Lock() + defer p.access.Unlock() + if p.systemProxy == nil { + if !enabled { + p.systemProxyEnabled = false + return nil + } + return E.New("the system proxy is not available") + } + previousEnabled := p.systemProxyEnabled + p.systemProxyEnabled = enabled + err := p.applySystemProxyLocked() + if err != nil { + p.systemProxyEnabled = previousEnabled + rollbackError := p.applySystemProxyLocked() + return E.Errors(err, rollbackError) + } + return nil +} + +func (p *windowsPlatformInterface) HandleSessionChange(eventType uint32, sessionID uint32, state ownerState) (uint32, bool, error) { + p.access.Lock() + defer p.access.Unlock() + if eventType == windows.WTS_SESSION_LOGOFF { + if p.sessionID != sessionID { + return 0, false, nil + } + return 0, false, p.releaseOwnerLocked() + } + if eventType != windows.WTS_SESSION_LOGON && + eventType != windows.WTS_CONSOLE_CONNECT && + eventType != windows.WTS_REMOTE_CONNECT && + eventType != windows.WTS_SESSION_UNLOCK { + return 0, false, nil + } + token, err := querySessionImpersonationToken(sessionID) + if err != nil { + return 0, false, err + } + userID, tokenSessionID, err := impersonationTokenIdentity(token) + if err != nil { + token.Close() + return 0, false, err + } + if userID != state.UserID || tokenSessionID != sessionID { + token.Close() + return 0, false, nil + } + err = p.replaceOwnerTokenLocked(userID, sessionID, token) + if err != nil { + return 0, false, err + } + return sessionID, true, nil +} + +func (d *Daemon) handlePlatformSessionChange(eventType uint32, sessionID uint32) error { + d.lifecycleAccess.Lock() + defer d.lifecycleAccess.Unlock() + if d.closed || d.platform == nil { + return nil + } + state, err := loadOwnerState() + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + newSessionID, changed, err := d.platform.HandleSessionChange(eventType, sessionID, state) + if err != nil { + return err + } + if !changed { + return nil + } + return saveOwner(state.UserID, newSessionID) +} + +func (p *windowsPlatformInterface) Close() error { + p.access.Lock() + defer p.access.Unlock() + systemProxyError := p.disableSystemProxyLocked() + p.systemProxy = nil + ownerError := p.closeOwnerTokenLocked() + return E.Errors(systemProxyError, ownerError) +} + +func (p *windowsPlatformInterface) applySystemProxyLocked() error { + if p.systemProxy == nil { + return nil + } + if p.systemProxyEnabled { + if p.systemProxy.IsEnabled() { + return nil + } + return p.runUserOperationLocked(p.systemProxy.Enable) + } + return p.disableSystemProxyLocked() +} + +func (p *windowsPlatformInterface) disableSystemProxyLocked() error { + if p.systemProxy == nil || !p.systemProxy.IsEnabled() { + return nil + } + return p.runUserOperationLocked(p.systemProxy.Disable) +} + +func (p *windowsPlatformInterface) runUserOperationLocked(operation func() error) error { + if listenAddress != "" { + return operation() + } + if p.token == 0 { + return nil + } + return runImpersonated(p.token, operation) +} + +func (p *windowsPlatformInterface) replaceOwnerTokenLocked(userID string, sessionID uint32, token windows.Token) error { + err := p.disableSystemProxyLocked() + if err != nil { + return E.Errors(err, token.Close()) + } + err = p.closeOwnerTokenLocked() + if err != nil { + return E.Errors(err, token.Close()) + } + p.ownerUserID = userID + p.sessionID = sessionID + p.token = token + err = p.applySystemProxyLocked() + if err != nil { + return E.Errors(err, p.closeOwnerTokenLocked()) + } + return nil +} + +func (p *windowsPlatformInterface) releaseOwnerLocked() error { + err := p.disableSystemProxyLocked() + if err != nil { + return err + } + err = p.closeOwnerTokenLocked() + p.ownerUserID = "" + p.sessionID = 0 + return err +} + +func (p *windowsPlatformInterface) closeOwnerTokenLocked() error { + if p.token == 0 { + return nil + } + err := p.token.Close() + p.token = 0 + return err +} + +func runImpersonated(token windows.Token, operation func() error) error { + result := make(chan error, 1) + go func() { + runtime.LockOSThread() + err := windows.SetThreadToken(nil, token) + if err != nil { + runtime.UnlockOSThread() + result <- E.Cause(err, "impersonate owner") + return + } + operationError := operation() + revertError := windows.RevertToSelf() + if revertError == nil { + runtime.UnlockOSThread() + } else { + revertError = E.Cause(revertError, "revert owner impersonation") + } + result <- E.Errors(operationError, revertError) + }() + return <-result +} + +func querySessionImpersonationToken(sessionID uint32) (windows.Token, error) { + var primaryToken windows.Token + err := winio.RunWithPrivileges([]string{seTcbPrivilege}, func() error { + return windows.WTSQueryUserToken(sessionID, &primaryToken) + }) + if err != nil { + return 0, E.Cause(err, "query session user token") + } + defer primaryToken.Close() + return duplicateImpersonationToken(primaryToken) +} + +func duplicateImpersonationToken(token windows.Token) (windows.Token, error) { + var duplicatedToken windows.Token + err := windows.DuplicateTokenEx( + token, + windows.TOKEN_QUERY|windows.TOKEN_IMPERSONATE, + nil, + windows.SecurityImpersonation, + windows.TokenImpersonation, + &duplicatedToken, + ) + if err != nil { + return 0, E.Cause(err, "duplicate owner impersonation token") + } + return duplicatedToken, nil +} + +func validateImpersonationToken(token windows.Token, expectedUserID string, expectedSessionID uint32) error { + userID, sessionID, err := impersonationTokenIdentity(token) + if err != nil { + return err + } + if userID != expectedUserID || sessionID != expectedSessionID { + return E.New("owner token identity does not match authenticated application") + } + return nil +} + +func impersonationTokenIdentity(token windows.Token) (string, uint32, error) { + user, err := token.GetTokenUser() + if err != nil { + return "", 0, E.Cause(err, "query owner token user") + } + userID := user.User.Sid.String() + if userID == "" { + return "", 0, E.New("owner token has an invalid user SID") + } + var sessionID uint32 + var returnLength uint32 + err = windows.GetTokenInformation( + token, + windows.TokenSessionId, + (*byte)(unsafe.Pointer(&sessionID)), + uint32(unsafe.Sizeof(sessionID)), + &returnLength, + ) + if err != nil { + return "", 0, E.Cause(err, "query owner token session") + } + return userID, sessionID, nil +} + +var _ daemonPlatform = (*windowsPlatformInterface)(nil) diff --git a/experimental/boxdd/report.go b/experimental/boxdd/report.go new file mode 100644 index 0000000000..e230f44fdb --- /dev/null +++ b/experimental/boxdd/report.go @@ -0,0 +1,247 @@ +package main + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "time" + + "github.com/sagernet/sing-box/experimental/libbox" + "github.com/sagernet/sing/common/rw" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const ( + // File names within a report directory follow the client convention: + // sing-box-for-apple Library/Shared/CrashReportArchive.swift (ReportArchive) + // and sing-box experimental/libbox/report.go. + readMarkerFileName = ".read" + ownerMarkerFileName = ".owner" + metadataFileName = "metadata.json" + configSnapshotFileName = "configuration.json" + goLogFileName = "go.log" + nativeLogFileName = "native.log" +) + +func reportPath(reportsDirectory string, name string) (string, error) { + if !filepath.IsLocal(name) || name == "." || name != filepath.Base(name) { + return "", status.Error(codes.InvalidArgument, "invalid report name") + } + fullPath := filepath.Join(reportsDirectory, name) + info, err := os.Stat(fullPath) + if err != nil { + if os.IsNotExist(err) { + return "", status.Error(codes.NotFound, "report not found") + } + return "", err + } + if !info.IsDir() { + return "", status.Error(codes.NotFound, "report not found") + } + return fullPath, nil +} + +func reportPathForUser(reportsDirectory string, name string, userID string) (string, error) { + fullPath, err := reportPath(reportsDirectory, name) + if err != nil { + return "", err + } + if !reportOwnedBy(fullPath, userID) { + return "", status.Error(codes.NotFound, "report not found") + } + return fullPath, nil +} + +func reportOwnedBy(fullPath string, userID string) bool { + ownerContent, err := os.ReadFile(filepath.Join(fullPath, ownerMarkerFileName)) + return err == nil && strings.TrimSpace(string(ownerContent)) == userID +} + +func tagUnownedReports(reportsDirectory string, userID string) error { + if userID == "" { + return nil + } + entries, err := os.ReadDir(reportsDirectory) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + ownerPath := filepath.Join(reportsDirectory, entry.Name(), ownerMarkerFileName) + _, err = os.Stat(ownerPath) + if err == nil { + continue + } + if !os.IsNotExist(err) { + return err + } + err = os.WriteFile(ownerPath, []byte(userID+"\n"), 0o600) + if err != nil { + return err + } + } + return nil +} + +func (d *Daemon) reportCaller(ctx context.Context, reportsDirectoryName string) (string, string, error) { + identity, err := peerIdentityFromContext(ctx) + if err != nil { + return "", "", err + } + d.lifecycleAccess.Lock() + defer d.lifecycleAccess.Unlock() + ownerUserID, err := loadOwner() + if err != nil { + return "", "", err + } + if ownerUserID != identity.UserID { + return "", "", status.Error(codes.PermissionDenied, "the service is owned by another user") + } + reportsDirectory := filepath.Join(userWorkingDirectory(identity.UserID), reportsDirectoryName) + err = tagUnownedReports(reportsDirectory, identity.UserID) + if err != nil { + return "", "", err + } + return reportsDirectory, identity.UserID, nil +} + +func deleteReportsForUser(reportsDirectory string, userID string) error { + entries, err := os.ReadDir(reportsDirectory) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + fullPath := filepath.Join(reportsDirectory, entry.Name()) + if !reportOwnedBy(fullPath, userID) { + continue + } + err = os.RemoveAll(fullPath) + if err != nil { + return err + } + } + return nil +} + +func reportTime(fullPath string, timestampKey string) time.Time { + metadataContent, err := os.ReadFile(filepath.Join(fullPath, metadataFileName)) + if err == nil { + var metadata map[string]any + err = json.Unmarshal(metadataContent, &metadata) + if err == nil { + if timestamp, isString := metadata[timestampKey].(string); isString && timestamp != "" { + parsedTime, parseError := time.Parse(time.RFC3339, timestamp) + if parseError == nil { + return parsedTime + } + } + } + } + info, err := os.Stat(fullPath) + if err != nil { + return time.Time{} + } + return info.ModTime() +} + +func reportIsRead(fullPath string) bool { + _, err := os.Stat(filepath.Join(fullPath, readMarkerFileName)) + return err == nil +} + +func exportReportArchive(reportsDirectory string, name string, userID string, withConfiguration bool, withLog bool, encrypt bool) (*CrashReportArchive, error) { + fullPath, err := reportPathForUser(reportsDirectory, name, userID) + if err != nil { + return nil, err + } + tempRoot := filepath.Join(filepath.Dir(reportsDirectory), "temp") + err = os.MkdirAll(tempRoot, 0o700) + if err != nil { + return nil, err + } + tempDirectory, err := os.MkdirTemp(tempRoot, "report-") + if err != nil { + return nil, err + } + defer os.RemoveAll(tempDirectory) + strippedPath := filepath.Join(tempDirectory, name) + err = copyDirectory(fullPath, strippedPath) + if err != nil { + return nil, err + } + os.Remove(filepath.Join(strippedPath, readMarkerFileName)) + os.Remove(filepath.Join(strippedPath, ownerMarkerFileName)) + if !withConfiguration { + os.Remove(filepath.Join(strippedPath, configSnapshotFileName)) + } + if !withLog { + os.Remove(filepath.Join(strippedPath, goLogFileName)) + os.Remove(filepath.Join(strippedPath, nativeLogFileName)) + } + fileName := name + ".zip" + if encrypt { + fileName += ".age" + } + archivePath := filepath.Join(tempDirectory, fileName) + err = libbox.CreateZipArchive(strippedPath, archivePath, encrypt) + if err != nil { + return nil, err + } + data, err := os.ReadFile(archivePath) + if err != nil { + return nil, err + } + return &CrashReportArchive{ + FileName: fileName, + Data: data, + }, nil +} + +func copyDirectory(sourcePath string, destinationPath string) error { + err := os.MkdirAll(destinationPath, 0o700) + if err != nil { + return err + } + entries, err := os.ReadDir(sourcePath) + if err != nil { + return err + } + for _, entry := range entries { + sourceEntryPath := filepath.Join(sourcePath, entry.Name()) + destinationEntryPath := filepath.Join(destinationPath, entry.Name()) + if entry.IsDir() { + err = copyDirectory(sourceEntryPath, destinationEntryPath) + if err != nil { + return err + } + continue + } + info, err := entry.Info() + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return status.Error(codes.FailedPrecondition, "report contains a non-regular file") + } + err = rw.CopyFile(sourceEntryPath, destinationEntryPath) + if err != nil { + return err + } + } + return nil +} diff --git a/experimental/boxdd/security_windows.go b/experimental/boxdd/security_windows.go new file mode 100644 index 0000000000..6d3cc113fa --- /dev/null +++ b/experimental/boxdd/security_windows.go @@ -0,0 +1,589 @@ +//go:build windows + +package main + +import ( + "bytes" + "crypto/sha1" + "encoding/binary" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + "unicode/utf16" + "unsafe" + + E "github.com/sagernet/sing/common/exceptions" + + "github.com/tailscale/go-winio" + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/svc/mgr" +) + +const ( + trustedInstallerUserID = "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464" + fileDeleteChildAccess = 0x00000040 +) + +func secureWindowsInstallation(executablePath string, allowUnsafeInstallation bool) (string, error) { + daemonExecutable, err := openLockedExecutable(executablePath) + if err != nil { + return "", err + } + defer windows.CloseHandle(daemonExecutable) + daemonPath, err := finalWindowsPath(daemonExecutable) + if err != nil { + return "", err + } + installationDirectory, applicationPath, err := installedApplicationPath(daemonPath) + if err != nil { + return "", err + } + applicationExecutable, err := openLockedExecutable(applicationPath) + if err != nil { + return "", E.Cause(err, "open installed application") + } + defer windows.CloseHandle(applicationExecutable) + daemonSigner, err := authenticodeSigner(daemonPath, daemonExecutable) + if err != nil { + return "", E.Cause(err, "authenticate installed daemon") + } + applicationFinalPath, err := finalWindowsPath(applicationExecutable) + if err != nil { + return "", err + } + applicationSigner, err := authenticodeSigner(applicationFinalPath, applicationExecutable) + if err != nil { + return "", E.Cause(err, "authenticate installed application") + } + if !bytes.Equal(daemonSigner, applicationSigner) { + return "", E.New("installed application and daemon have different signing certificates") + } + if allowUnsafeInstallation { + return daemonPath, nil + } + volumeRoot, err := validateFixedNTFSVolume(installationDirectory) + if err != nil { + return "", err + } + err = validateInstallationAncestors(filepath.Dir(installationDirectory), volumeRoot, false) + if err != nil { + return "", err + } + err = validateTreeHasNoReparsePoints(installationDirectory) + if err != nil { + return "", err + } + err = applyProtectedTree( + installationDirectory, + "O:SYG:SYD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GRGX;;;AU)", + "O:SYG:SYD:P(A;;FA;;;SY)(A;;FA;;;BA)(A;;GRGX;;;AU)", + ) + if err != nil { + return "", err + } + return daemonPath, nil +} + +func installedApplicationPath(daemonPath string) (string, string, error) { + daemonDirectory := filepath.Dir(daemonPath) + resourcesDirectory := filepath.Dir(daemonDirectory) + installationDirectory := filepath.Dir(resourcesDirectory) + if !strings.EqualFold(filepath.Base(daemonPath), daemonExecutableName) || + !strings.EqualFold(filepath.Base(daemonDirectory), "daemon") || + !strings.EqualFold(filepath.Base(resourcesDirectory), "resources") { + return "", "", E.New("daemon executable is outside the installed sing-box layout") + } + return installationDirectory, filepath.Join(installationDirectory, applicationExecutableName), nil +} + +func windowsWorkingDirectorySecurityDescriptors(serviceUserID *windows.SID) (string, string, error) { + serviceUserIDString := serviceUserID.String() + if serviceUserIDString == "" { + return "", "", E.New("daemon service has an invalid SID") + } + directoryDescriptor := fmt.Sprintf( + "O:SYG:SYD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;FA;;;%s)", + serviceUserIDString, + ) + fileDescriptor := fmt.Sprintf( + "O:SYG:SYD:P(A;;FA;;;SY)(A;;FA;;;BA)(A;;FA;;;%s)", + serviceUserIDString, + ) + return directoryDescriptor, fileDescriptor, nil +} + +func secureWindowsWorkingDirectory(path string, serviceUserID *windows.SID) error { + directoryDescriptor, fileDescriptor, err := windowsWorkingDirectorySecurityDescriptors(serviceUserID) + if err != nil { + return err + } + err = validateTreeHasNoReparsePoints(path) + if err != nil { + return err + } + return applyProtectedTree(path, directoryDescriptor, fileDescriptor) +} + +func secureWindowsWorkingDirectoryRoot(path string, serviceUserID *windows.SID) error { + directorySecurityDescriptor, _, err := windowsWorkingDirectorySecurityDescriptors(serviceUserID) + if err != nil { + return err + } + directoryDescriptor, err := windows.SecurityDescriptorFromString(directorySecurityDescriptor) + if err != nil { + return err + } + return winio.RunWithPrivilege(winio.SeRestorePrivilege, func() error { + return applyProtectedFileSecurity(path, directoryDescriptor) + }) +} + +func windowsServiceSID() (*windows.SID, error) { + serviceNameUTF16 := utf16.Encode([]rune(strings.ToUpper(serviceName))) + serviceNameContent := make([]byte, len(serviceNameUTF16)*2) + for index, codeUnit := range serviceNameUTF16 { + binary.LittleEndian.PutUint16(serviceNameContent[index*2:], codeUnit) + } + serviceNameHash := sha1.Sum(serviceNameContent) + return windows.StringToSid(fmt.Sprintf( + "S-1-5-80-%d-%d-%d-%d-%d", + binary.LittleEndian.Uint32(serviceNameHash[0:4]), + binary.LittleEndian.Uint32(serviceNameHash[4:8]), + binary.LittleEndian.Uint32(serviceNameHash[8:12]), + binary.LittleEndian.Uint32(serviceNameHash[12:16]), + binary.LittleEndian.Uint32(serviceNameHash[16:20]), + )) +} + +func validateProtectedWindowsWorkingDirectory(path string, serviceUserID *windows.SID) error { + return validateWindowsWorkingDirectory(path, serviceUserID, false) +} + +func validateRepairableWindowsWorkingDirectory(path string, serviceUserID *windows.SID) error { + return validateWindowsWorkingDirectory(path, serviceUserID, true) +} + +func validateWindowsWorkingDirectory(path string, serviceUserID *windows.SID, allowAdditionalAccessControlEntries bool) error { + attributes, err := windowsFileAttributes(path) + if err != nil { + return err + } + if attributes&windows.FILE_ATTRIBUTE_DIRECTORY == 0 { + return E.New("daemon working directory path is not a directory") + } + if attributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return E.New("daemon working directory is a reparse point") + } + descriptor, err := windows.GetNamedSecurityInfo( + path, + windows.SE_FILE_OBJECT, + windows.OWNER_SECURITY_INFORMATION|windows.GROUP_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION, + ) + if err != nil { + return err + } + owner, _, err := descriptor.Owner() + if err != nil { + return err + } + systemUserID, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid) + if err != nil { + return err + } + if !windows.EqualSid(owner, systemUserID) { + return E.New("daemon working directory is not owned by SYSTEM") + } + control, _, err := descriptor.Control() + if err != nil { + return err + } + if control&windows.SE_DACL_PROTECTED == 0 { + return E.New("daemon working directory access control is inherited") + } + discretionaryAccessControlList, _, err := descriptor.DACL() + if err != nil { + return err + } + if discretionaryAccessControlList == nil || + (!allowAdditionalAccessControlEntries && discretionaryAccessControlList.AceCount != 3) || + (allowAdditionalAccessControlEntries && discretionaryAccessControlList.AceCount < 3) { + return E.New("daemon working directory has unexpected access control entries") + } + administratorsUserID, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid) + if err != nil { + return err + } + expectedUsers := map[string]bool{ + systemUserID.String(): false, + administratorsUserID.String(): false, + serviceUserID.String(): false, + } + for index := uint32(0); index < uint32(discretionaryAccessControlList.AceCount); index++ { + var accessControlEntry *windows.ACCESS_ALLOWED_ACE + err = windows.GetAce(discretionaryAccessControlList, index, &accessControlEntry) + if err != nil { + return err + } + if accessControlEntry.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE { + return E.New("daemon working directory has an unsafe access control entry") + } + userID := (*windows.SID)(unsafe.Pointer(&accessControlEntry.SidStart)).String() + seen, exists := expectedUsers[userID] + if !exists { + if allowAdditionalAccessControlEntries { + continue + } + return E.New("daemon working directory grants access to an unexpected principal") + } + if accessControlEntry.Header.AceFlags != windows.OBJECT_INHERIT_ACE|windows.CONTAINER_INHERIT_ACE || + uint32(accessControlEntry.Mask) != 0x001F01FF { + return E.New("daemon working directory has an unsafe access control entry") + } + if seen { + return E.New("daemon working directory has a duplicate access control entry") + } + expectedUsers[userID] = true + } + for _, seen := range expectedUsers { + if !seen { + return E.New("daemon working directory is missing a required access control entry") + } + } + return nil +} + +func applyProtectedServiceSecurity(service *mgr.Service) error { + descriptor, err := windows.SecurityDescriptorFromString( + "D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;0x2008d;;;AU)", + ) + if err != nil { + return err + } + discretionaryAccessControlList, _, err := descriptor.DACL() + if err != nil { + return err + } + return windows.SetSecurityInfo( + service.Handle, + windows.SE_SERVICE, + windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION, + nil, + nil, + discretionaryAccessControlList, + nil, + ) +} + +func allowAuthenticatedUsersToQueryCurrentProcess() error { + descriptor, err := windows.SecurityDescriptorFromString( + "D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;0x101000;;;AU)", + ) + if err != nil { + return err + } + discretionaryAccessControlList, _, err := descriptor.DACL() + if err != nil { + return err + } + return windows.SetSecurityInfo( + windows.CurrentProcess(), + windows.SE_KERNEL_OBJECT, + windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION, + nil, + nil, + discretionaryAccessControlList, + nil, + ) +} + +func validateFixedNTFSVolume(path string) (string, error) { + pathPointer, err := windows.UTF16PtrFromString(path) + if err != nil { + return "", err + } + volumePathBuffer := make([]uint16, windows.MAX_LONG_PATH) + err = windows.GetVolumePathName(pathPointer, &volumePathBuffer[0], uint32(len(volumePathBuffer))) + if err != nil { + return "", E.Cause(err, "resolve installation volume") + } + volumePath := windows.UTF16ToString(volumePathBuffer) + volumePathPointer, err := windows.UTF16PtrFromString(volumePath) + if err != nil { + return "", err + } + if windows.GetDriveType(volumePathPointer) != windows.DRIVE_FIXED { + return "", E.New("sing-box must be installed on a fixed local drive") + } + fileSystemNameBuffer := make([]uint16, 32) + err = windows.GetVolumeInformation( + volumePathPointer, + nil, + 0, + nil, + nil, + nil, + &fileSystemNameBuffer[0], + uint32(len(fileSystemNameBuffer)), + ) + if err != nil { + return "", E.Cause(err, "query installation file system") + } + if !strings.EqualFold(windows.UTF16ToString(fileSystemNameBuffer), "NTFS") { + return "", E.New("sing-box must be installed on NTFS") + } + return filepath.Clean(volumePath), nil +} + +func resolveWindowsServiceWorkingDirectory(path string, allowUnsafePermissions bool) (string, error) { + if path == "" { + return "", E.New("missing daemon working directory") + } + absolutePath, err := filepath.Abs(path) + if err != nil { + return "", E.Cause(err, "resolve daemon working directory") + } + cleanPath := filepath.Clean(absolutePath) + parentPath := filepath.Dir(cleanPath) + parentAttributes, err := windowsFileAttributes(parentPath) + if err != nil { + return "", E.Cause(err, "query daemon working directory parent") + } + if parentAttributes&windows.FILE_ATTRIBUTE_DIRECTORY == 0 { + return "", E.New("daemon working directory parent is not a directory") + } + if parentAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return "", E.New("daemon working directory parent is a reparse point") + } + volumeRoot, err := validateFixedNTFSVolume(parentPath) + if err != nil { + return "", E.Cause(err, "validate daemon working directory volume") + } + if strings.EqualFold(cleanPath, filepath.Clean(volumeRoot)) { + return "", E.New("daemon working directory must not be a volume root") + } + err = validateInstallationAncestors(parentPath, volumeRoot, allowUnsafePermissions) + if err != nil { + return "", E.Cause(err, "validate daemon working directory ancestors") + } + return cleanPath, nil +} + +func validateInstallationAncestors(path string, volumeRoot string, allowUnsafePermissions bool) error { + currentPath := filepath.Clean(path) + cleanVolumeRoot := filepath.Clean(volumeRoot) + for { + err := validateInstallationAncestor(currentPath, allowUnsafePermissions) + if err != nil { + return err + } + if strings.EqualFold(currentPath, cleanVolumeRoot) { + return nil + } + parentPath := filepath.Dir(currentPath) + if parentPath == currentPath { + return E.New("installation path is outside its resolved volume") + } + currentPath = parentPath + } +} + +func validateInstallationAncestor(path string, allowUnsafePermissions bool) error { + attributes, err := windowsFileAttributes(path) + if err != nil { + return err + } + if attributes&windows.FILE_ATTRIBUTE_DIRECTORY == 0 { + return E.New("installation ancestor is not a directory: ", path) + } + if attributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return E.New("installation ancestor is a reparse point: ", path) + } + if allowUnsafePermissions { + return nil + } + descriptor, err := windows.GetNamedSecurityInfo( + path, + windows.SE_FILE_OBJECT, + windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION, + ) + if err != nil { + return E.Cause(err, "query installation ancestor security") + } + owner, _, err := descriptor.Owner() + if err != nil { + return err + } + if !trustedAdministrativeUser(owner) { + return E.New("installation ancestor is owned by an unprivileged principal: ", path) + } + discretionaryAccessControlList, _, err := descriptor.DACL() + if err != nil { + return err + } + if discretionaryAccessControlList == nil { + return E.New("installation ancestor has an empty access control list: ", path) + } + for index := uint32(0); index < uint32(discretionaryAccessControlList.AceCount); index++ { + var accessControlEntry *windows.ACCESS_ALLOWED_ACE + err = windows.GetAce(discretionaryAccessControlList, index, &accessControlEntry) + if err != nil { + return err + } + if accessControlEntry.Header.AceFlags&windows.INHERIT_ONLY_ACE != 0 { + continue + } + if accessControlEntry.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE { + continue + } + mask := uint32(accessControlEntry.Mask) + dangerousAccess := uint32(windows.DELETE | windows.WRITE_DAC | windows.WRITE_OWNER | windows.GENERIC_WRITE | windows.GENERIC_ALL | fileDeleteChildAccess) + if mask&dangerousAccess == 0 { + continue + } + principal := (*windows.SID)(unsafe.Pointer(&accessControlEntry.SidStart)) + if !trustedAdministrativeUser(principal) { + return E.New("installation ancestor is replaceable by an unprivileged principal: ", path) + } + } + return nil +} + +func trustedAdministrativeUser(userID *windows.SID) bool { + if userID == nil { + return false + } + return userID.IsWellKnown(windows.WinLocalSystemSid) || + userID.IsWellKnown(windows.WinBuiltinAdministratorsSid) || + userID.String() == trustedInstallerUserID +} + +func validateTreeHasNoReparsePoints(root string) error { + return filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkError error) error { + if walkError != nil { + return walkError + } + attributes, err := windowsFileAttributes(path) + if err != nil { + return err + } + if attributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return E.New("protected tree contains a reparse point: ", path) + } + return nil + }) +} + +func applyProtectedTree(root string, directorySecurityDescriptor string, fileSecurityDescriptor string) error { + return winio.RunWithPrivilege(winio.SeRestorePrivilege, func() error { + directoryDescriptor, err := windows.SecurityDescriptorFromString(directorySecurityDescriptor) + if err != nil { + return err + } + fileDescriptor, err := windows.SecurityDescriptorFromString(fileSecurityDescriptor) + if err != nil { + return err + } + return filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkError error) error { + if walkError != nil { + return walkError + } + descriptor := fileDescriptor + if entry.IsDir() { + descriptor = directoryDescriptor + } + err = applyProtectedFileSecurity(path, descriptor) + if err != nil { + return E.Cause(err, "secure ", path) + } + return nil + }) + }) +} + +func applyProtectedFileSecurity(path string, descriptor *windows.SECURITY_DESCRIPTOR) error { + owner, _, err := descriptor.Owner() + if err != nil { + return err + } + group, _, err := descriptor.Group() + if err != nil { + return err + } + discretionaryAccessControlList, _, err := descriptor.DACL() + if err != nil { + return err + } + return windows.SetNamedSecurityInfo( + path, + windows.SE_FILE_OBJECT, + windows.OWNER_SECURITY_INFORMATION| + windows.GROUP_SECURITY_INFORMATION| + windows.DACL_SECURITY_INFORMATION| + windows.PROTECTED_DACL_SECURITY_INFORMATION, + owner, + group, + discretionaryAccessControlList, + nil, + ) +} + +func windowsFileAttributes(path string) (uint32, error) { + pathPointer, err := windows.UTF16PtrFromString(path) + if err != nil { + return 0, err + } + return windows.GetFileAttributes(pathPointer) +} + +func ensureWindowsWorkingDirectory(path string) error { + serviceUserID, err := windowsServiceSID() + if err != nil { + return E.Cause(err, "create daemon service SID") + } + created := false + _, err = os.Lstat(path) + if os.IsNotExist(err) { + serviceUserIDString := serviceUserID.String() + if serviceUserIDString == "" { + return E.New("daemon service has an invalid SID") + } + descriptor, descriptorError := windows.SecurityDescriptorFromString( + fmt.Sprintf("D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;FA;;;%s)", serviceUserIDString), + ) + if descriptorError != nil { + return descriptorError + } + pathPointer, pathError := windows.UTF16PtrFromString(path) + if pathError != nil { + return pathError + } + securityAttributes := &windows.SecurityAttributes{ + Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})), + SecurityDescriptor: descriptor, + } + err = windows.CreateDirectory(pathPointer, securityAttributes) + if err != nil { + return E.Cause(err, "create protected daemon working directory") + } + created = true + } else if err != nil { + return err + } + if !created { + err = validateRepairableWindowsWorkingDirectory(path, serviceUserID) + if err != nil { + return err + } + err = secureWindowsWorkingDirectoryRoot(path, serviceUserID) + if err != nil { + return err + } + } + err = secureWindowsWorkingDirectory(path, serviceUserID) + if err != nil { + return err + } + return validateProtectedWindowsWorkingDirectory(path, serviceUserID) +} diff --git a/experimental/boxdd/server.go b/experimental/boxdd/server.go new file mode 100644 index 0000000000..af50f8193b --- /dev/null +++ b/experimental/boxdd/server.go @@ -0,0 +1,416 @@ +package main + +import ( + "context" + "errors" + "net" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/daemon" + "github.com/sagernet/sing-box/experimental/libbox" + "github.com/sagernet/sing-box/include" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/service/oomkiller" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/service" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/health" + "google.golang.org/grpc/health/grpc_health_v1" + "google.golang.org/grpc/reflection" + "google.golang.org/grpc/status" +) + +type Daemon struct { + ctx context.Context + logger log.ContextLogger + startedService *daemon.StartedService + server *grpc.Server + runtimeWorkingDirectory string + lifecycleAccess sync.Mutex + closed bool + peerAccess sync.Mutex + peerConnections map[peerConnection]peerIdentity + platform daemonPlatform +} + +func newDaemon() (*Daemon, error) { + ctx := include.Context(context.Background()) + d := &Daemon{ + ctx: ctx, + logger: log.StdLogger(), + runtimeWorkingDirectory: workingDirectory, + } + platformInterface, err := newPlatformInterface(d) + if err != nil { + return nil, err + } + d.platform = platformInterface + if platformInterface != nil { + service.MustRegister[adapter.PlatformInterface](ctx, platformInterface) + } + registerSecurityPolicy(ctx, d) + d.startedService = daemon.NewStartedService(daemon.ServiceOptions{ + Context: ctx, + LogMaxLines: 3000, + }) + reporter := libbox.NewOOMReporter(d.startedService) + service.MustRegister[oomkiller.OOMReporter](ctx, reporter) + managedService := daemon.NewManagedService(daemon.ManagedServiceOptions{ + Handler: &managedHandler{d}, + Debug: debugEnabled, + OOMReporter: reporter, + }) + authorizer := newAuthorizer(d) + serverOptions := []grpc.ServerOption{ + grpc.ChainUnaryInterceptor(newUnaryAuthorizeInterceptor(authorizer), daemon.UnaryLocaleInterceptor), + grpc.ChainStreamInterceptor(newStreamAuthorizeInterceptor(authorizer), daemon.StreamLocaleInterceptor), + } + platformOptions, err := platformServerOptions(d) + if err != nil { + return nil, err + } + serverOptions = append(serverOptions, platformOptions...) + d.server = grpc.NewServer(serverOptions...) + daemon.RegisterStartedServiceServer(d.server, d.startedService) + daemon.RegisterManagedServiceServer(d.server, managedService) + RegisterDesktopServiceServer(d.server, &desktopService{daemon: d}) + healthServer := health.NewServer() + healthServer.SetServingStatus(daemon.StartedService_ServiceDesc.ServiceName, grpc_health_v1.HealthCheckResponse_SERVING) + healthServer.SetServingStatus(daemon.ManagedService_ServiceDesc.ServiceName, grpc_health_v1.HealthCheckResponse_SERVING) + healthServer.SetServingStatus(DesktopService_ServiceDesc.ServiceName, grpc_health_v1.HealthCheckResponse_SERVING) + grpc_health_v1.RegisterHealthServer(d.server, healthServer) + if listenAddress != "" { + reflection.Register(d.server) + } + return d, nil +} + +func (d *Daemon) listen() (net.Listener, error) { + if listenAddress != "" { + d.logger.Warn("listening on TCP address ", listenAddress, ": development only, no access control") + return net.Listen("tcp", listenAddress) + } + return listenEndpoint() +} + +func (d *Daemon) Start() error { + listener, err := d.listen() + if err != nil { + return err + } + d.logger.Info("daemon listening at ", listener.Addr()) + go func() { + serveError := d.server.Serve(listener) + if serveError != nil && !errors.Is(serveError, grpc.ErrServerStopped) { + d.logger.Error("serve: ", serveError) + } + }() + go d.restore() + return nil +} + +func (d *Daemon) restore() { + d.lifecycleAccess.Lock() + defer d.lifecycleAccess.Unlock() + if d.closed { + return + } + ownerState, err := loadOwnerState() + if err != nil { + if !os.IsNotExist(err) { + d.logger.Warn("load owner: ", err) + } + return + } + ownerUserID := ownerState.UserID + ownerWorkingDirectory := userWorkingDirectory(ownerUserID) + err = d.configureWorkingDirectoryLocked(ownerWorkingDirectory) + if err != nil { + d.logger.Warn("configure working directory: ", err) + return + } + options, err := loadStartOptions(ownerUserID) + if err != nil { + if !os.IsNotExist(err) { + d.logger.Warn("load start options: ", err) + } + return + } + err = tagUnownedReports(filepath.Join(ownerWorkingDirectory, crashReportsDirectoryName), ownerUserID) + if err != nil { + d.logger.Warn("tag crash reports: ", err) + } + err = tagUnownedReports(filepath.Join(ownerWorkingDirectory, oomReportsDirectoryName), ownerUserID) + if err != nil { + d.logger.Warn("tag OOM reports: ", err) + } + if !options.WasRunning { + return + } + if d.platform != nil { + d.platform.SetSystemProxyPreference(options.systemProxyEnabled()) + err = d.platform.RestoreOwner(ownerState) + if err != nil { + d.logger.Warn("restore owner session: ", err) + } + } + configContent, err := loadServiceConfig(ownerUserID) + if err != nil { + d.logger.Error("restore service: ", err) + return + } + d.logger.Info("restoring service") + err = d.startServiceLocked(d.ctx, ownerUserID, configContent, options) + if err != nil { + d.logger.Error("restore service: ", err) + } +} + +func (d *Daemon) configureWorkingDirectoryLocked(directory string) error { + if d.runtimeWorkingDirectory == directory { + return nil + } + err := os.MkdirAll(directory, 0o700) + if err != nil { + return err + } + err = os.Chdir(directory) + if err != nil { + return err + } + err = libbox.Setup(&libbox.SetupOptions{ + BasePath: directory, + WorkingPath: directory, + TempPath: directory, + CrashReportSource: "Daemon", + }) + if err != nil { + return err + } + libbox.PromoteOOMDraft() + d.runtimeWorkingDirectory = directory + return nil +} + +func (d *Daemon) startServiceLocked(ctx context.Context, ownerUserID string, configContent string, options startOptions) error { + directory := userWorkingDirectory(ownerUserID) + err := d.configureWorkingDirectoryLocked(directory) + if err != nil { + return err + } + _ = os.WriteFile(filepath.Join(directory, configSnapshotFileName), []byte(configContent), 0o600) + libbox.ReloadSetupOptions(&libbox.SetupOptions{ + OomKillerEnabled: options.OOMKillerEnabled, + OomKillerDisabled: options.OOMKillerDisabled, + OomMemoryLimit: options.OOMMemoryLimit, + }) + d.startedService.SetOOMKillerOptions(options.OOMKillerEnabled, options.OOMKillerDisabled, uint64(options.OOMMemoryLimit)) + if d.platform != nil { + d.platform.SetSystemProxyPreference(options.systemProxyEnabled()) + err = d.platform.ResetPlatformOptions() + if err != nil { + return err + } + } + err = d.startedService.StartOrReloadService(ctx, configContent, nil) + if err != nil && d.platform != nil { + return E.Errors(err, d.platform.ResetPlatformOptions()) + } + return err +} + +func (d *Daemon) stopServiceLocked(ownerUserID string) error { + options, err := loadStartOptions(ownerUserID) + if err != nil && !os.IsNotExist(err) { + return err + } + if d.platform != nil { + err = d.platform.ResetPlatformOptions() + if err != nil { + return err + } + } + if d.startedService.Instance() != nil { + err = d.startedService.CloseService() + if err != nil { + return err + } + } + directory := userWorkingDirectory(ownerUserID) + crashReportError := tagUnownedReports(filepath.Join(directory, crashReportsDirectoryName), ownerUserID) + if crashReportError != nil { + return crashReportError + } + oomReportError := tagUnownedReports(filepath.Join(directory, oomReportsDirectoryName), ownerUserID) + if oomReportError != nil { + return oomReportError + } + options.WasRunning = false + return saveStartOptions(ownerUserID, options) +} + +func (d *Daemon) Close() { + d.lifecycleAccess.Lock() + d.closed = true + d.lifecycleAccess.Unlock() + d.server.Stop() + d.lifecycleAccess.Lock() + if d.platform != nil { + _ = d.platform.ResetPlatformOptions() + } + _ = d.startedService.CloseService() + d.startedService.Close() + if d.platform != nil { + _ = d.platform.Close() + } + d.lifecycleAccess.Unlock() +} + +func (d *Daemon) disconnectPeerConnectionsExcept(userID string) { + d.peerAccess.Lock() + var connections []peerConnection + for connection, identity := range d.peerConnections { + if identity.UserID != userID { + connections = append(connections, connection) + } + } + d.peerAccess.Unlock() + for _, connection := range connections { + connection.Close() + } +} + +type Authorizer interface { + Authorize(ctx context.Context, method string) error + InvokeUnary(ctx context.Context, method string, handler func() (any, error)) (any, error) +} + +func newAuthorizer(daemon *Daemon) Authorizer { + if listenAddress != "" { + return &allowAllAuthorizer{daemon: daemon} + } + return &daemonAuthorizer{daemon: daemon} +} + +type allowAllAuthorizer struct { + daemon *Daemon +} + +func (a *allowAllAuthorizer) Authorize(ctx context.Context, method string) error { + return nil +} + +func (a *allowAllAuthorizer) InvokeUnary(ctx context.Context, method string, handler func() (any, error)) (any, error) { + if ownerProtectedMethod(method) { + a.daemon.lifecycleAccess.Lock() + defer a.daemon.lifecycleAccess.Unlock() + } + return handler() +} + +type daemonAuthorizer struct { + daemon *Daemon +} + +func (a *daemonAuthorizer) Authorize(ctx context.Context, method string) error { + identity, err := peerIdentityFromContext(ctx) + if err != nil { + return status.Error(codes.Unauthenticated, err.Error()) + } + desktopPrefix := "/" + DesktopService_ServiceDesc.ServiceName + "/" + if strings.HasPrefix(method, desktopPrefix) { + return nil + } + if ownerProtectedMethod(method) { + a.daemon.lifecycleAccess.Lock() + defer a.daemon.lifecycleAccess.Unlock() + err = a.daemon.authorizeOwnerLocked(identity.UserID) + if err != nil { + return err + } + err = a.daemon.preparePlatformOwnerLocked(identity) + if err != nil { + return err + } + if a.daemon.platform != nil { + return saveOwner(identity.UserID, identity.SessionID) + } + return nil + } + return status.Error(codes.PermissionDenied, "the service is not available") +} + +func (a *daemonAuthorizer) InvokeUnary(ctx context.Context, method string, handler func() (any, error)) (any, error) { + if !ownerProtectedMethod(method) { + err := a.Authorize(ctx, method) + if err != nil { + return nil, err + } + return handler() + } + identity, err := peerIdentityFromContext(ctx) + if err != nil { + return nil, status.Error(codes.Unauthenticated, err.Error()) + } + a.daemon.lifecycleAccess.Lock() + defer a.daemon.lifecycleAccess.Unlock() + err = a.daemon.authorizeOwnerLocked(identity.UserID) + if err != nil { + return nil, err + } + err = a.daemon.preparePlatformOwnerLocked(identity) + if err != nil { + return nil, err + } + if a.daemon.platform != nil { + err = saveOwner(identity.UserID, identity.SessionID) + if err != nil { + return nil, err + } + } + return handler() +} + +func ownerProtectedMethod(method string) bool { + startedPrefix := "/" + daemon.StartedService_ServiceDesc.ServiceName + "/" + managedPrefix := "/" + daemon.ManagedService_ServiceDesc.ServiceName + "/" + return strings.HasPrefix(method, startedPrefix) || strings.HasPrefix(method, managedPrefix) +} + +func (d *Daemon) authorizeOwnerLocked(userID string) error { + ownerUserID, err := loadOwner() + if err != nil { + if os.IsNotExist(err) { + return status.Error(codes.PermissionDenied, "the service has no owner") + } + return err + } + if ownerUserID == "" || ownerUserID != userID { + return status.Error(codes.PermissionDenied, "the service is owned by another user") + } + return nil +} + +func newUnaryAuthorizeInterceptor(authorizer Authorizer) grpc.UnaryServerInterceptor { + return func(ctx context.Context, request any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { + return authorizer.InvokeUnary(ctx, info.FullMethod, func() (any, error) { + return handler(ctx, request) + }) + } +} + +func newStreamAuthorizeInterceptor(authorizer Authorizer) grpc.StreamServerInterceptor { + return func(server any, stream grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { + err := authorizer.Authorize(stream.Context(), info.FullMethod) + if err != nil { + return err + } + return handler(server, stream) + } +} diff --git a/experimental/boxdd/server_unix.go b/experimental/boxdd/server_unix.go new file mode 100644 index 0000000000..72bff96493 --- /dev/null +++ b/experimental/boxdd/server_unix.go @@ -0,0 +1,30 @@ +//go:build !windows + +package main + +import ( + "net" + "os" + "path/filepath" +) + +func listenEndpoint() (net.Listener, error) { + path := socketPath + if path == "" { + path = filepath.Join(workingDirectory, serviceName+".sock") + } + err := os.Remove(path) + if err != nil && !os.IsNotExist(err) { + return nil, err + } + listener, err := net.Listen("unix", path) + if err != nil { + return nil, err + } + err = os.Chmod(path, 0o666) + if err != nil { + listener.Close() + return nil, err + } + return listener, nil +} diff --git a/experimental/boxdd/server_windows.go b/experimental/boxdd/server_windows.go new file mode 100644 index 0000000000..0adfdca248 --- /dev/null +++ b/experimental/boxdd/server_windows.go @@ -0,0 +1,38 @@ +package main + +import ( + "net" + "strings" + + E "github.com/sagernet/sing/common/exceptions" + + "github.com/tailscale/go-winio" +) + +// libuv (Node net.connect) opens the client end with GENERIC_READ|GENERIC_WRITE and +// falls back to read-only/write-only opens on ERROR_ACCESS_DENIED (src/win/pipe.c, +// open_named_pipe), so the client principal needs the full GRGW grant. +const pipeSecurityDescriptor = `D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GRGW;;;AU)` + +// winio's PipeConfig defaults to zero-quota pipe instances, where NPFS pends every +// WriteFile until the peer posts a consuming ReadFile. Node's http2 client never +// posts one when it opens a pipe immediately after destroying a previous pipe +// socket (the renderer host's deadline-abort-then-retry pattern), which deadlocks +// the gRPC handshake on the server's initial SETTINGS write and surfaces in the +// app as a connect timeout. Nonzero quotas let the handshake complete into the +// pipe buffer. +const ( + pipeBufferSize = 65536 + daemonPipePath = `\\.\pipe\ProtectedPrefix\Administrators\sing-box` +) + +func listenEndpoint() (net.Listener, error) { + if socketPath != "" && !strings.EqualFold(socketPath, daemonPipePath) { + return nil, E.New("custom Windows daemon pipe paths are not supported") + } + return winio.ListenPipe(daemonPipePath, &winio.PipeConfig{ + SecurityDescriptor: pipeSecurityDescriptor, + InputBufferSize: pipeBufferSize, + OutputBufferSize: pipeBufferSize, + }) +} diff --git a/experimental/boxdd/snapshot.go b/experimental/boxdd/snapshot.go new file mode 100644 index 0000000000..e5d20d88a0 --- /dev/null +++ b/experimental/boxdd/snapshot.go @@ -0,0 +1,101 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + + "github.com/sagernet/sing/common/json" + "github.com/sagernet/tailscale/atomicfile" +) + +const ( + serviceConfigFileName = "config.json" + startOptionsFileName = "start_options.json" + ownerFileName = "owner.json" + usersDirectoryName = "users" +) + +type startOptions struct { + WasRunning bool `json:"was_running"` + OOMKillerEnabled bool `json:"oom_killer_enabled"` + OOMKillerDisabled bool `json:"oom_killer_disabled"` + OOMMemoryLimit int64 `json:"oom_memory_limit"` + SystemProxyEnabled *bool `json:"system_proxy_enabled,omitempty"` +} + +type ownerState struct { + UserID string `json:"user_id"` + SessionID uint32 `json:"session_id,omitempty"` +} + +func userWorkingDirectory(userID string) string { + digest := sha256.Sum256([]byte(userID)) + return filepath.Join(workingDirectory, usersDirectoryName, hex.EncodeToString(digest[:])) +} + +func loadOwner() (string, error) { + state, err := loadOwnerState() + if err != nil { + return "", err + } + return state.UserID, nil +} + +func loadOwnerState() (ownerState, error) { + content, err := os.ReadFile(filepath.Join(workingDirectory, ownerFileName)) + if err != nil { + return ownerState{}, err + } + state, err := json.UnmarshalExtended[ownerState](content) + if err != nil { + return ownerState{}, err + } + return state, nil +} + +func saveOwner(userID string, sessionID uint32) error { + content, err := json.Marshal(ownerState{UserID: userID, SessionID: sessionID}) + if err != nil { + return err + } + return atomicfile.WriteFile(filepath.Join(workingDirectory, ownerFileName), content, 0o600) +} + +func (o startOptions) systemProxyEnabled() bool { + return o.SystemProxyEnabled == nil || *o.SystemProxyEnabled +} + +func loadServiceConfig(userID string) (string, error) { + content, err := os.ReadFile(filepath.Join(userWorkingDirectory(userID), serviceConfigFileName)) + if err != nil { + return "", err + } + return string(content), nil +} + +func loadStartOptions(userID string) (startOptions, error) { + content, err := os.ReadFile(filepath.Join(userWorkingDirectory(userID), startOptionsFileName)) + if err != nil { + return startOptions{}, err + } + options, err := json.UnmarshalExtended[startOptions](content) + if err != nil { + return startOptions{}, err + } + return options, nil +} + +func saveStartOptions(userID string, options startOptions) error { + content, err := json.Marshal(options) + if err != nil { + return err + } + directory := userWorkingDirectory(userID) + err = os.MkdirAll(directory, 0o700) + if err != nil { + return err + } + return atomicfile.WriteFile(filepath.Join(directory, startOptionsFileName), content, 0o600) +} diff --git a/experimental/boxdd/update_executable.go b/experimental/boxdd/update_executable.go new file mode 100644 index 0000000000..91287b7235 --- /dev/null +++ b/experimental/boxdd/update_executable.go @@ -0,0 +1,77 @@ +//go:build windows + +package main + +import ( + "debug/pe" + "encoding/binary" + "os" + + E "github.com/sagernet/sing/common/exceptions" +) + +const ( + peSecurityDirectoryIndex = 4 + nsisFirstHeaderSize = 28 + nsisSignature = 0xDEADBEEF + nsisIdentity = "NullsoftInst" + nsisMaximumAlignmentTail = 8 +) + +func validateNSISExecutable(path string) error { + executable, err := os.Open(path) + if err != nil { + return err + } + defer executable.Close() + information, err := executable.Stat() + if err != nil { + return err + } + peFile, err := pe.NewFile(executable) + if err != nil { + return err + } + defer peFile.Close() + var overlayOffset uint64 + for _, section := range peFile.Sections { + sectionEnd := uint64(section.Offset) + uint64(section.Size) + if sectionEnd > overlayOffset { + overlayOffset = sectionEnd + } + } + contentEnd := uint64(information.Size()) + var securityOffset uint64 + switch optionalHeader := peFile.OptionalHeader.(type) { + case *pe.OptionalHeader32: + securityOffset = uint64(optionalHeader.DataDirectory[peSecurityDirectoryIndex].VirtualAddress) + case *pe.OptionalHeader64: + securityOffset = uint64(optionalHeader.DataDirectory[peSecurityDirectoryIndex].VirtualAddress) + } + if securityOffset != 0 { + if securityOffset > contentEnd { + return E.New("invalid Authenticode directory offset") + } + contentEnd = securityOffset + } + if overlayOffset+nsisFirstHeaderSize > contentEnd { + return E.New("missing NSIS first header") + } + firstHeader := make([]byte, nsisFirstHeaderSize) + _, err = executable.ReadAt(firstHeader, int64(overlayOffset)) + if err != nil { + return err + } + if binary.LittleEndian.Uint32(firstHeader[4:8]) != nsisSignature || string(firstHeader[8:20]) != nsisIdentity { + return E.New("invalid NSIS first header signature") + } + headerSize := uint64(binary.LittleEndian.Uint32(firstHeader[20:24])) + archiveSize := uint64(binary.LittleEndian.Uint32(firstHeader[24:28])) + if headerSize < nsisFirstHeaderSize || archiveSize < headerSize || overlayOffset+archiveSize > contentEnd { + return E.New("invalid NSIS archive size") + } + if contentEnd-(overlayOffset+archiveSize) > nsisMaximumAlignmentTail { + return E.New("unexpected data after NSIS archive") + } + return nil +} diff --git a/experimental/boxdd/update_stub.go b/experimental/boxdd/update_stub.go new file mode 100644 index 0000000000..2416e0119a --- /dev/null +++ b/experimental/boxdd/update_stub.go @@ -0,0 +1,12 @@ +//go:build !windows + +package main + +import ( + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func (d *Daemon) installUpdate(identity peerIdentity, installerPath string) (*InstallUpdateResponse, error) { + return nil, status.Error(codes.Unimplemented, "update installation is not supported on this platform") +} diff --git a/experimental/boxdd/update_windows.go b/experimental/boxdd/update_windows.go new file mode 100644 index 0000000000..a9dbdba70e --- /dev/null +++ b/experimental/boxdd/update_windows.go @@ -0,0 +1,266 @@ +//go:build windows + +package main + +import ( + "bytes" + "fmt" + "path/filepath" + "strings" + "unsafe" + + "github.com/sagernet/sing-box/common/badversion" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/experimental/libbox" + E "github.com/sagernet/sing/common/exceptions" + + "github.com/tailscale/go-winio" + "golang.org/x/sys/windows" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const ( + updateInstallerDesktop = `winsta0\default` + updateProductName = "sing-box" + seTcbPrivilege = "SeTcbPrivilege" + seAssignPrimaryToken = "SeAssignPrimaryTokenPrivilege" + seIncreaseQuota = "SeIncreaseQuotaPrivilege" +) + +func (d *Daemon) installUpdate(identity peerIdentity, installerPath string) (*InstallUpdateResponse, error) { + if installerPath == "" { + return nil, status.Error(codes.InvalidArgument, "missing update installer path") + } + platform := d.platform.(*windowsPlatformInterface) + platform.updateAccess.Lock() + if platform.updateInProgress { + platform.updateAccess.Unlock() + return nil, status.Error(codes.Aborted, "update installation is already in progress") + } + platform.updateInProgress = true + platform.updateAccess.Unlock() + started := false + defer func() { + if started { + return + } + platform.updateAccess.Lock() + platform.updateInProgress = false + platform.updateAccess.Unlock() + }() + + installer, err := openLockedExecutable(installerPath) + if err != nil { + return nil, E.Cause(err, "open update installer") + } + defer windows.CloseHandle(installer) + installerFinalPath, err := finalWindowsPath(installer) + if err != nil { + return nil, E.Cause(err, "resolve update installer") + } + installerIdentity, err := windowsExecutableIdentity(installerFinalPath) + if err != nil { + return nil, status.Error(codes.InvalidArgument, E.Cause(err, "read update installer identity").Error()) + } + if installerIdentity.productName != updateProductName { + return nil, status.Error(codes.InvalidArgument, "update executable is not a sing-box installer") + } + err = validateNSISExecutable(installerFinalPath) + if err != nil { + return nil, status.Error(codes.InvalidArgument, E.Cause(err, "update executable is not a sing-box installer").Error()) + } + installerSigner, err := authenticodeSigner(installerFinalPath, installer) + if err != nil { + return nil, E.Cause(err, "authenticate update installer") + } + if !bytes.Equal(installerSigner, platform.daemonSigner) { + return &InstallUpdateResponse{Result: InstallUpdateResult_INSTALL_UPDATE_RESULT_SIGNER_MISMATCH}, nil + } + if !badversion.IsValid(installerIdentity.version) { + return nil, status.Error(codes.InvalidArgument, "update installer has an invalid version") + } + if !libbox.CompareSemver(installerIdentity.version, C.Version) { + return &InstallUpdateResponse{Result: InstallUpdateResult_INSTALL_UPDATE_RESULT_NOT_NEWER}, nil + } + installerProcess, err := launchUpdateInstaller(installerFinalPath, identity.SessionID) + if err != nil { + return nil, E.Cause(err, "launch update installer") + } + started = true + go platform.waitUpdateInstaller(installerProcess) + return &InstallUpdateResponse{Result: InstallUpdateResult_INSTALL_UPDATE_RESULT_STARTED}, nil +} + +func (p *windowsPlatformInterface) waitUpdateInstaller(process windows.Handle) { + defer windows.CloseHandle(process) + _, _ = windows.WaitForSingleObject(process, windows.INFINITE) + p.updateAccess.Lock() + p.updateInProgress = false + p.updateAccess.Unlock() +} + +type windowsExecutableVersionIdentity struct { + productName string + version string +} + +type windowsVersionTranslation struct { + language uint16 + codePage uint16 +} + +func windowsExecutableIdentity(path string) (windowsExecutableVersionIdentity, error) { + var zero windows.Handle + informationSize, err := windows.GetFileVersionInfoSize(path, &zero) + if err != nil { + return windowsExecutableVersionIdentity{}, err + } + information := make([]byte, informationSize) + err = windows.GetFileVersionInfo(path, 0, informationSize, unsafe.Pointer(&information[0])) + if err != nil { + return windowsExecutableVersionIdentity{}, err + } + var translationsPointer *windowsVersionTranslation + var translationsSize uint32 + err = windows.VerQueryValue( + unsafe.Pointer(&information[0]), + `\VarFileInfo\Translation`, + unsafe.Pointer(&translationsPointer), + &translationsSize, + ) + if err != nil { + return windowsExecutableVersionIdentity{}, E.Cause(err, "query version translations") + } + if translationsPointer == nil || translationsSize == 0 || translationsSize%uint32(unsafe.Sizeof(windowsVersionTranslation{})) != 0 { + return windowsExecutableVersionIdentity{}, E.New("invalid version translations") + } + translations := unsafe.Slice( + translationsPointer, + int(translationsSize/uint32(unsafe.Sizeof(windowsVersionTranslation{}))), + ) + for _, translation := range translations { + productName, productErr := windowsVersionString(information, translation, "ProductName") + if productErr != nil { + continue + } + version, versionErr := windowsVersionString(information, translation, "FileVersion") + if versionErr != nil { + continue + } + return windowsExecutableVersionIdentity{ + productName: strings.TrimSpace(productName), + version: strings.TrimSpace(version), + }, nil + } + return windowsExecutableVersionIdentity{}, E.New("missing product name or file version") +} + +func windowsVersionString(information []byte, translation windowsVersionTranslation, name string) (string, error) { + query := fmt.Sprintf(`\StringFileInfo\%04X%04X\%s`, translation.language, translation.codePage, name) + var valuePointer *uint16 + var valueLength uint32 + err := windows.VerQueryValue( + unsafe.Pointer(&information[0]), + query, + unsafe.Pointer(&valuePointer), + &valueLength, + ) + if err != nil { + return "", err + } + if valuePointer == nil || valueLength == 0 { + return "", E.New("empty version string") + } + return windows.UTF16ToString(unsafe.Slice(valuePointer, int(valueLength))), nil +} + +func launchUpdateInstaller(installerPath string, sessionID uint32) (windows.Handle, error) { + var processToken windows.Token + err := windows.OpenProcessToken( + windows.CurrentProcess(), + windows.TOKEN_DUPLICATE|windows.TOKEN_QUERY, + &processToken, + ) + if err != nil { + return 0, E.Cause(err, "open daemon process token") + } + defer processToken.Close() + var installerToken windows.Token + err = windows.DuplicateTokenEx( + processToken, + windows.TOKEN_ALL_ACCESS, + nil, + windows.SecurityImpersonation, + windows.TokenPrimary, + &installerToken, + ) + if err != nil { + return 0, E.Cause(err, "duplicate daemon process token") + } + defer installerToken.Close() + var installerProcess windows.Handle + err = winio.RunWithPrivileges( + []string{seTcbPrivilege, seAssignPrimaryToken, seIncreaseQuota}, + func() error { + err = windows.SetTokenInformation( + installerToken, + windows.TokenSessionId, + (*byte)(unsafe.Pointer(&sessionID)), + uint32(unsafe.Sizeof(sessionID)), + ) + if err != nil { + return E.Cause(err, "set update installer session") + } + installerProcess, err = createUpdateInstallerProcess(installerToken, installerPath) + return err + }, + ) + return installerProcess, err +} + +func createUpdateInstallerProcess(token windows.Token, installerPath string) (windows.Handle, error) { + applicationName, err := windows.UTF16PtrFromString(installerPath) + if err != nil { + return 0, err + } + commandLine, err := windows.UTF16FromString(windows.ComposeCommandLine([]string{ + installerPath, + "--updated", + "--force-run", + })) + if err != nil { + return 0, err + } + desktop, err := windows.UTF16PtrFromString(updateInstallerDesktop) + if err != nil { + return 0, err + } + workingDirectory, err := windows.UTF16PtrFromString(filepath.Dir(installerPath)) + if err != nil { + return 0, err + } + startupInformation := windows.StartupInfo{ + Cb: uint32(unsafe.Sizeof(windows.StartupInfo{})), + Desktop: desktop, + } + var processInformation windows.ProcessInformation + err = windows.CreateProcessAsUser( + token, + applicationName, + &commandLine[0], + nil, + nil, + false, + windows.CREATE_NEW_PROCESS_GROUP|windows.CREATE_UNICODE_ENVIRONMENT, + nil, + workingDirectory, + &startupInformation, + &processInformation, + ) + if err != nil { + return 0, err + } + windows.CloseHandle(processInformation.Thread) + return processInformation.Process, nil +} diff --git a/experimental/boxdd/user_token_windows.go b/experimental/boxdd/user_token_windows.go new file mode 100644 index 0000000000..6fafe90e4a --- /dev/null +++ b/experimental/boxdd/user_token_windows.go @@ -0,0 +1,376 @@ +//go:build windows + +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package main + +import ( + "io" + "os/user" + "strings" + "syscall" + "unsafe" + + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/tailscale/util/winutil" + "github.com/sagernet/tailscale/util/winutil/winenv" + + winio "github.com/tailscale/go-winio" + "golang.org/x/sys/windows" +) + +const ( + windowsLogonSource = "singbox" + kerberosPackageName = "Kerberos" + msv1PackageName = "MICROSOFT_AUTHENTICATION_PACKAGE_V1_0" + kerbS4ULogon int32 = 12 + msv1S4ULogonMessage int32 = 12 + s4uCheckLogonHours uint32 = 0x2 + networkLogon int32 = 3 + tokenSourceLength = 8 + seBackupPrivilege = "SeBackupPrivilege" + seRestorePrivilege = "SeRestorePrivilege" +) + +type ( + lsaHandle windows.Handle + lsaOperationalMode uint32 +) + +type kerberosS4ULogon struct { + MessageType int32 + Flags uint32 + ClientUPN windows.NTUnicodeString + ClientRealm windows.NTUnicodeString +} + +type msv1S4ULogon struct { + MessageType int32 + Flags uint32 + UserPrincipalName windows.NTUnicodeString + DomainName windows.NTUnicodeString +} + +type tokenSource struct { + SourceName [tokenSourceLength]byte + SourceIdentifier windows.LUID +} + +type quotaLimits struct { + PagedPoolLimit uintptr + NonPagedPoolLimit uintptr + MinimumWorkingSetSize uintptr + MaximumWorkingSetSize uintptr + PagefileLimit uintptr + TimeLimit int64 +} + +func acquireWindowsUserSession(requestedUser *user.User) (windows.Token, io.Closer, error) { + var ( + primaryToken windows.Token + profile *winutil.UserProfile + ) + err := winio.RunWithPrivileges([]string{seTcbPrivilege, seBackupPrivilege, seRestorePrivilege}, func() error { + impersonationToken, err := logonWindowsUserS4U(requestedUser) + if err != nil { + return err + } + defer impersonationToken.Close() + primaryToken, err = duplicatePrimaryToken(impersonationToken) + if err != nil { + return err + } + tokenUser, err := primaryToken.GetTokenUser() + if err != nil { + return E.Cause(err, "query S4U token user") + } + if !strings.EqualFold(tokenUser.User.Sid.String(), requestedUser.Uid) { + return E.New("S4U token identity does not match requested Windows user") + } + profile, err = winutil.LoadUserProfile(primaryToken, requestedUser) + if err != nil { + return E.Cause(err, "load Windows user profile") + } + return nil + }) + if err != nil { + if primaryToken != 0 { + err = E.Errors(err, primaryToken.Close()) + } + return 0, nil, err + } + return primaryToken, common.Closer(func() error { + profileError := winio.RunWithPrivileges([]string{seBackupPrivilege, seRestorePrivilege}, profile.Close) + return E.Errors(profileError, primaryToken.Close()) + }), nil +} + +func logonWindowsUserS4U(requestedUser *user.User) (token windows.Token, err error) { + processName, err := windows.NewNTString(windowsLogonSource) + if err != nil { + return 0, err + } + var ( + handle lsaHandle + mode lsaOperationalMode + ) + status := lsaRegisterLogonProcess(processName, &handle, &mode) + if status != 0 { + return 0, E.Cause(status, "register LSA logon process") + } + defer func() { + closeStatus := lsaDeregisterLogonProcess(handle) + if closeStatus != 0 { + err = E.Errors(err, E.Cause(closeStatus, "deregister LSA logon process")) + } + }() + + username, domainUser, err := classifyWindowsUser(requestedUser.Username) + if err != nil { + return 0, err + } + var ( + packageName string + authenticationInformation unsafe.Pointer + authenticationInformationLength uint32 + ) + if domainUser { + if !winenv.IsDomainJoined() { + return 0, E.New("cannot log on as a domain user from a Windows device that is not domain joined") + } + packageName = kerberosPackageName + upn, err := samAccountNameToUPN(username) + if err != nil { + return 0, E.Cause(err, "resolve Windows user principal name") + } + upn16, err := windows.UTF16FromString(upn) + if err != nil { + return 0, err + } + logonInfo, logonInfoLen, buffers := winutil.AllocateContiguousBuffer[kerberosS4ULogon](upn16) + logonInfo.MessageType = kerbS4ULogon + logonInfo.Flags = s4uCheckLogonHours + winutil.SetNTString(&logonInfo.ClientUPN, buffers[0]) + authenticationInformation = unsafe.Pointer(logonInfo) + authenticationInformationLength = logonInfoLen + } else { + packageName = msv1PackageName + username16, err := windows.UTF16FromString(username) + if err != nil { + return 0, err + } + thisComputer := []uint16{'.', 0} + logonInfo, logonInfoLen, buffers := winutil.AllocateContiguousBuffer[msv1S4ULogon](username16, thisComputer) + logonInfo.MessageType = msv1S4ULogonMessage + logonInfo.Flags = s4uCheckLogonHours + winutil.SetNTString(&logonInfo.UserPrincipalName, buffers[0]) + winutil.SetNTString(&logonInfo.DomainName, buffers[1]) + authenticationInformation = unsafe.Pointer(logonInfo) + authenticationInformationLength = logonInfoLen + } + + packageString, err := windows.NewNTString(packageName) + if err != nil { + return 0, err + } + var packageID uint32 + status = lsaLookupAuthenticationPackage(handle, packageString, &packageID) + if status != 0 { + return 0, E.Cause(status, "lookup LSA authentication package") + } + + var source tokenSource + copy(source.SourceName[:], windowsLogonSource) + err = allocateLocallyUniqueID(&source.SourceIdentifier) + if err != nil { + return 0, E.Cause(err, "allocate LSA logon identifier") + } + originName, err := windows.NewNTString(windowsLogonSource) + if err != nil { + return 0, err + } + var ( + profileBuffer uintptr + profileBufferLength uint32 + logonID windows.LUID + quotas quotaLimits + subStatus windows.NTStatus + ) + status = lsaLogonUser( + handle, + originName, + networkLogon, + packageID, + authenticationInformation, + authenticationInformationLength, + nil, + &source, + &profileBuffer, + &profileBufferLength, + &logonID, + &token, + "as, + &subStatus, + ) + if profileBuffer != 0 { + defer lsaFreeReturnBuffer(profileBuffer) + } + if status != 0 { + return 0, E.New("S4U logon for ", requestedUser.Username, " failed: ", status, ", substatus: ", subStatus) + } + return token, nil +} + +func classifyWindowsUser(username string) (sanitizedUsername string, domainUser bool, err error) { + domain, account, hasDomain := strings.Cut(username, `\`) + if !hasDomain { + return username, false, nil + } + if domain == "." { + return account, false, nil + } + computerName, err := windows.ComputerName() + if err != nil { + return "", false, E.Cause(err, "query Windows computer name") + } + if strings.EqualFold(domain, computerName) { + return account, false, nil + } + return username, true, nil +} + +func samAccountNameToUPN(samAccountName string) (string, error) { + _, account, _ := strings.Cut(samAccountName, `\`) + upn, err := windows.TranslateAccountName(samAccountName, windows.NameSamCompatible, windows.NameUserPrincipal, 50) + if err == nil { + return upn, nil + } + canonicalName, canonicalError := windows.TranslateAccountName(samAccountName, windows.NameSamCompatible, windows.NameCanonical, 50) + if canonicalError != nil { + return "", E.Errors(err, canonicalError) + } + domain, _, found := strings.Cut(canonicalName, "/") + if !found || domain == "" { + return "", E.New("invalid canonical domain name for ", samAccountName) + } + return account + "@" + domain, nil +} + +func duplicatePrimaryToken(impersonationToken windows.Token) (windows.Token, error) { + securityDescriptor, err := windows.GetSecurityInfo( + windows.Handle(impersonationToken), + windows.SE_KERNEL_OBJECT, + windows.DACL_SECURITY_INFORMATION, + ) + if err != nil { + return 0, E.Cause(err, "query S4U token security") + } + securityAttributes := windows.SecurityAttributes{ + Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})), + SecurityDescriptor: securityDescriptor, + } + var primaryToken windows.Token + err = windows.DuplicateTokenEx( + impersonationToken, + 0, + &securityAttributes, + windows.SecurityImpersonation, + windows.TokenPrimary, + &primaryToken, + ) + if err != nil { + return 0, E.Cause(err, "duplicate S4U primary token") + } + return primaryToken, nil +} + +var ( + modAdvapi32 = windows.NewLazySystemDLL("advapi32.dll") + modSecur32 = windows.NewLazySystemDLL("secur32.dll") + + procAllocateLocallyUniqueID = modAdvapi32.NewProc("AllocateLocallyUniqueId") + procLsaDeregisterLogonProcess = modSecur32.NewProc("LsaDeregisterLogonProcess") + procLsaFreeReturnBuffer = modSecur32.NewProc("LsaFreeReturnBuffer") + procLsaLogonUser = modSecur32.NewProc("LsaLogonUser") + procLsaLookupAuthenticationPackage = modSecur32.NewProc("LsaLookupAuthenticationPackage") + procLsaRegisterLogonProcess = modSecur32.NewProc("LsaRegisterLogonProcess") +) + +func allocateLocallyUniqueID(luid *windows.LUID) error { + result, _, callError := syscall.SyscallN(procAllocateLocallyUniqueID.Addr(), uintptr(unsafe.Pointer(luid))) + if result == 0 { + if callError == 0 { + return syscall.EINVAL + } + return callError + } + return nil +} + +func lsaDeregisterLogonProcess(handle lsaHandle) windows.NTStatus { + result, _, _ := syscall.SyscallN(procLsaDeregisterLogonProcess.Addr(), uintptr(handle)) + return windows.NTStatus(result) +} + +func lsaFreeReturnBuffer(buffer uintptr) windows.NTStatus { + result, _, _ := syscall.SyscallN(procLsaFreeReturnBuffer.Addr(), buffer) + return windows.NTStatus(result) +} + +func lsaLookupAuthenticationPackage(handle lsaHandle, packageName *windows.NTString, packageID *uint32) windows.NTStatus { + result, _, _ := syscall.SyscallN( + procLsaLookupAuthenticationPackage.Addr(), + uintptr(handle), + uintptr(unsafe.Pointer(packageName)), + uintptr(unsafe.Pointer(packageID)), + ) + return windows.NTStatus(result) +} + +func lsaRegisterLogonProcess(processName *windows.NTString, handle *lsaHandle, mode *lsaOperationalMode) windows.NTStatus { + result, _, _ := syscall.SyscallN( + procLsaRegisterLogonProcess.Addr(), + uintptr(unsafe.Pointer(processName)), + uintptr(unsafe.Pointer(handle)), + uintptr(unsafe.Pointer(mode)), + ) + return windows.NTStatus(result) +} + +func lsaLogonUser( + handle lsaHandle, + originName *windows.NTString, + logonType int32, + authenticationPackage uint32, + authenticationInformation unsafe.Pointer, + authenticationInformationLength uint32, + localGroups *windows.Tokengroups, + sourceContext *tokenSource, + profileBuffer *uintptr, + profileBufferLength *uint32, + logonID *windows.LUID, + token *windows.Token, + quotas *quotaLimits, + subStatus *windows.NTStatus, +) windows.NTStatus { + result, _, _ := syscall.SyscallN( + procLsaLogonUser.Addr(), + uintptr(handle), + uintptr(unsafe.Pointer(originName)), + uintptr(logonType), + uintptr(authenticationPackage), + uintptr(authenticationInformation), + uintptr(authenticationInformationLength), + uintptr(unsafe.Pointer(localGroups)), + uintptr(unsafe.Pointer(sourceContext)), + uintptr(unsafe.Pointer(profileBuffer)), + uintptr(unsafe.Pointer(profileBufferLength)), + uintptr(unsafe.Pointer(logonID)), + uintptr(unsafe.Pointer(token)), + uintptr(unsafe.Pointer(quotas)), + uintptr(unsafe.Pointer(subStatus)), + ) + return windows.NTStatus(result) +} diff --git a/experimental/cachefile/cache.go b/experimental/cachefile/cache.go index 567ba02640..3ab30218d2 100644 --- a/experimental/cachefile/cache.go +++ b/experimental/cachefile/cache.go @@ -45,6 +45,7 @@ type CacheFile struct { logger logger.Logger path string cacheID []byte + cacheIDText string storeFakeIP bool storeRDRC bool storeWARPConfig bool @@ -106,11 +107,12 @@ func New(ctx context.Context, logger logger.Logger, options option.CacheFileOpti logger: logger, path: filemanager.BasePath(ctx, path), cacheID: cacheIDBytes, - storeFakeIP: options.StoreFakeIP, - storeRDRC: options.StoreRDRC, + cacheIDText: options.CacheID, + storeFakeIP: options.StoreFakeIP, + storeRDRC: options.StoreRDRC, storeWARPConfig: options.StoreWARPConfig, storeMASQUEConfig: options.StoreMASQUEConfig, - storeDNS: options.StoreDNS, + storeDNS: options.StoreDNS, rdrcTimeout: rdrcTimeout, saveDomain: make(map[netip.Addr]string), saveAddress4: make(map[string]netip.Addr), @@ -128,6 +130,10 @@ func (c *CacheFile) Dependencies() []string { return nil } +func (c *CacheFile) CacheID() string { + return c.cacheIDText +} + func (c *CacheFile) SetOptimisticTimeout(timeout time.Duration) { c.optimisticTimeout = timeout } @@ -167,11 +173,13 @@ func (c *CacheFile) startCacheCleanup() { func (c *CacheFile) start() error { const fileMode = 0o666 + cacheFile, err := filemanager.OpenFile(c.ctx, c.path, os.O_RDWR|os.O_CREATE, fileMode) + if err != nil { + return err + } + cacheFile.Close() options := bbolt.Options{Timeout: time.Second} - var ( - db *bbolt.DB - err error - ) + var db *bbolt.DB for range 10 { db, err = bbolt.Open(c.path, fileMode, &options) if err == nil { @@ -181,7 +189,7 @@ func (c *CacheFile) start() error { continue } if E.IsMulti(err, bboltErrors.ErrInvalid, bboltErrors.ErrChecksum, bboltErrors.ErrVersionMismatch) { - rmErr := os.Remove(c.path) + rmErr := filemanager.Remove(c.ctx, c.path) if rmErr != nil { return err } @@ -264,7 +272,7 @@ func (c *CacheFile) resetDB() { c.resetAccess.Lock() defer c.resetAccess.Unlock() c.DB.Close() - os.Remove(c.path) + filemanager.Remove(c.ctx, c.path) db, err := bbolt.Open(c.path, 0o666, &bbolt.Options{Timeout: time.Second}) if err == nil { _ = filemanager.Chown(c.ctx, c.path) diff --git a/experimental/clashapi/server.go b/experimental/clashapi/server.go index 866088cacc..025e61e8ce 100644 --- a/experimental/clashapi/server.go +++ b/experimental/clashapi/server.go @@ -139,6 +139,10 @@ func NewServer(ctx context.Context, logFactory log.ObservableFactory, options op }) if options.ExternalUI != "" { s.externalUI = filemanager.BasePath(ctx, os.ExpandEnv(options.ExternalUI)) + _, err := filemanager.ReadDir(ctx, s.externalUI) + if err != nil && !os.IsNotExist(err) { + return nil, E.Cause(err, "read external UI directory") + } chiRouter.Group(func(r chi.Router) { r.Get("/ui", http.RedirectHandler("/ui/", http.StatusMovedPermanently).ServeHTTP) r.Handle("/ui/*", http.StripPrefix("/ui/", http.FileServer(Dir(s.externalUI)))) diff --git a/experimental/clashapi/server_resources.go b/experimental/clashapi/server_resources.go index ad9fff5369..a5997ebea6 100644 --- a/experimental/clashapi/server_resources.go +++ b/experimental/clashapi/server_resources.go @@ -7,7 +7,6 @@ import ( "io" "net" "net/http" - "os" "path/filepath" "strings" @@ -24,9 +23,9 @@ func (s *Server) checkAndDownloadExternalUI() { if s.externalUI == "" { return } - entries, err := os.ReadDir(s.externalUI) + entries, err := filemanager.ReadDir(s.ctx, s.externalUI) if err != nil { - os.MkdirAll(s.externalUI, 0o755) + filemanager.MkdirAll(s.ctx, s.externalUI, 0o755) } if len(entries) == 0 { err = s.downloadExternalUI() @@ -79,7 +78,7 @@ func (s *Server) downloadExternalUI() error { } err = s.downloadZIP(response.Body, s.externalUI) if err != nil { - removeAllInDirectory(s.externalUI) + removeAllInDirectory(s.ctx, s.externalUI) } return err } @@ -89,7 +88,7 @@ func (s *Server) downloadZIP(body io.Reader, output string) error { if err != nil { return err } - defer os.Remove(tempFile.Name()) + defer filemanager.Remove(s.ctx, tempFile.Name()) _, err = io.Copy(tempFile, body) tempFile.Close() if err != nil { @@ -113,7 +112,7 @@ func (s *Server) downloadZIP(body io.Reader, output string) error { if len(pathElements) > 1 { saveDirectory = filepath.Join(saveDirectory, filepath.Join(pathElements[:len(pathElements)-1]...)) } - err = os.MkdirAll(saveDirectory, 0o755) + err = filemanager.MkdirAll(s.ctx, saveDirectory, 0o755) if err != nil { return err } @@ -140,13 +139,13 @@ func downloadZIPEntry(ctx context.Context, zipFile *zip.File, savePath string) e return common.Error(io.Copy(saveFile, reader)) } -func removeAllInDirectory(directory string) { - dirEntries, err := os.ReadDir(directory) +func removeAllInDirectory(ctx context.Context, directory string) { + dirEntries, err := filemanager.ReadDir(ctx, directory) if err != nil { return } for _, dirEntry := range dirEntries { - os.RemoveAll(filepath.Join(directory, dirEntry.Name())) + filemanager.RemoveAll(ctx, filepath.Join(directory, dirEntry.Name())) } } diff --git a/experimental/deprecated/constants.go b/experimental/deprecated/constants.go index 106514caae..7819402e43 100644 --- a/experimental/deprecated/constants.go +++ b/experimental/deprecated/constants.go @@ -36,10 +36,14 @@ func (n Note) Impending() bool { } func (n Note) Message() string { + return n.MessageForLocale(locale.Current()) +} + +func (n Note) MessageForLocale(selectedLocale *locale.Locale) string { if n.MigrationLink != "" { - return fmt.Sprintf(locale.Current().DeprecatedMessage, n.Description, n.DeprecatedVersion, n.ScheduledVersion) + return fmt.Sprintf(selectedLocale.DeprecatedMessage, n.Description, n.DeprecatedVersion, n.ScheduledVersion) } else { - return fmt.Sprintf(locale.Current().DeprecatedMessageNoLink, n.Description, n.DeprecatedVersion, n.ScheduledVersion) + return fmt.Sprintf(selectedLocale.DeprecatedMessageNoLink, n.Description, n.DeprecatedVersion, n.ScheduledVersion) } } diff --git a/experimental/libbox/command_client.go b/experimental/libbox/command_client.go index c46b8d627b..f15e7219ae 100644 --- a/experimental/libbox/command_client.go +++ b/experimental/libbox/command_client.go @@ -151,8 +151,8 @@ func networkConnectionFromFileDescriptor(fileDescriptor int32) (net.Conn, error) func localDialOptions(contextDialer func(context.Context, string) (net.Conn, error)) []grpc.DialOption { options := []grpc.DialOption{ grpc.WithTransportCredentials(insecure.NewCredentials()), - grpc.WithUnaryInterceptor(unaryClientAuthInterceptor), - grpc.WithStreamInterceptor(streamClientAuthInterceptor), + grpc.WithChainUnaryInterceptor(daemon.UnaryClientLocaleInterceptor, unaryClientAuthInterceptor), + grpc.WithChainStreamInterceptor(daemon.StreamClientLocaleInterceptor, streamClientAuthInterceptor), } if contextDialer != nil { options = append(options, grpc.WithContextDialer(contextDialer)) @@ -550,10 +550,10 @@ func (c *CommandClient) SelectOutbound(groupTag string, outboundTag string) erro return nil } -func (c *CommandClient) URLTest(groupTag string) error { +func (c *CommandClient) URLTest(outboundTag string) error { _, err := callWithResult(c, func(ctx context.Context, client daemon.StartedServiceClient) (*emptypb.Empty, error) { return client.URLTest(ctx, &daemon.URLTestRequest{ - OutboundTag: groupTag, + OutboundTag: outboundTag, }) }) if err != nil { @@ -879,32 +879,26 @@ func (c *CommandClient) StartSTUNTest(server string, outboundTag string, handler return session, nil } -func (c *CommandClient) SubscribeTailscaleStatus(handler TailscaleStatusHandler) (*TailscaleStatusSubscription, error) { +func subscribeStatus[T any](c *CommandClient, session *streamSession, name string, start func(context.Context, daemon.StartedServiceClient) (grpc.ServerStreamingClient[T], error), onUpdate func(*T), onError func(string)) error { client, parentCtx, err := c.getClientForCall() if err != nil { - return nil, E.Cause(err, "subscribe tailscale status") + return E.Cause(err, "subscribe ", name) } streamCtx, cancel := context.WithCancel(parentCtx) - session := &TailscaleStatusSubscription{ - streamSession: streamSession{ - ctx: streamCtx, - cancel: cancel, - closeDone: make(chan struct{}), - }, + *session = streamSession{ + ctx: streamCtx, + cancel: cancel, + closeDone: make(chan struct{}), } - failStart := func(cause error, message string) (*TailscaleStatusSubscription, error) { + stream, err := start(streamCtx, client) + if err != nil { cancel() if c.standalone { c.closeConnection() } - return nil, E.Cause(cause, message) - } - - stream, err := client.SubscribeTailscaleStatus(streamCtx, &emptypb.Empty{}) - if err != nil { - return failStart(err, "subscribe tailscale status") + return E.Cause(err, "subscribe ", name) } standalone := c.standalone @@ -924,71 +918,139 @@ func (c *CommandClient) SubscribeTailscaleStatus(handler TailscaleStatusHandler) if status.Code(recvErr) == codes.NotFound || status.Code(recvErr) == codes.Unavailable { return } - handler.OnError(E.Cause(recvErr, "tailscale status recv").Error()) + onError(E.Cause(recvErr, name, " recv").Error()) return } - handler.OnStatusUpdate(tailscaleStatusUpdateFromGRPC(event)) + onUpdate(event) } }() + return nil +} +func (c *CommandClient) SubscribeTailscaleStatus(handler TailscaleStatusHandler) (*TailscaleStatusSubscription, error) { + session := new(TailscaleStatusSubscription) + err := subscribeStatus(c, &session.streamSession, "tailscale status", func(ctx context.Context, client daemon.StartedServiceClient) (grpc.ServerStreamingClient[daemon.TailscaleStatusUpdate], error) { + return client.SubscribeTailscaleStatus(ctx, &emptypb.Empty{}) + }, func(update *daemon.TailscaleStatusUpdate) { + handler.OnStatusUpdate(tailscaleStatusUpdateFromGRPC(update)) + }, handler.OnError) + if err != nil { + return nil, err + } return session, nil } func (c *CommandClient) SubscribeUSBIPServerStatus(handler USBIPServerStatusHandler) (*USBIPServerStatusSubscription, error) { - client, parentCtx, err := c.getClientForCall() + session := new(USBIPServerStatusSubscription) + err := subscribeStatus(c, &session.streamSession, "usbip server status", func(ctx context.Context, client daemon.StartedServiceClient) (grpc.ServerStreamingClient[daemon.USBIPServerStatusUpdate], error) { + return client.SubscribeUSBIPServerStatus(ctx, &emptypb.Empty{}) + }, func(update *daemon.USBIPServerStatusUpdate) { + handler.OnStatusUpdate(usbipServerStatusUpdateFromGRPC(update)) + }, handler.OnError) if err != nil { - return nil, E.Cause(err, "subscribe usbip server status") + return nil, err } + return session, nil +} - streamCtx, cancel := context.WithCancel(parentCtx) - session := &USBIPServerStatusSubscription{ - streamSession: streamSession{ - ctx: streamCtx, - cancel: cancel, - closeDone: make(chan struct{}), - }, +func (c *CommandClient) SubscribeOpenConnectStatus(handler OpenConnectStatusHandler) (*OpenConnectStatusSubscription, error) { + session := new(OpenConnectStatusSubscription) + err := subscribeStatus(c, &session.streamSession, "openconnect status", func(ctx context.Context, client daemon.StartedServiceClient) (grpc.ServerStreamingClient[daemon.OpenConnectStatusUpdate], error) { + return client.SubscribeOpenConnectStatus(ctx, &emptypb.Empty{}) + }, func(update *daemon.OpenConnectStatusUpdate) { + handler.OnStatusUpdate(openConnectStatusUpdateFromGRPC(update)) + }, handler.OnError) + if err != nil { + return nil, err } + return session, nil +} - failStart := func(cause error, message string) (*USBIPServerStatusSubscription, error) { - cancel() - if c.standalone { - c.closeConnection() - } - return nil, E.Cause(cause, message) +func (c *CommandClient) SubmitOpenConnectAuthResponse(endpointTag string, challengeID string, response *OpenConnectAuthResponse) error { + submission := &daemon.OpenConnectAuthResponseSubmission{ + EndpointTag: endpointTag, + ChallengeID: challengeID, + } + if response.formValues != nil { + submission.Response = &daemon.OpenConnectAuthResponseSubmission_Form{Form: &daemon.OpenConnectAuthFormResponse{ + Values: response.formValues.values, + }} + } + if response.browserResult != nil { + submission.Response = &daemon.OpenConnectAuthResponseSubmission_Browser{Browser: &daemon.OpenConnectBrowserResult{ + FinalURL: response.browserResult.FinalURL, + Cookies: common.Map(response.browserResult.cookies, func(cookie openConnectBrowserCookie) *daemon.OpenConnectBrowserCookie { + return &daemon.OpenConnectBrowserCookie{Name: cookie.Name, Value: cookie.Value} + }), + Headers: common.Map(response.browserResult.headers, func(header openConnectBrowserHeader) *daemon.OpenConnectBrowserHeader { + return &daemon.OpenConnectBrowserHeader{Name: header.Name, Values: header.Values} + }), + }} } - - stream, err := client.SubscribeUSBIPServerStatus(streamCtx, &emptypb.Empty{}) + _, err := callWithResult(c, func(ctx context.Context, client daemon.StartedServiceClient) (*emptypb.Empty, error) { + return client.SubmitOpenConnectAuthResponse(ctx, submission) + }) if err != nil { - return failStart(err, "subscribe usbip server status") + return E.Cause(err, "submit openconnect authentication response") } + return nil +} - standalone := c.standalone - go func() { - defer func() { - close(session.closeDone) - if standalone { - c.closeConnection() - } - }() - for { - event, recvErr := stream.Recv() - if recvErr != nil { - if session.ctx.Err() != nil { - return - } - if status.Code(recvErr) == codes.NotFound || status.Code(recvErr) == codes.Unavailable { - return - } - handler.OnError(E.Cause(recvErr, "usbip server status recv").Error()) - return - } - handler.OnStatusUpdate(usbipServerStatusUpdateFromGRPC(event)) - } - }() +func (c *CommandClient) CancelOpenConnectAuthChallenge(endpointTag string, challengeID string) error { + _, err := callWithResult(c, func(ctx context.Context, client daemon.StartedServiceClient) (*emptypb.Empty, error) { + return client.CancelOpenConnectAuthChallenge(ctx, &daemon.OpenConnectAuthChallengeCancel{ + EndpointTag: endpointTag, + ChallengeID: challengeID, + }) + }) + if err != nil { + return E.Cause(err, "cancel openconnect authentication challenge") + } + return nil +} +func (c *CommandClient) SubscribeOpenVPNStatus(handler OpenVPNStatusHandler) (*OpenVPNStatusSubscription, error) { + session := new(OpenVPNStatusSubscription) + err := subscribeStatus(c, &session.streamSession, "openvpn status", func(ctx context.Context, client daemon.StartedServiceClient) (grpc.ServerStreamingClient[daemon.OpenVPNStatusUpdate], error) { + return client.SubscribeOpenVPNStatus(ctx, &emptypb.Empty{}) + }, func(update *daemon.OpenVPNStatusUpdate) { + handler.OnStatusUpdate(openVPNStatusUpdateFromGRPC(update)) + }, handler.OnError) + if err != nil { + return nil, err + } return session, nil } +func (c *CommandClient) SubmitOpenVPNChallengeResponse(endpointTag string, challengeID string, response *OpenVPNChallengeResponse) error { + _, err := callWithResult(c, func(ctx context.Context, client daemon.StartedServiceClient) (*emptypb.Empty, error) { + return client.SubmitOpenVPNChallengeResponse(ctx, &daemon.OpenVPNChallengeSubmission{ + EndpointTag: endpointTag, + ChallengeID: challengeID, + Username: response.Username, + Password: response.Password, + Secret: response.Secret, + }) + }) + if err != nil { + return E.Cause(err, "submit openvpn challenge response") + } + return nil +} + +func (c *CommandClient) CancelOpenVPNChallenge(endpointTag string, challengeID string) error { + _, err := callWithResult(c, func(ctx context.Context, client daemon.StartedServiceClient) (*emptypb.Empty, error) { + return client.CancelOpenVPNChallenge(ctx, &daemon.OpenVPNChallengeCancel{ + EndpointTag: endpointTag, + ChallengeID: challengeID, + }) + }) + if err != nil { + return E.Cause(err, "cancel openvpn challenge") + } + return nil +} + func (c *CommandClient) SetTailscaleExitNode(endpointTag string, stableID string) error { _, err := callWithResult(c, func(ctx context.Context, client daemon.StartedServiceClient) (*emptypb.Empty, error) { return client.SetTailscaleExitNode(ctx, &daemon.SetTailscaleExitNodeRequest{ diff --git a/experimental/libbox/command_client_remote.go b/experimental/libbox/command_client_remote.go index bd6689fff8..e8ebceac15 100644 --- a/experimental/libbox/command_client_remote.go +++ b/experimental/libbox/command_client_remote.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/sagernet/sing-box/daemon" E "github.com/sagernet/sing/common/exceptions" "google.golang.org/grpc" @@ -62,19 +63,24 @@ func newRemoteConnection(options *RemoteConnectionOptions) (*remoteConnection, e if port == "" { port = defaultPort } + authorization := "" + if options.Secret != "" { + authorization = "Bearer " + options.Secret + } dialOptions := []grpc.DialOption{ grpc.WithTransportCredentials(transportCredentials), - } - if options.Secret != "" { - authorization := "Bearer " + options.Secret - dialOptions = append(dialOptions, - grpc.WithUnaryInterceptor(func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { - return invoker(metadata.AppendToOutgoingContext(ctx, "authorization", authorization), method, req, reply, cc, opts...) - }), - grpc.WithStreamInterceptor(func(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) { - return streamer(metadata.AppendToOutgoingContext(ctx, "authorization", authorization), desc, cc, method, opts...) - }), - ) + grpc.WithChainUnaryInterceptor(daemon.UnaryClientLocaleInterceptor, func(ctx context.Context, method string, request, reply any, connection *grpc.ClientConn, invoker grpc.UnaryInvoker, options ...grpc.CallOption) error { + if authorization != "" { + ctx = metadata.AppendToOutgoingContext(ctx, "authorization", authorization) + } + return invoker(ctx, method, request, reply, connection, options...) + }), + grpc.WithChainStreamInterceptor(daemon.StreamClientLocaleInterceptor, func(ctx context.Context, description *grpc.StreamDesc, connection *grpc.ClientConn, method string, streamer grpc.Streamer, options ...grpc.CallOption) (grpc.ClientStream, error) { + if authorization != "" { + ctx = metadata.AppendToOutgoingContext(ctx, "authorization", authorization) + } + return streamer(ctx, description, connection, method, options...) + }), } return &remoteConnection{ target: net.JoinHostPort(host, port), diff --git a/experimental/libbox/command_server.go b/experimental/libbox/command_server.go index dc09fa31ed..312431b354 100644 --- a/experimental/libbox/command_server.go +++ b/experimental/libbox/command_server.go @@ -29,6 +29,7 @@ import ( type CommandServer struct { *daemon.StartedService + ctx context.Context managedService *daemon.ManagedService handler CommandServerHandler platformInterface PlatformInterface @@ -56,6 +57,7 @@ func NewCommandServer(handler CommandServerHandler, platformInterface PlatformIn } service.MustRegister[adapter.PlatformInterface](ctx, platformWrapper) server := &CommandServer{ + ctx: ctx, handler: handler, platformInterface: platformInterface, platformWrapper: platformWrapper, @@ -161,8 +163,8 @@ func (s *CommandServer) Start() error { } s.listener = listener serverOptions := []grpc.ServerOption{ - grpc.UnaryInterceptor(unaryAuthInterceptor), - grpc.StreamInterceptor(streamAuthInterceptor), + grpc.ChainUnaryInterceptor(unaryAuthInterceptor, daemon.UnaryLocaleInterceptor), + grpc.ChainStreamInterceptor(streamAuthInterceptor, daemon.StreamLocaleInterceptor), } s.grpcServer = grpc.NewServer(serverOptions...) daemon.RegisterStartedServiceServer(s.grpcServer, s.StartedService) @@ -191,7 +193,7 @@ type OverrideOptions struct { func (s *CommandServer) StartOrReloadService(configContent string, options *OverrideOptions) error { saveConfigSnapshot(configContent) - err := s.StartedService.StartOrReloadService(configContent, &daemon.OverrideOptions{ + err := s.StartedService.StartOrReloadService(s.ctx, configContent, &daemon.OverrideOptions{ AutoRedirect: options.AutoRedirect, IncludePackage: iteratorToArray(options.IncludePackage), ExcludePackage: iteratorToArray(options.ExcludePackage), @@ -277,7 +279,7 @@ func (h *platformHandler) ServiceStop() error { return (*CommandServer)(h).handler.ServiceStop() } -func (h *platformHandler) ServiceReload() error { +func (h *platformHandler) ServiceReload(ctx context.Context) error { return (*CommandServer)(h).handler.ServiceReload() } diff --git a/experimental/libbox/command_types_openconnect.go b/experimental/libbox/command_types_openconnect.go new file mode 100644 index 0000000000..b31abe9aa2 --- /dev/null +++ b/experimental/libbox/command_types_openconnect.go @@ -0,0 +1,261 @@ +package libbox + +import ( + "strings" + + "github.com/sagernet/sing-box/daemon" + "github.com/sagernet/sing/common" +) + +type OpenConnectStatusUpdate struct { + endpoints []*OpenConnectEndpointStatus +} + +func (u *OpenConnectStatusUpdate) Endpoints() OpenConnectEndpointStatusIterator { + return newIterator(u.endpoints) +} + +type OpenConnectEndpointStatusIterator interface { + Next() *OpenConnectEndpointStatus + HasNext() bool +} + +type OpenConnectEndpointStatus struct { + EndpointTag string + State string + StateText string + AuthChallenge *OpenConnectAuthChallenge + Error string + TunnelInfo *OpenConnectTunnelInfo +} + +type OpenConnectTunnelInfo struct { + Server string + Flavor string + Transport string + ipv4 []string + ipv6 []string + dns []string + MTU int32 + ConnectedSince int64 +} + +func (i *OpenConnectTunnelInfo) IPv4() StringIterator { + return newIterator(i.ipv4) +} + +func (i *OpenConnectTunnelInfo) IPv6() StringIterator { + return newIterator(i.ipv6) +} + +func (i *OpenConnectTunnelInfo) DNS() StringIterator { + return newIterator(i.dns) +} + +type OpenConnectAuthChallenge struct { + ID string + Banner string + Message string + Error string + Form *OpenConnectAuthForm + Browser *OpenConnectBrowserRequest +} + +type OpenConnectAuthForm struct { + fields []*OpenConnectAuthFormField +} + +func (f *OpenConnectAuthForm) Fields() OpenConnectAuthFormFieldIterator { + return newIterator(f.fields) +} + +type OpenConnectBrowserRequest struct { + URL string + FinalURL string + CacheID string + cookieNames []string + earlyCookieNames []string + headerNames []string + callbackURLPrefixes []string +} + +func (r *OpenConnectBrowserRequest) CookieNames() StringIterator { + return newIterator(r.cookieNames) +} + +func (r *OpenConnectBrowserRequest) EarlyCookieNames() StringIterator { + return newIterator(r.earlyCookieNames) +} + +func (r *OpenConnectBrowserRequest) HeaderNames() StringIterator { + return newIterator(r.headerNames) +} + +func (r *OpenConnectBrowserRequest) CallbackURLPrefixes() StringIterator { + return newIterator(r.callbackURLPrefixes) +} + +type OpenConnectAuthFormFieldIterator interface { + Next() *OpenConnectAuthFormField + HasNext() bool +} + +type OpenConnectAuthFormField struct { + SubmissionKey string + Name string + Label string + Kind string + Value string + options []*OpenConnectAuthFormChoice +} + +func (f *OpenConnectAuthFormField) Options() OpenConnectAuthFormChoiceIterator { + return newIterator(f.options) +} + +type OpenConnectAuthFormChoiceIterator interface { + Next() *OpenConnectAuthFormChoice + HasNext() bool +} + +type OpenConnectAuthFormChoice struct { + Value string + Label string +} + +type OpenConnectFormValues struct { + values map[string]string +} + +func NewOpenConnectFormValues() *OpenConnectFormValues { + return &OpenConnectFormValues{values: make(map[string]string)} +} + +func (v *OpenConnectFormValues) Add(key string, value string) { + v.values[key] = value +} + +type OpenConnectBrowserResult struct { + FinalURL string + cookies []openConnectBrowserCookie + headers []openConnectBrowserHeader +} + +func NewOpenConnectBrowserResult(finalURL string) *OpenConnectBrowserResult { + return &OpenConnectBrowserResult{FinalURL: finalURL} +} + +func (r *OpenConnectBrowserResult) AddCookie(name string, value string) { + r.cookies = append(r.cookies, openConnectBrowserCookie{Name: name, Value: value}) +} + +func (r *OpenConnectBrowserResult) AddHeader(name string, value string) { + for i := range r.headers { + if strings.EqualFold(r.headers[i].Name, name) { + r.headers[i].Values = append(r.headers[i].Values, value) + return + } + } + r.headers = append(r.headers, openConnectBrowserHeader{Name: name, Values: []string{value}}) +} + +type openConnectBrowserCookie struct { + Name string + Value string +} + +type openConnectBrowserHeader struct { + Name string + Values []string +} + +type OpenConnectAuthResponse struct { + formValues *OpenConnectFormValues + browserResult *OpenConnectBrowserResult +} + +func NewOpenConnectAuthFormResponse(values *OpenConnectFormValues) *OpenConnectAuthResponse { + return &OpenConnectAuthResponse{formValues: values} +} + +func NewOpenConnectBrowserAuthResponse(result *OpenConnectBrowserResult) *OpenConnectAuthResponse { + return &OpenConnectAuthResponse{browserResult: result} +} + +type OpenConnectStatusHandler interface { + OnStatusUpdate(status *OpenConnectStatusUpdate) + OnError(message string) +} + +type OpenConnectStatusSubscription struct { + streamSession +} + +func openConnectStatusUpdateFromGRPC(update *daemon.OpenConnectStatusUpdate) *OpenConnectStatusUpdate { + return &OpenConnectStatusUpdate{ + endpoints: common.Map(update.Endpoints, openConnectEndpointStatusFromGRPC), + } +} + +func openConnectEndpointStatusFromGRPC(status *daemon.OpenConnectEndpointStatus) *OpenConnectEndpointStatus { + result := &OpenConnectEndpointStatus{ + EndpointTag: status.EndpointTag, + State: status.State, + StateText: status.StateText, + Error: status.Error, + } + if status.AuthChallenge != nil { + challenge := &OpenConnectAuthChallenge{ + ID: status.AuthChallenge.Id, + Banner: status.AuthChallenge.Banner, + Message: status.AuthChallenge.Message, + Error: status.AuthChallenge.Error, + } + form := status.AuthChallenge.GetForm() + if form != nil { + challenge.Form = &OpenConnectAuthForm{ + fields: common.Map(form.Fields, func(field *daemon.OpenConnectAuthFormField) *OpenConnectAuthFormField { + return &OpenConnectAuthFormField{ + SubmissionKey: field.SubmissionKey, + Name: field.Name, + Label: field.Label, + Kind: field.Kind, + Value: field.Value, + options: common.Map(field.Options, func(option *daemon.OpenConnectAuthFormChoice) *OpenConnectAuthFormChoice { + return &OpenConnectAuthFormChoice{ + Value: option.Value, + Label: option.Label, + } + }), + } + }), + } + } + browser := status.AuthChallenge.GetBrowser() + if browser != nil { + challenge.Browser = &OpenConnectBrowserRequest{ + URL: browser.Url, + FinalURL: browser.FinalURL, + CacheID: browser.CacheID, + cookieNames: browser.CookieNames, + earlyCookieNames: browser.EarlyCookieNames, + headerNames: browser.HeaderNames, + callbackURLPrefixes: browser.CallbackURLPrefixes, + } + } + result.AuthChallenge = challenge + } + if status.TunnelInfo != nil { + result.TunnelInfo = &OpenConnectTunnelInfo{ + Server: status.TunnelInfo.Server, + Flavor: status.TunnelInfo.Flavor, + Transport: status.TunnelInfo.Transport, + ipv4: status.TunnelInfo.Ipv4, + ipv6: status.TunnelInfo.Ipv6, + dns: status.TunnelInfo.Dns, + MTU: int32(status.TunnelInfo.Mtu), + ConnectedSince: status.TunnelInfo.ConnectedSince, + } + } + return result +} diff --git a/experimental/libbox/command_types_openvpn.go b/experimental/libbox/command_types_openvpn.go new file mode 100644 index 0000000000..1de1dc1dcc --- /dev/null +++ b/experimental/libbox/command_types_openvpn.go @@ -0,0 +1,119 @@ +package libbox + +import ( + "github.com/sagernet/sing-box/daemon" + "github.com/sagernet/sing/common" +) + +type OpenVPNStatusUpdate struct { + endpoints []*OpenVPNEndpointStatus +} + +func (u *OpenVPNStatusUpdate) Endpoints() OpenVPNEndpointStatusIterator { + return newIterator(u.endpoints) +} + +type OpenVPNEndpointStatusIterator interface { + Next() *OpenVPNEndpointStatus + HasNext() bool +} + +type OpenVPNEndpointStatus struct { + EndpointTag string + State string + StateText string + Challenge *OpenVPNChallenge + Error string + TunnelInfo *OpenVPNTunnelInfo +} + +type OpenVPNTunnelInfo struct { + Server string + Network string + Cipher string + ipv4 []string + ipv6 []string + dns []string + MTU int32 + ConnectedSince int64 +} + +func (i *OpenVPNTunnelInfo) IPv4() StringIterator { + return newIterator(i.ipv4) +} + +func (i *OpenVPNTunnelInfo) IPv6() StringIterator { + return newIterator(i.ipv6) +} + +func (i *OpenVPNTunnelInfo) DNS() StringIterator { + return newIterator(i.dns) +} + +type OpenVPNChallenge struct { + ID string + Kind string + Username string + Message string + URL string + SecretMessage string + Echo bool + PreviousError string + Deadline int64 +} + +type OpenVPNChallengeResponse struct { + Username string + Password string + Secret string +} + +type OpenVPNStatusHandler interface { + OnStatusUpdate(status *OpenVPNStatusUpdate) + OnError(message string) +} + +type OpenVPNStatusSubscription struct { + streamSession +} + +func openVPNStatusUpdateFromGRPC(update *daemon.OpenVPNStatusUpdate) *OpenVPNStatusUpdate { + return &OpenVPNStatusUpdate{ + endpoints: common.Map(update.Endpoints, openVPNEndpointStatusFromGRPC), + } +} + +func openVPNEndpointStatusFromGRPC(status *daemon.OpenVPNEndpointStatus) *OpenVPNEndpointStatus { + result := &OpenVPNEndpointStatus{ + EndpointTag: status.EndpointTag, + State: status.State, + StateText: status.StateText, + Error: status.Error, + } + if status.Challenge != nil { + result.Challenge = &OpenVPNChallenge{ + ID: status.Challenge.Id, + Kind: status.Challenge.Kind, + Username: status.Challenge.Username, + Message: status.Challenge.Message, + URL: status.Challenge.Url, + SecretMessage: status.Challenge.SecretMessage, + Echo: status.Challenge.Echo, + PreviousError: status.Challenge.PreviousError, + Deadline: status.Challenge.Deadline, + } + } + if status.TunnelInfo != nil { + result.TunnelInfo = &OpenVPNTunnelInfo{ + Server: status.TunnelInfo.Server, + Network: status.TunnelInfo.Network, + Cipher: status.TunnelInfo.Cipher, + ipv4: status.TunnelInfo.Ipv4, + ipv6: status.TunnelInfo.Ipv6, + dns: status.TunnelInfo.Dns, + MTU: int32(status.TunnelInfo.Mtu), + ConnectedSince: status.TunnelInfo.ConnectedSince, + } + } + return result +} diff --git a/experimental/libbox/command_types_tailscale.go b/experimental/libbox/command_types_tailscale.go index ffb9b242f2..b3aeae5b3a 100644 --- a/experimental/libbox/command_types_tailscale.go +++ b/experimental/libbox/command_types_tailscale.go @@ -18,6 +18,7 @@ type TailscaleEndpointStatusIterator interface { type TailscaleEndpointStatus struct { EndpointTag string BackendState string + StateText string AuthURL string NetworkName string MagicDNSSuffix string @@ -104,6 +105,7 @@ func tailscaleEndpointStatusFromGRPC(status *daemon.TailscaleEndpointStatus) *Ta result := &TailscaleEndpointStatus{ EndpointTag: status.EndpointTag, BackendState: status.BackendState, + StateText: status.StateText, AuthURL: status.AuthURL, NetworkName: status.NetworkName, MagicDNSSuffix: status.MagicDNSSuffix, diff --git a/experimental/libbox/config.go b/experimental/libbox/config.go index 1e8b29dd63..b52a5bcc7a 100644 --- a/experimental/libbox/config.go +++ b/experimental/libbox/config.go @@ -5,6 +5,7 @@ import ( "context" "net/netip" "os" + "reflect" box "github.com/sagernet/sing-box" "github.com/sagernet/sing-box/adapter" @@ -13,6 +14,7 @@ import ( "github.com/sagernet/sing-box/include" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/schema" "github.com/sagernet/sing-box/service/oomkiller" tun "github.com/sagernet/sing-tun" "github.com/sagernet/sing/common/control" @@ -107,6 +109,10 @@ func (s *platformInterfaceStub) OpenInterface(options *tun.Options, platformOpti return nil, os.ErrInvalid } +func (s *platformInterfaceStub) ProcessPlatformOptions(options option.TunPlatformOptions) error { + return nil +} + func (s *platformInterfaceStub) UsePlatformDefaultInterfaceMonitor() bool { return true } @@ -266,6 +272,14 @@ func (s *interfaceMonitorStub) MyInterface() string { return "" } +func GenerateConfigSchema() (*StringBox, error) { + content, err := schema.Generate(baseContext(nil), reflect.TypeFor[option.Options]()) + if err != nil { + return nil, err + } + return wrapString(string(content)), nil +} + func FormatConfig(configContent string) (*StringBox, error) { options, err := parseConfig(baseContext(nil), configContent) if err != nil { diff --git a/experimental/libbox/dns.go b/experimental/libbox/dns.go index b7b3b0f67d..596c2d9c45 100644 --- a/experimental/libbox/dns.go +++ b/experimental/libbox/dns.go @@ -108,6 +108,12 @@ func (p *platformTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*m } } +func (p *platformTransport) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { + go func() { + callback(p.Exchange(ctx, message)) + }() +} + type Func interface { Invoke() error } diff --git a/experimental/libbox/ffi.json b/experimental/libbox/ffi.json index 81fae27dac..90d9bd59f6 100644 --- a/experimental/libbox/ffi.json +++ b/experimental/libbox/ffi.json @@ -44,7 +44,7 @@ "ts_omit_synology", "ts_omit_bird" ], - "ldflags": "-X github.com/sagernet/sing-box/constant.Version=${VERSION} -X internal/godebug.defaultGODEBUG=multipathtcp=0 -s -w -buildid= -checklinkname=0", + "ldflags": "-X github.com/sagernet/sing-box/constant.Version=${VERSION} -X runtime.godebugDefault=multipathtcp=0,tlssha1=1,tlsunsafeekm=1 -s -w -buildid= -checklinkname=0", "trimpath": true } }, @@ -73,7 +73,7 @@ "ts_omit_synology", "ts_omit_bird" ], - "ldflags": "-X github.com/sagernet/sing-box/constant.Version=${VERSION} -X internal/godebug.defaultGODEBUG=multipathtcp=0 -s -w -buildid= -checklinkname=0", + "ldflags": "-X github.com/sagernet/sing-box/constant.Version=${VERSION} -X runtime.godebugDefault=multipathtcp=0,tlssha1=1,tlsunsafeekm=1 -s -w -buildid= -checklinkname=0", "trimpath": true } }, @@ -105,7 +105,7 @@ "ts_omit_synology", "ts_omit_bird" ], - "ldflags": "-X github.com/sagernet/sing-box/constant.Version=${VERSION} -X internal/godebug.defaultGODEBUG=multipathtcp=0 -s -w -buildid= -checklinkname=0", + "ldflags": "-X github.com/sagernet/sing-box/constant.Version=${VERSION} -X runtime.godebugDefault=multipathtcp=0,tlssha1=1,tlsunsafeekm=1 -s -w -buildid= -checklinkname=0", "trimpath": true }, "overrides": [ @@ -146,7 +146,7 @@ "ts_omit_synology", "ts_omit_bird" ], - "ldflags": "-X github.com/sagernet/sing-box/constant.Version=${VERSION} -X internal/godebug.defaultGODEBUG=multipathtcp=0 -s -w -buildid= -checklinkname=0", + "ldflags": "-X github.com/sagernet/sing-box/constant.Version=${VERSION} -X runtime.godebugDefault=multipathtcp=0,tlssha1=1,tlsunsafeekm=1 -s -w -buildid= -checklinkname=0", "trimpath": true } } diff --git a/experimental/libbox/log.go b/experimental/libbox/log.go index 9a3ebf707f..fc2ba66547 100644 --- a/experimental/libbox/log.go +++ b/experimental/libbox/log.go @@ -11,6 +11,8 @@ import ( "runtime" "runtime/debug" "time" + + "filippo.io/age" ) type crashReportMetadata struct { @@ -93,7 +95,7 @@ func redirectStderr(path string) error { return nil } -func CreateZipArchive(sourcePath string, destinationPath string) error { +func CreateZipArchive(sourcePath string, destinationPath string, encrypt bool) error { sourceInfo, err := os.Stat(sourcePath) if err != nil { return err @@ -110,7 +112,23 @@ func CreateZipArchive(sourcePath string, destinationPath string) error { _ = destinationFile.Close() }() - zipWriter := zip.NewWriter(destinationFile) + var archiveWriter io.Writer = destinationFile + var encryptedWriter io.WriteCloser + if encrypt { + const reportEncryptionRecipient = "age1pq18zn32skjt2ns56lja2nl4sztmx5u820fp67z2dg6ueyffy5l30gkdlae58a0qfd0ms5729gsdenkxdd2s55eh9g6nx80s9e757jf853p5r48vs2wkzyqyde5qd2hqpv9f5sfejn2svyqxse22pzz2l5vee4g90j0d2jsmycgwv4v4y79dshdc8jwjvkuhsawt7s22vgccdww2fpfxjke9gje4ugkgwn8938ejhh6y63u0uf5x0gxn706x0j00nwkesy3w3yryqun23f2e2zxxfv85wnwtaf5rsu4arrkhk6jsazqcjtps8qv0sz3z966hyspxyq7zqsgntyvhzqxpal6ex6khg267fpe5lxzknugynw48fsmqugncysxgpyprljmnce6dehtv6mu2v3x2qvudlkvhj89s58u3qwu5cerqfam9kdmtyd64y68p0mwclrmpume3gz47nkyjnj6c30elz3x4fv926qn3ulz8kmnwkxu4yg08v2as86yukzgfvgq8fxjchy2lkzrtr9jdyp5ymk7c7wn69jgcays3t3x5dlswmuphr4fqctj96zgftuqs0v95744h2h7gwdpn2nmtz26gtrsq3dcwp7l2qpldkcg64zx5k5g37zq4fspn2xesvmftvd2cwcgf93l0dcmwyz7zkfymdq6a6fyp2u738hx92cc6k4kejn5zcqgslw60g6ejueg8kyn4feycklg4v7utfsd5x3f8pc63xnj2la9wzlgn9992j3spq4c6l4s9s594gfzrps46pakwwspcx2njleu8xm9ys059f9zwfma0je40qlj8czsgx0pqkyfhxewclphty82q5s7rsyvya39a753eez5y85s9arn8vrftfsdzpvz44guus2qxzzl8wuk8fw0ykmj7vyq4wqzy5uwvxaywxf3f94jeh38lm66tvrzc842zx3p59x9dxmmt78prqftngjn6f5v2kvcphc6nspv8nfa5xcvpw9apft2583qux0tnvfwht8tkagymv2z84pccm8mdke9d9pw0ymhp9pnnqeyndwp8hzyjdcfr2phwxktawn5umav8zyw3v9wcsgaq9c36l52wd4s3uf3jsxh2dukw6vqh2t5ww9crl2mvv2zsua7q5v35l4mpkzn45ps9w6mewfd0q6ar0fhfd0eevvseyel5n8az3s5xnv627u2ggq4jkw75mzg5wxpadzg32668ts04xd9n92g6ct3ewkfwku80fevy7ypj6qxddn6fanztvngw499syynu9d6ak3070uq7wg3frc3c7jphz49vxfhe4fh2ggkfjsup2reveyvwcqp9j9nzfj6gysmc3tu8wux35p2tvd6mysu2sleslrds7zyewj9kj4qzqvy4juj0yn0pvhljfxr92acus8ztzvfsv5ekx92rywzqhtg9rav074esg9rqzsenpzjv0r87slhczwrkdt22gka0w4hnrj27rpp0rmw2z7dgnz6ly2p0fqh845ej4jujkk86kz4pcdld73rez2crqe65gc8437l4kt40v2e9lxe8raqxvr5wx9m44jpc5nr4ydhh7mtv0aqkx3e7w96jep2l63ez3wecaja2g47z7f9dyd6hpgfsejx25y3mpfsx4uncmmm5uvg9dhyy0lad2wfg2ju6jrqa5d5k6zgvcmy7l8pfasfj7yt0z5qqap5yvc4av4vhjq86ff6sfqj5x0lxzug5ca08yua72pgjgx49y0ftq63u3v53wmy2t9jg77sfwjvzcka5ur6z922gua6g323dtxggwkkqq7z6uu9mpkrjre2nd0lw04x4gt4gcn4llm4wh6ksvu7nx4erttv47fgzcw9sdxx0c6ll9pf0ntae5clp3qclwg4uhvd02lenv0mard9lmtalss2sg8d4ay78u4cgem02swh3pq5" + var recipient *age.HybridRecipient + recipient, err = age.ParseHybridRecipient(reportEncryptionRecipient) + if err != nil { + return err + } + encryptedWriter, err = age.Encrypt(destinationFile, recipient) + if err != nil { + return err + } + archiveWriter = encryptedWriter + } + + zipWriter := zip.NewWriter(archiveWriter) rootName := filepath.Base(sourcePath) err = filepath.WalkDir(sourcePath, func(path string, d fs.DirEntry, err error) error { @@ -162,8 +180,21 @@ func CreateZipArchive(sourcePath string, destinationPath string) error { }) if err != nil { _ = zipWriter.Close() + if encryptedWriter != nil { + _ = encryptedWriter.Close() + } return err } - return zipWriter.Close() + err = zipWriter.Close() + if err != nil { + if encryptedWriter != nil { + _ = encryptedWriter.Close() + } + return err + } + if encryptedWriter != nil { + return encryptedWriter.Close() + } + return nil } diff --git a/experimental/libbox/monitor.go b/experimental/libbox/monitor.go index 45b11c46de..d1f8f93878 100644 --- a/experimental/libbox/monitor.go +++ b/experimental/libbox/monitor.go @@ -15,9 +15,10 @@ var ( type platformDefaultInterfaceMonitor struct { *platformInterfaceWrapper - logger logger.Logger - callbacks list.List[tun.DefaultInterfaceUpdateCallback] - myInterfaces []string + logger logger.Logger + callbacks list.List[tun.DefaultInterfaceUpdateCallback] + myInterfaces []string + defaultInterfaceInitialized bool } func (m *platformDefaultInterfaceMonitor) Start() error { @@ -77,6 +78,7 @@ func (m *platformDefaultInterfaceMonitor) updateDefaultInterface(interfaceName s m.defaultInterfaceAccess.Lock() if interfaceIndex32 == -1 { m.defaultInterface = nil + m.defaultInterfaceInitialized = true callbacks := m.callbacks.Array() m.defaultInterfaceAccess.Unlock() for _, callback := range callbacks { @@ -92,10 +94,11 @@ func (m *platformDefaultInterfaceMonitor) updateDefaultInterface(interfaceName s return } m.defaultInterface = newInterface - if oldInterface != nil && oldInterface.Name == m.defaultInterface.Name && oldInterface.Index == m.defaultInterface.Index { + if m.defaultInterfaceInitialized && oldInterface != nil && oldInterface.Name == m.defaultInterface.Name && oldInterface.Index == m.defaultInterface.Index { m.defaultInterfaceAccess.Unlock() return } + m.defaultInterfaceInitialized = true callbacks := m.callbacks.Array() m.defaultInterfaceAccess.Unlock() for _, callback := range callbacks { diff --git a/experimental/libbox/oom_report.go b/experimental/libbox/oom_report.go index 519446bab8..a712f953bd 100644 --- a/experimental/libbox/oom_report.go +++ b/experimental/libbox/oom_report.go @@ -68,6 +68,10 @@ type oomReporter struct { var _ oomkiller.OOMReporter = (*oomReporter)(nil) +func NewOOMReporter(startedService *daemon.StartedService) oomkiller.OOMReporter { + return &oomReporter{startedService: startedService} +} + func (r *oomReporter) WriteReport(memoryUsage uint64) error { draftPath := filepath.Join(sWorkingPath, "oom_draft") draftInfo, err := os.Stat(draftPath) diff --git a/experimental/libbox/semver.go b/experimental/libbox/semver.go index b0919222c4..3f4d877eff 100644 --- a/experimental/libbox/semver.go +++ b/experimental/libbox/semver.go @@ -3,6 +3,8 @@ package libbox import ( "strings" + "github.com/sagernet/sing-box/common/badversion" + "golang.org/x/mod/semver" ) @@ -15,7 +17,7 @@ func CompareSemver(left string, right string) bool { if !semver.IsValid(normalizedRight) { return false } - return semver.Compare(normalizedLeft, normalizedRight) > 0 + return badversion.Parse(normalizedLeft).GreaterThan(badversion.Parse(normalizedRight)) } func normalizeSemver(version string) string { diff --git a/experimental/libbox/service.go b/experimental/libbox/service.go index 9d0bc37e7c..21d7adcfa7 100644 --- a/experimental/libbox/service.go +++ b/experimental/libbox/service.go @@ -85,6 +85,10 @@ func (w *platformInterfaceWrapper) OpenInterface(options *tun.Options, platformO return tun.New(*options) } +func (w *platformInterfaceWrapper) ProcessPlatformOptions(options option.TunPlatformOptions) error { + return nil +} + func myTunAddress(options *tun.Options) []netip.Addr { addresses := make([]netip.Addr, 0, len(options.Inet4Address)+len(options.Inet6Address)) for _, prefix := range options.Inet4Address { diff --git a/experimental/libbox/setup.go b/experimental/libbox/setup.go index 9f8aa03cf9..8c27613029 100644 --- a/experimental/libbox/setup.go +++ b/experimental/libbox/setup.go @@ -6,7 +6,6 @@ import ( "path/filepath" "runtime" "runtime/debug" - "strings" "time" "github.com/sagernet/sing-box/common/networkquality" @@ -102,12 +101,9 @@ func Setup(options *SetupOptions) error { return redirectStderr(filepath.Join(sWorkingPath, "CrashReport-"+sCrashReportSource+".log")) } -func SetLocale(localeId string) error { - if strings.Contains(localeId, "@") { - localeId = strings.Split(localeId, "@")[0] - } - if !locale.Set(localeId) { - return E.New("unsupported locale: ", localeId) +func SetLocale(localeID string) error { + if !locale.Set(localeID) { + return E.New("unsupported locale: ", localeID) } return nil } diff --git a/experimental/locale/locale.go b/experimental/locale/locale.go index e5575af4eb..06ad77e0e4 100644 --- a/experimental/locale/locale.go +++ b/experimental/locale/locale.go @@ -1,32 +1,195 @@ package locale +import ( + "context" + "strings" + "sync/atomic" + + "golang.org/x/text/language" +) + var ( - localeRegistry = make(map[string]*Locale) - current = defaultLocal + localeRegistry = map[string]*Locale{ + "en": defaultLocale, + } + localeMatcher = language.NewMatcher( + []language.Tag{ + language.English, + language.SimplifiedChinese, + language.TraditionalChinese, + language.Persian, + language.Russian, + }, + language.PreferSameScript(true), + ) + localeNames = []string{"en", "zh-Hans", "zh-Hant", "fa", "ru"} + current atomic.Pointer[Locale] ) type Locale struct { - // deprecated messages for graphical clients Locale string DeprecatedMessage string DeprecatedMessageNoLink string + InsecureFeatureMessage string + ExternalPathFeature string + TailscaleInitializing string + TailscaleInUse string + TailscaleNeedsLogin string + TailscaleNeedsApproval string + TailscaleStopped string + TailscaleStarting string + TailscaleRunning string + VPNConnecting string + VPNAuthentication string + VPNConnected string + VPNError string + Unknown string } -var defaultLocal = &Locale{ - Locale: "en_US", - DeprecatedMessage: "%s is deprecated in sing-box %s and will be removed in sing-box %s please checkout documentation for migration.", +var defaultLocale = &Locale{ + Locale: "en", + DeprecatedMessage: "%s is deprecated in sing-box %s and will be removed in sing-box %s. Please check the documentation for migration.", DeprecatedMessageNoLink: "%s is deprecated in sing-box %s and will be removed in sing-box %s.", + InsecureFeatureMessage: "%s is considered insecure in the graphical client for sing-box on %s. Enable Insecure Mode in `Settings - Core - Insecure Mode` to use it.", + ExternalPathFeature: "Access to %s (outside of the working directory) is considered insecure in the graphical client for sing-box on %s. Enable Insecure Mode in `Settings - Core - Insecure Mode` to use it.", + TailscaleInitializing: "Initializing", + TailscaleInUse: "In use by another user", + TailscaleNeedsLogin: "Needs login", + TailscaleNeedsApproval: "Needs approval", + TailscaleStopped: "Stopped", + TailscaleStarting: "Starting", + TailscaleRunning: "Running", + VPNConnecting: "Connecting", + VPNAuthentication: "Authentication required", + VPNConnected: "Connected", + VPNError: "Error", + Unknown: "Unknown", +} + +type contextKey struct{} + +func init() { + current.Store(defaultLocale) } func Current() *Locale { - return current + return current.Load() } -func Set(localeId string) bool { - locale, loaded := localeRegistry[localeId] +func ContextWithLocale(ctx context.Context, localeID string) (context.Context, bool) { + selectedLocale, loaded := selectLocale(localeID) + if !loaded { + return ctx, false + } + return context.WithValue(ctx, contextKey{}, selectedLocale), true +} + +func FromContext(ctx context.Context) *Locale { + selectedLocale, loaded := ctx.Value(contextKey{}).(*Locale) + if loaded { + return selectedLocale + } + return Current() +} + +func selectLocale(localeID string) (*Locale, bool) { + localeName, loaded := match(localeID) + if !loaded { + return nil, false + } + selectedLocale, loaded := localeRegistry[localeName] + return selectedLocale, loaded +} + +func Set(localeID string) bool { + selectedLocale, loaded := selectLocale(localeID) if !loaded { return false } - current = locale + current.Store(selectedLocale) return true } + +func match(localeID string) (string, bool) { + localeEntries := strings.Split(localeID, ",") + for i, localeEntry := range localeEntries { + languageID, options, hasOptions := strings.Cut(localeEntry, ";") + languageID, _, _ = strings.Cut(strings.TrimSpace(languageID), "@") + languageID = strings.ReplaceAll(languageID, "_", "-") + if !hasOptions { + languageID, _, _ = strings.Cut(languageID, ".") + } + switch { + case strings.EqualFold(languageID, "C"), strings.EqualFold(languageID, "POSIX"): + languageID = "en" + case strings.EqualFold(languageID, "zh-CHS"): + languageID = "zh-Hans" + case strings.EqualFold(languageID, "zh-CHT"): + languageID = "zh-Hant" + } + localeEntries[i] = languageID + if hasOptions { + localeEntries[i] += ";" + options + } + } + localeID = strings.Join(localeEntries, ",") + tags, _, err := language.ParseAcceptLanguage(localeID) + if err != nil || len(tags) == 0 { + return "", false + } + for i, tag := range tags { + base, script, region := tag.Raw() + if base.String() != "zh" && base.String() != "cmn" { + continue + } + if script.String() == "Hans" || script.String() == "Hant" { + continue + } + languageID := "zh-Hans" + if region.String() == "TW" || region.String() == "HK" || region.String() == "MO" { + languageID = "zh-Hant" + } + if region.String() != "ZZ" { + languageID += "-" + region.String() + } + tags[i] = language.MustParse(languageID) + } + _, localeIndex, _ := localeMatcher.Match(tags...) + return localeNames[localeIndex], true +} + +func (l *Locale) TailscaleStateText(state string) string { + switch state { + case "NoState": + return l.TailscaleInitializing + case "InUseOtherUser": + return l.TailscaleInUse + case "NeedsLogin": + return l.TailscaleNeedsLogin + case "NeedsMachineAuth": + return l.TailscaleNeedsApproval + case "Stopped": + return l.TailscaleStopped + case "Starting": + return l.TailscaleStarting + case "Running": + return l.TailscaleRunning + default: + return l.Unknown + } +} + +func (l *Locale) VPNStateText(state string) string { + switch state { + case "connecting": + return l.VPNConnecting + case "auth-pending": + return l.VPNAuthentication + case "connected": + return l.VPNConnected + case "error": + return l.VPNError + default: + return l.Unknown + } +} diff --git a/experimental/locale/locale_fa.go b/experimental/locale/locale_fa.go new file mode 100644 index 0000000000..7c2281b55f --- /dev/null +++ b/experimental/locale/locale_fa.go @@ -0,0 +1,23 @@ +package locale + +func init() { + localeRegistry["fa"] = &Locale{ + Locale: "fa", + DeprecatedMessage: "%s از sing-box %s منسوخ شده است و در sing-box %s حذف خواهد شد؛ لطفاً راهنمای مهاجرت را ببینید.", + DeprecatedMessageNoLink: "%s از sing-box %s منسوخ شده است و در sing-box %s حذف خواهد شد.", + InsecureFeatureMessage: "%s در کلاینت گرافیکی sing-box برای %s ناامن تلقی می\u200cشود. برای استفاده، `حالت ناامن` را در `تنظیمات - هسته - حالت ناامن` فعال کنید.", + ExternalPathFeature: "دسترسی به %s (خارج از پوشهٔ کاری) در کلاینت گرافیکی sing-box برای %s ناامن تلقی می\u200cشود. برای استفاده، `حالت ناامن` را در `تنظیمات - هسته - حالت ناامن` فعال کنید.", + TailscaleInitializing: "در حال راه\u200cاندازی", + TailscaleInUse: "در حال استفاده توسط کاربر دیگری", + TailscaleNeedsLogin: "نیاز به ورود", + TailscaleNeedsApproval: "نیاز به تأیید", + TailscaleStopped: "متوقف\u200cشده", + TailscaleStarting: "در حال شروع", + TailscaleRunning: "در حال اجرا", + VPNConnecting: "در حال اتصال", + VPNAuthentication: "نیاز به احراز هویت", + VPNConnected: "متصل", + VPNError: "خطا", + Unknown: "ناشناخته", + } +} diff --git a/experimental/locale/locale_ru.go b/experimental/locale/locale_ru.go new file mode 100644 index 0000000000..0cdce750a4 --- /dev/null +++ b/experimental/locale/locale_ru.go @@ -0,0 +1,23 @@ +package locale + +func init() { + localeRegistry["ru"] = &Locale{ + Locale: "ru", + DeprecatedMessage: "Использование %s устарело в sing-box %s, и эта возможность будет удалена в sing-box %s. Ознакомьтесь с руководством по миграции.", + DeprecatedMessageNoLink: "Использование %s устарело в sing-box %s, и эта возможность будет удалена в sing-box %s.", + InsecureFeatureMessage: "%s считается небезопасным в графическом клиенте sing-box для %s. Чтобы использовать эту возможность, включите `Небезопасный режим` в разделе `Настройки — Ядро — Небезопасный режим`.", + ExternalPathFeature: "Доступ к %s (за пределами рабочего каталога) считается небезопасным в графическом клиенте sing-box для %s. Чтобы использовать эту возможность, включите `Небезопасный режим` в разделе `Настройки — Ядро — Небезопасный режим`.", + TailscaleInitializing: "Инициализация", + TailscaleInUse: "Используется другим пользователем", + TailscaleNeedsLogin: "Требуется вход", + TailscaleNeedsApproval: "Требуется подтверждение", + TailscaleStopped: "Остановлено", + TailscaleStarting: "Запуск", + TailscaleRunning: "Работает", + VPNConnecting: "Подключение", + VPNAuthentication: "Требуется аутентификация", + VPNConnected: "Подключено", + VPNError: "Ошибка", + Unknown: "Неизвестно", + } +} diff --git a/experimental/locale/locale_zh_CN.go b/experimental/locale/locale_zh_CN.go index f5605d9d16..3d4cce8143 100644 --- a/experimental/locale/locale_zh_CN.go +++ b/experimental/locale/locale_zh_CN.go @@ -3,9 +3,23 @@ package locale var warningMessageForEndUsers = "\n\n如果您不明白此消息意味着什么:您的配置文件已过时,且将很快不可用。请联系您的配置提供者以更新配置。" func init() { - localeRegistry["zh_CN"] = &Locale{ - Locale: "zh_CN", + localeRegistry["zh-Hans"] = &Locale{ + Locale: "zh-Hans", DeprecatedMessage: "%s 已在 sing-box %s 中被弃用,且将在 sing-box %s 中被移除,请参阅迁移指南。" + warningMessageForEndUsers, DeprecatedMessageNoLink: "%s 已在 sing-box %s 中被弃用,且将在 sing-box %s 中被移除。" + warningMessageForEndUsers, + InsecureFeatureMessage: "%s 在 sing-box 的 %s 图形客户端中被视为不安全。请在 `设置 - 核心 - 不安全模式` 中启用不安全模式后使用。", + ExternalPathFeature: "访问 %s(位于工作目录之外)在 sing-box 的 %s 图形客户端中是不安全的。请在 `设置 - 核心 - 不安全模式` 中启用不安全模式后使用。", + TailscaleInitializing: "正在初始化", + TailscaleInUse: "正由其他用户使用", + TailscaleNeedsLogin: "需要登录", + TailscaleNeedsApproval: "需要批准", + TailscaleStopped: "已停止", + TailscaleStarting: "启动中", + TailscaleRunning: "运行中", + VPNConnecting: "正在连接", + VPNAuthentication: "需要认证", + VPNConnected: "已连接", + VPNError: "错误", + Unknown: "未知", } } diff --git a/experimental/locale/locale_zh_TW.go b/experimental/locale/locale_zh_TW.go new file mode 100644 index 0000000000..efd8ac4cd6 --- /dev/null +++ b/experimental/locale/locale_zh_TW.go @@ -0,0 +1,23 @@ +package locale + +func init() { + localeRegistry["zh-Hant"] = &Locale{ + Locale: "zh-Hant", + DeprecatedMessage: "%s 已在 sing-box %s 中棄用,且將在 sing-box %s 中移除,請參閱遷移指南。", + DeprecatedMessageNoLink: "%s 已在 sing-box %s 中棄用,且將在 sing-box %s 中移除。", + InsecureFeatureMessage: "%s 在 sing-box 的 %s 圖形用戶端中被視為不安全。請在 `設置 - 核心 - 不安全模式` 中啟用不安全模式後使用。", + ExternalPathFeature: "存取 %s(位於工作目錄之外)在 sing-box 的 %s 圖形用戶端中被視為不安全。請在 `設置 - 核心 - 不安全模式` 中啟用不安全模式後使用。", + TailscaleInitializing: "正在初始化", + TailscaleInUse: "正由其他使用者使用", + TailscaleNeedsLogin: "需要登入", + TailscaleNeedsApproval: "需要核准", + TailscaleStopped: "已停止", + TailscaleStarting: "啟動中", + TailscaleRunning: "執行中", + VPNConnecting: "正在連線", + VPNAuthentication: "需要認證", + VPNConnected: "已連線", + VPNError: "錯誤", + Unknown: "未知", + } +} diff --git a/go.mod b/go.mod index 16f7c0081c..8cb298a094 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.6 require github.com/amnezia-vpn/amneziawg-go v0.2.17-0.20251219021448-449d7cffd4ad require ( + filippo.io/age v1.3.1 github.com/Diniboy1123/connect-ip-go v0.0.0-20260409225322-8d7bb0a858a2 github.com/Psiphon-Labs/psiphon-tunnel-core v1.0.11-0.20260126173038-c86a1497a760 github.com/anthropics/anthropic-sdk-go v1.26.0 @@ -31,7 +32,7 @@ require ( github.com/libdns/libdns v1.1.1 github.com/logrusorgru/aurora v2.0.3+incompatible github.com/mdlayher/netlink v1.9.0 - github.com/metacubex/utls v1.8.4 + github.com/metacubex/utls v1.8.7 github.com/mholt/acmez/v3 v3.1.6 github.com/miekg/dns v1.1.72 github.com/net2share/vaydns v0.2.6 @@ -43,35 +44,40 @@ require ( github.com/sagernet/asc-go v0.0.0-20241217030726-d563060fe4e1 github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a github.com/sagernet/cors v1.2.1 - github.com/sagernet/cronet-go v0.0.0-20260620140045-05ab0dc17597 - github.com/sagernet/cronet-go/all v0.0.0-20260620140045-05ab0dc17597 + github.com/sagernet/cronet-go v0.0.0-20260712143338-d22f2ea3630e + github.com/sagernet/cronet-go/all v0.0.0-20260712143338-d22f2ea3630e github.com/sagernet/fswatch v0.1.2 github.com/sagernet/gliderssh v0.3.4-0.20260531100337-2194faca5648 github.com/sagernet/gomobile v0.1.12 github.com/sagernet/gvisor v0.0.0-20250811.0-sing-box-mod.1 github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a - github.com/sagernet/nftables v0.3.0-mod.3 + github.com/sagernet/nftables v0.3.0-mod.4 github.com/sagernet/quic-go v0.59.0-sing-box-mod.4 - github.com/sagernet/sing v0.8.12-0.20260702081104-2ded2af32d3d + github.com/sagernet/sing v0.8.12-0.20260721063414-596db5dd6ef4 github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 github.com/sagernet/sing-mux v0.3.5 - github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc + github.com/sagernet/sing-openconnect v0.0.0-20260722140139-e0c977659ca2 + github.com/sagernet/sing-openvpn v0.0.0-20260721005523-64b754d1c277 + github.com/sagernet/sing-quic v0.6.4-0.20260720125339-63fa6a7ebc31 github.com/sagernet/sing-shadowsocks v0.2.8 github.com/sagernet/sing-shadowsocks2 v0.2.1 github.com/sagernet/sing-shadowtls v0.2.1 - github.com/sagernet/sing-snell v0.0.0-20260710094516-a4e97ee24beb - github.com/sagernet/sing-tun v0.8.12-0.20260711072757-9e647fcd6b38 + github.com/sagernet/sing-snell v0.0.0-20260719094200-c43fbee0e839 + github.com/sagernet/sing-tun v0.8.12-0.20260724023008-68aa7bdc2819 github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 github.com/sagernet/smux v1.5.50-sing-box-mod.1 - github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7.0.20260711004045-7d3e867147ad - github.com/sagernet/wireguard-go v0.0.5-0.20260706153856-2c27bbf4f97f + github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7.0.20260717155615-b353b93d194a + github.com/sagernet/wireguard-go v0.0.5-0.20260717024847-6f5e8b1947ae github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 + github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 github.com/tidwall/gjson v1.18.0 github.com/vishvananda/netns v0.0.5 + github.com/xchacha20-poly1305/sing-trusttunnel v0.2.1 github.com/yosida95/uritemplate/v3 v3.0.2 go.uber.org/zap v1.27.1 go4.org/netipx v0.0.0-20231129151722-fdeea329fbba @@ -81,6 +87,7 @@ require ( golang.org/x/net v0.50.0 golang.org/x/sync v0.19.0 golang.org/x/sys v0.41.0 + golang.org/x/text v0.34.0 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 google.golang.org/grpc v1.79.1 google.golang.org/protobuf v1.36.11 @@ -99,9 +106,12 @@ require ( require ( filippo.io/edwards25519 v1.1.0 // indirect + filippo.io/hpke v0.4.0 // indirect + github.com/RyuaNerin/go-krypto v1.3.0 // indirect github.com/ajg/form v1.5.1 // indirect github.com/akutz/memconn v0.1.0 // indirect github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect + github.com/anchore/go-lzo v0.1.0 // indirect github.com/andybalholm/brotli v1.2.0 // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect @@ -111,6 +121,7 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1 // indirect + github.com/dgryski/go-camellia v0.0.0-20191119043421-69a8a13fb23d // indirect github.com/ebitengine/purego v0.10.0 // indirect github.com/florianl/go-nfqueue/v2 v2.0.2 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect @@ -121,8 +132,9 @@ require ( github.com/go-ole/go-ole v1.3.0 // indirect github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/pool v0.2.1 // indirect - github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/btree v1.1.3 // indirect + github.com/google/certificate-transparency-go v1.3.2 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/gopacket v1.1.19 // indirect @@ -143,42 +155,45 @@ require ( github.com/mitchellh/go-ps v1.0.0 // indirect github.com/philhofer/fwd v1.2.0 // indirect github.com/pierrec/lz4/v4 v4.1.21 // indirect + github.com/pion/dtls/v3 v3.1.5 // indirect + github.com/pion/logging v0.2.4 // indirect + github.com/pion/transport/v4 v4.0.2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus-community/pro-bing v0.4.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/safchain/ethtool v0.3.0 // indirect - github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260620135226-def9ff0fb992 // indirect - github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260620135226-def9ff0fb992 // indirect + github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/smallstep/pkcs7 v0.1.1 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect - github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 // indirect github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a // indirect github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 // indirect @@ -187,15 +202,16 @@ require ( github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect + github.com/tjfoc/gmsm v1.4.1 // indirect github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect github.com/x448/float16 v0.8.4 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect github.com/zeebo/blake3 v0.2.4 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap/exp v0.3.0 // indirect go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/term v0.40.0 // indirect - golang.org/x/text v0.34.0 // indirect golang.org/x/time v0.14.0 // indirect golang.org/x/tools v0.42.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect @@ -243,7 +259,6 @@ require ( github.com/pion/dtls/v2 v2.2.7 // indirect github.com/pion/ice/v2 v2.3.24 // indirect github.com/pion/interceptor v0.1.25 // indirect - github.com/pion/logging v0.2.2 // indirect github.com/pion/mdns v0.0.12 // indirect github.com/pion/randutil v0.1.0 // indirect github.com/pion/rtcp v1.2.12 // indirect @@ -266,7 +281,6 @@ require ( github.com/sirupsen/logrus v1.9.4 // indirect github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635 // indirect github.com/things-go/go-socks5 v0.1.1 // indirect - github.com/tjfoc/gmsm v1.4.1 // indirect github.com/wader/filtertransport v0.0.0-20200316221534-bdd9e61eee78 // indirect github.com/wlynxg/anet v0.0.5 // indirect github.com/xtaci/kcp-go/v5 v5.6.70 // indirect diff --git a/go.sum b/go.sum index b2f3075e26..273c19ab15 100644 --- a/go.sum +++ b/go.sum @@ -1,25 +1,107 @@ +4d63.com/gocheckcompilerdirectives v1.2.1 h1:AHcMYuw56NPjq/2y615IGg2kYkBdTvOaojYCBcRE7MA= +4d63.com/gocheckcompilerdirectives v1.2.1/go.mod h1:yjDJSxmDTtIHHCqX0ufRYZDL6vQtMG7tJdKVeWwsqvs= +4d63.com/gochecknoglobals v0.2.1 h1:1eiorGsgHOFOuoOiJDy2psSrQbRdIHrlge0IJIkUgDc= +4d63.com/gochecknoglobals v0.2.1/go.mod h1:KRE8wtJB3CXCsb1xy421JfTHIIbmT3U5ruxw2Qu8fSU= +bitbucket.org/creachadair/shell v0.0.8 h1:3yM6JcAfaGWzjzcCamTblzSIWXm/YSs0PFGIzBm2HTo= +bitbucket.org/creachadair/shell v0.0.8/go.mod h1:vINzudofoUXZSJ5tREgpy+Etyjsag3ait5WOWImEVZ0= +c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd h1:ZLsPO6WdZ5zatV4UfVpr7oAwLGRZ+sebTUruuM4Ra3M= +c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo= +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.119.0 h1:tw7OjErMzJKbbjaEHkrt60KQrK5Wus/boCZ7tm5/RNE= +cloud.google.com/go v0.119.0/go.mod h1:fwB8QLzTcNevxqi8dcpR+hoMIs3jBherGS9VUBDAW08= +cloud.google.com/go/ai v0.10.0 h1:hwj6CI6sMKubXodoJJGTy/c2T1RbbLGM6TL3QoAvzU8= +cloud.google.com/go/ai v0.10.0/go.mod h1:kvnt2KeHqX8+41PVeMRBETDyQAp/RFvBWGdx/aGjNMo= +cloud.google.com/go/auth v0.15.0 h1:Ly0u4aA5vG/fsSsxu98qCQBemXtAtJf+95z9HK+cxps= +cloud.google.com/go/auth v0.15.0/go.mod h1:WJDGqZ1o9E9wKIL+IwStfyn/+s59zl4Bi+1KQNVXLZ8= +cloud.google.com/go/auth/oauth2adapt v0.2.7 h1:/Lc7xODdqcEw8IrZ9SvwnlLX6j9FHQM74z6cBk9Rw6M= +cloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/longrunning v0.6.5 h1:sD+t8DO8j4HKW4QfouCklg7ZC1qC4uzVZt8iz3uTW+Q= +cloud.google.com/go/longrunning v0.6.5/go.mod h1:Et04XK+0TTLKa5IPYryKf5DkpwImy6TluQ1QTLwlKmY= +cloud.google.com/go/monitoring v1.24.0 h1:csSKiCJ+WVRgNkRzzz3BPoGjFhjPY23ZTcaenToJxMM= +cloud.google.com/go/monitoring v1.24.0/go.mod h1:Bd1PRK5bmQBQNnuGwHBfUamAV1ys9049oEPHnn4pcsc= +cloud.google.com/go/trace v1.11.3 h1:c+I4YFjxRQjvAhRmSsmjpASUKq88chOX854ied0K/pE= +cloud.google.com/go/trace v1.11.3/go.mod h1:pt7zCYiDSQjC9Y2oqCsh9jF4GStB/hmjrYLsxRR27q8= code.pfad.fr/check v1.1.0 h1:GWvjdzhSEgHvEHe2uJujDcpmZoySKuHQNrZMfzfO0bE= code.pfad.fr/check v1.1.0/go.mod h1:NiUH13DtYsb7xp5wll0U4SXx7KhXQVCtRgdC96IPfoM= +contrib.go.opencensus.io/exporter/stackdriver v0.13.14 h1:zBakwHardp9Jcb8sQHcHpXy/0+JIb1M8KjigCJzx7+4= +contrib.go.opencensus.io/exporter/stackdriver v0.13.14/go.mod h1:5pSSGY0Bhuk7waTHuDf4aQ8D2DrhgETRo9fy6k3Xlzc= +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +filippo.io/age v1.3.1 h1:hbzdQOJkuaMEpRCLSN1/C5DX74RPcNCk6oqhKMXmZi0= +filippo.io/age v1.3.1/go.mod h1:EZorDTYUxt836i3zdori5IJX/v2Lj6kWFU0cfh6C0D4= filippo.io/bigmod v0.0.1 h1:OaEqDr3gEbofpnHbGqZweSL/bLMhy1pb54puiCDeuOA= filippo.io/bigmod v0.0.1/go.mod h1:KyzqAbH7bRH6MOuOF1TPfUjvLoi0mRF2bIyD2ouRNQI= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A= +filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY= filippo.io/keygen v0.0.0-20230306160926-5201437acf8e h1:+xwUCyMiCWKWsI0RowhzB4sngpUdMHgU6lLuWJCX5Dg= filippo.io/keygen v0.0.0-20230306160926-5201437acf8e/go.mod h1:ZGSiF/b2hd6MRghF/cid0vXw8pXykRTmIu+JSPw/NCQ= +filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc= +filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA= +fyne.io/systray v1.11.1-0.20250812065214-4856ac3adc3c h1:km4PIleGtbbF1oxmFQuO93CyNCldwuRTPB8WlzNWNZs= +fyne.io/systray v1.11.1-0.20250812065214-4856ac3adc3c/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= +github.com/Abirdcfly/dupword v0.0.11 h1:z6v8rMETchZXUIuHxYNmlUAuKuB21PeaSymTed16wgU= +github.com/Abirdcfly/dupword v0.0.11/go.mod h1:wH8mVGuf3CP5fsBTkfWwwwKTjDnVVCxtU8d8rgeVYXA= github.com/AdguardTeam/golibs v0.32.7 h1:3dmGlAVgmvquCCwHsvEl58KKcRAK3z1UnjMnwSIeDH4= github.com/AdguardTeam/golibs v0.32.7/go.mod h1:bE8KV1zqTzgZjmjFyBJ9f9O5DEKO717r7e57j1HclJA= +github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w= +github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0= github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 h1:cTp8I5+VIoKjsnZuH8vjyaysT/ses3EvZeaV/1UkF2M= github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8= +github.com/Antonboom/errname v0.1.9 h1:BZDX4r3l4TBZxZ2o2LNrlGxSHran4d1u4veZdoORTT4= +github.com/Antonboom/errname v0.1.9/go.mod h1:nLTcJzevREuAsgTbG85UsuiWpMpAqbKD1HNZ29OzE58= +github.com/Antonboom/nilnil v0.1.4 h1:yWIfwbCRDpJiJvs7Quz55dzeXCgORQyAG29N9/J5H2Q= +github.com/Antonboom/nilnil v0.1.4/go.mod h1:iOov/7gRcXkeEU+EMGpBu2ORih3iyVEiWjeste1SJm8= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 h1:g0EZJwz7xkXQiZAI5xi9f3WWFYBlX1CPTrR+NDToRkQ= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0/go.mod h1:XCW7KnZet0Opnr7HccfUw1PLc4CjHqpcaxW8DHklNkQ= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0 h1:tfLQ34V6F7tVSwoTf/4lH5sE0o6eCJuNDTmH09nDpbc= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 h1:ywEEhmNahHBihViHepv3xPBn1663uRv2t2q/ESv9seY= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY= +github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU= +github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs= github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +github.com/Djarvur/go-err113 v0.1.0 h1:uCRZZOdMQ0TZPHYTdYpoC0bLYJKPEHPUJ8MeAa51lNU= +github.com/Djarvur/go-err113 v0.1.0/go.mod h1:4UJr5HIiMZrwgkSPdsjy2uOQExX/WEILpIrO9UPGuXs= +github.com/GaijinEntertainment/go-exhaustruct/v2 v2.3.0 h1:+r1rSv4gvYn0wmRjC8X7IAzX8QezqtFV9m0MUHFJgts= +github.com/GaijinEntertainment/go-exhaustruct/v2 v2.3.0/go.mod h1:b3g59n2Y+T5xmcxJL+UEG2f8cQploZm1mR/v6BW0mU0= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= github.com/Jigsaw-Code/outline-sdk v0.0.20 h1:4ep7MK9lFmcyPIRIbn4xrP1VKdJNsqR6+iJEOHDKnNg= github.com/Jigsaw-Code/outline-sdk v0.0.20/go.mod h1:CFDKyGZA4zatKE4vMLe8TyQpZCyINOeRFbMAmYHxodw= +github.com/Jigsaw-Code/outline-sdk/x v0.0.8 h1:fFHFXW7CKhRiegyNSdP25S/WIiVrRnMKysHDoO/N2Xg= +github.com/Jigsaw-Code/outline-sdk/x v0.0.8/go.mod h1:zqSH7yEYIQ0pYOhrr4QnodATVb5X/eZXV4AjUp9zhvs= github.com/Jigsaw-Code/outline-ss-server v1.8.0 h1:6h7CZsyl1vQLz3nvxmL9FbhDug4QxJ1YTxm534eye1E= github.com/Jigsaw-Code/outline-ss-server v1.8.0/go.mod h1:slnHH3OZsQmZx/DRKhxvvaGE/8+n3Lkd6363h1ev71E= +github.com/Kodeworks/golang-image-ico v0.0.0-20141118225523-73f0f4cfade9 h1:1ltqoej5GtaWF8jaiA49HwsZD459jqm9YFz9ZtMFpQA= +github.com/Kodeworks/golang-image-ico v0.0.0-20141118225523-73f0f4cfade9/go.mod h1:7uhhqiBaR4CpN0k9rMjOtjpcfGd6DG2m04zQxKnWQ0I= +github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= +github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= +github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= +github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= +github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= +github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= +github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA= +github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM= +github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= +github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= +github.com/Microsoft/hcsshim v0.8.14 h1:lbPVK25c1cu5xTLITwpUcxoA9vKrKErASPYygvouJns= +github.com/Microsoft/hcsshim v0.8.14/go.mod h1:NtVKoYxQuTLx6gEq0L96c9Ju4JbRJ4nY2ow3VK6a9Lg= github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/OpenPeeDeeP/depguard v1.1.1 h1:TSUznLjvp/4IUP+OQ0t/4jF4QUyxIcVX8YnghZdunyA= +github.com/OpenPeeDeeP/depguard v1.1.1/go.mod h1:JtAMzWkmFEzDPyAd+W0NHl1lvpQKTvT9jnRVsohBKpc= +github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c h1:kMFnB0vCcX7IL/m9Y5LO+KQYv+t1CQOiFe6+SV2J7bE= +github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= github.com/Psiphon-Inc/rotate-safe-writer v0.0.0-20210303140923-464a7a37606e h1:NPfqIbzmijrl0VclX2t8eO5EPBhqe47LLGKpRrcVjXk= github.com/Psiphon-Inc/rotate-safe-writer v0.0.0-20210303140923-464a7a37606e/go.mod h1:ZdY5pBfat/WVzw3eXbIf7N1nZN0XD5H5+X8ZMDWbCs4= github.com/Psiphon-Inc/uds-ipc v1.0.1 h1:K3Z0cS1XfzDdhxWTIwh/hiLrkRR83ZxUo2bqgBOGuZE= @@ -34,16 +116,34 @@ github.com/Psiphon-Labs/psiphon-tunnel-core v1.0.11-0.20260126173038-c86a1497a76 github.com/Psiphon-Labs/psiphon-tunnel-core v1.0.11-0.20260126173038-c86a1497a760/go.mod h1:FcBjozoGjC2I7ZID0lXFlVQcy/jx6irCzlPxapGebEY= github.com/Psiphon-Labs/utls v0.0.0-20260129182755-24497d415a8d h1:PlKwrArEuQOVqEmThSs9KsXMiBduP8MSu9rlWmQ4jgE= github.com/Psiphon-Labs/utls v0.0.0-20260129182755-24497d415a8d/go.mod h1:1vv0gVAzq9e2XYkW8HAKrmtuuZrBdDixQFx5H22KAjI= +github.com/RyuaNerin/elliptic2 v1.0.0 h1:xuqrsvIXvT35maN5uiQV56TPZ/RQ+UhDF+fRFToDeJg= +github.com/RyuaNerin/elliptic2 v1.0.0/go.mod h1:wWB8fWrJI/6EPJkyV/r1Rj0hxUgrusmqSj8JN6yNf/A= +github.com/RyuaNerin/go-krypto v1.3.0 h1:smavTzSMAx8iuVlGb4pEwl9MD2qicqMzuXR2QWp2/Pg= +github.com/RyuaNerin/go-krypto v1.3.0/go.mod h1:9R9TU936laAIqAmjcHo/LsaXYOZlymudOAxjaBf62UM= +github.com/RyuaNerin/testingutil v0.1.0 h1:IYT6JL57RV3U2ml3dLHZsVtPOP6yNK7WUVdzzlpNrss= +github.com/RyuaNerin/testingutil v0.1.0/go.mod h1:yTqj6Ta/ycHMPJHRyO12Mz3VrvTloWOsy23WOZH19AA= +github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow= +github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4= +github.com/acomagu/bufpipe v1.0.4 h1:e3H4WUzM3npvo5uv95QuJM3cQspFNtFBzvJ2oNjKIDQ= +github.com/acomagu/bufpipe v1.0.4/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4= github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= +github.com/alexkohler/prealloc v1.0.0 h1:Hbq0/3fJPQhNkN0dR95AVrr6R7tou91y0uHG5pOcUuw= +github.com/alexkohler/prealloc v1.0.0/go.mod h1:VetnK3dIgFBBKmg0YnD9F9x6Icjd+9cvfHR56wJVlKE= +github.com/alingse/asasalint v0.0.11 h1:SFwnQXJ49Kx/1GghOFz1XGqHYKp21Kq1nHad/0WQRnw= +github.com/alingse/asasalint v0.0.11/go.mod h1:nCaoMhw7a9kSJObvQyVzNTPBDbNpdocqrSP7t/cW5+I= github.com/ameshkov/dnsstamps v1.0.3 h1:Srzik+J9mivH1alRACTbys2xOxs0lRH9qnTA7Y1OYVo= github.com/ameshkov/dnsstamps v1.0.3/go.mod h1:Ii3eUu73dx4Vw5O4wjzmT5+lkCwovjzaEZZ4gKyIH5A= github.com/amnezia-vpn/amneziawg-go v0.2.17-0.20251219021448-449d7cffd4ad h1:O3bvp109FZxwf3lWDd5lxmQz8NwQJgvk1nd60vnXMgw= github.com/amnezia-vpn/amneziawg-go v0.2.17-0.20251219021448-449d7cffd4ad/go.mod h1:aMgOk9MuX0xI7b5TKAYp8pLM54RlXcOPzDvYw3YEO5A= +github.com/anchore/go-lzo v0.1.0 h1:NgAacnzqPeGH49Ky19QKLBZEuFRqtTG9cdaucc3Vncs= +github.com/anchore/go-lzo v0.1.0/go.mod h1:3kLx0bve2oN1iDwgM1U5zGku1Tfbdb0No5qp1eL1fIk= +github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= @@ -52,10 +152,68 @@ github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAf github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q= github.com/anytls/sing-anytls v0.0.11 h1:w8e9Uj1oP3m4zxkyZDewPk0EcQbvVxb7Nn+rapEx4fc= github.com/anytls/sing-anytls v0.0.11/go.mod h1:7rjN6IukwysmdusYsrV51Fgu1uW6vsrdd6ctjnEAln8= +github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA= +github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= github.com/armon/go-proxyproto v0.0.0-20180202201750-5b7edb60ff5f h1:SaJ6yqg936TshyeFZqQE+N+9hYkIeL9AMr7S4voCl10= github.com/armon/go-proxyproto v0.0.0-20180202201750-5b7edb60ff5f/go.mod h1:QmP9hvJ91BbJmGVGSbutW19IC0Q9phDCLGaomwTJbgU= +github.com/ashanbrown/forbidigo v1.5.1 h1:WXhzLjOlnuDYPYQo/eFlcFMi8X/kLfvWLYu6CSoebis= +github.com/ashanbrown/forbidigo v1.5.1/go.mod h1:Y8j9jy9ZYAEHXdu723cUlraTqbzjKF1MUyfOKL+AjcU= +github.com/ashanbrown/makezero v1.1.1 h1:iCQ87C0V0vSyO+M9E/FZYbu65auqH0lnsOkf5FcB28s= +github.com/ashanbrown/makezero v1.1.1/go.mod h1:i1bJLCRSCHOcOa9Y6MyF2FTfMZMFdHvxKHxgO5Z1axI= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU= +github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= +github.com/aws/aws-sdk-go-v2 v1.30.3 h1:jUeBtG0Ih+ZIFH0F4UkmL9w3cSpaMv9tYYDbzILP8dY= +github.com/aws/aws-sdk-go-v2 v1.30.3/go.mod h1:nIQjQVp5sfpQcTc9mPSr1B0PaWK5ByX9MOoDadSN4lc= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 h1:tW1/Rkad38LA15X4UQtjXZXNKsCgkshC3EbmcUmghTg= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3/go.mod h1:UbnqO+zjqk3uIt9yCACHJ9IVNhyhOCnYk8yA19SAWrM= +github.com/aws/aws-sdk-go-v2/config v1.27.27 h1:HdqgGt1OAP0HkEDDShEl0oSYa9ZZBSOmKpdpsDMdO90= +github.com/aws/aws-sdk-go-v2/config v1.27.27/go.mod h1:MVYamCg76dFNINkZFu4n4RjDixhVr51HLj4ErWzrVwg= +github.com/aws/aws-sdk-go-v2/credentials v1.17.27 h1:2raNba6gr2IfA0eqqiP2XiQ0UVOpGPgDSi0I9iAP+UI= +github.com/aws/aws-sdk-go-v2/credentials v1.17.27/go.mod h1:gniiwbGahQByxan6YjQUMcW4Aov6bLC3m+evgcoN4r4= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11 h1:KreluoV8FZDEtI6Co2xuNk/UqI9iwMrOx/87PBNIKqw= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11/go.mod h1:SeSUYBLsMYFoRvHE0Tjvn7kbxaUhl75CJi1sbfhMxkU= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.64 h1:9QJQs36z61YB8nxGwRDfWXEDYbU6H7jdI6zFiAX1vag= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.11.64/go.mod h1:4Q7R9MFpXRdjO3YnAfUTdnuENs32WzBkASt6VxSYDYQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15 h1:SoNJ4RlFEQEbtDcCEt+QG56MY4fm4W8rYirAmq+/DdU= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15/go.mod h1:U9ke74k1n2bf+RIgoX1SXFed1HLs51OgUSs+Ph0KJP8= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15 h1:C6WHdGnTDIYETAm5iErQUiVNsclNx9qbJVPIt03B6bI= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15/go.mod h1:ZQLZqhcu+JhSrA9/NXRm8SkDvsycE+JkV3WGY41e+IM= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.25 h1:AzwRi5OKKwo4QNqPf7TjeO+tK8AyOK3GVSwmRPo7/Cs= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.25/go.mod h1:SUbB4wcbSEyCvqBxv/O/IBf93RbEze7U7OnoTlpPB+g= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3 h1:dT3MqvGhSoaIhRseqw2I0yH81l7wiR2vjs57O51EAm8= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3/go.mod h1:GlAeCkHwugxdHaueRr4nhPuY+WW+gR8UjlcqzPr1SPI= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.28 h1:vGWm5vTpMr39tEZfQeDiDAMgk+5qsnvRny3FjLpnH5w= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.28/go.mod h1:spfrICMD6wCAhjhzHuy6DOZZ+LAIY10UxhUmLzpJTTs= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17 h1:HGErhhrxZlQ044RiM+WdoZxp0p+EGM62y3L6pwA4olE= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17/go.mod h1:RkZEx4l0EHYDJpWppMJ3nD9wZJAa8/0lq9aVC+r2UII= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.14.2 h1:NbWkRxEEIRSCqxhsHQuMiTH7yo+JZW1gp8v3elSVMTQ= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.14.2/go.mod h1:4tfW5l4IAB32VWCDEBxCRtR9T4BWy4I4kr1spr8NgZM= +github.com/aws/aws-sdk-go-v2/service/s3 v1.33.0 h1:L5h2fymEdVJYvn6hYO8Jx48YmC6xVmjmgHJV3oGKgmc= +github.com/aws/aws-sdk-go-v2/service/s3 v1.33.0/go.mod h1:J9kLNzEiHSeGMyN7238EjJmBpCniVzFda75Gxl/NqB8= +github.com/aws/aws-sdk-go-v2/service/ssm v1.38.0 h1:JON9MBvwUlM8HXylfB2caZuH3VXz9RxO4SMp2+TNc3Q= +github.com/aws/aws-sdk-go-v2/service/ssm v1.38.0/go.mod h1:JjBzoceyKkpQY3v1GPIdg6kHqUFHRJ7SDlwtwoH0Qh8= +github.com/aws/aws-sdk-go-v2/service/sso v1.22.4 h1:BXx0ZIxvrJdSgSvKTZ+yRBeSqqgPM89VPlulEcl37tM= +github.com/aws/aws-sdk-go-v2/service/sso v1.22.4/go.mod h1:ooyCOXjvJEsUw7x+ZDHeISPMhtwI3ZCB7ggFMcFfWLU= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 h1:yiwVzJW2ZxZTurVbYWA7QOrAaCYQR72t0wrSBfoesUE= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4/go.mod h1:0oxfLkpz3rQ/CHlx5hB7H69YUpFiI1tql6Q6Ne+1bCw= +github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 h1:ZsDKRLXGWHk8WdtyYMoGNO7bTudrvuKpDKgMVRlepGE= +github.com/aws/aws-sdk-go-v2/service/sts v1.30.3/go.mod h1:zwySh8fpFyXp9yOr/KVzxOl8SRqgf/IDw5aUt9UKFcQ= +github.com/aws/smithy-go v1.20.3 h1:ryHwveWzPV5BIof6fyDvor6V3iUL7nTfiTKXHiW05nE= +github.com/aws/smithy-go v1.20.3/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E= github.com/axiomhq/hyperloglog v0.2.6 h1:sRhvvF3RIXWQgAXaTphLp4yJiX4S0IN3MWTaAgZoRJw= github.com/axiomhq/hyperloglog v0.2.6/go.mod h1:YjX/dQqCR/7QYX0g8mu8UZAjpIenz1FKM71UEsjFoTo= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/bazelbuild/rules_go v0.38.1 h1:YGNsLhWe18Ielebav7cClP3GMwBxBE+xEArLHtmXDx8= +github.com/bazelbuild/rules_go v0.38.1/go.mod h1:TMHmtfpvyfsxaqfL9WnahCsXMWDMICTw7XeK9yVb+YU= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bgentry/speakeasy v0.2.0 h1:tgObeVOf8WAvtuAX6DhJ4xks4CFNwPDZiqzGqIHE51E= +github.com/bgentry/speakeasy v0.2.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bifurcation/mint v0.0.0-20180306135233-198357931e61 h1:BU+NxuoaYPIvvp8NNkNlLr8aA0utGyuunf4Q3LJ0bh0= github.com/bifurcation/mint v0.0.0-20180306135233-198357931e61/go.mod h1:zVt7zX3K/aDCk9Tj+VM7YymsX66ERvzCJzw8rFCX2JU= github.com/biter777/countries v1.7.5 h1:MJ+n3+rSxWQdqVJU8eBy9RqcdH6ePPn4PJHocVWUa+Q= @@ -64,42 +222,125 @@ github.com/bits-and-blooms/bitset v1.10.0 h1:ePXTeiPEazB5+opbv5fr8umg2R/1NlzgDsy github.com/bits-and-blooms/bitset v1.10.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/bits-and-blooms/bloom/v3 v3.6.0 h1:dTU0OVLJSoOhz9m68FTXMFfA39nR8U/nTCs1zb26mOI= github.com/bits-and-blooms/bloom/v3 v3.6.0/go.mod h1:VKlUSvp0lFIYqxJjzdnSsZEw4iHb1kOL2tfHTgyJBHg= +github.com/bkielbasa/cyclop v1.2.0 h1:7Jmnh0yL2DjKfw28p86YTd/B4lRGcNuu12sKE35sM7A= +github.com/bkielbasa/cyclop v1.2.0/go.mod h1:qOI0yy6A7dYC4Zgsa72Ppm9kONl0RoIlPbzot9mhmeI= +github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb h1:m935MPodAbYS46DG4pJSv7WO+VECIWUQ7OJYSoTrMh4= +github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb/go.mod h1:PkYb9DJNAwrSvRx5DYA+gUcOIgTGVMNkfSCbZM8cWpI= +github.com/blizzy78/varnamelen v0.8.0 h1:oqSblyuQvFsW1hbBHh1zfwrKe3kcSj0rnXkKzsQ089M= +github.com/blizzy78/varnamelen v0.8.0/go.mod h1:V9TzQZ4fLJ1DSrjVDfl89H7aMnTvKkApdHeyESmyR7k= +github.com/boltdb/bolt v1.3.1 h1:JQmyP4ZBrce+ZQu0dY660FMfatumYDLun9hBCUVIkF4= +github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps= +github.com/bombsimon/wsl/v3 v3.4.0 h1:RkSxjT3tmlptwfgEgTgU+KYKLI35p/tviNXNXiL2aNU= +github.com/bombsimon/wsl/v3 v3.4.0/go.mod h1:KkIB+TXkqy6MvK9BDZVbZxKNYsE1/oLRJbIFtf14qqo= +github.com/breml/bidichk v0.2.4 h1:i3yedFWWQ7YzjdZJHnPo9d/xURinSq3OM+gyM43K4/8= +github.com/breml/bidichk v0.2.4/go.mod h1:7Zk0kRFt1LIZxtQdl9W9JwGAcLTTkOs+tN7wuEYGJ3s= +github.com/breml/errchkjson v0.3.1 h1:hlIeXuspTyt8Y/UmP5qy1JocGNR00KQHgfaNtRAjoxQ= +github.com/breml/errchkjson v0.3.1/go.mod h1:XroxrzKjdiutFyW3nWhw34VGg7kiMsDQox73yWCGI2U= +github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= +github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/butuzov/ireturn v0.2.0 h1:kCHi+YzC150GE98WFuZQu9yrTn6GEydO2AuPLbTgnO4= +github.com/butuzov/ireturn v0.2.0/go.mod h1:Wh6Zl3IMtTpaIKbmwzqi6olnM9ptYQxxVacMsOEFPoc= +github.com/bwesterb/go-ristretto v1.2.3 h1:1w53tCkGhCQ5djbat3+MH0BAQ5Kfgbt56UZQ/JMzngw= +github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= github.com/caddyserver/certmagic v0.25.3-0.20260421143802-60d9d8b415d6 h1:LYSB6VgWzKtNrcxElw3c97BP40Oc7bizKxA9K1Vi/5k= github.com/caddyserver/certmagic v0.25.3-0.20260421143802-60d9d8b415d6/go.mod h1:llW/CvsNmza8S6hmsuggsZeiX+uS27dkqY27wDIuBWg= github.com/caddyserver/zerossl v0.1.5 h1:dkvOjBAEEtY6LIGAHei7sw2UgqSD6TrWweXpV7lvEvE= github.com/caddyserver/zerossl v0.1.5/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4= +github.com/cavaliergopher/cpio v1.0.1 h1:KQFSeKmZhv0cr+kawA3a0xTQCU4QxXF1vhU7P7av2KM= +github.com/cavaliergopher/cpio v1.0.1/go.mod h1:pBdaqQjnvXxdS/6CvNDwIANIFSP0xRKI16PX4xejRQc= +github.com/ccojocar/zxcvbn-go v1.0.2 h1:na/czXU8RrhXO4EZme6eQJLR4PzcGsahsBOAwU6I3Vg= +github.com/ccojocar/zxcvbn-go v1.0.2/go.mod h1:g1qkXtUSvHP8lhHp5GrSmTz6uWALGRMQdw6Qnz/hi60= +github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= +github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/census-instrumentation/opencensus-proto v0.4.1 h1:iKLQ0xPNFxR/2hzXZMrBo8f1j86j5WHzznCCQxV/b8g= +github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw= github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charithe/durationcheck v0.0.10 h1:wgw73BiocdBDQPik+zcEoBG/ob8uyBHf2iyoHGPf5w4= +github.com/charithe/durationcheck v0.0.10/go.mod h1:bCWXb7gYRysD1CU3C+u4ceO49LoGOY1C1L6uouGNreQ= +github.com/chavacava/garif v0.0.0-20230227094218-b8c73b2037b8 h1:W9o46d2kbNL06lq7UNDPV0zYLzkrde/bjIqO02eoll0= +github.com/chavacava/garif v0.0.0-20230227094218-b8c73b2037b8/go.mod h1:gakxgyXaaPkxvLw1XQxNGK4I37ys9iBRzNUx/B7pUCo= github.com/cheekybits/genny v0.0.0-20170328200008-9127e812e1e9 h1:a1zrFsLFac2xoM6zG1u72DWJwZG3ayttYLfmLbxVETk= github.com/cheekybits/genny v0.0.0-20170328200008-9127e812e1e9/go.mod h1:+tQajlRqAUrPI7DOSpB0XAqZYtQakVtB7wXkRAgjxjQ= +github.com/cheggaaa/pb/v3 v3.1.6 h1:h0x+vd7EiUohAJ29DJtJy+SNAc55t/elW3jCD086EXk= +github.com/cheggaaa/pb/v3 v3.1.6/go.mod h1:urxmfVtaxT+9aWk92DbsvXFZtNSWQSO5TRAp+MJ3l1s= github.com/cilium/ebpf v0.15.0 h1:7NxJhNiBT3NG8pZJ3c+yfrVdHY8ScgKD27sScgjLMMk= github.com/cilium/ebpf v0.15.0/go.mod h1:DHp1WyrLeiBh19Cf/tfiSMhqheEiK8fXFZ4No0P1Hso= +github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f h1:WBZRG4aNOuI15bLRrCgN8fCq8E5Xuty6jGbmSNEvSsU= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/cognusion/go-cache-lru v0.0.0-20170419142635-f73e2280ecea h1:9C2rdYRp8Vzwhm3sbFX0yYfB+70zKFRjn7cnPCucHSw= github.com/cognusion/go-cache-lru v0.0.0-20170419142635-f73e2280ecea/go.mod h1:MdyNkAe06D7xmJsf+MsLvbZKYNXuOHLKJrvw+x4LlcQ= +github.com/containerd/cgroups v1.0.1 h1:iJnMvco9XGvKUvNQkv88bE4uJXxRQH18efbKo9w5vHQ= +github.com/containerd/cgroups v1.0.1/go.mod h1:0SJrPIenamHDcZhEcJMNBB85rHcUsw4f25ZfBiPYRkU= +github.com/containerd/console v1.0.1 h1:u7SFAJyRqWcG6ogaMAx3KjSTy1e3hT9QxqX7Jco7dRc= +github.com/containerd/console v1.0.1/go.mod h1:XUsP6YE/mKtz6bxc+I8UiKKTP04qjQL4qcS3XoQ5xkw= +github.com/containerd/containerd v1.4.13 h1:Z0CbagVdn9VN4K6htOCY/jApSw8YKP+RdLZ5dkXF8PM= +github.com/containerd/containerd v1.4.13/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= +github.com/containerd/continuity v0.3.0 h1:nisirsYROK15TAMVukJOUyGJjz4BNQJBVsNvAXZJ/eg= +github.com/containerd/continuity v0.3.0/go.mod h1:wJEAIwKOm/pBZuBd0JmeTvnLquTB1Ag8espWhkykbPM= +github.com/containerd/fifo v1.0.0 h1:6PirWBr9/L7GDamKr+XM0IeUFXu5mf3M/BPpH9gaLBU= +github.com/containerd/fifo v1.0.0/go.mod h1:ocF/ME1SX5b1AOlWi9r677YJmCPSwwWnQ9O123vzpE4= +github.com/containerd/go-runc v1.0.0 h1:oU+lLv1ULm5taqgV/CJivypVODI4SUz1znWjv3nNYS0= +github.com/containerd/go-runc v1.0.0/go.mod h1:cNU0ZbCgCQVZK4lgG3P+9tn9/PaJNmoDXPpoJhDR+Ok= +github.com/containerd/stargz-snapshotter/estargz v0.16.3 h1:7evrXtoh1mSbGj/pfRccTampEyKpjpOnS3CyiV1Ebr8= +github.com/containerd/stargz-snapshotter/estargz v0.16.3/go.mod h1:uyr4BfYfOj3G9WBVE8cOlQmXAbPN9VEQpBBeJIuOipU= +github.com/containerd/ttrpc v1.1.0 h1:GbtyLRxb0gOLR0TYQWt3O6B0NvT8tMdorEHqIQo/lWI= +github.com/containerd/ttrpc v1.1.0/go.mod h1:XX4ZTnoOId4HklF4edwc4DcqskFZuvXB1Evzy5KFQpQ= +github.com/containerd/typeurl v1.0.2 h1:Chlt8zIieDbzQFzXzAeBEF92KhExuE4p9p92/QmY7aY= +github.com/containerd/typeurl v1.0.2/go.mod h1:9trJWW2sRlGub4wZJRTW83VtbOLS6hwcDZXTn6oPz9s= +github.com/containerd/typeurl/v2 v2.2.3 h1:yNA/94zxWdvYACdYO8zofhrTVuQY73fFU1y++dYSw40= +github.com/containerd/typeurl/v2 v2.2.3/go.mod h1:95ljDnPfD3bAbDJRugOiShd/DlAAsxGtUBhJxIn7SCk= github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0= github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= +github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= +github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= +github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf h1:iW4rZ826su+pqaw19uhpSCzhj44qo35pNgKFGqzDKkU= +github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/cretz/bine v0.2.0 h1:8GiDRGlTgz+o8H9DSnsl+5MeBK4HsExxgl6WgzOCuZo= github.com/cretz/bine v0.2.0/go.mod h1:WU4o9QR9wWp8AVKtTM1XD5vUHkEqnf2vVSo6dBqbetI= +github.com/curioswitch/go-reassign v0.2.0 h1:G9UZyOcpk/d7Gd6mqYgd8XYWFMw/znxwGDUstnC9DIo= +github.com/curioswitch/go-reassign v0.2.0/go.mod h1:x6OpXuWvgfQaMGks2BZybTngWjT84hqJfKoO8Tt/Roc= +github.com/cyphar/filepath-securejoin v0.3.6 h1:4d9N5ykBnSp5Xn2JkhocYDkOpURL/18CYMpo6xB9uWM= +github.com/cyphar/filepath-securejoin v0.3.6/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= +github.com/daixiang0/gci v0.10.1 h1:eheNA3ljF6SxnPD/vE4lCBusVHmV3Rs3dkKvFrJ7MR0= +github.com/daixiang0/gci v0.10.1/go.mod h1:xtHP9N7AHdNvtRNfcx9gwTDfw7FRJx4bZUsiEfiNNAI= github.com/database64128/netx-go v0.1.1 h1:dT5LG7Gs7zFZBthFBbzWE6K8wAHjSNAaK7wCYZT7NzM= github.com/database64128/netx-go v0.1.1/go.mod h1:LNlYVipaYkQArRFDNNJ02VkNV+My9A5XR/IGS7sIBQc= github.com/database64128/tfo-go/v2 v2.3.2 h1:UhZMKiMq3swZGUiETkLBDzQnZBPSAeBMClpJGlnJ5Fw= github.com/database64128/tfo-go/v2 v2.3.2/go.mod h1:GC3uB5oa4beGpCUbRb2ZOWP73bJJFmMyAVgQSO7r724= +github.com/dave/astrid v0.0.0-20170323122508-8c2895878b14 h1:YI1gOOdmMk3xodBao7fehcvoZsEeOyy/cfhlpCSPgM4= +github.com/dave/astrid v0.0.0-20170323122508-8c2895878b14/go.mod h1:Sth2QfxfATb/nW4EsrSi2KyJmbcniZ8TgTaji17D6ms= +github.com/dave/brenda v1.1.0 h1:Sl1LlwXnbw7xMhq3y2x11McFu43AjDcwkllxxgZ3EZw= +github.com/dave/brenda v1.1.0/go.mod h1:4wCUr6gSlu5/1Tk7akE5X7UorwiQ8Rij0SKH3/BGMOM= +github.com/dave/courtney v0.4.0 h1:Vb8hi+k3O0h5++BR96FIcX0x3NovRbnhGd/dRr8inBk= +github.com/dave/courtney v0.4.0/go.mod h1:3WSU3yaloZXYAxRuWt8oRyVb9SaRiMBt5Kz/2J227tM= +github.com/dave/jennifer v1.7.0 h1:uRbSBH9UTS64yXbh4FrMHfgfY762RD+C7bUPKODpSJE= +github.com/dave/jennifer v1.7.0/go.mod h1:nXbxhEmQfOZhWml3D1cDK5M1FLnMSozpbFN/m3RmGZc= +github.com/dave/patsy v0.0.0-20210517141501-957256f50cba h1:1o36L4EKbZzazMk8iGC4kXpVnZ6TPxR2mZ9qVKjNNAs= +github.com/dave/patsy v0.0.0-20210517141501-957256f50cba/go.mod h1:qfR88CgEGLoiqDaE+xxDCi5QA5v4vUoW0UCX2Nd5Tlc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -110,51 +351,131 @@ github.com/dchest/siphash v1.2.3 h1:QXwFc8cFOR2dSa/gE6o/HokBMWtLUaNDVd+22aKHeEA= github.com/dchest/siphash v1.2.3/go.mod h1:0NvQU092bT0ipiFN++/rXm69QG9tVxLAlQHIXMPAkHc= github.com/deckarep/golang-set v0.0.0-20171013212420-1d4478f51bed h1:njG8LmGD6JCWJu4bwIKmkOHvch70UOEIqczl5vp7Gok= github.com/deckarep/golang-set v0.0.0-20171013212420-1d4478f51bed/go.mod h1:93vsz/8Wt4joVM7c2AVqh+YRMiUSc14yDtF28KmMOgQ= +github.com/deckarep/golang-set/v2 v2.8.0 h1:swm0rlPCmdWn9mESxKOjWk8hXSqoxOp+ZlfuyaAdFlQ= +github.com/deckarep/golang-set/v2 v2.8.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= +github.com/denis-tingaikin/go-header v0.4.3 h1:tEaZKAlqql6SKCY++utLmkPLd6K8IBM20Ha7UVm+mtU= +github.com/denis-tingaikin/go-header v0.4.3/go.mod h1:0wOCWuN71D5qIgE2nz9KrKmuYBAC2Mra5RassOIQ2/c= github.com/dgraph-io/badger v1.5.4-0.20180815194500-3a87f6d9c273 h1:45qZ7jowabqhyi3l9Ervox4dhQvLGB5BJPdC8w0a77k= github.com/dgraph-io/badger v1.5.4-0.20180815194500-3a87f6d9c273/go.mod h1:VZxzAIRPHRVNRKRo6AXrX9BJegn6il06VMTZVJYCIjQ= github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1 h1:CaO/zOnF8VvUfEbhRatPcwKVWamvbYd8tQGRWacE9kU= github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1/go.mod h1:+hnT3ywWDTAFrW5aE+u2Sa/wT555ZqwoCS+pk3p6ry4= +github.com/dgryski/go-camellia v0.0.0-20191119043421-69a8a13fb23d h1:CPqTNIigGweVPT4CYb+OO2E6XyRKFOmvTHwWRLgCAlE= +github.com/dgryski/go-camellia v0.0.0-20191119043421-69a8a13fb23d/go.mod h1:QX5ZVULjAfZJux/W62Y91HvCh9hyW6enAwcrrv/sLj0= github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38= github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33 h1:ucRHb6/lvW/+mTEIGbvhcYU3S8+uSNkuMjx/qZFfhtM= github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yezR0y7jJ5pceLHthLaYf4bA5T14B6q39S4q2Q= +github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A= +github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= +github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= +github.com/docker/cli v27.5.1+incompatible h1:JB9cieUT9YNiMITtIsguaN55PLOHhBSz3LKVc6cqWaY= +github.com/docker/cli v27.5.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= +github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/docker/docker v24.0.7+incompatible h1:Wo6l37AuwP3JaMnZa226lzVXGA3F9Ig1seQen0cKYlM= +github.com/docker/docker v24.0.7+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker-credential-helpers v0.8.2 h1:bX3YxiGzFP5sOXWc3bTPEXdEaZSeVMrFgOr3T+zrFAo= +github.com/docker/docker-credential-helpers v0.8.2/go.mod h1:P3ci7E3lwkZg6XiHdRKft1KckHiO9a2rNtyFbZ/ry9M= +github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw= +github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dsnet/try v0.0.3 h1:ptR59SsrcFUYbT/FhAbKTV6iLkeD6O18qfIWRml2fqI= +github.com/dsnet/try v0.0.3/go.mod h1:WBM8tRpUmnXXhY1U6/S8dt6UWdHTQ7y8A5YSkRCkq40= github.com/dunglas/httpsfv v1.0.2 h1:iERDp/YAfnojSDJ7PW3dj1AReJz4MrwbECSSE59JWL0= github.com/dunglas/httpsfv v1.0.2/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/dvyukov/go-fuzz v0.0.0-20210103155950-6a8e9d1f2415 h1:q1oJaUPdmpDm/VyXosjgPgr6wS7c5iV2p0PwJD73bUI= +github.com/dvyukov/go-fuzz v0.0.0-20210103155950-6a8e9d1f2415/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw= github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/elazarl/goproxy v0.0.0-20200809112317-0581fc3aee2d h1:rtM8HsT3NG37YPjz8sYSbUSdElP9lUsQENYzJDZDUBE= github.com/elazarl/goproxy v0.0.0-20200809112317-0581fc3aee2d/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= github.com/elazarl/goproxy/ext v0.0.0-20200809112317-0581fc3aee2d h1:st1tmvy+4duoRj+RaeeJoECWCWM015fBtf/4aR+hhqk= github.com/elazarl/goproxy/ext v0.0.0-20200809112317-0581fc3aee2d/go.mod h1:gNh8nYJoAm43RfaxurUnxr+N1PwuFV3ZMl/efxlIlY8= +github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= +github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/enfein/mieru/v3 v3.27.0 h1:+E/1TF7OfimS2h582atEXQxPtJMyvqUTFBJUgzn1rxg= github.com/enfein/mieru/v3 v3.27.0/go.mod h1:zJBUCsi5rxyvHM8fjFf+GLaEl4OEjjBXr1s5F6Qd3hM= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= +github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= +github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= +github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= +github.com/esimonov/ifshort v1.0.4 h1:6SID4yGWfRae/M7hkVDVVyppy8q/v9OuxNdmjLQStBA= +github.com/esimonov/ifshort v1.0.4/go.mod h1:Pe8zjlRrJ80+q2CxHLfEOfTwxCZ4O+MuhcHcfgNWTk0= +github.com/ettle/strcase v0.1.1 h1:htFueZyVeE1XNnMEfbqp5r67qAN/4r6ya1ysq8Q+Zcw= +github.com/ettle/strcase v0.1.1/go.mod h1:hzDLsPC7/lwKyBOywSHEP89nt2pDgdy+No1NBA9o9VY= +github.com/evanphx/json-patch v5.6.0+incompatible h1:jBYDEEiFBPxA0v50tFdvOzQQTCvpL6mnFh5mB2/l16U= +github.com/evanphx/json-patch v5.6.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch/v5 v5.7.0 h1:nJqP7uwL84RJInrohHfW0Fx3awjbm8qZeFv0nW9SYGc= +github.com/evanphx/json-patch/v5 v5.7.0/go.mod h1:VNkHZ/282BpEyt/tObQO8s5CMPmYYq14uClGH4abBuQ= +github.com/evanw/esbuild v0.19.4 h1:Etk+6ZCjtNxZZLEgMKSqpO0/oM0k1WYKJabaPMJ39iQ= +github.com/evanw/esbuild v0.19.4/go.mod h1:iINY06rn799hi48UqEnaQvVfZWe6W9bET78LbvN8VWk= +github.com/eycorsican/go-tun2socks v1.16.11 h1:+hJDNgisrYaGEqoSxhdikMgMJ4Ilfwm/IZDrWRrbaH8= +github.com/eycorsican/go-tun2socks v1.16.11/go.mod h1:wgB2BFT8ZaPKyKOQ/5dljMG/YIow+AIXyq4KBwJ5sGQ= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4= +github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94= +github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g= +github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/firefart/nonamedreturns v1.0.4 h1:abzI1p7mAEPYuR4A+VLKn4eNDOycjYo2phmY9sfv40Y= +github.com/firefart/nonamedreturns v1.0.4/go.mod h1:TDhe/tjI1BXo48CmYbUduTV7BdIga8MAO/xbKdcVsGI= github.com/florianl/go-nfqueue v1.1.1-0.20200829120558-a2f196e98ab0 h1:7ZJyJV4KiWBijCCzUPvVaqxsDxO36+KD0XKBdEN3I+8= github.com/florianl/go-nfqueue v1.1.1-0.20200829120558-a2f196e98ab0/go.mod h1:2z3Tfqwv2ueuK6h563xUHRcCh1mv38wS9EjiWiesk84= github.com/florianl/go-nfqueue/v2 v2.0.2 h1:FL5lQTeetgpCvac1TRwSfgaXUn0YSO7WzGvWNIp3JPE= github.com/florianl/go-nfqueue/v2 v2.0.2/go.mod h1:VA09+iPOT43OMoCKNfXHyzujQUty2xmzyCRkBOlmabc= github.com/flynn/noise v1.1.0 h1:KjPQoQCEFdZDiP03phOvGi11+SVVhBG2wOWAorLsstg= github.com/flynn/noise v1.1.0/go.mod h1:xbMo+0i6+IGbYdJhF31t2eR1BIU0CYc12+BNAKwUTag= +github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= +github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/frankban/quicktest v1.14.5 h1:dfYrrRyLtiqT9GyKXgdh+k4inNeTvmGbuSgZ3lx3GhA= github.com/frankban/quicktest v1.14.5/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fullstorydev/grpcurl v1.9.3 h1:PC1Xi3w+JAvEE2Tg2Gf2RfVgPbf9+tbuQr1ZkyVU3jk= +github.com/fullstorydev/grpcurl v1.9.3/go.mod h1:/b4Wxe8bG6ndAjlfSUjwseQReUDUvBJiFEB7UllOlUE= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= +github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo= +github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA= github.com/gaissmai/bart v0.18.0 h1:jQLBT/RduJu0pv/tLwXE+xKPgtWJejbxuXAR+wLJafo= github.com/gaissmai/bart v0.18.0/go.mod h1:JJzMAhNF5Rjo4SF4jWBrANuJfqY+FvsFhW7t1UZJ+XY= +github.com/gaukas/godicttls v0.0.4 h1:NlRaXb3J6hAnTmWdsEKb9bcSBD6BvcIjdGdeb0zfXbk= +github.com/gaukas/godicttls v0.0.4/go.mod h1:l6EenT4TLWgTdwslVb4sEMOCf7Bv0JAK67deKr9/NCI= +github.com/getsentry/sentry-go v0.31.1 h1:ELVc0h7gwyhnXHDouXkhqTFSO5oslsRDk0++eyE0KJ4= +github.com/getsentry/sentry-go v0.31.1/go.mod h1:CYNcMMz73YigoHljQRG+qPF+eMq8gG72XcGN/p71BAY= github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I= github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4= github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0= +github.com/go-critic/go-critic v0.8.0 h1:4zOcpvDoKvBOl+R1W81IBznr78f8YaE4zKXkfDVxGGA= +github.com/go-critic/go-critic v0.8.0/go.mod h1:5TjdkPI9cu/yKbYS96BTsslihjKd6zg6vd8O9RZXj2s= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= +github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= +github.com/go-git/go-git/v5 v5.8.1 h1:Zo79E4p7TRk0xoRgMq0RShiTHGKcKI4+DI6BfJc/Q+A= +github.com/go-git/go-git/v5 v5.8.1/go.mod h1:FHFuoD6yGz5OSKEBK+aWN9Oah0q54Jxl0abmj6GnqAo= github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced h1:Q311OHjMh/u5E2TITc++WlTP5We0xNseRMkHDyvhW7I= @@ -163,26 +484,70 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-logr/zapr v1.2.4 h1:QHVo+6stLbfJmYGkQ7uGHUCu5hnAFAj6mDe6Ea0SeOo= +github.com/go-logr/zapr v1.2.4/go.mod h1:FyHWQIzQORZ0QVE1BtVHv3cKtNLuXsbNLtpuhNapBOA= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-openapi/jsonpointer v0.20.0 h1:ESKJdU9ASRfaPNOPRx12IUyA1vn3R9GiE3KYD14BXdQ= +github.com/go-openapi/jsonpointer v0.20.0/go.mod h1:6PGzBjjIIumbLYysB73Klnms1mwnU4G3YHOECG3CedA= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU= +github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= +github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU= +github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= +github.com/go-toolsmith/astcast v1.1.0 h1:+JN9xZV1A+Re+95pgnMgDboWNVnIMMQXwfBwLRPgSC8= +github.com/go-toolsmith/astcast v1.1.0/go.mod h1:qdcuFWeGGS2xX5bLM/c3U9lewg7+Zu4mr+xPwZIB4ZU= +github.com/go-toolsmith/astcopy v1.1.0 h1:YGwBN0WM+ekI/6SS6+52zLDEf8Yvp3n2seZITCUBt5s= +github.com/go-toolsmith/astcopy v1.1.0/go.mod h1:hXM6gan18VA1T/daUEHCFcYiW8Ai1tIwIzHY6srfEAw= +github.com/go-toolsmith/astequal v1.1.0 h1:kHKm1AWqClYn15R0K1KKE4RG614D46n+nqUQ06E1dTw= +github.com/go-toolsmith/astequal v1.1.0/go.mod h1:sedf7VIdCL22LD8qIvv7Nn9MuWJruQA/ysswh64lffQ= +github.com/go-toolsmith/astfmt v1.1.0 h1:iJVPDPp6/7AaeLJEruMsBUlOYCmvg0MoCfJprsOmcco= +github.com/go-toolsmith/astfmt v1.1.0/go.mod h1:OrcLlRwu0CuiIBp/8b5PYF9ktGVZUjlNMV634mhwuQ4= +github.com/go-toolsmith/astp v1.1.0 h1:dXPuCl6u2llURjdPLLDxJeZInAeZ0/eZwFJmqZMnpQA= +github.com/go-toolsmith/astp v1.1.0/go.mod h1:0T1xFGz9hicKs8Z5MfAqSUitoUYS30pDMsRVIDHs8CA= +github.com/go-toolsmith/strparse v1.1.0 h1:GAioeZUK9TGxnLS+qfdqNbA4z0SSm5zVNtCQiyP2Bvw= +github.com/go-toolsmith/strparse v1.1.0/go.mod h1:7ksGy58fsaQkGQlY8WVoBFNyEPMGuJin1rfoPS4lBSQ= +github.com/go-toolsmith/typep v1.1.0 h1:fIRYDyF+JywLfqzyhdiHzRop/GQDxxNhLGQ6gFUNHus= +github.com/go-toolsmith/typep v1.1.0/go.mod h1:fVIw+7zjdsMxDA3ITWnH1yOiw1rnTQKCsF/sk2H/qig= +github.com/go-xmlfmt/xmlfmt v1.1.2 h1:Nea7b4icn8s57fTx1M5AI4qQT5HEM3rVUO8MuE6g80U= +github.com/go-xmlfmt/xmlfmt v1.1.2/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM= +github.com/gobuffalo/flect v1.0.2 h1:eqjPGSo2WmjgY2XlpGwo2NXgL3RucAKo4k4qQMNA5sA= +github.com/gobuffalo/flect v1.0.2/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs= github.com/gobwas/glob v0.2.4-0.20180402141543-f00a7392b439 h1:T6zlOdzrYuHf6HUKujm9bzkzbZ5Iv/xf6rs8BHZDpoI= github.com/gobwas/glob v0.2.4-0.20180402141543-f00a7392b439/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/goccy/go-yaml v1.17.1 h1:LI34wktB2xEE3ONG/2Ar54+/HJVBriAGJ55PHls4YuY= +github.com/goccy/go-yaml v1.17.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= +github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= github.com/gofrs/uuid/v5 v5.4.0 h1:EfbpCTjqMuGyq5ZJwxqzn3Cbr2d0rUZU7v5ycAk/e/0= github.com/gofrs/uuid/v5 v5.4.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/glog v1.2.5 h1:DrW6hGnjIhtvhOIiAKT6Psh/Kd/ldepEa81DKeiRJ5I= +github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.7.0-rc.1 h1:YojYx61/OLFsiv6Rw1Z96LpldJIy31o+UHmwAUMJ6/U= +github.com/golang/mock v1.7.0-rc.1/go.mod h1:s42URUywIqd+OcERslBJvOjepvNymP31m3q8d/GkuRs= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= @@ -194,81 +559,289 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golangci/check v0.0.0-20180506172741-cfe4005ccda2 h1:23T5iq8rbUYlhpt5DB4XJkc6BU31uODLD1o1gKvZmD0= +github.com/golangci/check v0.0.0-20180506172741-cfe4005ccda2/go.mod h1:k9Qvh+8juN+UKMCS/3jFtGICgW8O96FVaZsaxdzDkR4= +github.com/golangci/dupl v0.0.0-20180902072040-3e9179ac440a h1:w8hkcTqaFpzKqonE9uMCefW1WDie15eSP/4MssdenaM= +github.com/golangci/dupl v0.0.0-20180902072040-3e9179ac440a/go.mod h1:ryS0uhF+x9jgbj/N71xsEqODy9BN81/GonCZiOzirOk= +github.com/golangci/go-misc v0.0.0-20220329215616-d24fe342adfe h1:6RGUuS7EGotKx6J5HIP8ZtyMdiDscjMLfRBSPuzVVeo= +github.com/golangci/go-misc v0.0.0-20220329215616-d24fe342adfe/go.mod h1:gjqyPShc/m8pEMpk0a3SeagVb0kaqvhscv+i9jI5ZhQ= +github.com/golangci/gofmt v0.0.0-20220901101216-f2edd75033f2 h1:amWTbTGqOZ71ruzrdA+Nx5WA3tV1N0goTspwmKCQvBY= +github.com/golangci/gofmt v0.0.0-20220901101216-f2edd75033f2/go.mod h1:9wOXstvyDRshQ9LggQuzBCGysxs3b6Uo/1MvYCR2NMs= +github.com/golangci/golangci-lint v1.52.2 h1:FrPElUUI5rrHXg1mQ7KxI1MXPAw5lBVskiz7U7a8a1A= +github.com/golangci/golangci-lint v1.52.2/go.mod h1:S5fhC5sHM5kE22/HcATKd1XLWQxX+y7mHj8B5H91Q/0= +github.com/golangci/lint-1 v0.0.0-20191013205115-297bf364a8e0 h1:MfyDlzVjl1hoaPzPD4Gpb/QgoRfSBR0jdhwGyAWwMSA= +github.com/golangci/lint-1 v0.0.0-20191013205115-297bf364a8e0/go.mod h1:66R6K6P6VWk9I95jvqGxkqJxVWGFy9XlDwLwVz1RCFg= +github.com/golangci/maligned v0.0.0-20180506175553-b1d89398deca h1:kNY3/svz5T29MYHubXix4aDDuE3RWHkPvopM/EDv/MA= +github.com/golangci/maligned v0.0.0-20180506175553-b1d89398deca/go.mod h1:tvlJhZqDe4LMs4ZHD0oMUlt9G2LWuDGoisJTBzLMV9o= +github.com/golangci/misspell v0.6.0 h1:JCle2HUTNWirNlDIAUO44hUsKhOFqGPoC4LZxlaSXDs= +github.com/golangci/misspell v0.6.0/go.mod h1:keMNyY6R9isGaSAu+4Q8NMBwMPkh15Gtc8UCVoDtAWo= +github.com/golangci/revgrep v0.0.0-20220804021717-745bb2f7c2e6 h1:DIPQnGy2Gv2FSA4B/hh8Q7xx3B7AIDk3DAMeHclH1vQ= +github.com/golangci/revgrep v0.0.0-20220804021717-745bb2f7c2e6/go.mod h1:0AKcRCkMoKvUvlf89F6O7H2LYdhr1zBh736mBItOdRs= +github.com/golangci/unconvert v0.0.0-20180507085042-28b1c447d1f4 h1:zwtduBRr5SSWhqsYNgcuWO2kFlpdOZbP0+yRjmvPGys= +github.com/golangci/unconvert v0.0.0-20180507085042-28b1c447d1f4/go.mod h1:Izgrg8RkN3rCIMLGE9CyYmU9pY2Jer6DgANEnZ/L/cQ= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/certificate-transparency-go v1.3.2 h1:9ahSNZF2o7SYMaKaXhAumVEzXB2QaayzII9C8rv7v+A= +github.com/google/certificate-transparency-go v1.3.2/go.mod h1:H5FpMUaGa5Ab2+KCYsxg6sELw3Flkl7pGZzWdBoYLXs= +github.com/google/generative-ai-go v0.19.0 h1:R71szggh8wHMCUlEMsW2A/3T+5LdEIkiaHSYgSpUgdg= +github.com/google/generative-ai-go v0.19.0/go.mod h1:JYolL13VG7j79kM5BtHz4qwONHkeJQzOCkKXnpqtS/E= +github.com/google/gnostic-models v0.6.9-0.20230804172637-c7be7c783f49 h1:0VpGH+cDhbDtdcweoyCVsF3fhN8kejK6rFe/2FFX2nU= +github.com/google/gnostic-models v0.6.9-0.20230804172637-c7be7c783f49/go.mod h1:BkkQ4L1KS1xMt2aWSPStnn55ChGC0DPOn2FQYj+f25M= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-containerregistry v0.20.3 h1:oNx7IdTI936V8CQRveCjaxOiegWwvM7kqkbXTpyiovI= +github.com/google/go-containerregistry v0.20.3/go.mod h1:w00pIgBRDVUDFM6bq+Qx8lwNWK+cxgCuX1vd3PIBDNI= +github.com/google/go-github/v66 v66.0.0 h1:ADJsaXj9UotwdgK8/iFZtv7MLc8E8WBl62WLd/D/9+M= +github.com/google/go-github/v66 v66.0.0/go.mod h1:+4SO9Zkuyf8ytMj0csN1NR/5OTR+MfqPp8P8dVlcvY4= +github.com/google/go-licenses v1.6.0 h1:MM+VCXf0slYkpWO0mECvdYDVCxZXIQNal5wqUIXEZ/A= +github.com/google/go-licenses v1.6.0/go.mod h1:Z8jgz2isEhdenOqd/00pq7I4y4k1xVVQJv415otjclo= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/go-tpm v0.9.4 h1:awZRf9FwOeTunQmHoDYSHJps3ie6f1UlhS1fOdPEt1I= +github.com/google/go-tpm v0.9.4/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= +github.com/google/goterm v0.0.0-20200907032337-555d40f16ae2 h1:CVuJwN34x4xM2aT4sIKhmeib40NeBPhRihNjQmpJsA4= +github.com/google/goterm v0.0.0-20200907032337-555d40f16ae2/go.mod h1:nOFQdrUlIlx6M6ODdSpBj1NVA+VgLC6kmw60mkw34H4= +github.com/google/licenseclassifier v0.0.0-20210722185704-3043a050f148 h1:TJsAqW6zLRMDTyGmc9TPosfn9OyVlHs8Hrn3pY6ONSY= +github.com/google/licenseclassifier v0.0.0-20210722185704-3043a050f148/go.mod h1:rq9F0RSpNKlrefnf6ZYMHKUnEJBCNzf6AcCXMYBeYvE= github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI= github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4= github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y= github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg= +github.com/google/renameio/v2 v2.0.0 h1:UifI23ZTGY8Tt29JbYFiuyIU3eX+RNFtUwefq9qAhxg= +github.com/google/renameio/v2 v2.0.0/go.mod h1:BtmJXm5YlszgC+TD4HOEEUFgkJP3nLxehU6hfe7jRt4= +github.com/google/rpmpack v0.5.0 h1:L16KZ3QvkFGpYhmp23iQip+mx1X39foEsqszjMNBm8A= +github.com/google/rpmpack v0.5.0/go.mod h1:uqVAUVQLq8UY2hCDfmJ/+rtO3aw7qyhc90rCVEabEfI= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/subcommands v1.0.2-0.20190508160503-636abe8753b8 h1:8nlgEAjIalk6uj/CGKCdOO8CQqTeysvcW4RFZ6HbkGM= +github.com/google/subcommands v1.0.2-0.20190508160503-636abe8753b8/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= +github.com/google/trillian v1.7.2 h1:EPBxc4YWY4Ak8tcuhyFleY+zYlbCDCa4Sn24e1Ka8Js= +github.com/google/trillian v1.7.2/go.mod h1:mfQJW4qRH6/ilABtPYNBerVJAJ/upxHLX81zxNQw05s= github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.5 h1:VgzTY2jogw3xt39CusEnFJWm7rlsq5yL5q9XdLOuP5g= +github.com/googleapis/enterprise-certificate-proxy v0.3.5/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= +github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q= +github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA= +github.com/googleapis/gnostic v0.5.5 h1:9fHAtK0uDfpveeqqo1hkEZJcFvYXAiCN3UutL8F9xHw= +github.com/googleapis/gnostic v0.5.5/go.mod h1:7+EbHbldMins07ALC74bsA81Ovc97DwqyJO1AENw9kA= +github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0= +github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w= +github.com/gordonklaus/ineffassign v0.1.0 h1:y2Gd/9I7MdY1oEIt+n+rowjBNDcLQq3RsH5hwJd0f9s= +github.com/gordonklaus/ineffassign v0.1.0/go.mod h1:Qcp2HIAYhR7mNUVSIxZww3Guk4it82ghYcEXIAk+QT0= +github.com/goreleaser/chglog v0.5.0 h1:Sk6BMIpx8+vpAf8KyPit34OgWui8c7nKTMHhYx88jJ4= +github.com/goreleaser/chglog v0.5.0/go.mod h1:Ri46M3lrMuv76FHszs3vtABR8J8k1w9JHYAzxeeOl28= +github.com/goreleaser/fileglob v1.3.0 h1:/X6J7U8lbDpQtBvGcwwPS6OpzkNVlVEsFUVRx9+k+7I= +github.com/goreleaser/fileglob v1.3.0/go.mod h1:Jx6BoXv3mbYkEzwm9THo7xbr5egkAraxkGorbJb4RxU= +github.com/goreleaser/nfpm/v2 v2.33.1 h1:EkdAzZyVhAI9JC1vjmjjbmnNzyH1J6Cu4JCsA7YcQuc= +github.com/goreleaser/nfpm/v2 v2.33.1/go.mod h1:8wwWWvJWmn84xo/Sqiv0aMvEGTHlHZTXTEuVSgQpkIM= +github.com/gorilla/csrf v1.7.3 h1:BHWt6FTLZAb2HtWT5KDBf6qgpZzvtbp9QWDRKZMXJC0= +github.com/gorilla/csrf v1.7.3/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gostaticanalysis/analysisutil v0.7.1 h1:ZMCjoue3DtDWQ5WyU16YbjbQEQ3VuzwxALrpYd+HeKk= +github.com/gostaticanalysis/analysisutil v0.7.1/go.mod h1:v21E3hY37WKMGSnbsw2S/ojApNWb6C1//mXO48CXbVc= +github.com/gostaticanalysis/comment v1.4.2 h1:hlnx5+S2fY9Zo9ePo4AhgYsYHbM2+eAv8m/s1JiCd6Q= +github.com/gostaticanalysis/comment v1.4.2/go.mod h1:KLUTGDv6HOCotCH8h2erHKmpci2ZoR8VPu34YA2uzdM= +github.com/gostaticanalysis/forcetypeassert v0.1.0 h1:6eUflI3DiGusXGK6X7cCcIgVCpZ2CiZ1Q7jl6ZxNV70= +github.com/gostaticanalysis/forcetypeassert v0.1.0/go.mod h1:qZEedyP/sY1lTGV1uJ3VhWZ2mqag3IkWsDHVbplHXak= +github.com/gostaticanalysis/nilerr v0.1.1 h1:ThE+hJP0fEp4zWLkWHWcRyI2Od0p7DlgYG3Uqrmrcpk= +github.com/gostaticanalysis/nilerr v0.1.1/go.mod h1:wZYb6YI5YAxxq0i1+VJbY0s2YONW0HU0GPE3+5PWN4A= github.com/grafov/m3u8 v0.0.0-20171211212457-6ab8f28ed427 h1:xh96CCAZTX8LJPFoOVRgTwZbn2DvJl8fyCyivohhSIg= github.com/grafov/m3u8 v0.0.0-20171211212457-6ab8f28ed427/go.mod h1:PdjzaU/pJUo4jTIn2rcgMFs+HqBGl/sPJLr8BI0Xq/I= +github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= +github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= +github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1 h1:qnpSQwGEnkcRpTqNOIR6bJbR0gAorgP9CSALpRcKoAA= +github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1/go.mod h1:lXGCsh6c22WGtjr+qGHj1otzZpV/1kwTMAqkwZsnWRU= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.1.0 h1:pRhl55Yx1eC7BZ1N+BBWwnKaMyD8uC+34TLdndZMAKk= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.1.0/go.mod h1:XKMd7iuf/RGPSMJ/U4HP0zS2Z9Fh8Ps9a+6X26m/tmI= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= +github.com/hanwen/go-fuse/v2 v2.3.0 h1:t5ivNIH2PK+zw4OBul/iJjsoG9K6kXo4nMDoBpciC8A= +github.com/hanwen/go-fuse/v2 v2.3.0/go.mod h1:xKwi1cF7nXAOBCXujD5ie0ZKsxc8GGSA1rlMJc+8IJs= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-hclog v1.6.2 h1:NOtoftovWkDheyUM/8JW3QMiXyxJK3uHRK7wV04nD2I= +github.com/hashicorp/go-hclog v1.6.2/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= +github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-metrics v0.5.4 h1:8mmPiIJkTPPEbAiV97IxdAGNdRdaWwVap1BU6elejKY= +github.com/hashicorp/go-metrics v0.5.4/go.mod h1:CG5yz4NZ/AI/aQt9Ucm/vdBnbh7fvmv4lxZ350i+QQI= +github.com/hashicorp/go-msgpack/v2 v2.1.2 h1:4Ee8FTp834e+ewB71RDrQ0VKpyFdrKOjvYtnQ/ltVj0= +github.com/hashicorp/go-msgpack/v2 v2.1.2/go.mod h1:upybraOAblm4S7rx0+jeNy+CWWhzywQsSRV5033mMu4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= +github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hashicorp/raft v1.7.2 h1:pyvxhfJ4R8VIAlHKvLoKQWElZspsCVT6YWuxVxsPAgc= +github.com/hashicorp/raft v1.7.2/go.mod h1:DfvCGFxpAUPE0L4Uc8JLlTPtc3GzSbdH0MTJCLgnmJQ= +github.com/hashicorp/raft-boltdb/v2 v2.3.1 h1:ackhdCNPKblmOhjEU9+4lHSJYFkJd6Jqyvj6eW9pwkc= +github.com/hashicorp/raft-boltdb/v2 v2.3.1/go.mod h1:n4S+g43dXF1tqDT+yzcXHhXM6y7MrlUd3TTwGRcUvQE= github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/hiddify/GooseRelayVPN v0.0.0-20260429125124-0e68c2a3ae4c h1:9Zc0hJ14G+SNJvGxtaSCSsvGB/RdLltrgjgR//9zgTQ= github.com/hiddify/GooseRelayVPN v0.0.0-20260429125124-0e68c2a3ae4c/go.mod h1:LI/1qA7FDU3MumegfyFOHGZCXcOw6rjIk7NPXga3/pQ= github.com/hiddify/hmrd_multi_resolver_dns v0.0.0-20260429114007-8d809dc33d0e h1:GsHpSccRtSFe1l3hq0WtBjqsKjJdU+an2D+uCsXm+xk= github.com/hiddify/hmrd_multi_resolver_dns v0.0.0-20260429114007-8d809dc33d0e/go.mod h1:7u9Ece+I3gvMFzcpqhROWaykNUyoE21g/fYQMBasAWY= github.com/hiddify/vaydns v0.0.0-20260401180616-890dc987a6a9 h1:KXnaABX8hHmkcL0jbL769hEIGI5+z/DajCrlO+Bkzcc= github.com/hiddify/vaydns v0.0.0-20260401180616-890dc987a6a9/go.mod h1:+8kEfQsZJn7/4aIppVekrSuqhrKjGBIgnacTJkdAlS8= +github.com/huandu/xstrings v1.4.0 h1:D17IlohoQq4UcpqD7fDk80P7l+lwAmlFaBHgOipl2FU= +github.com/huandu/xstrings v1.4.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/hugelgupf/socketpair v0.0.0-20190730060125-05d35a94e714 h1:/jC7qQFrv8CrSJVmaolDVOxTfS9kc36uB6H40kdbQq8= +github.com/hugelgupf/socketpair v0.0.0-20190730060125-05d35a94e714/go.mod h1:2Goc3h8EklBH5mspfHFxBnEoURQCGzQQH1ga9Myjvis= github.com/huin/goupnp v1.2.0 h1:uOKW26NG1hsSSbXIZ1IR7XP9Gjd1U8pnLaCMgntmkmY= github.com/huin/goupnp v1.2.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= +github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= +github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= +github.com/illarion/gonotify v1.0.1 h1:F1d+0Fgbq/sDWjj/r66ekjDG+IDeecQKUFH4wNwsoio= +github.com/illarion/gonotify v1.0.1/go.mod h1:zt5pmDofZpU1f8aqlK0+95eQhoEAn/d4G4B/FjVW4jE= +github.com/illarion/gonotify/v3 v3.0.2 h1:O7S6vcopHexutmpObkeWsnzMJt/r1hONIEogeVNmJMk= +github.com/illarion/gonotify/v3 v3.0.2/go.mod h1:HWGPdPe817GfvY3w7cx6zkbzNZfi3QjcBm/wgVvEL1U= +github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= +github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/insomniacslk/dhcp v0.0.0-20260220084031-5adc3eb26f91 h1:u9i04mGE3iliBh0EFuWaKsmcwrLacqGmq1G3XoaM7gY= github.com/insomniacslk/dhcp v0.0.0-20260220084031-5adc3eb26f91/go.mod h1:qfvBmyDNp+/liLEYWRvqny/PEz9hGe2Dz833eXILSmo= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= +github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= +github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4= +github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc= +github.com/jgautheron/goconst v1.5.1 h1:HxVbL1MhydKs8R8n/HE5NPvzfaYmQJA3o879lE4+WcM= +github.com/jgautheron/goconst v1.5.1/go.mod h1:aAosetZ5zaeC/2EfMeRswtxUFBpe2Hr7HzkgX4fanO4= +github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= +github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= +github.com/jingyugao/rowserrcheck v1.1.1 h1:zibz55j/MJtLsjP1OF4bSdgXxwL1b+Vn7Tjzq7gFzUs= +github.com/jingyugao/rowserrcheck v1.1.1/go.mod h1:4yvlZSDb3IyDTUZJUmpZfm2Hwok+Dtp+nu2qOq+er9c= +github.com/jinzhu/copier v0.3.5 h1:GlvfUwHk62RokgqVNvYsku0TATCF7bAHVwEXoBh3iJg= +github.com/jinzhu/copier v0.3.5/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= +github.com/jirfag/go-printf-func-name v0.0.0-20200119135958-7558a9eaa5af h1:KA9BjwUk7KlCh6S9EAGWBt1oExIUv9WyNCiRz5amv48= +github.com/jirfag/go-printf-func-name v0.0.0-20200119135958-7558a9eaa5af/go.mod h1:HEWGJkRDzjJY2sqdDwxccsGicWEf9BQOZsq2tV+xzM0= +github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24 h1:liMMTbpW34dhU4az1GN0pTPADwNmvoRSeoZ6PItiqnY= +github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= +github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= +github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e h1:a+PGEeXb+exwBS3NboqXHyxarD9kaboBbrSp+7GuBuc= +github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e/go.mod h1:ZybsQk6DWyN5t7An1MuPm1gtSZ1xDaTXS9ZjIOxvQrk= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86 h1:elKwZS1OcdQ0WwEDBeqxKwb7WB62QX8bvZ/FJnVXIfk= github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86/go.mod h1:aFAMtuldEgx/4q7iSGazk22+IcgvtiC+HIimFO9XlS8= github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I= github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/julz/importas v0.1.0 h1:F78HnrsjY3cR7j0etXy5+TU1Zuy7Xt08X/1aJnH5xXY= +github.com/julz/importas v0.1.0/go.mod h1:oSFU2R4XK/P7kNBrnL/FEQlDGN1/6WoxXEjSSXO0DV0= +github.com/junk1tm/musttag v0.5.0 h1:bV1DTdi38Hi4pG4OVWa7Kap0hi0o7EczuK6wQt9zPOM= +github.com/junk1tm/musttag v0.5.0/go.mod h1:PcR7BA+oREQYvHwgjIDmw3exJeds5JzRcvEJTfjrA0M= github.com/kamstrup/intmap v0.5.2 h1:qnwBm1mh4XAnW9W9Ue9tZtTff8pS6+s6iKF6JRIV2Dk= github.com/kamstrup/intmap v0.5.2/go.mod h1:gWUVWHKzWj8xpJVFf5GC0O26bWmv3GqdnIX/LMT6Aq4= +github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 h1:iQTw/8FWTuc7uiaSepXwyf3o52HaUYcV+Tu66S3F5GA= +github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/keltia/proxy v0.9.3 h1:Cpv6VA50SXSY+JxQ6q+BHpPMNAfWGZU4Qb5kdwUR1TY= +github.com/keltia/proxy v0.9.3/go.mod h1:fLU4DmBPG0oh0md9fWggE2oG2m7Lchv3eim+GiO3pZY= +github.com/keltia/ripe-atlas v0.0.0-20211221125000-f6eb808d5dc6 h1:9bEIiZu9Vonja7prDPBNMJRP9cLqVSKojqqrMhLiTeg= +github.com/keltia/ripe-atlas v0.0.0-20211221125000-f6eb808d5dc6/go.mod h1:zYa+dM8811qRhclezc/AKX9imyQwPjjSk2cH0xTgTag= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/keybase/dbus v0.0.0-20220506165403-5aa21ea2c23a h1:K0EAzgzEQHW4Y5lxrmvPMltmlRDzlhLfGmots9EHUTI= +github.com/keybase/dbus v0.0.0-20220506165403-5aa21ea2c23a/go.mod h1:YPNKjjE7Ubp9dTbnWvsP3HT+hYnY6TfXzubYTBeUxc8= github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= +github.com/kisielk/errcheck v1.9.0 h1:9xt1zI9EBfcYBvdU1nVrzMzzUPUtPKs9bVSIM3TAb3M= +github.com/kisielk/errcheck v1.9.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0D+/VL/i8= +github.com/kisielk/gotool v1.0.0 h1:AV2c/EiW3KqPNT9ZKl07ehoAGi4C5/01Cfbblndcapg= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kkHAIKE/contextcheck v1.1.4 h1:B6zAaLhOEEcjvUgIYEqystmnFk1Oemn8bvJhbt0GMb8= +github.com/kkHAIKE/contextcheck v1.1.4/go.mod h1:1+i/gWqokIa+dm31mqGLZhZJ7Uh44DJGZVmr6QRBNJg= github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= +github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/klauspost/reedsolomon v1.13.0 h1:E0Cmgf2kMuhZTj6eefnvpKC4/Q4jhCi9YIjcZjK4arc= github.com/klauspost/reedsolomon v1.13.0/go.mod h1:ggJT9lc71Vu+cSOPBlxGvBN6TfAS77qB4fp8vJ05NSA= github.com/koron/go-ssdp v0.0.4 h1:1IDwrghSKYM7yLf7XCzbByg2sJ/JcNOZRXS2jczTwz0= github.com/koron/go-ssdp v0.0.4/go.mod h1:oDXq+E5IL5q0U8uSBcoAXzTzInwy5lEgC91HoKtbmZk= +github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ= +github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk= github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kulti/thelper v0.6.3 h1:ElhKf+AlItIu+xGnI990no4cE2+XaSu1ULymV2Yulxs= +github.com/kulti/thelper v0.6.3/go.mod h1:DsqKShOvP40epevkFrvIwkCMNYxMeTNjdWL4dqWHZ6I= +github.com/kunwardeep/paralleltest v1.0.6 h1:FCKYMF1OF2+RveWlABsdnmsvJrei5aoyZoaGS+Ugg8g= +github.com/kunwardeep/paralleltest v1.0.6/go.mod h1:Y0Y0XISdZM5IKm3TREQMZ6iteqn1YuwCsJO/0kL9Zes= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/kyoh86/exportloopref v0.1.11 h1:1Z0bcmTypkL3Q4k+IDHMWTcnCliEZcaPiIe0/ymEyhQ= +github.com/kyoh86/exportloopref v0.1.11/go.mod h1:qkV4UF1zGl6EkF1ox8L5t9SwyeBAZ3qLMd6up458uqA= +github.com/ldez/gomoddirectives v0.2.3 h1:y7MBaisZVDYmKvt9/l1mjNCiSA1BVn34U0ObUcJwlhA= +github.com/ldez/gomoddirectives v0.2.3/go.mod h1:cpgBogWITnCfRq2qGoDkKMEVSaarhdBr6g8G04uz6d0= +github.com/ldez/tagliatelle v0.5.0 h1:epgfuYt9v0CG3fms0pEgIMNPuFf/LpPIfjk4kyqSioo= +github.com/ldez/tagliatelle v0.5.0/go.mod h1:rj1HmWiL1MiKQuOONhd09iySTEkUuE/8+5jtPYz9xa4= +github.com/leonklingele/grouper v1.1.1 h1:suWXRU57D4/Enn6pXR0QVqqWWrnJ9Osrz+5rjt8ivzU= +github.com/leonklingele/grouper v1.1.1/go.mod h1:uk3I3uDfi9B6PeUjsCKi6ndcf63Uy7snXgR4yDYQVDY= github.com/letsencrypt/challtestsrv v1.4.2 h1:0ON3ldMhZyWlfVNYYpFuWRTmZNnyfiL9Hh5YzC3JVwU= github.com/letsencrypt/challtestsrv v1.4.2/go.mod h1:GhqMqcSoeGpYd5zX5TgwA6er/1MbWzx/o7yuuVya+Wk= github.com/letsencrypt/pebble/v2 v2.10.0 h1:Wq6gYXlsY6ubqI3hhxsTzdyotvfdjFBxuwYqCLCnj/U= github.com/letsencrypt/pebble/v2 v2.10.0/go.mod h1:Sk8cmUIPcIdv2nINo+9PB4L+ZBhzY+F9A1a/h/xmWiQ= +github.com/letsencrypt/pkcs11key/v4 v4.0.0 h1:qLc/OznH7xMr5ARJgkZCCWk+EomQkiNTOoOF5LAgagc= +github.com/letsencrypt/pkcs11key/v4 v4.0.0/go.mod h1:EFUvBDay26dErnNb70Nd0/VW3tJiIbETBPTl9ATXQag= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/libdns/acmedns v0.5.0 h1:5pRtmUj4Lb/QkNJSl1xgOGBUJTWW7RjpNaIhjpDXjPE= github.com/libdns/acmedns v0.5.0/go.mod h1:X7UAFP1Ep9NpTwWpVlrZzJLR7epynAy0wrIxSPFgKjQ= github.com/libdns/alidns v1.0.6 h1:/Ii428ty6WHFJmE24rZxq2taq++gh7rf9jhgLfp8PmM= @@ -285,43 +858,132 @@ github.com/libp2p/go-reuseport v0.4.0 h1:nR5KU7hD0WxXCJbmw7r2rhRYruNRl2koHw8fQsc github.com/libp2p/go-reuseport v0.4.0/go.mod h1:ZtI03j/wO5hZVDFo2jKywN6bYKWLOy8Se6DrI2E1cLU= github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8= github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= +github.com/lufeee/execinquery v1.2.1 h1:hf0Ems4SHcUGBxpGN7Jz78z1ppVkP/837ZlETPCEtOM= +github.com/lufeee/execinquery v1.2.1/go.mod h1:EC7DrEKView09ocscGHC+apXMIaorh4xqSxS/dy8SbM= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/lxn/walk v0.0.0-20210112085537-c389da54e794 h1:NVRJ0Uy0SOFcXSKLsS65OmI1sgCCfiDUPj+cwnH7GZw= +github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ= +github.com/lxn/win v0.0.0-20210218163916-a377121e959e h1:H+t6A/QJMbhCSEH5rAuRxh+CtW96g0Or0Fxa9IKr4uc= +github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/maratori/testableexamples v1.0.0 h1:dU5alXRrD8WKSjOUnmJZuzdxWOEQ57+7s93SLMxb2vI= +github.com/maratori/testableexamples v1.0.0/go.mod h1:4rhjL1n20TUTT4vdh3RDqSizKLyXp7K2u6HgraZCGzE= +github.com/maratori/testpackage v1.1.1 h1:S58XVV5AD7HADMmD0fNnziNHqKvSdDuEKdPD1rNTU04= +github.com/maratori/testpackage v1.1.1/go.mod h1:s4gRK/ym6AMrqpOa/kEbQTV4Q4jb7WeLZzVhVVVOQMc= github.com/marusama/semaphore v0.0.0-20171214154724-565ffd8e868a h1:6SRny9FLB1eWasPyDUqBQnMi9NhXU01XIlB0ao89YoI= github.com/marusama/semaphore v0.0.0-20171214154724-565ffd8e868a/go.mod h1:TmeOqAKoDinfPfSohs14CO3VcEf7o+Bem6JiNe05yrQ= +github.com/matoous/godox v0.0.0-20230222163458-006bad1f9d26 h1:gWg6ZQ4JhDfJPqlo2srm/LN17lpybq15AryXIRcWYLE= +github.com/matoous/godox v0.0.0-20230222163458-006bad1f9d26/go.mod h1:1BELzlh859Sh1c6+90blK8lbYy0kwQf1bYlBhBysy1s= +github.com/mattbaird/jsonpatch v0.0.0-20171005235357-81af80346b1a h1:+J2gw7Bw77w/fbK7wnNJJDKmw1IbWft2Ul5BzrG1Qm8= +github.com/mattbaird/jsonpatch v0.0.0-20171005235357-81af80346b1a/go.mod h1:M1qoD/MqPgTZIk0EWKB38wE28ACRfVcn+cU08jyArI0= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= +github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= +github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/mbilski/exhaustivestruct v1.2.0 h1:wCBmUnSYufAHO6J4AVWY6ff+oxWxsVFrwgOdMUQePUo= +github.com/mbilski/exhaustivestruct v1.2.0/go.mod h1:OeTBVxQWoEmB2J2JCHmXWPJ0aksxSUOUy+nvtVEfzXc= +github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= +github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o= github.com/mdlayher/netlink v1.9.0 h1:G8+GLq2x3v4D4MVIqDdNUhTUC7TKiCy/6MDkmItfKco= github.com/mdlayher/netlink v1.9.0/go.mod h1:YBnl5BXsCoRuwBjKKlZ+aYmEoq0r12FDA/3JC+94KDg= +github.com/mdlayher/packet v1.1.2 h1:3Up1NG6LZrsgDVn6X4L9Ge/iyRyxFEFD9o6Pr3Q1nQY= +github.com/mdlayher/packet v1.1.2/go.mod h1:GEu1+n9sG5VtiRE4SydOmX5GTwyyYlteZiFU+x0kew4= +github.com/mdlayher/sdnotify v1.0.0 h1:Ma9XeLVN/l0qpyx1tNeMSeTjCPH6NtuD6/N9XdTlQ3c= +github.com/mdlayher/sdnotify v1.0.0/go.mod h1:HQUmpM4XgYkhDLtd+Uad8ZFK1T9D5+pNxnXQjCeJlGE= github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos= github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= -github.com/metacubex/utls v1.8.4 h1:HmL9nUApDdWSkgUyodfwF6hSjtiwCGGdyhaSpEejKpg= -github.com/metacubex/utls v1.8.4/go.mod h1:kncGGVhFaoGn5M3pFe3SXhZCzsbCJayNOH4UEqTKTko= +github.com/metacubex/utls v1.8.7 h1:Cp+yWkNTFkSihETgGWq34hlVFds5HpYWVOR1xovUVTs= +github.com/metacubex/utls v1.8.7/go.mod h1:kncGGVhFaoGn5M3pFe3SXhZCzsbCJayNOH4UEqTKTko= +github.com/mgechev/revive v1.3.1 h1:OlQkcH40IB2cGuprTPcjB0iIUddgVZgGmDX3IAMR8D4= +github.com/mgechev/revive v1.3.1/go.mod h1:YlD6TTWl2B8A103R9KWJSPVI9DrEf+oqr15q21Ld+5I= github.com/mholt/acmez/v3 v3.1.6 h1:eGVQNObP0pBN4sxqrXeg7MYqTOWyoiYpQqITVWlrevk= github.com/mholt/acmez/v3 v3.1.6/go.mod h1:5nTPosTGosLxF3+LU4ygbgMRFDhbAVpqMI4+a4aHLBY= github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= +github.com/miekg/pkcs11 v1.1.1 h1:Ugu9pdy6vAYku5DEpVWVFPYnzV+bxB+iRdbuFSu7TvU= +github.com/miekg/pkcs11 v1.1.1/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs= +github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws= +github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/panicwrap v0.0.0-20170106182340-fce601fe5557 h1:w1QuuAA2km2Hax+EPamrq5ZRBeaNv2vsjvgB4an0zoU= +github.com/mitchellh/panicwrap v0.0.0-20170106182340-fce601fe5557/go.mod h1:QuAqW7/z+iv6aWFJdrA8kCbsF0OOJVKCICqTcYBexuY= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/moby/buildkit v0.20.2 h1:qIeR47eQ1tzI1rwz0on3Xx2enRw/1CKjFhoONVcTlMA= +github.com/moby/buildkit v0.20.2/go.mod h1:DhaF82FjwOElTftl0JUAJpH/SUIUx4UvcFncLeOtlDI= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/mohae/deepcopy v0.0.0-20170308212314-bb9b5e7adda9 h1:Sha2bQdoWE5YQPTlJOL31rmce94/tYi113SlFo1xQ2c= +github.com/mohae/deepcopy v0.0.0-20170308212314-bb9b5e7adda9/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= +github.com/moricho/tparallel v0.3.1 h1:fQKD4U1wRMAYNngDonW5XupoB/ZGJHdpzrWqgyg9krA= +github.com/moricho/tparallel v0.3.1/go.mod h1:leENX2cUv7Sv2qDgdi0D0fCftN8fRC67Bcn8pqzeYNI= github.com/mroth/weightedrand v1.0.0 h1:V8JeHChvl2MP1sAoXq4brElOcza+jxLkRuwvtQu8L3E= github.com/mroth/weightedrand v1.0.0/go.mod h1:3p2SIcC8al1YMzGhAIoXD+r9olo/g/cdJgAD905gyNE= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81U= +github.com/nakabonne/nestif v0.3.1/go.mod h1:9EtoZochLn5iUprVDmDjqGKPofoUEBL8U4Ngq6aY7OE= +github.com/nbutton23/zxcvbn-go v0.0.0-20210217022336-fa2cb2858354 h1:4kuARK6Y6FxaNu/BnU2OAaLF86eTVhP2hjTB6iMvItA= +github.com/nbutton23/zxcvbn-go v0.0.0-20210217022336-fa2cb2858354/go.mod h1:KSVJerMDfblTH7p5MZaTt+8zaT2iEk3AkVb9PQdZuE8= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/nishanths/exhaustive v0.10.0 h1:BMznKAcVa9WOoLq/kTGp4NJOJSMwEpcpjFNAVRfPlSo= +github.com/nishanths/exhaustive v0.10.0/go.mod h1:IbwrGdVMizvDcIxPYGVdQn5BqWJaOwpCvg4RGb8r/TA= +github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm/w98Vk= +github.com/nishanths/predeclared v0.2.2/go.mod h1:RROzoN6TnGQupbC+lqggsOlcgysk3LMK/HI84Mp280c= +github.com/nunnatsa/ginkgolinter v0.11.2 h1:xzQpAsEyZe5F1RMy2Z5kn8UFCGiWfKqJOUd2ZzBXA4M= +github.com/nunnatsa/ginkgolinter v0.11.2/go.mod h1:dJIGXYXbkBswqa/pIzG0QlVTTDSBMxDoCFwhsl4Uras= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= github.com/onsi/ginkgo/v2 v2.12.0 h1:UIVDowFPwpg6yMUpPjGkYvf06K3RAiJXUhCxEwQVHRI= github.com/onsi/ginkgo/v2 v2.12.0/go.mod h1:ZNEzXISYlqpb8S36iN71ifqLi3vVD1rVJGvWRCJOUpQ= github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8= github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY= github.com/openai/openai-go/v3 v3.26.0 h1:bRt6H/ozMNt/dDkN4gobnLqaEGrRGBzmbVs0xxJEnQE= github.com/openai/openai-go/v3 v3.26.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/opencontainers/runtime-spec v1.1.0-rc.1 h1:wHa9jroFfKGQqFHj0I1fMRKLl0pfj+ynAqBxo3v6u9w= +github.com/opencontainers/runtime-spec v1.1.0-rc.1/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= github.com/oschwald/geoip2-golang v1.9.0 h1:uvD3O6fXAXs+usU+UGExshpdP13GAqp4GBrzN7IgKZc= github.com/oschwald/geoip2-golang v1.9.0/go.mod h1:BHK6TvDyATVQhKNbQBdrj9eAvuwOMi2zSFXizL3K81Y= github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE= github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8= +github.com/otiai10/copy v1.6.0 h1:IinKAryFFuPONZ7cm6T6E2QX/vcJwSnlaA5lfoaXIiQ= +github.com/otiai10/copy v1.6.0/go.mod h1:XWfuS3CrI0R6IE0FbgHsEazaXO8G0LpMp9o8tos0x4E= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/pebbe/zmq4 v1.2.10 h1:wQkqRZ3CZeABIeidr3e8uQZMMH5YAykA/WN0L5zkd1c= github.com/pebbe/zmq4 v1.2.10/go.mod h1:nqnPueOapVhE2wItZ0uOErngczsJdLOGkebMxaO8r48= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= +github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= +github.com/peterbourgon/ff/v3 v3.4.0 h1:QBvM/rizZM1cB0p0lGMdmR7HxZeI/ZrBWB4DqLkMUBc= +github.com/peterbourgon/ff/v3 v3.4.0/go.mod h1:zjJVUhx+twciwfDl0zBcFzl4dW8axCRyXE/eKY9RztQ= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= @@ -330,12 +992,16 @@ github.com/pion/datachannel v1.5.5 h1:10ef4kwdjije+M9d7Xm9im2Y3O6A6ccQb0zcqZcJew github.com/pion/datachannel v1.5.5/go.mod h1:iMz+lECmfdCMqFRhXhcA/219B0SQlbpoR2V118yimL0= github.com/pion/dtls/v2 v2.2.7 h1:cSUBsETxepsCSFSxC3mc/aDo14qQLMSL+O6IjG28yV8= github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s= +github.com/pion/dtls/v3 v3.1.5 h1:9xJtVsHwMYeSjPp5Hh1FTis4DchnQWtnOa5o+6ygqfc= +github.com/pion/dtls/v3 v3.1.5/go.mod h1:gz1K4jg6c+fq86oQMH4pilpCEOEPwmEr2jY+VcF/mkU= github.com/pion/ice/v2 v2.3.24 h1:RYgzhH/u5lH0XO+ABatVKCtRd+4U1GEaCXSMjNr13tI= github.com/pion/ice/v2 v2.3.24/go.mod h1:KXJJcZK7E8WzrBEYnV4UtqEZsGeWfHxsNqhVcVvgjxw= github.com/pion/interceptor v0.1.25 h1:pwY9r7P6ToQ3+IF0bajN0xmk/fNw/suTgaTdlwTDmhc= github.com/pion/interceptor v0.1.25/go.mod h1:wkbPYAak5zKsfpVDYMtEfWEy8D4zL+rpxCxPImLOg3Y= github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY= github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= +github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= +github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= github.com/pion/mdns v0.0.12 h1:CiMYlY+O0azojWDmxdNr7ADGrnZ+V6Ilfner+6mSVK8= github.com/pion/mdns v0.0.12/go.mod h1:VExJjv8to/6Wqm1FXK+Ii/Z9tsVk/F5sD/N70cnYFbk= github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= @@ -366,26 +1032,62 @@ github.com/pion/transport/v2 v2.2.4/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLh github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0= github.com/pion/transport/v3 v3.0.2 h1:r+40RJR25S9w3jbA6/5uEPTzcdn7ncyU44RWCbHkLg4= github.com/pion/transport/v3 v3.0.2/go.mod h1:nIToODoOlb5If2jF9y2Igfx3PFYWfuXi37m0IlWa/D0= +github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk= +github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM= github.com/pion/turn/v2 v2.1.3 h1:pYxTVWG2gpC97opdRc5IGsQ1lJ9O/IlNhkzj7MMrGAA= github.com/pion/turn/v2 v2.1.3/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY= github.com/pion/webrtc/v3 v3.2.40 h1:Wtfi6AZMQg+624cvCXUuSmrKWepSB7zfgYDOYqsSOVU= github.com/pion/webrtc/v3 v3.2.40/go.mod h1:M1RAe3TNTD1tzyvqHrbVODfwdPGSXOUo/OgpoGGJqFY= github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0= github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= +github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= +github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e h1:aoZm08cpOy4WuID//EZDgcC4zIxODThtZNPirFr42+A= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA= +github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU= github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/polyfloyd/go-errorlint v1.4.1 h1:r8ru5FhXSn34YU1GJDOuoJv2LdsQkPmK325EOpPMJlM= +github.com/polyfloyd/go-errorlint v1.4.1/go.mod h1:k6fU/+fQe38ednoZS51T7gSIGQW1y94d6TkSr35OzH8= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyfyrrjEaAchdy3R4= github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4= +github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc= +github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= +github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/prometheus/prometheus v0.51.0 h1:aRdjTnmHLved29ILtdzZN2GNvOjWATtA/z+3fYuexOc= +github.com/prometheus/prometheus v0.51.0/go.mod h1:yv4MwOn3yHMQ6MZGHPg/U7Fcyqf+rxqiZfSur6myVtc= +github.com/puzpuzpuz/xsync v1.5.2 h1:yRAP4wqSOZG+/4pxJ08fPTwrfL0IzE/LKQ/cw509qGY= +github.com/puzpuzpuz/xsync v1.5.2/go.mod h1:K98BYhX3k1dQ2M63t1YNVDanbwUPmBCAhNmVrrxfiGg= +github.com/quasilyte/go-ruleguard v0.3.19 h1:tfMnabXle/HzOb5Xe9CUZYWXKfkS1KwRmZyPmD9nVcc= +github.com/quasilyte/go-ruleguard v0.3.19/go.mod h1:lHSn69Scl48I7Gt9cX3VrbsZYvYiBYszZOZW4A+oTEw= +github.com/quasilyte/gogrep v0.5.0 h1:eTKODPXbI8ffJMN+W2aE0+oL0z/nh8/5eNdiO34SOAo= +github.com/quasilyte/gogrep v0.5.0/go.mod h1:Cm9lpz9NZjEoL1tgZ2OgeUKPIxL1meE7eo60Z6Sk+Ng= +github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 h1:TCg2WBOl980XxGFEZSS6KlBGIV0diGdySzxATTWoqaU= +github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727/go.mod h1:rlzQ04UMyJXu/aOvhd8qT+hvDrFpiwqp8MRXDY9szc0= +github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 h1:M8mH9eK4OUR4lu7Gd+PU1fV2/qnDNfzT635KRSObncs= +github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567/go.mod h1:DWNGW8A4Y+GyBgPuaQJuWiy0XYftx4Xm/y5Jqk9I6VQ= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/qtls-go1-20 v0.4.1 h1:D33340mCNDAIKBqXuAvexTNMUByrYmFYVfKfDN5nfFs= +github.com/quic-go/qtls-go1-20 v0.4.1/go.mod h1:X9Nh97ZL80Z+bX/gUXMbipO6OxdiDi58b/fMC9mAL+k= github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/refraction-networking/conjure v0.7.11-0.20240130155008-c8df96195ab2 h1:m2ZH6WV69otVmBpWbk8et3MypHFsjcYXTNrknQKS/PY= @@ -400,9 +1102,20 @@ github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEv github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 h1:f/FNXud6gA3MNr8meMVVGxhp+QBTqY91tM8HjEuMjGg= github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3/go.mod h1:HgjTstvQsPGkxUsCd2KWxErBblirPizecHcpD3ffK+s= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= +github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ryancurrah/gomodguard v1.3.0 h1:q15RT/pd6UggBXVBuLps8BXRvl5GPBcwVA7BJHMLuTw= +github.com/ryancurrah/gomodguard v1.3.0/go.mod h1:ggBxb3luypPEzqVtq33ee7YSN35V28XeGnid8dnni50= +github.com/ryanrolds/sqlclosecheck v0.4.0 h1:i8SX60Rppc1wRuyQjMciLqIzV3xnoHB7/tXbr6RGYNI= +github.com/ryanrolds/sqlclosecheck v0.4.0/go.mod h1:TBRRjzL31JONc9i4XMinicuo+s+E8yKZ5FN8X3G6CKQ= github.com/ryanuber/go-glob v0.0.0-20170128012129-256dc444b735 h1:7YvPJVmEeFHR1Tj9sZEYsmarJEQfMVYpd/Vyy/A8dqE= github.com/ryanuber/go-glob v0.0.0-20170128012129-256dc444b735/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0= @@ -413,68 +1126,68 @@ github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a h1:+NkI2670SQpQWvkk github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a/go.mod h1:63s7jpZqcDAIpj8oI/1v4Izok+npJOHACFCU6+huCkM= github.com/sagernet/cors v1.2.1 h1:Cv5Z8y9YSD6Gm+qSpNrL3LO4lD3eQVvbFYJSG7JCMHQ= github.com/sagernet/cors v1.2.1/go.mod h1:O64VyOjjhrkLmQIjF4KGRrJO/5dVXFdpEmCW/eISRAI= -github.com/sagernet/cronet-go v0.0.0-20260620140045-05ab0dc17597 h1:QkwE/ZFnShDuPF+ExmAyZlQaMwFNgkYZMekrabiStfg= -github.com/sagernet/cronet-go v0.0.0-20260620140045-05ab0dc17597/go.mod h1:T/mwtrpC4JlWfScw73CmSBvHzIvc7BatQ1MhRr+cYNw= -github.com/sagernet/cronet-go/all v0.0.0-20260620140045-05ab0dc17597 h1:cLALmGKP9eOS8622gWQIiVbZlOfH29PGNsoxbEloIdk= -github.com/sagernet/cronet-go/all v0.0.0-20260620140045-05ab0dc17597/go.mod h1:zVHZ5tgDTwbNvUGffAgLmouYs4in0grEzhSdaggoZOw= -github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260620135226-def9ff0fb992 h1:J9l8PP4vb79Wm5zKaMO6LNZ/AiP1FvyAWJBlkKHrRBU= -github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:XXDwdjX/T8xftoeJxQmbBoYXZp8MAPFR2CwbFuTpEtw= -github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260620135226-def9ff0fb992 h1:wXDjUNeKuihv85Kg51FomkiEH7xGsDgRcfRLiyZxacQ= -github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:iNiUGoLtnr8/JTuVNj7XJbmpOAp2C6+B81KDrPxwaZM= -github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260620135226-def9ff0fb992 h1:IF04nGyY3Q6Nbk9XJwTX1mckwhf12iIx4RhZ4TLOZIU= -github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260620135226-def9ff0fb992/go.mod h1:19ILNUOGIzRdOqa2mq+iY0JoHxuieB7/lnjYeaA2vEc= -github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260620135226-def9ff0fb992 h1:dlnG1E42xx8ms2fyZigYwsYJ1Gqoj2QT8WeGlpOAWK8= -github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:JxzGyQf94Cr6sBShKqODGDyRUlESfJK/Njcz9Lz6qMQ= -github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260620135226-def9ff0fb992 h1:aYAQQN3jZP89MmT1ZzpJz52jAsXx1WApmv5Qidc+ez4= -github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:KN+9T9TBycGOLzmKU4QdcHAJEj6Nlx48ifnlTvvHMvs= -github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260620135226-def9ff0fb992 h1:n81+aLphvjLpL2M6lI+BC8Ldw4S/FPA3CFDXTWL4g3I= -github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:kojvtUc29KKnk8hs2QIANynVR59921SnGWA9kXohHc0= -github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260620135226-def9ff0fb992 h1:mOseesF+CxgPG2U1a2Yh2fUUMdOaPxSuk4eIL6g7EU8= -github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260620135226-def9ff0fb992/go.mod h1:hkQzRE5GDbaH1/ioqYh0Taho4L6i0yLRCVEZ5xHz5M0= -github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260620135226-def9ff0fb992 h1:JK9kP72IxAoyVQTnh9gGkh8S9RjP4FBkj95WjrLPyKs= -github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:tzVJFTOm66UxLxy6K0ZN5Ic2PC79e+sKKnt+V9puEa4= -github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260620135226-def9ff0fb992 h1:3EtgLRsUpmeRaOBynTARxVC8tDegykigutmpAow2ayc= -github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260620135226-def9ff0fb992/go.mod h1:M/pN6m3j0HFU6/y83n0HU6GLYys3tYdr/xTE8hVEGMo= -github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260620135226-def9ff0fb992 h1:Vr3I2sC9E/1FurpZwJXAL29C7jJROyN3JfulQWjhKuA= -github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:cGh5hO6eljCo6KMQ/Cel8Xgq4+etL0awZLRBDVG1EZQ= -github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260620135226-def9ff0fb992 h1:flxwC8loz0C4LQ/tLK7LvNMKx4iqaXayPggzzCxCevI= -github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260620135226-def9ff0fb992/go.mod h1:JFE0/cxaKkx0wqPMZU7MgaplQlU0zudv82dROJjClKU= -github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260620135226-def9ff0fb992 h1:G4vwPmOVR/jXDngIUC9owbEtMKXLZl/BgUHhEWWl8ec= -github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:vU8VftFeSt7fURCa3JXD6+k6ss1YAX+idQjPvHmJ2tI= -github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260620135226-def9ff0fb992 h1:zHIxR2FlJOW5GRmgwyA2Gjgx7potOCtlOmdl8k4mwJI= -github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260620135226-def9ff0fb992/go.mod h1:vCe4OUuL+XOUge9v3MyTD45BnuAXiH+DkjN9quDXJzQ= -github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260620135226-def9ff0fb992 h1:U1OrR5zP+lkOPqrDpZsn8sPK1XBWZ84isXaFBZLsOfs= -github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260620135226-def9ff0fb992/go.mod h1:w9amBWrvjtohQzBGCKJ7LCh22LhTIJs4sE7cYaKQzM0= -github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260620135226-def9ff0fb992 h1:F59ptY4AdtKEg73OWaL+lTb5yoxp5b/gTuDbvA6xMyg= -github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:TqlsFtcYS/etTeck46kHBeT8Le0Igw1Q/AV88UnMS3s= -github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260620135226-def9ff0fb992 h1:Y5axK4sCWXH+2OCpKYPI8nX3OSBuRb/6yC+5xBi9/uo= -github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260620135226-def9ff0fb992/go.mod h1:B6Qd0vys8sv9OKVRN6J9RqDzYRGE938Fb2zrYdBDyTQ= -github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260620135226-def9ff0fb992 h1:QCBwCdv9y+RJj7p0b9Db2p9fFt1wtnawD0sn9oV3vRs= -github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260620135226-def9ff0fb992/go.mod h1:3tXMMFY7AHugOVBZ5Al7cL7JKsnFOe5bMVr0hZPk3ow= -github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260620135226-def9ff0fb992 h1:6gXFFaAMiGCPZdfUs64qzj9cl7EleVs9HsNfRi8jWWw= -github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:Wt5uFdU3tnmm8YzobYewwdF7Mt6SucRQg6xeTNWC3Tk= -github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260620135226-def9ff0fb992 h1:VGoIX2u4CWVg9kiyjQdIpFMaFUNCW3yz0pyrEKI5X0o= -github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260620135226-def9ff0fb992/go.mod h1:lyIF6wKBLwWa5ZXaAKbAoewewl+yCHo2iYev39Mbj4E= -github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260620135226-def9ff0fb992 h1:SjPuqtUNCzIDaEm7iY86JZ7L+ixTmai4i2DIC++eUvw= -github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260620135226-def9ff0fb992/go.mod h1:H46PnSTTZNcZokLLiDeMDaHiS1l14PH3tzWi0eykjD8= -github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260620135226-def9ff0fb992 h1:ao2FrDzTYhu2MYsMri9nzqIdnAL7ooUWQN6/FFr+Lbk= -github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260620135226-def9ff0fb992/go.mod h1:RBhSUDAKWq7fswtV4nQUQhuaTLcX3ettR7teA7/yf2w= -github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260620135226-def9ff0fb992 h1:5gPMu6EUlX6gqCRTOJrJk1FMILO6ugtnopQF1c2R/lY= -github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260620135226-def9ff0fb992/go.mod h1:wRzoIOGG4xbpp3Gh3triLKwMwYriScXzFtunLYhY4w0= -github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260620135226-def9ff0fb992 h1:UaLOQKbjnLrO943Sm+ff/jm+NmemRuJXiImmtqShd8s= -github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:LNiZXmWil1OPwKCheqQjtakZlJuKGFz+iv2eGF76Hhs= -github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260620135226-def9ff0fb992 h1:wxlsDfxDowVk99Ay2hzfuIXPpXH1lGQxSL/2sX/D7jw= -github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260620135226-def9ff0fb992/go.mod h1:YFDGKTkpkJGc5+hnX/RYosZyTWg9h+68VB55fYRRLYc= -github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260620135226-def9ff0fb992 h1:pgRpWh2JPE73mtedovPPu3gmmAqHz3Rfz79QVxciu2o= -github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260620135226-def9ff0fb992/go.mod h1:aaX0YGl8nhGmfRWI8bc3BtDjY8Vzx6O0cS/e1uqxDq4= -github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260620135226-def9ff0fb992 h1:9jtXyxukTS2ZdhMy3u1hg4StkZpgP48BOfgYhXf835w= -github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:EdzMKA96xITc42QEI+ct4SwqX8Dn3ltKK8wzdkLWpSc= -github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260620135226-def9ff0fb992 h1:gKgD1LJZbZzacaaqBQX/YKy4dyomhn8xtfmDKayVLW4= -github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260620135226-def9ff0fb992/go.mod h1:qix4kv1TTAJ5tY4lJ9vjhe9EY4mM+B7H5giOhbxDVcc= -github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260620135226-def9ff0fb992 h1:Lv8gtKP4QRn6Yjv3h5L215aGQBgwCyAE5YqejmN9Bqc= -github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:lm9w/oCCRyBiUa3G8lDQTT8x/ONUvgVR2iV9fVzUZB8= -github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260620135226-def9ff0fb992 h1:2wsRAcqJHOTlj6zGc3oQyxAZHDnGwyB/pTdAOUtLgHY= -github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:n34YyLgapgjWdKa0IoeczjAFCwD3/dxbsH5sucKw0bw= +github.com/sagernet/cronet-go v0.0.0-20260712143338-d22f2ea3630e h1:Y5mhsZrYuZ9jraIqg7hg1fw4zoVreae81fWvmozbCsQ= +github.com/sagernet/cronet-go v0.0.0-20260712143338-d22f2ea3630e/go.mod h1:T/mwtrpC4JlWfScw73CmSBvHzIvc7BatQ1MhRr+cYNw= +github.com/sagernet/cronet-go/all v0.0.0-20260712143338-d22f2ea3630e h1:rdNlS1dRSi7jQe/ingFf7QmV9ZCUZMbxAfHnBrdVW7g= +github.com/sagernet/cronet-go/all v0.0.0-20260712143338-d22f2ea3630e/go.mod h1:WNl4xfTNuR+f7SObmuBtrk0p4MhlmvuuiWYoty3U52E= +github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260712142643-1e5048bd5587 h1:ENmDXbGH92/jsMwhjIxK2a0URkA8ILC3npjqmTGj0Yc= +github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:XXDwdjX/T8xftoeJxQmbBoYXZp8MAPFR2CwbFuTpEtw= +github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260712142643-1e5048bd5587 h1:5xn/EZOO5LriSEih91thvTuR+gxb59jNxNQiB/KQPEc= +github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:iNiUGoLtnr8/JTuVNj7XJbmpOAp2C6+B81KDrPxwaZM= +github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260712142643-1e5048bd5587 h1:2/UN0LvWnAM0Sc9B/zE2qWylGrX2hQXhknyQyTlvz4k= +github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260712142643-1e5048bd5587/go.mod h1:19ILNUOGIzRdOqa2mq+iY0JoHxuieB7/lnjYeaA2vEc= +github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260712142643-1e5048bd5587 h1:SDULc9o0HkneJD38H1G+HRLby69zfdtyQk5qaKtn/tU= +github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:JxzGyQf94Cr6sBShKqODGDyRUlESfJK/Njcz9Lz6qMQ= +github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260712142643-1e5048bd5587 h1:hfcM9YccWN4O2LENHN16Jgm4g1/1PRouV0RmvvrS1f0= +github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:KN+9T9TBycGOLzmKU4QdcHAJEj6Nlx48ifnlTvvHMvs= +github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260712142643-1e5048bd5587 h1:arY9CL3C7lwJfG2Cdz2ZLvHLzT1wnzCVyAomNER6CrY= +github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:kojvtUc29KKnk8hs2QIANynVR59921SnGWA9kXohHc0= +github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260712142643-1e5048bd5587 h1:HrtCf6KPJsW9KJ4L4T8HMW1vx+3xefLRLObpjZEtzF4= +github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260712142643-1e5048bd5587/go.mod h1:hkQzRE5GDbaH1/ioqYh0Taho4L6i0yLRCVEZ5xHz5M0= +github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260712142643-1e5048bd5587 h1:Inzzp4hyvcC0lawhndaK8iwEN0vGLDJLCThbaKZpksg= +github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:tzVJFTOm66UxLxy6K0ZN5Ic2PC79e+sKKnt+V9puEa4= +github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260712142643-1e5048bd5587 h1:qN6vqr9nFnZqjZXhLez+7tMcbm3FMfhaJRWNMlK3SLI= +github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260712142643-1e5048bd5587/go.mod h1:M/pN6m3j0HFU6/y83n0HU6GLYys3tYdr/xTE8hVEGMo= +github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260712142643-1e5048bd5587 h1:Zwzpw6555p3rWw844JxNZ+5iRqli6ZBOEtMH3qq1c7s= +github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:cGh5hO6eljCo6KMQ/Cel8Xgq4+etL0awZLRBDVG1EZQ= +github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260712142643-1e5048bd5587 h1:CwsGHfo1HwvEhA2Wnh3O8WxazN3d4Un44LXhiuIvA/w= +github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260712142643-1e5048bd5587/go.mod h1:JFE0/cxaKkx0wqPMZU7MgaplQlU0zudv82dROJjClKU= +github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260712142643-1e5048bd5587 h1:Pn6vsVFOJkj9q/XKBOZosfuDLB6luNMLkqq6YLUPXEk= +github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:vU8VftFeSt7fURCa3JXD6+k6ss1YAX+idQjPvHmJ2tI= +github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260712142643-1e5048bd5587 h1:vgRWcEr2jlgICj28XwDMXPQcy8mTob1ZcO+tPdiZjes= +github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260712142643-1e5048bd5587/go.mod h1:vCe4OUuL+XOUge9v3MyTD45BnuAXiH+DkjN9quDXJzQ= +github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260712142643-1e5048bd5587 h1:uiO62HvSAdRJR3d1Jc4duxWig3kkdwHBSQ3TmUFqw48= +github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260712142643-1e5048bd5587/go.mod h1:w9amBWrvjtohQzBGCKJ7LCh22LhTIJs4sE7cYaKQzM0= +github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260712142643-1e5048bd5587 h1:mXioRdq9h2YlIr9XGM51kXgIkToywwVfnGmJiaz/uzM= +github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:TqlsFtcYS/etTeck46kHBeT8Le0Igw1Q/AV88UnMS3s= +github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260712142643-1e5048bd5587 h1:oOlfOL+sq0KNZRsk06+5KgvtKw2TEXl2EZnLakTx+WM= +github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260712142643-1e5048bd5587/go.mod h1:B6Qd0vys8sv9OKVRN6J9RqDzYRGE938Fb2zrYdBDyTQ= +github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260712142643-1e5048bd5587 h1:UDCa0lYiSUXD4wbxA/G5pGPSXhJaesCQCh5IEiZKb3M= +github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260712142643-1e5048bd5587/go.mod h1:3tXMMFY7AHugOVBZ5Al7cL7JKsnFOe5bMVr0hZPk3ow= +github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260712142643-1e5048bd5587 h1:Yg2Ut7mPs0GK4W6p9LDh8RrDOnhTe4YpvG/WeJyUqMo= +github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:Wt5uFdU3tnmm8YzobYewwdF7Mt6SucRQg6xeTNWC3Tk= +github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260712142643-1e5048bd5587 h1:NtagC/YHvucD0Azh86aVghOk4z5f7oOAAy25Uw4knXQ= +github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260712142643-1e5048bd5587/go.mod h1:lyIF6wKBLwWa5ZXaAKbAoewewl+yCHo2iYev39Mbj4E= +github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260712142643-1e5048bd5587 h1:INzfLHBKjgJxoUDBeCaiB9hYqjhn5yfudkMz2phYypA= +github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260712142643-1e5048bd5587/go.mod h1:H46PnSTTZNcZokLLiDeMDaHiS1l14PH3tzWi0eykjD8= +github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260712142643-1e5048bd5587 h1:IX5NCEV9nojHdjpOkxKVN4L5FUuA5nwawXlSTdFXjCE= +github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260712142643-1e5048bd5587/go.mod h1:RBhSUDAKWq7fswtV4nQUQhuaTLcX3ettR7teA7/yf2w= +github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260712142643-1e5048bd5587 h1:pzgA94sR7kvrP+H86/to2oN3efYORQmh0Q3b6AyfRJQ= +github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260712142643-1e5048bd5587/go.mod h1:wRzoIOGG4xbpp3Gh3triLKwMwYriScXzFtunLYhY4w0= +github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260712142643-1e5048bd5587 h1:nkS6jhF90E24PW37Xemhs6+hUM0l2iRcQcZFhPJL8R8= +github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:LNiZXmWil1OPwKCheqQjtakZlJuKGFz+iv2eGF76Hhs= +github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260712142643-1e5048bd5587 h1:EbydHlp6vWdqhb8e8zBPVJv/F3HPob+FgO6rsSdNSr0= +github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260712142643-1e5048bd5587/go.mod h1:YFDGKTkpkJGc5+hnX/RYosZyTWg9h+68VB55fYRRLYc= +github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260712142643-1e5048bd5587 h1:sSfVTswgqQZJqh9wTP0Acvvo5/qYARAoHegRYwZ+gyU= +github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260712142643-1e5048bd5587/go.mod h1:aaX0YGl8nhGmfRWI8bc3BtDjY8Vzx6O0cS/e1uqxDq4= +github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260712142643-1e5048bd5587 h1:lHqqbALbKdJdq/1rcI4yyg3zvibHDw7wmhDYVbjy498= +github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:EdzMKA96xITc42QEI+ct4SwqX8Dn3ltKK8wzdkLWpSc= +github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260712142643-1e5048bd5587 h1:23GyWjb58Nk9a7WXgRfFUiTrMD9kvYSRHx73AaBthYc= +github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260712142643-1e5048bd5587/go.mod h1:qix4kv1TTAJ5tY4lJ9vjhe9EY4mM+B7H5giOhbxDVcc= +github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260712142643-1e5048bd5587 h1:64EMjgVuZMD4TX5b7oWUfWRVp8aVl3hg4aWrriQKOWo= +github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:lm9w/oCCRyBiUa3G8lDQTT8x/ONUvgVR2iV9fVzUZB8= +github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260712142643-1e5048bd5587 h1:x8cvgMQUs0EgVwt3iT/isRQ9KImvXvPobLKO5bHTN4o= +github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:n34YyLgapgjWdKa0IoeczjAFCwD3/dxbsH5sucKw0bw= github.com/sagernet/fswatch v0.1.2 h1:/TT7k4mkce1qFPxamLO842WjqBgbTBiXP2mlUjp9PFk= github.com/sagernet/fswatch v0.1.2/go.mod h1:5BpGmpUQVd3Mc5r313HRpvADHRg3/rKn5QbwFteB880= github.com/sagernet/gliderssh v0.3.4-0.20260531100337-2194faca5648 h1:IWVjKBARzVjdmH0VUaeTBOBli1qkwKmTG4XfbkpSS20= @@ -485,63 +1198,126 @@ github.com/sagernet/gvisor v0.0.0-20250811.0-sing-box-mod.1 h1:AzCE2RhBjLJ4WIWc/ github.com/sagernet/gvisor v0.0.0-20250811.0-sing-box-mod.1/go.mod h1:NJKBtm9nVEK3iyOYWsUlrDQuoGh4zJ4KOPhSYVidvQ4= github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a h1:ObwtHN2VpqE0ZNjr6sGeT00J8uU7JF4cNUdb44/Duis= github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a/go.mod h1:xLnfdiJbSp8rNqYEdIW/6eDO4mVoogml14Bh2hSiFpM= -github.com/sagernet/nftables v0.3.0-mod.3 h1:CVfbVTd3Z/LQVc1Z3c1hpiriplJ4xDVHjfQCETiN9RA= -github.com/sagernet/nftables v0.3.0-mod.3/go.mod h1:8kslHG4VvYNihcco+i6uxIX7qbT8A56T0y5q7U44ZaQ= +github.com/sagernet/nftables v0.3.0-mod.4 h1:vnOtcDYeSXv2e5RoRuGH0lrpttQFJ8iC4ICS2nhlDSo= +github.com/sagernet/nftables v0.3.0-mod.4/go.mod h1:8kslHG4VvYNihcco+i6uxIX7qbT8A56T0y5q7U44ZaQ= github.com/sagernet/quic-go v0.59.0-sing-box-mod.4 h1:6qvrUW79S+CrPwWz6cMePXohgjHoKxLo3c+MDhNwc3o= github.com/sagernet/quic-go v0.59.0-sing-box-mod.4/go.mod h1:OqILvS182CyOol5zNNo6bguvOGgXzV459+chpRaUC+4= -github.com/sagernet/sing v0.8.12-0.20260702081104-2ded2af32d3d h1:BhsQU0Iug1tU4xR52cjm8Sc+LBo+KwdyLTRn3ie9moo= -github.com/sagernet/sing v0.8.12-0.20260702081104-2ded2af32d3d/go.mod h1:olXxWQNqRW/l2Q6JI3b2Qmz8iQnIFlOeeH8bx6JhgUA= +github.com/sagernet/sing v0.8.12-0.20260721063414-596db5dd6ef4 h1:dIHd4IiQs0mtptfe8SdwDascDwh1w2LqDI8exoDuKeQ= +github.com/sagernet/sing v0.8.12-0.20260721063414-596db5dd6ef4/go.mod h1:olXxWQNqRW/l2Q6JI3b2Qmz8iQnIFlOeeH8bx6JhgUA= github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 h1:3y6++yIa8XlDhxPkpR4p+7RUHVY2KTP9CPIGnWmOlO8= github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3/go.mod h1:XEqEDYRCAYLaoPjZ1ifVWJg5iWAJHL2gOAXe/PM28Cg= github.com/sagernet/sing-mux v0.3.5 h1:RHnhVEc+SFqkrK4xMygYjDwwLhzp2Bj3lztSukONfhI= github.com/sagernet/sing-mux v0.3.5/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk= -github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc h1:zdc0fj4JdAdgAmQIoh7ZF+B/wPTEF2X75lYDqTmvlaw= -github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc/go.mod h1:9k+dzGsWMttUGldBzq3dU792YHXzW6NgfbOGltnXq+0= +github.com/sagernet/sing-openconnect v0.0.0-20260722140139-e0c977659ca2 h1:IOzb7F/NRhAKx49+8HEsJmKrZT73EPseFouxQ6MvAOo= +github.com/sagernet/sing-openconnect v0.0.0-20260722140139-e0c977659ca2/go.mod h1:4AKZLVcvY3r54UaK2Gbnm7aN8pOwdLz+y4EP0QFZ5Eg= +github.com/sagernet/sing-openvpn v0.0.0-20260721005523-64b754d1c277 h1:4H38L3OxOx1fGEuH4n9lh/5O7XtZTgQ/1V/gdQ+b+Es= +github.com/sagernet/sing-openvpn v0.0.0-20260721005523-64b754d1c277/go.mod h1:PWX7WygD8jpwfqfaGNySXpJYTn0SOwjBI1BKHHC2+Bw= +github.com/sagernet/sing-quic v0.6.4-0.20260720125339-63fa6a7ebc31 h1:FuiTs45PaA1/f2Kj6E2QlctFBrJUUFB8QAHnV5im9c8= +github.com/sagernet/sing-quic v0.6.4-0.20260720125339-63fa6a7ebc31/go.mod h1:9k+dzGsWMttUGldBzq3dU792YHXzW6NgfbOGltnXq+0= github.com/sagernet/sing-shadowsocks v0.2.8 h1:PURj5PRoAkqeHh2ZW205RWzN9E9RtKCVCzByXruQWfE= github.com/sagernet/sing-shadowsocks v0.2.8/go.mod h1:lo7TWEMDcN5/h5B8S0ew+r78ZODn6SwVaFhvB6H+PTI= github.com/sagernet/sing-shadowsocks2 v0.2.1 h1:dWV9OXCeFPuYGHb6IRqlSptVnSzOelnqqs2gQ2/Qioo= github.com/sagernet/sing-shadowsocks2 v0.2.1/go.mod h1:RnXS0lExcDAovvDeniJ4IKa2IuChrdipolPYWBv9hWQ= github.com/sagernet/sing-shadowtls v0.2.1 h1:ZiHZdnEnP+YS73NMsxiZmIFCwNd0M4k7PkGCKNXhbaM= github.com/sagernet/sing-shadowtls v0.2.1/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA= -github.com/sagernet/sing-snell v0.0.0-20260710094516-a4e97ee24beb h1:VvU2/PZqP5tbKTDq0BxkhRO8ZnKI4UJzziakgBiP2Qg= -github.com/sagernet/sing-snell v0.0.0-20260710094516-a4e97ee24beb/go.mod h1:PcwzX/Xvqky0EP3kGt8OCjYb3R1pydenPHNQZcPZmXY= -github.com/sagernet/sing-tun v0.8.12-0.20260711072757-9e647fcd6b38 h1:O+Ci3Re4JyPa19JqYqs116WOT29Nmo+QwTSlL3rV2Ao= -github.com/sagernet/sing-tun v0.8.12-0.20260711072757-9e647fcd6b38/go.mod h1:QvarqUtHfj1ULaRR+6kZOS/OoCE+pYGq67A5tyIy+dQ= +github.com/sagernet/sing-snell v0.0.0-20260719094200-c43fbee0e839 h1:YL0oCb55moImUGvjhhVEYODGMo5i9dAf+RpmMFPCq9w= +github.com/sagernet/sing-snell v0.0.0-20260719094200-c43fbee0e839/go.mod h1:PcwzX/Xvqky0EP3kGt8OCjYb3R1pydenPHNQZcPZmXY= +github.com/sagernet/sing-tun v0.8.12-0.20260724023008-68aa7bdc2819 h1:JxzMsy4EMUsAJqKtDbWKW79NIvWfuD4ARPl5RLoMPjE= +github.com/sagernet/sing-tun v0.8.12-0.20260724023008-68aa7bdc2819/go.mod h1:BPbhYPXfcoJhQERTFf9DLEXkp2lMA8J47f892W+a/gM= github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb h1:KEMbfexD4DvrQGYWwx6r+AwH9Veh8z6cnBZmtCS2G+0= github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb/go.mod h1:D4CnJX3MNAAANhbQUxfIRgBdnvlTEaV7h6ojedcs+pw= github.com/sagernet/smux v1.5.50-sing-box-mod.1 h1:XkJcivBC9V4wBjiGXIXZ229aZCU1hzcbp6kSkkyQ478= github.com/sagernet/smux v1.5.50-sing-box-mod.1/go.mod h1:NjhsCEWedJm7eFLyhuBgIEzwfhRmytrUoiLluxs5Sk8= +github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7.0.20260717155615-b353b93d194a h1:mORXldIuzgU8Bk6n9KpOmLv1rV7Ta8us+lZ9JSv2eBw= +github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7.0.20260717155615-b353b93d194a/go.mod h1:p8Ms8FbGlwQJyHb862XmdShTS50fFJ8C71VdO6xvWyk= github.com/sagernet/wireguard-go v0.0.5-0.20260706153856-2c27bbf4f97f h1:TzN97RL07xWb3gZtmqFhsdkud4f6G/pohiaOLiqSBj4= github.com/sagernet/wireguard-go v0.0.5-0.20260706153856-2c27bbf4f97f/go.mod h1:hEqi4y5czEg6LYtX2Bpjg+lV0b/J1n+5rA885Z66Mx0= +github.com/sagernet/wireguard-go v0.0.5-0.20260717024847-6f5e8b1947ae h1:GmxlXWnRmeNfPE1tWXRZIFgKJd5BH5okoDHKZkkI5bw= +github.com/sagernet/wireguard-go v0.0.5-0.20260717024847-6f5e8b1947ae/go.mod h1:hEqi4y5czEg6LYtX2Bpjg+lV0b/J1n+5rA885Z66Mx0= github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 h1:6uUiZcDRnZSAegryaUGwPC/Fj13JSHwiTftrXhMmYOc= github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854/go.mod h1:LtfoSK3+NG57tvnVEHgcuBW9ujgE8enPSgzgwStwCAA= +github.com/sanposhiho/wastedassign/v2 v2.0.7 h1:J+6nrY4VW+gC9xFzUc+XjPD3g3wF3je/NsJFwFK7Uxc= +github.com/sanposhiho/wastedassign/v2 v2.0.7/go.mod h1:KyZ0MWTwxxBmfwn33zh3k1dmsbF2ud9pAAGfoLfjhtI= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/sashamelentyev/interfacebloat v1.1.0 h1:xdRdJp0irL086OyW1H/RTZTr1h/tMEOsumirXcOJqAw= +github.com/sashamelentyev/interfacebloat v1.1.0/go.mod h1:+Y9yU5YdTkrNvoX0xHc84dxiN1iBi9+G8zZIhPVoNjQ= +github.com/sashamelentyev/usestdlibvars v1.23.0 h1:01h+/2Kd+NblNItNeux0veSL5cBF1jbEOPrEhDzGYq0= +github.com/sashamelentyev/usestdlibvars v1.23.0/go.mod h1:YPwr/Y1LATzHI93CqoPUN/2BzGQ/6N/cl/KwgR0B/aU= +github.com/sclevine/agouti v3.0.0+incompatible h1:8IBJS6PWz3uTlMP3YBIR5f+KAldcGuOeFkFbUWfBgK4= +github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw= +github.com/securego/gosec/v2 v2.22.2 h1:IXbuI7cJninj0nRpZSLCUlotsj8jGusohfONMrHoF6g= +github.com/securego/gosec/v2 v2.22.2/go.mod h1:UEBGA+dSKb+VqM6TdehR7lnQtIIMorYJ4/9CW1KVQBE= github.com/sergeyfrolov/bsbuffer v0.0.0-20180903213811-94e85abb8507 h1:ML7ZNtcln5UBo5Wv7RIv9Xg3Pr5VuRCWLFXEwda54Y4= github.com/sergeyfrolov/bsbuffer v0.0.0-20180903213811-94e85abb8507/go.mod h1:DbI1gxrXI2jRGw7XGEUZQOOMd6PsnKzRrCKabvvMrwM= +github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/shadowsocks/go-shadowsocks2 v0.1.5 h1:PDSQv9y2S85Fl7VBeOMF9StzeXZyK1HakRm86CUbr28= github.com/shadowsocks/go-shadowsocks2 v0.1.5/go.mod h1:AGGpIoek4HRno4xzyFiAtLHkOpcoznZEkAccaI/rplM= +github.com/shazow/go-diff v0.0.0-20160112020656-b6b7b6733b8c h1:W65qqJCIOVP4jpqPQ0YvHYKwcMEMVWIzWC5iNQQfBTU= +github.com/shazow/go-diff v0.0.0-20160112020656-b6b7b6733b8c/go.mod h1:/PevMnwAxekIXwN8qQyfc5gl2NlkB3CQlkizAbOkeBs= github.com/shirou/gopsutil/v4 v4.24.5 h1:gGsArG5K6vmsh5hcFOHaPm87UD003CaDMkAOweSQjhM= github.com/shirou/gopsutil/v4 v4.24.5/go.mod h1:aoebb2vxetJ/yIDZISmduFvVNPHqXQ9SEJwRXxkf0RA= github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= +github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shtorm-7/connect-ip-go v1.0.0-extended-1.0.0 h1:ws7BIsYLd31Wjifq88BYCHRVlgO+07iwil39s6ERba8= github.com/shtorm-7/connect-ip-go v1.0.0-extended-1.0.0/go.mod h1:mRwx4w32qQxsWB2kThuHpbo7iNjJiq1jYWubgqEPjHA= github.com/shtorm-7/dnscrypt/v2 v2.4.0-extended-1.0.0 h1:e5s7RKBd2rIPR0StbvZ2vTVtJ5jDTsTk5wtIIapZTRg= github.com/shtorm-7/dnscrypt/v2 v2.4.0-extended-1.0.0/go.mod h1:WpEFV2uhebXb8Jhes/5/fSdpmhGV8TL22RDaeWwV6hI= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/sivchari/containedctx v1.0.3 h1:x+etemjbsh2fB5ewm5FeLNi5bUjK0V8n0RB+Wwfd0XE= +github.com/sivchari/containedctx v1.0.3/go.mod h1:c1RDvCbnJLtH4lLcYD/GqwiBSSf4F5Qk0xld2rBqzJ4= +github.com/sivchari/nosnakecase v1.7.0 h1:7QkpWIRMe8x25gckkFd2A5Pi6Ymo0qgr4JrhGt95do8= +github.com/sivchari/nosnakecase v1.7.0/go.mod h1:CwDzrzPea40/GB6uynrNLiorAlgFRvRbFSgJx2Gs+QY= +github.com/sivchari/tenv v1.7.1 h1:PSpuD4bu6fSmtWMxSGWcvqUUgIn7k3yOJhOIzVWn8Ak= +github.com/sivchari/tenv v1.7.1/go.mod h1:64yStXKSOxDfX47NlhVwND4dHwfZDdbp2Lyl018Icvg= +github.com/skeema/knownhosts v1.2.1 h1:SHWdIUa82uGZz+F+47k8SY4QhhI291cXCpopT1lK2AQ= +github.com/skeema/knownhosts v1.2.1/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= +github.com/smallstep/pkcs7 v0.1.1 h1:x+rPdt2W088V9Vkjho4KtoggyktZJlMduZAtRHm68LU= +github.com/smallstep/pkcs7 v0.1.1/go.mod h1:dL6j5AIz9GHjVEBTXtW+QliALcgM19RtXaTeyxI+AfA= +github.com/soheilhy/cmux v0.1.5 h1:jjzc5WVemNEDTLwv9tlmemhC73tI08BNOIGwBOo10Js= +github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0= +github.com/sonatard/noctx v0.0.2 h1:L7Dz4De2zDQhW8S0t+KUjY0MAQJd6SgVwhzNIc4ok00= +github.com/sonatard/noctx v0.0.2/go.mod h1:kzFz+CzWSjQ2OzIm46uJZoXuBpa2+0y3T36U18dWqIo= github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 h1:TG/diQgUe0pntT/2D9tmUCz4VNwm9MfrtPr0SU2qSX8= github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8/go.mod h1:P5HUIBuIWKbyjl083/loAegFkfbFNx5i2qEP4CNbm7E= +github.com/sourcegraph/go-diff v0.7.0 h1:9uLlrd5T46OXs5qpp8L/MTltk0zikUGi0sNNyCpA8G0= +github.com/sourcegraph/go-diff v0.7.0/go.mod h1:iBszgVvyxdc8SFZ7gm69go2KDdt3ag071iBaWPF6cjs= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72 h1:qLC7fQah7D6K1B0ujays3HV9gkFtllcxhzImRR7ArPQ= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.9.5 h1:stMpOSZFs//0Lv29HduCmli3GUfpFoF3Y1Q/aXj/wVM= +github.com/spf13/afero v1.9.5/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ= +github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA= +github.com/spf13/cast v1.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= +github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.16.0 h1:rGGH0XDZhdUOryiDWjmIvUSWpbNqisK8Wk0Vyefw8hc= +github.com/spf13/viper v1.16.0/go.mod h1:yg78JgCJcbrQOvV9YLXgkLaZqUidkY9K+Dd1FofRzQg= +github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= +github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= +github.com/src-d/gcfg v1.4.0 h1:xXbNR5AlLSA315x2UO+fTSSAXCDf+Ar38/6oyGbDKQ4= +github.com/src-d/gcfg v1.4.0/go.mod h1:p/UMsR43ujA89BJY9duynAwIpvqEujIH/jFlfL7jWoI= +github.com/ssgreg/nlreturn/v2 v2.2.1 h1:X4XDI7jstt3ySqGU86YGAURbxw3oTDPK9sPEi6YEwQ0= +github.com/ssgreg/nlreturn/v2 v2.2.1/go.mod h1:E/iiPB78hV7Szg2YfRgyIrk1AD6JVMTRkkxBiELzh2I= +github.com/stacklok/frizbee v0.1.7 h1:IgrZy8dqKy+vBxNWrZTbDoctnV0doQKrFC6bNbWP5ho= +github.com/stacklok/frizbee v0.1.7/go.mod h1:eqMjHEgRYDSlpYpir3wXO6jyGpxr1dnFTvrTdrTIF7E= github.com/starifly/sing-vmess v0.2.7-mod.9 h1:xobAmejSbBQ0A3f/EtJ9cJd3m6gK7dDPccPdeGz7tXY= github.com/starifly/sing-vmess v0.2.7-mod.9/go.mod h1:5aYoOtYksAyS0NXDm0qKeTYW1yoE1bJVcv+XLcVoyJs= +github.com/stbenjam/no-sprintf-host-port v0.1.1 h1:tYugd/yrm1O0dV+ThCbaKZh195Dfm07ysF0U6JQXczc= +github.com/stbenjam/no-sprintf-host-port v0.1.1/go.mod h1:TLhvtIvONRzdmkFiio4O8LHsN9N74I+PhRquPsxpL0I= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -552,24 +1328,48 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8= +github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635 h1:kdXcSzyDtseVEc4yCz2qF8ZrQvIDBJLl4S1c3GCXmoI= github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww= +github.com/t-yuki/gocover-cobertura v0.0.0-20180217150009-aaee18c8195c h1:+aPplBwWcHBo6q9xrfWdMrT9o4kltkmmvpemgIjep/8= +github.com/t-yuki/gocover-cobertura v0.0.0-20180217150009-aaee18c8195c/go.mod h1:SbErYREK7xXdsRiigaQiQkI9McGRzYMvlKYaP3Nimdk= github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e h1:PtWT87weP5LWHEY//SWsYkSO3RWRZo4OSWagh3YD2vQ= github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4= +github.com/tailscale/depaware v0.0.0-20210622194025-720c4b409502 h1:34icjjmqJ2HPjrSuJYEkdZ+0ItmGQAQ75cRHIiftIyE= +github.com/tailscale/depaware v0.0.0-20210622194025-720c4b409502/go.mod h1:p9lPsd+cx33L3H9nNoecRRxPssFKUwwI50I3pZ0yT+8= github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4= github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg= +github.com/tailscale/goexpect v0.0.0-20210902213824-6e8c725cea41 h1:/V2rCMMWcsjYaYO2MeovLw+ClP63OtXgCF2Y1eb8+Ns= +github.com/tailscale/goexpect v0.0.0-20210902213824-6e8c725cea41/go.mod h1:/roCdA6gg6lQyw/Oz6gIIGu3ggJKYhF+WC/AQReE5XQ= +github.com/tailscale/golang-x-crypto v0.0.0-20240108194725-7ce1f622c780 h1:U0J2CUrrTcc2wmr9tSLYEo+USfwNikRRsmxVLD4eZ7E= +github.com/tailscale/golang-x-crypto v0.0.0-20240108194725-7ce1f622c780/go.mod h1:ikbF+YT089eInTp9f2vmvy4+ZVnW5hzX1q2WknxSprQ= github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 h1:4chzWmimtJPxRs2O36yuGRW3f9SYV+bMTTvMBI0EKio= github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05/go.mod h1:PdCqy9JzfWMJf1H5UJW2ip33/d4YkoKN0r67yKH1mG8= github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a h1:SJy1Pu0eH1C29XwJucQo73FrleVK6t4kYz4NVhp34Yw= github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8= +github.com/tailscale/mkctr v0.0.0-20240102155253-bf50773ba734 h1:93cvKHbvsPK3MKfFTvR00d0b0R0bzRKBW9yrj813fhI= +github.com/tailscale/mkctr v0.0.0-20240102155253-bf50773ba734/go.mod h1:6v53VHLmLKUaqWMpSGDeRWhltLSCEteMItYoiKLpdJk= github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU= github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0= github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+yfntqhI3oAu9i27nEojcQ4NuBQOo5ZFA= github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc= github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:UBPHPtv8+nEAy2PD8RyAhOYvau1ek0HDJqLS/Pysi14= github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ= +github.com/tailscale/wireguard-go v0.0.0-20231121184858-cc193a0b3272 h1:zwsem4CaamMdC3tFoTpzrsUSMDPV0K6rhnQdF7kXekQ= +github.com/tailscale/wireguard-go v0.0.0-20231121184858-cc193a0b3272/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4= +github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek= +github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg= github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA= github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk= +github.com/tcnksm/go-httpstat v0.2.0 h1:rP7T5e5U2HfmOBmZzGgGZjBQ5/GluWUylujl0tJ04I0= +github.com/tcnksm/go-httpstat v0.2.0/go.mod h1:s3JVJFtQxtBEBC9dwcdTTXS9xFnM3SXAZwPG41aurT8= +github.com/tdakkota/asciicheck v0.2.0 h1:o8jvnUANo0qXtnslk2d3nMKTFNlOnJjRrNcj0j9qkHM= +github.com/tdakkota/asciicheck v0.2.0/go.mod h1:Qb7Y9EgjCLJGup51gDHFzbI08/gbGhL/UVhYIPWG2rg= +github.com/tetafro/godot v1.4.11 h1:BVoBIqAf/2QdbFmSwAWnaIqDivZdOV0ZRwEm6jivLKw= +github.com/tetafro/godot v1.4.11/go.mod h1:LR3CJpxDVGlYOWn3ZZg1PgNZdTUvzsZWu8xaEohUpn8= +github.com/tevino/abool v1.2.0 h1:heAkClL8H6w+mK5md9dzsuohKeXHUpY7Vw0ZCKW+huA= +github.com/tevino/abool v1.2.0/go.mod h1:qc66Pna1RiIsPa7O4Egxxs9OqkuxDX55zznh9K07Tzg= github.com/things-go/go-socks5 v0.1.1 h1:48hy9cHEXPKeG91G/g4n8zW4uynzPUQy/FkcrJ7r5AY= github.com/things-go/go-socks5 v0.1.1/go.mod h1:1YBHVYG7Oli5ae+Pwkp630cPAwY1pjUPmohO1n0Emg0= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -582,6 +1382,10 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/timakin/bodyclose v0.0.0-20230421092635-574207250966 h1:quvGphlmUVU+nhpFa4gg4yJyTRJ13reZMDHrKwYw53M= +github.com/timakin/bodyclose v0.0.0-20230421092635-574207250966/go.mod h1:27bSVNWSBOHm+qRp1T9qzaIpsWEP6TbUnei/43HK+PQ= +github.com/timonwong/loggercheck v0.9.4 h1:HKKhqrjcVj8sxL7K77beXh0adEm6DLjV/QOGeMXEVi4= +github.com/timonwong/loggercheck v0.9.4/go.mod h1:caz4zlPcgvpEkXgVnAJGowHAMW2NwHaNlpS8xDbVhTg= github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s= github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho= @@ -590,19 +1394,55 @@ github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFA github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75 h1:6fotK7otjonDflCTK0BCfls4SPy3NcCVb5dqqmbRknE= +github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75/go.mod h1:KO6IkyS8Y3j8OdNO85qEYBsRPuteD+YciPomcXdrMnk= +github.com/tomarrell/wrapcheck/v2 v2.8.1 h1:HxSqDSN0sAt0yJYsrcYVoEeyM4aI9yAm3KQpIXDJRhQ= +github.com/tomarrell/wrapcheck/v2 v2.8.1/go.mod h1:/n2Q3NZ4XFT50ho6Hbxg+RV1uyo2Uow/Vdm9NQcl5SE= +github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce h1:fb190+cK2Xz/dvi9Hv8eCYJYvIGUTN2/KLq1pT6CjEc= +github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce/go.mod h1:o8v6yHRoik09Xen7gje4m9ERNah1d1PPsVq1VEx9vE4= +github.com/tommy-muehle/go-mnd/v2 v2.5.1 h1:NowYhSdyE/1zwK9QCLeRb6USWdoif80Ie+v+yU8u1Zw= +github.com/tommy-muehle/go-mnd/v2 v2.5.1/go.mod h1:WsUAkMJMYww6l/ufffCD3m+P7LEvr8TnZn9lwVDlgzw= +github.com/toqueteos/webbrowser v1.2.0 h1:tVP/gpK69Fx+qMJKsLE7TD8LuGWPnEV71wBN9rrstGQ= +github.com/toqueteos/webbrowser v1.2.0/go.mod h1:XWoZq4cyp9WeUeak7w7LXRUQf1F1ATJMir8RTqb4ayM= +github.com/transparency-dev/merkle v0.0.2 h1:Q9nBoQcZcgPamMkGn7ghV8XiTZ/kRxn1yCG81+twTK4= +github.com/transparency-dev/merkle v0.0.2/go.mod h1:pqSy+OXefQ1EDUVmAJ8MUhHB9TXGuzVAT58PqBoHz1A= github.com/twmb/murmur3 v1.1.6 h1:mqrRot1BRxm+Yct+vavLMou2/iJt0tNVTTC0QoIjaZg= github.com/twmb/murmur3 v1.1.6/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ= +github.com/u-root/u-root v0.11.0 h1:6gCZLOeRyevw7gbTwMj3fKxnr9+yHFlgF3N7udUVNO8= +github.com/u-root/u-root v0.11.0/go.mod h1:DBkDtiZyONk9hzVEdB/PWI9B4TxDkElWlVTHseglrZY= github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM= github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA= +github.com/ulikunitz/xz v0.5.11 h1:kpFauv27b6ynzBNT/Xy+1k+fK4WswhN/6PN5WhFAGw8= +github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/ultraware/funlen v0.0.3 h1:5ylVWm8wsNwH5aWo9438pwvsK0QiqVuUrt9bn7S/iLA= +github.com/ultraware/funlen v0.0.3/go.mod h1:Dp4UiAus7Wdb9KUZsYWZEWiRzGuM2kXM1lPbfaF6xhA= +github.com/ultraware/whitespace v0.0.5 h1:hh+/cpIcopyMYbZNVov9iSxvJU3OYQg78Sfaqzi/CzI= +github.com/ultraware/whitespace v0.0.5/go.mod h1:aVMh/gQve5Maj9hQ/hg+F75lr/X5A89uZnzAmWSineA= +github.com/uudashr/gocognit v1.2.0 h1:3BU9aMr1xbhPlvJLSydKwdLN3tEUUrzPSSM8S4hDYRA= +github.com/uudashr/gocognit v1.2.0/go.mod h1:k/DdKPI6XBZO1q7HgoV2juESI2/Ofj9AcHPZhBBdrTU= +github.com/vbatts/tar-split v0.11.6 h1:4SjTW5+PU11n6fZenf2IPoV8/tz3AaYHMWjf23envGs= +github.com/vbatts/tar-split v0.11.6/go.mod h1:dqKNtesIOr2j2Qv3W/cHjnvk9I8+G7oAkFDFN6TCBEI= +github.com/vishvananda/netlink v1.2.1-beta.2 h1:Llsql0lnQEbHj0I1OuKyp8otXp0r3q0mPkuhwHfStVs= +github.com/vishvananda/netlink v1.2.1-beta.2/go.mod h1:twkDnbuQxJYemMlGd4JFIcuhgX83tXhKS2B/PRMpOho= github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/wader/filtertransport v0.0.0-20200316221534-bdd9e61eee78 h1:9sreu9e9KOihf2Y0NbpyfWhd1XFDcL4GTkPYL4IvMrg= github.com/wader/filtertransport v0.0.0-20200316221534-bdd9e61eee78/go.mod h1:HazXTRLhXFyq80TQp7PUXi6BKE6mS+ydEdzEqNBKopQ= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/xchacha20-poly1305/sing-trusttunnel v0.2.1 h1:3Hqp6hP3l8X4153tGrKLA5Uonh/66zhbp7HgaeC0lyE= +github.com/xchacha20-poly1305/sing-trusttunnel v0.2.1/go.mod h1:DwhGZ+Xy+r2LCoqVgGgk4opoiCRbrwNzcn41OePwIrU= +github.com/xiang90/probing v0.0.0-20221125231312-a49e3df8f510 h1:S2dVYn90KE98chqDkyE9Z4N61UnQd+KOfgp5Iu53llk= +github.com/xiang90/probing v0.0.0-20221125231312-a49e3df8f510/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xtaci/kcp-go/v5 v5.6.70 h1:AYX0QZl6PqmNj2IdYGZGuBfZuDUkUfl+eHYNijCqaO0= github.com/xtaci/kcp-go/v5 v5.6.70/go.mod h1:9O3D8WR+cyyUjGiTILYfg17vn72otWuXK2AFfqIe6CM= github.com/xtaci/lossyconn v0.0.0-20190602105132-8df528c0c9ae h1:J0GxkO96kL4WF+AIT3M4mfUVinOCPgf2uUWYFUzN0sM= @@ -611,8 +1451,15 @@ github.com/xtaci/smux v1.5.50 h1:y/1DlWQC9bnMeZzsyk4oL2hbLK6uVk4BKTz5BeQqUEA= github.com/xtaci/smux v1.5.50/go.mod h1:IGQ9QYrBphmb/4aTnLEcJby0TNr3NV+OslIOMrX825Q= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yagipy/maintidx v1.0.0 h1:h5NvIsCz+nRDapQ0exNv4aJ0yXSI0420omVANTv3GJM= +github.com/yagipy/maintidx v1.0.0/go.mod h1:0qNf/I/CCZXSMhsRsrEPDZ+DkekpKLXAJfsTACwgXLk= +github.com/yeya24/promlinter v0.2.0 h1:xFKDQ82orCU5jQujdaD8stOHiv8UN68BSdn2a8u8Y3o= +github.com/yeya24/promlinter v0.2.0/go.mod h1:u54lkmBOZrpEbQQ6gox2zWKKLKu2SGe+2KOiextY+IA= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= @@ -620,14 +1467,60 @@ github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY= github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI= github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE= +github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM= +github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= +gitlab.com/bosi/decorder v0.2.3 h1:gX4/RgK16ijY8V+BRQHAySfQAb354T7/xQpDB2n10P0= +gitlab.com/bosi/decorder v0.2.3/go.mod h1:9K1RB5+VPNQYtXtTDAzd2OEftsZb1oV0IrJrzChSdGE= +gitlab.com/digitalxero/go-conventional-commit v1.0.7 h1:8/dO6WWG+98PMhlZowt/YjuiKhqhGlOCwlIV8SqqGh8= +gitlab.com/digitalxero/go-conventional-commit v1.0.7/go.mod h1:05Xc2BFsSyC5tKhK0y+P3bs0AwUtNuTp+mTpbCU/DZ0= gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/goptlib v1.5.0 h1:rzdY78Ox2T+VlXcxGxELF+6VyUXlZBhmRqZu5etLm+c= gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/goptlib v1.5.0/go.mod h1:70bhd4JKW/+1HLfm+TMrgHJsUHG4coelMWwiVEJ2gAg= +go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk= +go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk= +go.etcd.io/etcd/api/v3 v3.6.0 h1:vdbkcUBGLf1vfopoGE/uS3Nv0KPyIpUV/HM6w9yx2kM= +go.etcd.io/etcd/api/v3 v3.6.0/go.mod h1:Wt5yZqEmxgTNJGHob7mTVBJDZNXiHPtXTcPab37iFOw= +go.etcd.io/etcd/client/pkg/v3 v3.6.0 h1:nchnPqpuxvv3UuGGHaz0DQKYi5EIW5wOYsgUNRc365k= +go.etcd.io/etcd/client/pkg/v3 v3.6.0/go.mod h1:Jv5SFWMnGvIBn8o3OaBq/PnT0jjsX8iNokAUessNjoA= +go.etcd.io/etcd/client/v3 v3.6.0 h1:/yjKzD+HW5v/3DVj9tpwFxzNbu8hjcKID183ug9duWk= +go.etcd.io/etcd/client/v3 v3.6.0/go.mod h1:Jzk/Knqe06pkOZPHXsQ0+vNDvMQrgIqJ0W8DwPdMJMg= +go.etcd.io/etcd/etcdctl/v3 v3.6.0 h1:Fob3xEUJ7uJCEYxPAihTkLufYnnVtGbCiRd0NqE7M1o= +go.etcd.io/etcd/etcdctl/v3 v3.6.0/go.mod h1:ukAtyfIbiTajTDRfXruqUluVGvqcn/aGn0HEWdnzWC4= +go.etcd.io/etcd/etcdutl/v3 v3.6.0 h1:MixJh+DgSa4h8D2jD27rZwT7O+im/Kp8BaCz86JsMfI= +go.etcd.io/etcd/etcdutl/v3 v3.6.0/go.mod h1:gheEcr7WMMV9TN+TvXSxP9ixk8Bg5Lwp63uz1OANeKg= +go.etcd.io/etcd/pkg/v3 v3.6.0 h1:0o70c/NR4OZNO5mOtRFBATtMv6xjEoTVZjFtn6MlsNE= +go.etcd.io/etcd/pkg/v3 v3.6.0/go.mod h1:pFym9TwvGyAp9VHK/0LoJ1n2D+sX4ukzP15ZqN5gYO8= +go.etcd.io/etcd/server/v3 v3.6.0 h1:YcYxiJzmFCpjzzd7d/XmQE09p60248OzaaOaySRJyt0= +go.etcd.io/etcd/server/v3 v3.6.0/go.mod h1:y8PLrWY4upkE79xxRCkbWmCmGUmTeAG0RmzfzDhHO/E= +go.etcd.io/etcd/tests/v3 v3.6.0 h1:GCOGw+hXIYxssTpOZw03N/0YwrjFuZs6fXPdi3XS57o= +go.etcd.io/etcd/tests/v3 v3.6.0/go.mod h1:wuyuwvXTF33++K6kQtpsMrbsISxCQZNbVGpFgx63E9w= +go.etcd.io/etcd/v3 v3.6.0 h1:38RAx40IZDrWQqPfIt6+nCfWeIDZNJrhJOP5DQwyR74= +go.etcd.io/etcd/v3 v3.6.0/go.mod h1:0sMPTfyOUZNFRYJEweFWFmr2vppoupl4gBiDF/IB7ng= +go.etcd.io/gofail v0.2.0 h1:p19drv16FKK345a09a1iubchlw/vmRuksmRzgBIGjcA= +go.etcd.io/gofail v0.2.0/go.mod h1:nL3ILMGfkXTekKI3clMBNazKnjUZjYLKmBHzsVAnC1o= +go.etcd.io/raft/v3 v3.6.0 h1:5NtvbDVYpnfZWcIHgGRk9DyzkBIXOi8j+DDp1IcnUWQ= +go.etcd.io/raft/v3 v3.6.0/go.mod h1:nLvLevg6+xrVtHUmVaTcTz603gQPHfh7kUAwV6YpfGo= +go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/detectors/gcp v1.39.0 h1:kWRNZMsfBHZ+uHjiH4y7Etn2FK26LAGkNFw7RHv1DhE= +go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 h1:1fTNlAIJZGWLP5FVu0fikVry1IsiUnXjf7QFvoNN3Xw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0/go.mod h1:zjPK58DtkqQFn+YUMbx0M2XV3QgKU0gS9LeGohREyK4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 h1:m639+BofXTvcY1q8CGs4ItwQarYtJPOWmVobfM1HpVI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0/go.mod h1:LjReUci/F4BUyv+y4dwnq3h/26iNOeC3wAIqgvTIZVo= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 h1:xJ2qHD0C1BeYVTLLR9sX12+Qb95kfeD/byKj6Ky1pXg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0/go.mod h1:u5BF1xyjstDowA1R5QAO9JHzqK+ublenEW/dyqTjBVk= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0 h1:T0Ec2E+3YZf5bgTNQVet8iTDW7oIk03tXHq+wkwIDnE= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0/go.mod h1:30v2gqH+vYGJsesLWFov8u47EpYTcIQcBjKpI6pJThg= go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= @@ -636,6 +1529,8 @@ go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2W go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= +go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -648,6 +1543,7 @@ go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U= go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek= go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g= @@ -664,21 +1560,33 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.30.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0= golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= +golang.org/x/exp/typeparams v0.0.0-20250305212735-054e65f0b394 h1:VI4qDpTkfFaCXEPrbojidLgVQhj2x4nzTccG0hjaLlU= +golang.org/x/exp/typeparams v0.0.0-20250305212735-054e65f0b394/go.mod h1:LKZHyeOpPuZcMgxeHjJp4p5yvxrCX1xDvH10zYHhjjQ= golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w= golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b h1:Wh+f8QHJXR411sJR8/vRBTZ7YapZaRvUcLFFJhusH0k= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mobile v0.0.0-20240520174638-fa72addaaa1b h1:WX7nnnLfCEXg+FmdYZPai2XuP3VqCP1HZVMST0n9DF0= +golang.org/x/mobile v0.0.0-20240520174638-fa72addaaa1b/go.mod h1:EiXZlVfUTaAyySFVJb9rsODuiO+WXu8HrUuySb7nYFw= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -697,7 +1605,10 @@ golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -709,6 +1620,10 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -734,9 +1649,16 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 h1:bTLqdHv7xrGlFbvf5/TXNxy/iUwwdkjhqQTJDjW7aj0= +golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZrmR51cJDQxmJUHUOfRAaNyWBM+R+548= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -746,7 +1668,11 @@ golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -758,7 +1684,10 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= @@ -772,8 +1701,16 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= +golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= +golang.org/x/vuln v1.1.4 h1:Ju8QsuyhX3Hk8ma3CesTbO8vfJD9EvUBgHvkxHBzj0I= +golang.org/x/vuln v1.1.4/go.mod h1:F+45wmU18ym/ca5PLTPLsSzr2KppzswxPP603ldA67s= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -787,12 +1724,22 @@ golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdI golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ= golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE= golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI= +gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= +gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/api v0.226.0 h1:9A29y1XUD+YRXfnHkO66KggxHBZWg9LsTGqm7TkUvtQ= +google.golang.org/api v0.226.0/go.mod h1:WP/0Xm4LVvMOCldfvOISnWquSRWbG2kArDZcg+W2DbY= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20250122153221-138b5a5a4fd4 h1:Pw6WnI9W/LIdRxqK7T6XGugGbHIRl5Q7q3BssH6xk4s= +google.golang.org/genproto v0.0.0-20250122153221-138b5a5a4fd4/go.mod h1:qbZzneIOXSq+KFAFut9krLfRLZiFLzZL5u2t8SV83EE= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= @@ -813,20 +1760,89 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI= +gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= +gopkg.in/src-d/go-billy.v4 v4.3.2 h1:0SQA1pRztfTFx2miS8sA97XvooFeNOmvUenF4o0EcVg= +gopkg.in/src-d/go-billy.v4 v4.3.2/go.mod h1:nDjArDMp+XMs1aFAESLRjfGSgfvoYN0hDfzEk0GjC98= +gopkg.in/src-d/go-git.v4 v4.13.1 h1:SRtFyV8Kxc0UP7aCHcijOMQGPxHSmMOPrzulQWolkYE= +gopkg.in/src-d/go-git.v4 v4.13.1/go.mod h1:nx5NYcxdKxq5fpltdHnPa2Exj4Sx0EclMWZQbYDu2z8= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0 h1:POO/ycCATvegFmVuPpQzZFJ+pGZeX22Ufu6fibxDVjU= gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.4.0 h1:ZazjZUfuVeZGLAmlKKuyv3IKP5orXcwtOwDQH6YVr6o= +gotest.tools/v3 v3.4.0/go.mod h1:CtbdzLSsqVhDgMtKsx03ird5YTGB3ar27v0u/yKBW5g= gvisor.dev/gvisor v0.0.0-20231202080848-1f7806d17489 h1:ze1vwAdliUAr68RQ5NtufWaXaOg8WUO2OACzEV+TNdE= gvisor.dev/gvisor v0.0.0-20231202080848-1f7806d17489/go.mod h1:10sU+Uh5KKNv1+2x2A0Gvzt8FjD3ASIhorV3YsauXhk= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI= +honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4= howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM= howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= +inet.af/peercred v0.0.0-20210906144145-0893ea02156a h1:qdkS8Q5/i10xU2ArJMKYhVa1DORzBfYS/qA2UK2jheg= +inet.af/peercred v0.0.0-20210906144145-0893ea02156a/go.mod h1:FjawnflS/udxX+SvpsMgZfdqx2aykOlkISeAsADi5IU= +inet.af/tcpproxy v0.0.0-20231102063150-2862066fc2a9 h1:zomTWJvjwLbKRgGameQtpK6DNFUbZ2oNJuWhgUkGp3M= +inet.af/tcpproxy v0.0.0-20231102063150-2862066fc2a9/go.mod h1:Tojt5kmHpDIR2jMojxzZK2w2ZR7OILODmUo2gaSwjrk= +inet.af/wf v0.0.0-20221017222439-36129f591884 h1:zg9snq3Cpy50lWuVqDYM7AIRVTtU50y5WXETMFohW/Q= +inet.af/wf v0.0.0-20221017222439-36129f591884/go.mod h1:bSAQ38BYbY68uwpasXOTZo22dKGy9SNvI6PZFeKomZE= +k8s.io/api v0.28.2 h1:9mpl5mOb6vXZvqbQmankOfPIGiudghwCoLl1EYfUZbw= +k8s.io/api v0.28.2/go.mod h1:RVnJBsjU8tcMq7C3iaRSGMeaKt2TWEUXcpIt/90fjEg= +k8s.io/apiextensions-apiserver v0.28.2 h1:J6/QRWIKV2/HwBhHRVITMLYoypCoPY1ftigDM0Kn+QU= +k8s.io/apiextensions-apiserver v0.28.2/go.mod h1:5tnkxLGa9nefefYzWuAlWZ7RZYuN/765Au8cWLA6SRg= +k8s.io/apimachinery v0.28.2 h1:KCOJLrc6gu+wV1BYgwik4AF4vXOlVJPdiqn0yAWWwXQ= +k8s.io/apimachinery v0.28.2/go.mod h1:RdzF87y/ngqk9H4z3EL2Rppv5jj95vGS/HaFXrLDApU= +k8s.io/apiserver v0.28.2 h1:rBeYkLvF94Nku9XfXyUIirsVzCzJBs6jMn3NWeHieyI= +k8s.io/apiserver v0.28.2/go.mod h1:f7D5e8wH8MWcKD7azq6Csw9UN+CjdtXIVQUyUhrtb+E= +k8s.io/client-go v0.28.2 h1:DNoYI1vGq0slMBN/SWKMZMw0Rq+0EQW6/AK4v9+3VeY= +k8s.io/client-go v0.28.2/go.mod h1:sMkApowspLuc7omj1FOSUxSoqjr+d5Q0Yc0LOFnYFJY= +k8s.io/component-base v0.28.2 h1:Yc1yU+6AQSlpJZyvehm/NkJBII72rzlEsd6MkBQ+G0E= +k8s.io/component-base v0.28.2/go.mod h1:4IuQPQviQCg3du4si8GpMrhAIegxpsgPngPRR/zWpzc= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20230928205116-a78145627833 h1:iFFEmmB7szQhJP42AvRD2+gzdVP7EuIKY1rJgxf0JZY= +k8s.io/kube-openapi v0.0.0-20230928205116-a78145627833/go.mod h1:AsvuZPBlUDVuCdzJ87iajxtXuR9oktsTctW/R9wwouA= +k8s.io/utils v0.0.0-20230726121419-3b25d923346b h1:sgn3ZU783SCgtaSJjpcVVlRqd6GSnlTLKgpAAttJvpI= +k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= +mvdan.cc/editorconfig v0.3.0 h1:D1D2wLYEYGpawWT5SpM5pRivgEgXjtEXwC9MWhEY0gQ= +mvdan.cc/editorconfig v0.3.0/go.mod h1:NcJHuDtNOTEJ6251indKiWuzK6+VcrMuLzGMLKBFupQ= +mvdan.cc/gofumpt v0.7.0 h1:bg91ttqXmi9y2xawvkuMXyvAA/1ZGJqYAEGjXuP0JXU= +mvdan.cc/gofumpt v0.7.0/go.mod h1:txVFJy/Sc/mvaycET54pV8SW8gWxTlUuGHVEcncmNUo= +mvdan.cc/interfacer v0.0.0-20180901003855-c20040233aed h1:WX1yoOaKQfddO/mLzdV4wptyWgoH/6hwLs7QHTixo0I= +mvdan.cc/interfacer v0.0.0-20180901003855-c20040233aed/go.mod h1:Xkxe497xwlCKkIaQYRfC7CSLworTXY9RMqwhhCm+8Nc= +mvdan.cc/lint v0.0.0-20170908181259-adc824a0674b h1:DxJ5nJdkhDlLok9K6qO+5290kphDJbHOQO1DFFFTeBo= +mvdan.cc/lint v0.0.0-20170908181259-adc824a0674b/go.mod h1:2odslEg/xrtNQqCYg2/jCoyKnw3vv5biOc3JnIcYfL4= +mvdan.cc/sh/v3 v3.11.0 h1:q5h+XMDRfUGUedCqFFsjoFjrhwf2Mvtt1rkMvVz0blw= +mvdan.cc/sh/v3 v3.11.0/go.mod h1:LRM+1NjoYCzuq/WZ6y44x14YNAI0NK7FLPeQSaFagGg= +mvdan.cc/unparam v0.0.0-20250301125049-0df0534333a4 h1:WjUu4yQoT5BHT1w8Zu56SP8367OuBV5jvo+4Ulppyf8= +mvdan.cc/unparam v0.0.0-20250301125049-0df0534333a4/go.mod h1:rthT7OuvRbaGcd5ginj6dA2oLE7YNlta9qhBNNdCaLE= +nhooyr.io/websocket v1.8.7 h1:usjR2uOr/zjjkVMy0lW+PPohFok7PCow5sDjLgX4P4g= +nhooyr.io/websocket v1.8.7/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0= +sigs.k8s.io/controller-runtime v0.16.2 h1:mwXAVuEk3EQf478PQwQ48zGOXvW27UJc8NHktQVuIPU= +sigs.k8s.io/controller-runtime v0.16.2/go.mod h1:vpMu3LpI5sYWtujJOa2uPK61nB5rbwlN7BAB8aSLvGU= +sigs.k8s.io/controller-tools v0.13.0 h1:NfrvuZ4bxyolhDBt/rCZhDnx3M2hzlhgo5n3Iv2RykI= +sigs.k8s.io/controller-tools v0.13.0/go.mod h1:5vw3En2NazbejQGCeWKRrE7q4P+CW8/klfVqP8QZkgA= +sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8= +sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo= +sigs.k8s.io/structured-merge-diff/v4 v4.3.0 h1:UZbZAZfX0wV2zr7YZorDz6GXROfDFj6LvqCRm4VUVKk= +sigs.k8s.io/structured-merge-diff/v4 v4.3.0/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= +sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= +sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= tailscale.com v1.58.2 h1:5trkhh/fpUn7f6TUcGUQYJ0GokdNNfNrjh9ONJhoc5A= diff --git a/include/openconnect.go b/include/openconnect.go new file mode 100644 index 0000000000..259340c9d2 --- /dev/null +++ b/include/openconnect.go @@ -0,0 +1,17 @@ +//go:build with_openconnect + +package include + +import ( + "github.com/sagernet/sing-box/adapter/endpoint" + "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/protocol/openconnect" +) + +func registerOpenConnectEndpoint(registry *endpoint.Registry) { + openconnect.RegisterEndpoint(registry) +} + +func registerOpenConnectDNSTransport(registry *dns.TransportRegistry) { + openconnect.RegisterDNSTransport(registry) +} diff --git a/include/openconnect_stub.go b/include/openconnect_stub.go new file mode 100644 index 0000000000..360ed8d9b9 --- /dev/null +++ b/include/openconnect_stub.go @@ -0,0 +1,30 @@ +//go:build !with_openconnect + +package include + +import ( + "context" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/endpoint" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" +) + +func registerOpenConnectEndpoint(registry *endpoint.Registry) { + endpoint.Register[option.OpenConnectEndpointOptions](registry, C.TypeOpenConnect, func(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.OpenConnectEndpointOptions) (adapter.Endpoint, error) { + if !options.System { + return nil, E.New(`OpenConnect is not included in this build, rebuild with -tags with_openconnect,with_gvisor for system:false`) + } + return nil, E.New(`OpenConnect is not included in this build, rebuild with -tags with_openconnect`) + }) +} + +func registerOpenConnectDNSTransport(registry *dns.TransportRegistry) { + dns.RegisterTransport[option.OpenConnectDNSServerOptions](registry, C.DNSTypeOpenConnect, func(ctx context.Context, logger log.ContextLogger, tag string, options option.OpenConnectDNSServerOptions) (adapter.DNSTransport, error) { + return nil, E.New(`OpenConnect is not included in this build, rebuild with -tags with_openconnect`) + }) +} diff --git a/include/openvpn.go b/include/openvpn.go new file mode 100644 index 0000000000..58d03c7094 --- /dev/null +++ b/include/openvpn.go @@ -0,0 +1,17 @@ +//go:build with_openvpn + +package include + +import ( + "github.com/sagernet/sing-box/adapter/endpoint" + "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/protocol/openvpn" +) + +func registerOpenVPNEndpoints(registry *endpoint.Registry) { + openvpn.RegisterEndpoint(registry) +} + +func registerOpenVPNDNSTransport(registry *dns.TransportRegistry) { + openvpn.RegisterDNSTransport(registry) +} diff --git a/include/openvpn_stub.go b/include/openvpn_stub.go new file mode 100644 index 0000000000..a1e24c8e78 --- /dev/null +++ b/include/openvpn_stub.go @@ -0,0 +1,36 @@ +//go:build !with_openvpn + +package include + +import ( + "context" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/endpoint" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" +) + +func registerOpenVPNEndpoints(registry *endpoint.Registry) { + endpoint.Register[option.OpenVPNClientEndpointOptions](registry, C.TypeOpenVPNClient, func(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.OpenVPNClientEndpointOptions) (adapter.Endpoint, error) { + if !options.System { + return nil, E.New(`OpenVPN is not included in this build, rebuild with -tags with_openvpn,with_gvisor for system:false`) + } + return nil, E.New(`OpenVPN is not included in this build, rebuild with -tags with_openvpn`) + }) + endpoint.Register[option.OpenVPNServerEndpointOptions](registry, C.TypeOpenVPNServer, func(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.OpenVPNServerEndpointOptions) (adapter.Endpoint, error) { + if !options.System { + return nil, E.New(`OpenVPN is not included in this build, rebuild with -tags with_openvpn,with_gvisor for system:false`) + } + return nil, E.New(`OpenVPN is not included in this build, rebuild with -tags with_openvpn`) + }) +} + +func registerOpenVPNDNSTransport(registry *dns.TransportRegistry) { + dns.RegisterTransport[option.OpenVPNDNSServerOptions](registry, C.DNSTypeOpenVPN, func(ctx context.Context, logger log.ContextLogger, tag string, options option.OpenVPNDNSServerOptions) (adapter.DNSTransport, error) { + return nil, E.New(`OpenVPN is not included in this build, rebuild with -tags with_openvpn`) + }) +} diff --git a/include/registry.go b/include/registry.go index 3ec27574ab..7b12c27fb5 100644 --- a/include/registry.go +++ b/include/registry.go @@ -43,6 +43,7 @@ import ( "github.com/sagernet/sing-box/protocol/ssh" "github.com/sagernet/sing-box/protocol/tor" "github.com/sagernet/sing-box/protocol/trojan" + "github.com/sagernet/sing-box/protocol/trusttunnel" "github.com/sagernet/sing-box/protocol/tun" "github.com/sagernet/sing-box/protocol/tunnel" "github.com/sagernet/sing-box/protocol/vless" @@ -82,6 +83,7 @@ func InboundRegistry() *inbound.Registry { anytls.RegisterInbound(registry) mieru.RegisterInbound(registry) ssh.RegisterInbound(registry) + trusttunnel.RegistryInbound(registry) registerQUICInbounds(registry) registerCloudflaredInbound(registry) @@ -122,6 +124,7 @@ func OutboundRegistry() *outbound.Registry { balancer.RegisterLoadBalance(registry) registerMASQUEOutbound(registry) legacyWireguard.RegisterOutbound(registry) //H + trusttunnel.RegisterOutbound(registry) registerQUICOutbounds(registry) registerStubForRemovedOutbounds(registry) @@ -133,6 +136,8 @@ func EndpointRegistry() *endpoint.Registry { registry := endpoint.NewRegistry() registerWireGuardEndpoint(registry) + registerOpenConnectEndpoint(registry) + registerOpenVPNEndpoints(registry) registerTailscaleEndpoint(registry) tunnel.RegisterServerEndpoint(registry) tunnel.RegisterClientEndpoint(registry) @@ -160,6 +165,8 @@ func DNSTransportRegistry() *dns.TransportRegistry { registerQUICTransports(registry) registerDHCPTransport(registry) registerTailscaleTransport(registry) + registerOpenConnectDNSTransport(registry) + registerOpenVPNDNSTransport(registry) return registry } diff --git a/internal/winmutex/mutex_windows.go b/internal/winmutex/mutex_windows.go new file mode 100644 index 0000000000..7eeb1deea1 --- /dev/null +++ b/internal/winmutex/mutex_windows.go @@ -0,0 +1,73 @@ +//go:build windows + +package winmutex + +import ( + "errors" + "runtime" + "time" + + E "github.com/sagernet/sing/common/exceptions" + + "golang.org/x/sys/windows" +) + +func WithLock[T any](name string, timeout time.Duration, operation func() (T, error)) (result T, err error) { + if timeout < 0 || timeout/time.Millisecond >= time.Duration(windows.INFINITE) { + return result, E.New("invalid named mutex timeout: ", timeout) + } + namePointer, err := windows.UTF16PtrFromString(name) + if err != nil { + return result, E.Cause(err, "encode named mutex ", name) + } + runtime.LockOSThread() + handle, err := windows.CreateMutex(nil, false, namePointer) + if err != nil && !errors.Is(err, windows.ERROR_ALREADY_EXISTS) { + runtime.UnlockOSThread() + return result, E.Cause(err, "create named mutex ", name) + } + waitMilliseconds := uint32((timeout + time.Millisecond - 1) / time.Millisecond) + waitResult, err := windows.WaitForSingleObject(handle, waitMilliseconds) + if err != nil { + closeErr := windows.CloseHandle(handle) + runtime.UnlockOSThread() + if closeErr != nil { + closeErr = E.Cause(closeErr, "close named mutex ", name) + } + return result, E.Errors(E.Cause(err, "wait named mutex ", name), closeErr) + } + switch waitResult { + case uint32(windows.WAIT_OBJECT_0), uint32(windows.WAIT_ABANDONED): + case uint32(windows.WAIT_TIMEOUT): + closeErr := windows.CloseHandle(handle) + runtime.UnlockOSThread() + if closeErr != nil { + return result, E.Errors( + E.New("wait named mutex ", name, ": timeout after ", timeout), + E.Cause(closeErr, "close named mutex ", name), + ) + } + return result, E.New("wait named mutex ", name, ": timeout after ", timeout) + default: + closeErr := windows.CloseHandle(handle) + runtime.UnlockOSThread() + unexpectedErr := E.New("wait named mutex ", name, ": unexpected result ", waitResult) + if closeErr != nil { + return result, E.Errors(unexpectedErr, E.Cause(closeErr, "close named mutex ", name)) + } + return result, unexpectedErr + } + defer runtime.UnlockOSThread() + defer func() { + releaseErr := windows.ReleaseMutex(handle) + if releaseErr != nil { + releaseErr = E.Cause(releaseErr, "release named mutex ", name) + } + closeErr := windows.CloseHandle(handle) + if closeErr != nil { + closeErr = E.Cause(closeErr, "close named mutex ", name) + } + err = E.Errors(err, releaseErr, closeErr) + }() + return operation() +} diff --git a/log/observable.go b/log/observable.go index d19001b6b0..b6bc34f3bf 100644 --- a/log/observable.go +++ b/log/observable.go @@ -56,9 +56,6 @@ func NewDefaultFactory( /*if platformWriter != nil { factory.platformFormatter.DisableColors = platformWriter.DisableColors() }*/ - if needObservable { - factory.observer = observable.NewObserver[Entry](factory.subscriber, 64) - } return factory } @@ -71,6 +68,9 @@ func (f *defaultFactory) Start() error { f.writer = logFile f.file = logFile } + if f.needObservable { + f.observer = observable.NewObserver[Entry](f.subscriber, 64) + } return nil } diff --git a/mkdocs.yml b/mkdocs.yml index d0836c1eb3..d0c0178be8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -62,6 +62,9 @@ nav: - Apple platforms: - clients/apple/index.md - Features: clients/apple/features.md + - Desktop: + - clients/desktop/index.md + - Features: clients/desktop/features.md - General: clients/general.md - Privacy policy: clients/privacy.md - Manual: @@ -77,6 +80,7 @@ nav: - TunnelVision: manual/misc/tunnelvision.md - Configuration: - configuration/index.md + - JSON Schema: configuration/schema.md - Log: - configuration/log/index.md - DNS: @@ -96,6 +100,8 @@ nav: - mDNS: configuration/dns/server/mdns.md - FakeIP: configuration/dns/server/fakeip.md - Tailscale: configuration/dns/server/tailscale.md + - OpenConnect: configuration/dns/server/openconnect.md + - OpenVPN: configuration/dns/server/openvpn.md - Resolved: configuration/dns/server/resolved.md - DNS Rule: configuration/dns/rule.md - DNS Rule Action: configuration/dns/rule_action.md @@ -136,6 +142,7 @@ nav: - Multiplex: configuration/shared/multiplex.md - V2Ray Transport: configuration/shared/v2ray-transport.md - UDP over TCP: configuration/shared/udp-over-tcp.md + - UDP NAT Fields: configuration/shared/udp-nat.md - TCP Brutal: configuration/shared/tcp-brutal.md - Wi-Fi State: configuration/shared/wifi-state.md - Neighbor Resolution: configuration/shared/neighbor.md @@ -143,6 +150,9 @@ nav: - configuration/endpoint/index.md - WireGuard: configuration/endpoint/wireguard.md - Tailscale: configuration/endpoint/tailscale.md + - OpenConnect Client: configuration/endpoint/openconnect.md + - OpenVPN Client: configuration/endpoint/openvpn-client.md + - OpenVPN Server: configuration/endpoint/openvpn-server.md - Inbound: - configuration/inbound/index.md - Direct: configuration/inbound/direct.md @@ -291,6 +301,7 @@ plugins: Multiplex: 多路复用 V2Ray Transport: V2Ray 传输层 Wi-Fi State: Wi-Fi 状态 + UDP NAT Fields: UDP NAT 字段 Endpoint: 端点 Inbound: 入站 diff --git a/option/acme.go b/option/acme.go index 31efffce14..df9daad7c9 100644 --- a/option/acme.go +++ b/option/acme.go @@ -1,9 +1,11 @@ package option import ( + "reflect" "strings" C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/schema" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/json/badjson" @@ -23,21 +25,25 @@ type ACMECertificateProviderOptions struct { AlternativeTLSPort uint16 `json:"alternative_tls_port,omitempty"` ExternalAccount *ACMEExternalAccountOptions `json:"external_account,omitempty"` DNS01Challenge *ACMEProviderDNS01ChallengeOptions `json:"dns01_challenge,omitempty"` - KeyType ACMEKeyType `json:"key_type,omitempty"` + KeyType ACMEKeyType `json:"key_type,omitempty" enum:"ed25519,p256,p384,rsa2048,rsa4096"` Profile string `json:"profile,omitempty"` HTTPClient *HTTPClientOptions `json:"http_client,omitempty"` } type _ACMEProviderDNS01ChallengeOptions struct { + AbstractACMEProviderDNS01ChallengeOptions + Provider string `json:"provider,omitempty" enum:"alidns,cloudflare,acmedns"` + AliDNSOptions ACMEDNS01AliDNSOptions `json:"-"` + CloudflareOptions ACMEDNS01CloudflareOptions `json:"-"` + ACMEDNSOptions ACMEDNS01ACMEDNSOptions `json:"-"` +} + +type AbstractACMEProviderDNS01ChallengeOptions struct { TTL badoption.Duration `json:"ttl,omitempty"` PropagationDelay badoption.Duration `json:"propagation_delay,omitempty"` PropagationTimeout badoption.Duration `json:"propagation_timeout,omitempty"` Resolvers badoption.Listable[string] `json:"resolvers,omitempty"` OverrideDomain string `json:"override_domain,omitempty"` - Provider string `json:"provider,omitempty"` - AliDNSOptions ACMEDNS01AliDNSOptions `json:"-"` - CloudflareOptions ACMEDNS01CloudflareOptions `json:"-"` - ACMEDNSOptions ACMEDNS01ACMEDNSOptions `json:"-"` } type ACMEProviderDNS01ChallengeOptions _ACMEProviderDNS01ChallengeOptions @@ -80,6 +86,14 @@ func (o *ACMEProviderDNS01ChallengeOptions) UnmarshalJSON(bytes []byte) error { return badjson.UnmarshallExcluded(bytes, (*_ACMEProviderDNS01ChallengeOptions)(o), v) } +func (o ACMEProviderDNS01ChallengeOptions) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return builder.Define("ACMEProviderDNS01Challenge", func() (*schema.Node, error) { + return schema.DiscriminatedUnion(builder, "provider", true, acmeDNS01Variants(), func(variant *schema.Node) error { + return builder.FlattenStruct(variant, reflect.TypeFor[AbstractACMEProviderDNS01ChallengeOptions]()) + }) + }) +} + type ACMEKeyType string const ( @@ -105,3 +119,7 @@ func (t *ACMEKeyType) UnmarshalJSON(data []byte) error { } return nil } + +func (t ACMEKeyType) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return schema.StringEnum("", "ed25519", "p256", "p384", "rsa2048", "rsa4096"), nil +} diff --git a/option/api.go b/option/api.go index 6a8766678d..bb8564a1d4 100644 --- a/option/api.go +++ b/option/api.go @@ -1,6 +1,9 @@ package option import ( + "reflect" + + "github.com/sagernet/sing-box/schema" "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/json/badoption" ) @@ -8,7 +11,7 @@ import ( type APIServiceOptions struct { ListenOptions Secret string `json:"secret,omitempty"` - AccessControlAllowOrigin badoption.Listable[string] `json:"access_control_allow_origin,omitempty"` + AccessControlAllowOrigin badoption.Listable[string] `json:"access_control_allow_origin,omitempty" examples:"http://sing-box-dashboard.sagernet.org/,https://sing-box-dashboard.sagernet.org/"` AccessControlAllowPrivateNetwork bool `json:"access_control_allow_private_network,omitempty"` Dashboard *APIDashboardOptions `json:"dashboard,omitempty"` InboundTLSOptionsContainer @@ -48,3 +51,12 @@ func (o *APIDashboardOptions) UnmarshalJSON(bytes []byte) error { } return json.UnmarshalDisallowUnknownFields(bytes, (*_APIDashboardOptions)(o)) } + +func (o APIDashboardOptions) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + objectForm := schema.StrictObject() + err := builder.FlattenStruct(objectForm, reflect.TypeFor[APIDashboardOptions]()) + if err != nil { + return nil, err + } + return schema.AnyOf(schema.BooleanNode(), schema.StringNode(), objectForm), nil +} diff --git a/option/ccm.go b/option/ccm.go index c916aaf221..d36ab42324 100644 --- a/option/ccm.go +++ b/option/ccm.go @@ -10,7 +10,7 @@ type CCMServiceOptions struct { CredentialPath string `json:"credential_path,omitempty"` Users []CCMUser `json:"users,omitempty"` Headers badoption.HTTPHeader `json:"headers,omitempty"` - Detour string `json:"detour,omitempty"` + Detour string `json:"detour,omitempty" reference:"outbound"` UsagesPath string `json:"usages_path,omitempty"` } diff --git a/option/certificate.go b/option/certificate.go index ab524b9921..38318698ea 100644 --- a/option/certificate.go +++ b/option/certificate.go @@ -1,13 +1,16 @@ package option import ( + "reflect" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/schema" "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/json/badoption" ) type _CertificateOptions struct { - Store string `json:"store,omitempty"` + Store string `json:"store,omitempty" enum:"system,mozilla,chrome,none"` Certificate badoption.Listable[string] `json:"certificate,omitempty"` CertificatePath badoption.Listable[string] `json:"certificate_path,omitempty"` CertificateDirectoryPath badoption.Listable[string] `json:"certificate_directory_path,omitempty"` @@ -34,3 +37,12 @@ func (o *CertificateOptions) UnmarshalJSON(data []byte) error { } return nil } + +func (o CertificateOptions) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + node := schema.StrictObject() + err := builder.FlattenStruct(node, reflect.TypeFor[CertificateOptions]()) + if err != nil { + return nil, err + } + return node, nil +} diff --git a/option/certificate_provider.go b/option/certificate_provider.go index a24abdc570..277596c98c 100644 --- a/option/certificate_provider.go +++ b/option/certificate_provider.go @@ -3,6 +3,7 @@ package option import ( "context" + "github.com/sagernet/sing-box/schema" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/json/badjson" @@ -10,6 +11,7 @@ import ( ) type CertificateProviderOptionsRegistry interface { + OptionTypes() []string CreateOptions(providerType string) (any, bool) } @@ -46,6 +48,16 @@ func (h *CertificateProvider) UnmarshalJSONContext(ctx context.Context, content return nil } +func (h CertificateProvider) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return builder.Define("CertificateProvider", func() (*schema.Node, error) { + registry := service.FromContext[CertificateProviderOptionsRegistry](builder.Context()) + if registry == nil { + return nil, E.New("missing certificate provider options registry in context") + } + return registryUnion(builder, registry, nil, true) + }) +} + type CertificateProviderOptions struct { Tag string `json:"-"` Type string `json:"-"` @@ -98,3 +110,17 @@ func (o *CertificateProviderOptions) UnmarshalJSONContext(ctx context.Context, c func (o *CertificateProviderOptions) IsShared() bool { return o.Tag != "" } + +func (o CertificateProviderOptions) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return builder.Define("CertificateProviderReference", func() (*schema.Node, error) { + registry := service.FromContext[CertificateProviderOptionsRegistry](builder.Context()) + if registry == nil { + return nil, E.New("missing certificate provider options registry in context") + } + union, err := registryUnion(builder, registry, nil, false) + if err != nil { + return nil, err + } + return schema.AnyOf(schema.TagReferenceNode("certificate_provider"), union), nil + }) +} diff --git a/option/cloudflared.go b/option/cloudflared.go index e94a20fefd..0eba05ac59 100644 --- a/option/cloudflared.go +++ b/option/cloudflared.go @@ -5,10 +5,10 @@ import "github.com/sagernet/sing/common/json/badoption" type CloudflaredInboundOptions struct { Token string `json:"token,omitempty"` HighAvailabilityConnections int `json:"ha_connections,omitempty"` - Protocol string `json:"protocol,omitempty"` + Protocol string `json:"protocol,omitempty" enum:"auto,quic,http2,h2mux"` PostQuantum bool `json:"post_quantum,omitempty"` - EdgeIPVersion int `json:"edge_ip_version,omitempty"` - DatagramVersion string `json:"datagram_version,omitempty"` + EdgeIPVersion int `json:"edge_ip_version,omitempty" enum:"0,4,6"` + DatagramVersion string `json:"datagram_version,omitempty" enum:"v2,v3"` GracePeriod badoption.Duration `json:"grace_period,omitempty"` Region string `json:"region,omitempty"` ControlDialer DialerOptions `json:"control_dialer,omitempty"` diff --git a/option/direct.go b/option/direct.go index a03f98d412..a11c45e980 100644 --- a/option/direct.go +++ b/option/direct.go @@ -17,11 +17,11 @@ type DirectInboundOptions struct { type _DirectOutboundOptions struct { DialerOptions // Deprecated: Use Route Action instead - OverrideAddress string `json:"override_address,omitempty"` + OverrideAddress string `json:"override_address,omitempty" schema:"omit"` // Deprecated: Use Route Action instead - OverridePort uint16 `json:"override_port,omitempty"` + OverridePort uint16 `json:"override_port,omitempty" schema:"omit"` // Deprecated: removed - ProxyProtocol uint8 `json:"proxy_protocol,omitempty"` + ProxyProtocol uint8 `json:"proxy_protocol,omitempty" schema:"omit"` } type DirectOutboundOptions _DirectOutboundOptions diff --git a/option/dns.go b/option/dns.go index 6ed6a66126..196a2bac94 100644 --- a/option/dns.go +++ b/option/dns.go @@ -3,8 +3,10 @@ package option import ( "context" "net/netip" + "reflect" C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/schema" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/json/badjson" @@ -16,7 +18,7 @@ import ( type RawDNSOptions struct { Servers []DNSServerOptions `json:"servers,omitempty"` Rules []DNSRule `json:"rules,omitempty"` - Final string `json:"final,omitempty"` + Final string `json:"final,omitempty" reference:"dns_server"` ReverseMapping bool `json:"reverse_mapping,omitempty"` DNSClientOptions } @@ -46,12 +48,23 @@ func (o *DNSOptions) UnmarshalJSONContext(ctx context.Context, content []byte) e return badjson.UnmarshallExcludedContext(ctx, content, legacyOptions, &o.RawDNSOptions) } +func (o DNSOptions) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return builder.Define("DNS", func() (*schema.Node, error) { + node := schema.StrictObject() + err := builder.FlattenStruct(node, reflect.TypeFor[RawDNSOptions]()) + if err != nil { + return nil, err + } + return node, nil + }) +} + type DNSClientOptions struct { Strategy DomainStrategy `json:"strategy,omitempty"` Timeout badoption.Duration `json:"timeout,omitempty"` DisableCache bool `json:"disable_cache,omitempty"` DisableExpire bool `json:"disable_expire,omitempty"` - IndependentCache bool `json:"independent_cache,omitempty"` + IndependentCache bool `json:"independent_cache,omitempty" schema:"omit"` CacheCapacity uint32 `json:"cache_capacity,omitempty"` Optimistic *OptimisticDNSOptions `json:"optimistic,omitempty"` ClientSubnet *badoption.Prefixable `json:"client_subnet,omitempty"` @@ -79,7 +92,17 @@ func (o *OptimisticDNSOptions) UnmarshalJSON(bytes []byte) error { return json.UnmarshalDisallowUnknownFields(bytes, (*_OptimisticDNSOptions)(o)) } +func (o OptimisticDNSOptions) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + objectForm := schema.StrictObject() + err := builder.FlattenStruct(objectForm, reflect.TypeFor[OptimisticDNSOptions]()) + if err != nil { + return nil, err + } + return schema.AnyOf(schema.BooleanNode(), objectForm), nil +} + type DNSTransportOptionsRegistry interface { + OptionTypes() []string CreateOptions(transportType string) (any, bool) } type _DNSServerOptions struct { @@ -122,6 +145,16 @@ func (o *DNSServerOptions) UnmarshalJSONContext(ctx context.Context, content []b return nil } +func (o DNSServerOptions) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return builder.Define("DNSServer", func() (*schema.Node, error) { + registry := service.FromContext[DNSTransportOptionsRegistry](builder.Context()) + if registry == nil { + return nil, E.New("missing DNS transport options registry in context") + } + return registryUnion(builder, registry, nil, true) + }) +} + type DNSServerAddressOptions struct { Server string `json:"server"` ServerPort uint16 `json:"server_port,omitempty"` @@ -176,8 +209,8 @@ type RemoteHTTPSDNSServerOptions struct { } type FakeIPDNSServerOptions struct { - Inet4Range *badoption.Prefix `json:"inet4_range,omitempty"` - Inet6Range *badoption.Prefix `json:"inet6_range,omitempty"` + Inet4Range *badoption.Prefix `json:"inet4_range,omitempty" examples:"198.18.0.0/15"` + Inet6Range *badoption.Prefix `json:"inet6_range,omitempty" examples:"fc00::/18"` } type DHCPDNSServerOptions struct { diff --git a/option/dns_record.go b/option/dns_record.go index f10e03d9b6..fe93dabc44 100644 --- a/option/dns_record.go +++ b/option/dns_record.go @@ -1,9 +1,12 @@ package option import ( + "cmp" "encoding/base64" + "slices" "strings" + "github.com/sagernet/sing-box/schema" "github.com/sagernet/sing/common/buf" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json" @@ -51,6 +54,43 @@ func (r *DNSRCode) Build() int { return int(*r) } +func (r DNSRCode) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return builder.Define("DNSRCode", func() (*schema.Node, error) { + type rCodeName struct { + name string + value int + canonical bool + } + rCodeNames := make([]rCodeName, 0, len(dns.StringToRcode)) + for name, value := range dns.StringToRcode { + canonicalName, canonical := dns.RcodeToString[value] + rCodeNames = append(rCodeNames, rCodeName{ + name: name, + value: value, + canonical: canonical && canonicalName == name, + }) + } + slices.SortFunc(rCodeNames, func(left rCodeName, right rCodeName) int { + comparison := cmp.Compare(left.value, right.value) + if comparison != 0 { + return comparison + } + if left.canonical != right.canonical { + if left.canonical { + return -1 + } + return 1 + } + return cmp.Compare(left.name, right.name) + }) + values := make([]string, 0, len(rCodeNames)) + for _, entry := range rCodeNames { + values = append(values, entry.name) + } + return schema.AnyOf(schema.IntegerNode(), schema.StringEnum(values...)), nil + }) +} + type DNSRecordOptions struct { dns.RR fromBase64 bool @@ -123,3 +163,7 @@ func (o DNSRecordOptions) Match(record dns.RR) bool { } return dns.IsDuplicate(o.RR, record) } + +func (o DNSRecordOptions) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return schema.StringNode(), nil +} diff --git a/option/dns_test.go b/option/dns_test.go index 4e7bf9a92b..312ae47192 100644 --- a/option/dns_test.go +++ b/option/dns_test.go @@ -13,6 +13,10 @@ import ( type stubDNSTransportOptionsRegistry struct{} +func (stubDNSTransportOptionsRegistry) OptionTypes() []string { + return []string{C.DNSTypeUDP, C.DNSTypeFakeIP} +} + func (stubDNSTransportOptionsRegistry) CreateOptions(transportType string) (any, bool) { switch transportType { case C.DNSTypeUDP: diff --git a/option/endpoint.go b/option/endpoint.go index 45c4f8311f..3645a0413d 100644 --- a/option/endpoint.go +++ b/option/endpoint.go @@ -3,6 +3,7 @@ package option import ( "context" + "github.com/sagernet/sing-box/schema" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/json/badjson" @@ -10,6 +11,7 @@ import ( ) type EndpointOptionsRegistry interface { + OptionTypes() []string CreateOptions(endpointType string) (any, bool) } @@ -45,3 +47,13 @@ func (h *Endpoint) UnmarshalJSONContext(ctx context.Context, content []byte) err h.Options = options return nil } + +func (h Endpoint) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return builder.Define("Endpoint", func() (*schema.Node, error) { + registry := service.FromContext[EndpointOptionsRegistry](builder.Context()) + if registry == nil { + return nil, E.New("missing endpoint options registry in context") + } + return registryUnion(builder, registry, nil, true) + }) +} diff --git a/option/experimental.go b/option/experimental.go index b8995fec98..87970fb519 100644 --- a/option/experimental.go +++ b/option/experimental.go @@ -21,7 +21,7 @@ type CacheFileOptions struct { Path string `json:"path,omitempty"` CacheID string `json:"cache_id,omitempty"` StoreFakeIP bool `json:"store_fakeip,omitempty"` - StoreRDRC bool `json:"store_rdrc,omitempty"` + StoreRDRC bool `json:"store_rdrc,omitempty" schema:"omit"` StoreWARPConfig bool `json:"store_warp_config,omitempty"` //H StoreMASQUEConfig bool `json:"store_masque_config,omitempty"` //H RDRCTimeout badoption.Duration `json:"rdrc_timeout,omitempty"` @@ -32,7 +32,7 @@ type ClashAPIOptions struct { ExternalController string `json:"external_controller,omitempty"` ExternalUI string `json:"external_ui,omitempty"` ExternalUIDownloadURL string `json:"external_ui_download_url,omitempty"` - ExternalUIDownloadDetour string `json:"external_ui_download_detour,omitempty"` + ExternalUIDownloadDetour string `json:"external_ui_download_detour,omitempty" reference:"outbound"` Secret string `json:"secret,omitempty"` DefaultMode string `json:"default_mode,omitempty"` ModeList []string `json:"-"` @@ -40,15 +40,15 @@ type ClashAPIOptions struct { AccessControlAllowPrivateNetwork bool `json:"access_control_allow_private_network,omitempty"` // Deprecated: migrated to global cache file - CacheFile string `json:"cache_file,omitempty"` + CacheFile string `json:"cache_file,omitempty" schema:"omit"` // Deprecated: migrated to global cache file - CacheID string `json:"cache_id,omitempty"` + CacheID string `json:"cache_id,omitempty" schema:"omit"` // Deprecated: migrated to global cache file - StoreMode bool `json:"store_mode,omitempty"` + StoreMode bool `json:"store_mode,omitempty" schema:"omit"` // Deprecated: migrated to global cache file - StoreSelected bool `json:"store_selected,omitempty"` + StoreSelected bool `json:"store_selected,omitempty" schema:"omit"` // Deprecated: migrated to global cache file - StoreFakeIP bool `json:"store_fakeip,omitempty"` + StoreFakeIP bool `json:"store_fakeip,omitempty" schema:"omit"` } type V2RayAPIOptions struct { diff --git a/option/group.go b/option/group.go index 979f1d31f5..773ec9bd8f 100644 --- a/option/group.go +++ b/option/group.go @@ -3,13 +3,13 @@ package option import "github.com/sagernet/sing/common/json/badoption" type SelectorOutboundOptions struct { - Outbounds []string `json:"outbounds"` - Default string `json:"default,omitempty"` + Outbounds []string `json:"outbounds" reference:"outbound"` + Default string `json:"default,omitempty" reference:"outbound"` InterruptExistConnections bool `json:"interrupt_exist_connections,omitempty"` } type URLTestOutboundOptions struct { - Outbounds []string `json:"outbounds"` + Outbounds []string `json:"outbounds" reference:"outbound"` URL string `json:"url,omitempty"` URLs []string `json:"urls,omitempty"` //H Interval badoption.Duration `json:"interval,omitempty"` diff --git a/option/http.go b/option/http.go index 1a97270443..4c6da9deb2 100644 --- a/option/http.go +++ b/option/http.go @@ -3,6 +3,7 @@ package option import ( "reflect" + "github.com/sagernet/sing-box/schema" "github.com/sagernet/sing/common/byteformats" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json" @@ -26,8 +27,8 @@ type QUICOptions struct { type _HTTPClientOptions struct { Tag string `json:"tag,omitempty"` - Engine string `json:"engine,omitempty"` - Version int `json:"version,omitempty"` + Engine string `json:"engine,omitempty" enum:"go,apple"` + Version int `json:"version,omitempty" enum:"0,1,2,3"` DisableVersionFallback bool `json:"disable_version_fallback,omitempty"` Headers badoption.HTTPHeader `json:"headers,omitempty"` HTTP2Options HTTP2Options `json:"-"` @@ -125,3 +126,33 @@ func httpClientVariant(options _HTTPClientOptions) any { return nil } } + +func describeHTTPClientObject(builder schema.Builder) (*schema.Node, error) { + node := schema.StrictObject() + err := builder.FlattenStruct(node, reflect.TypeFor[HTTPClient]()) + if err != nil { + return nil, err + } + err = builder.FlattenStruct(node, reflect.TypeFor[QUICOptions]()) + if err != nil { + return nil, err + } + return node, nil +} + +func (h HTTPClient) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return builder.Define("HTTPClient", func() (*schema.Node, error) { + return describeHTTPClientObject(builder) + }) +} + +func (o HTTPClientOptions) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return builder.Define("HTTPClientReference", func() (*schema.Node, error) { + clientObject, err := describeHTTPClientObject(builder) + if err != nil { + return nil, err + } + clientObject.Properties.Remove("tag") + return schema.AnyOf(schema.TagReferenceNode("http_client"), clientObject), nil + }) +} diff --git a/option/hysteria.go b/option/hysteria.go index f5ab87cec1..d1b9ad30a7 100644 --- a/option/hysteria.go +++ b/option/hysteria.go @@ -14,13 +14,13 @@ type HysteriaInboundOptions struct { Obfs string `json:"obfs,omitempty"` Users []HysteriaUser `json:"users,omitempty"` // Deprecated: use QUIC fields instead - ReceiveWindowConn uint64 `json:"recv_window_conn,omitempty"` + ReceiveWindowConn uint64 `json:"recv_window_conn,omitempty" schema:"omit"` // Deprecated: use QUIC fields instead - ReceiveWindowClient uint64 `json:"recv_window_client,omitempty"` + ReceiveWindowClient uint64 `json:"recv_window_client,omitempty" schema:"omit"` // Deprecated: use QUIC fields instead - MaxConnClient int `json:"max_conn_client,omitempty"` + MaxConnClient int `json:"max_conn_client,omitempty" schema:"omit"` // Deprecated: use QUIC fields instead - DisableMTUDiscovery bool `json:"disable_mtu_discovery,omitempty"` + DisableMTUDiscovery bool `json:"disable_mtu_discovery,omitempty" schema:"omit"` InboundTLSOptionsContainer QUICOptions } @@ -44,11 +44,11 @@ type HysteriaOutboundOptions struct { Auth []byte `json:"auth,omitempty"` AuthString string `json:"auth_str,omitempty"` // Deprecated: use QUIC fields instead - ReceiveWindowConn uint64 `json:"recv_window_conn,omitempty"` + ReceiveWindowConn uint64 `json:"recv_window_conn,omitempty" schema:"omit"` // Deprecated: use QUIC fields instead - ReceiveWindow uint64 `json:"recv_window,omitempty"` + ReceiveWindow uint64 `json:"recv_window,omitempty" schema:"omit"` // Deprecated: use QUIC fields instead - DisableMTUDiscovery bool `json:"disable_mtu_discovery,omitempty"` + DisableMTUDiscovery bool `json:"disable_mtu_discovery,omitempty" schema:"omit"` Network NetworkList `json:"network,omitempty"` OutboundTLSOptionsContainer QUICOptions diff --git a/option/hysteria2.go b/option/hysteria2.go index fdf0d2cd83..3598c555ec 100644 --- a/option/hysteria2.go +++ b/option/hysteria2.go @@ -2,8 +2,10 @@ package option import ( "net/url" + "reflect" C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/schema" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/json/badjson" @@ -20,7 +22,7 @@ type Hysteria2InboundOptions struct { InboundTLSOptionsContainer QUICOptions Masquerade *Hysteria2Masquerade `json:"masquerade,omitempty"` - BBRProfile string `json:"bbr_profile,omitempty"` + BBRProfile string `json:"bbr_profile,omitempty" enum:"standard,conservative,aggressive"` BrutalDebug bool `json:"brutal_debug,omitempty"` Realm *Hysteria2InboundRealm `json:"realm,omitempty"` } @@ -30,7 +32,7 @@ type Hysteria2Realm struct { Token string `json:"token,omitempty"` RealmID string `json:"realm_id"` STUNServers badoption.Listable[string] `json:"stun_servers"` - IPVersion int `json:"ip_version,omitempty"` + IPVersion int `json:"ip_version,omitempty" enum:"0,4,6"` PortMapping *Hysteria2RealmPortMapping `json:"port_mapping,omitempty"` HTTPClient *HTTPClientOptions `json:"http_client,omitempty"` } @@ -52,7 +54,7 @@ type Hysteria2ObfsGecko struct { } type _Hysteria2Obfs struct { - Type string `json:"type,omitempty"` + Type string `json:"type,omitempty" enum:"salamander,gecko"` Password string `json:"password,omitempty"` GeckoOptions Hysteria2ObfsGecko `json:"-"` } @@ -93,13 +95,23 @@ func (o *Hysteria2Obfs) UnmarshalJSON(bytes []byte) error { return badjson.UnmarshallExcluded(bytes, (*_Hysteria2Obfs)(o), v) } +func (o Hysteria2Obfs) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return schema.DiscriminatedUnion(builder, "type", true, []schema.UnionVariant{ + {Value: C.Hysteria2ObfsTypeSalamander}, + {Value: C.Hysteria2ObfsTypeGecko, StructType: reflect.TypeFor[Hysteria2ObfsGecko]()}, + }, func(variant *schema.Node) error { + variant.Properties.Put("password", schema.StringNode()) + return nil + }) +} + type Hysteria2User struct { Name string `json:"name,omitempty"` Password string `json:"password,omitempty"` } type _Hysteria2Masquerade struct { - Type string `json:"type,omitempty"` + Type string `json:"type,omitempty" enum:"file,proxy,string"` FileOptions Hysteria2MasqueradeFile `json:"-"` ProxyOptions Hysteria2MasqueradeProxy `json:"-"` StringOptions Hysteria2MasqueradeString `json:"-"` @@ -160,6 +172,18 @@ func (m *Hysteria2Masquerade) UnmarshalJSON(bytes []byte) error { return badjson.UnmarshallExcluded(bytes, (*_Hysteria2Masquerade)(m), v) } +func (m Hysteria2Masquerade) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + union, err := schema.DiscriminatedUnion(builder, "type", true, []schema.UnionVariant{ + {Value: C.Hysterai2MasqueradeTypeFile, StructType: reflect.TypeFor[Hysteria2MasqueradeFile]()}, + {Value: C.Hysterai2MasqueradeTypeProxy, StructType: reflect.TypeFor[Hysteria2MasqueradeProxy]()}, + {Value: C.Hysterai2MasqueradeTypeString, StructType: reflect.TypeFor[Hysteria2MasqueradeString]()}, + }, nil) + if err != nil { + return nil, err + } + return schema.AnyOf(schema.StringNode(), union), nil +} + type Hysteria2MasqueradeFile struct { Directory string `json:"directory"` } @@ -188,7 +212,7 @@ type Hysteria2OutboundOptions struct { Network NetworkList `json:"network,omitempty"` OutboundTLSOptionsContainer QUICOptions - BBRProfile string `json:"bbr_profile,omitempty"` + BBRProfile string `json:"bbr_profile,omitempty" enum:"standard,conservative,aggressive"` BrutalDebug bool `json:"brutal_debug,omitempty"` Realm *Hysteria2Realm `json:"realm,omitempty"` } diff --git a/option/inbound.go b/option/inbound.go index 21497a3f4e..337a33c914 100644 --- a/option/inbound.go +++ b/option/inbound.go @@ -4,6 +4,8 @@ import ( "context" "time" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/schema" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/json/badjson" @@ -12,6 +14,7 @@ import ( ) type InboundOptionsRegistry interface { + OptionTypes() []string CreateOptions(outboundType string) (any, bool) } @@ -54,13 +57,23 @@ func (h *Inbound) UnmarshalJSONContext(ctx context.Context, content []byte) erro return nil } +func (h Inbound) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return builder.Define("Inbound", func() (*schema.Node, error) { + registry := service.FromContext[InboundOptionsRegistry](builder.Context()) + if registry == nil { + return nil, E.New("missing inbound options registry in context") + } + return registryUnion(builder, registry, []string{C.TypeShadowsocksR}, true) + }) +} + // Deprecated: Use rule action instead type InboundOptions struct { - SniffEnabled bool `json:"sniff,omitempty"` - SniffOverrideDestination bool `json:"sniff_override_destination,omitempty"` - SniffTimeout badoption.Duration `json:"sniff_timeout,omitempty"` - DomainStrategy DomainStrategy `json:"domain_strategy,omitempty"` - UDPDisableDomainUnmapping bool `json:"udp_disable_domain_unmapping,omitempty"` + SniffEnabled bool `json:"sniff,omitempty" schema:"omit"` + SniffOverrideDestination bool `json:"sniff_override_destination,omitempty" schema:"omit"` + SniffTimeout badoption.Duration `json:"sniff_timeout,omitempty" schema:"omit"` + DomainStrategy DomainStrategy `json:"domain_strategy,omitempty" schema:"omit"` + UDPDisableDomainUnmapping bool `json:"udp_disable_domain_unmapping,omitempty" schema:"omit"` } type ListenOptions struct { @@ -69,7 +82,7 @@ type ListenOptions struct { BindInterface string `json:"bind_interface,omitempty"` RoutingMark FwMark `json:"routing_mark,omitempty"` ReuseAddr bool `json:"reuse_addr,omitempty"` - NetNs string `json:"netns,omitempty"` + NetNs string `json:"netns,omitempty" reference:"network_namespace"` DisableTCPKeepAlive bool `json:"disable_tcp_keep_alive,omitempty"` TCPKeepAlive badoption.Duration `json:"tcp_keep_alive,omitempty"` TCPKeepAliveInterval badoption.Duration `json:"tcp_keep_alive_interval,omitempty"` @@ -78,13 +91,61 @@ type ListenOptions struct { UDPFragment *bool `json:"udp_fragment,omitempty"` UDPFragmentDefault bool `json:"-"` UDPTimeout UDPTimeoutCompat `json:"udp_timeout,omitempty"` - Detour string `json:"detour,omitempty"` + Detour string `json:"detour,omitempty" reference:"inbound"` // Deprecated: removed - ProxyProtocol bool `json:"proxy_protocol,omitempty"` + ProxyProtocol bool `json:"proxy_protocol,omitempty" schema:"omit"` // Deprecated: removed - ProxyProtocolAcceptNoHeader bool `json:"proxy_protocol_accept_no_header,omitempty"` - InboundOptions + ProxyProtocolAcceptNoHeader bool `json:"proxy_protocol_accept_no_header,omitempty" schema:"omit"` + // Legacy inbound fields are rejected since sing-box 1.13.0. + //nolint:staticcheck + InboundOptions `schema:"omit"` +} + +type UDPNATBehavior uint8 + +const ( + UDPNATBehaviorEndpointIndependent UDPNATBehavior = iota + UDPNATBehaviorAddressDependent + UDPNATBehaviorAddressAndPortDependent +) + +func (b UDPNATBehavior) MarshalJSON() ([]byte, error) { + var value string + switch b { + case UDPNATBehaviorEndpointIndependent: + value = "endpoint_independent" + case UDPNATBehaviorAddressDependent: + value = "address_dependent" + case UDPNATBehaviorAddressAndPortDependent: + value = "address_and_port_dependent" + default: + return nil, E.New("unknown UDP NAT behavior: ", uint8(b)) + } + return json.Marshal(value) +} + +func (b *UDPNATBehavior) UnmarshalJSON(data []byte) error { + var value string + err := json.Unmarshal(data, &value) + if err != nil { + return err + } + switch value { + case "", "endpoint_independent": + *b = UDPNATBehaviorEndpointIndependent + case "address_dependent": + *b = UDPNATBehaviorAddressDependent + case "address_and_port_dependent": + *b = UDPNATBehaviorAddressAndPortDependent + default: + return E.New("unknown UDP NAT behavior: ", value) + } + return nil +} + +func (b UDPNATBehavior) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return schema.StringEnum("", "endpoint_independent", "address_dependent", "address_and_port_dependent"), nil } type UDPTimeoutCompat badoption.Duration @@ -103,6 +164,12 @@ func (c *UDPTimeoutCompat) UnmarshalJSON(data []byte) error { return json.Unmarshal(data, (*badoption.Duration)(c)) } +func (c UDPTimeoutCompat) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return builder.Define("UDPTimeout", func() (*schema.Node, error) { + return schema.AnyOf(schema.UnsignedNode(32), schema.DurationNode()), nil + }) +} + type ListenOptionsWrapper interface { TakeListenOptions() ListenOptions ReplaceListenOptions(options ListenOptions) diff --git a/option/multiplex.go b/option/multiplex.go index 309d8bdc39..0f5b89cba1 100644 --- a/option/multiplex.go +++ b/option/multiplex.go @@ -8,7 +8,7 @@ type InboundMultiplexOptions struct { type OutboundMultiplexOptions struct { Enabled bool `json:"enabled,omitempty"` - Protocol string `json:"protocol,omitempty"` + Protocol string `json:"protocol,omitempty" enum:"h2mux,smux,yamux"` MaxConnections int `json:"max_connections,omitempty"` MinStreams int `json:"min_streams,omitempty"` MaxStreams int `json:"max_streams,omitempty"` diff --git a/option/naive.go b/option/naive.go index da3a88db98..a76f39d1c9 100644 --- a/option/naive.go +++ b/option/naive.go @@ -20,7 +20,7 @@ type NaiveInboundOptions struct { ListenOptions Users []auth.User `json:"users,omitempty"` Network NetworkList `json:"network,omitempty"` - QUICCongestionControl string `json:"quic_congestion_control,omitempty"` + QUICCongestionControl string `json:"quic_congestion_control,omitempty" enum:"bbr,bbr_standard,bbr2,bbr2_variant,cubic,reno"` InboundTLSOptionsContainer } @@ -34,7 +34,7 @@ type NaiveOutboundOptions struct { ReceiveWindow *byteformats.MemoryBytes `json:"stream_receive_window,omitempty"` UDPOverTCP *UDPOverTCPOptions `json:"udp_over_tcp,omitempty"` QUIC bool `json:"quic,omitempty"` - QUICCongestionControl string `json:"quic_congestion_control,omitempty"` + QUICCongestionControl string `json:"quic_congestion_control,omitempty" enum:"bbr,bbr2,cubic,reno"` QUICSessionReceiveWindow *byteformats.MemoryBytes `json:"quic_session_receive_window,omitempty"` OutboundTLSOptionsContainer } diff --git a/option/netns.go b/option/netns.go index 9516b660b1..b206b01834 100644 --- a/option/netns.go +++ b/option/netns.go @@ -1,7 +1,10 @@ package option import ( + "reflect" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/schema" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/json/badjson" @@ -48,6 +51,19 @@ func (o *NetworkNamespace) UnmarshalJSON(content []byte) error { return badjson.UnmarshallExcluded(content, (*_NetworkNamespace)(o), v) } +func (o NetworkNamespace) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return builder.Define("NetworkNamespace", func() (*schema.Node, error) { + return schema.DiscriminatedUnion(builder, "type", false, []schema.UnionVariant{ + {Value: C.NetNsTypeDefault, StructType: reflect.TypeFor[DefaultNetworkNamespaceOptions](), TypeOptional: true}, + {Value: C.NetNsTypeUnshare, StructType: reflect.TypeFor[UnshareNetworkNamespaceOptions]()}, + }, func(variant *schema.Node) error { + variant.Properties.Put("tag", schema.StringNode()) + variant.Required = append(variant.Required, "tag") + return nil + }) + }) +} + type DefaultNetworkNamespaceOptions struct { Path string `json:"path"` } diff --git a/option/ocm.go b/option/ocm.go index c13a1c1f53..091aedde10 100644 --- a/option/ocm.go +++ b/option/ocm.go @@ -10,7 +10,7 @@ type OCMServiceOptions struct { CredentialPath string `json:"credential_path,omitempty"` Users []OCMUser `json:"users,omitempty"` Headers badoption.HTTPHeader `json:"headers,omitempty"` - Detour string `json:"detour,omitempty"` + Detour string `json:"detour,omitempty" reference:"outbound"` UsagesPath string `json:"usages_path,omitempty"` } diff --git a/option/openconnect.go b/option/openconnect.go new file mode 100644 index 0000000000..bc597b5ef5 --- /dev/null +++ b/option/openconnect.go @@ -0,0 +1,124 @@ +package option + +import "github.com/sagernet/sing/common/json/badoption" + +type OpenConnectEndpointOptions struct { + DialerOptions + System bool `json:"system,omitempty"` + Name string `json:"name,omitempty"` + UDPTimeout badoption.Duration `json:"udp_timeout,omitempty"` + UDPMapping UDPNATBehavior `json:"udp_mapping,omitempty"` + UDPFiltering UDPNATBehavior `json:"udp_filtering,omitempty"` + UDPNATMax uint32 `json:"udp_nat_max,omitempty"` + Server string `json:"server"` + Flavor string `json:"flavor,omitempty" enum:"anyconnect,gp,fortinet,f5,pulse,nc"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + AuthGroup string `json:"auth_group,omitempty"` + Cookie string `json:"cookie,omitempty"` + Token *OpenConnectTokenOptions `json:"token,omitempty"` + ReportedOS string `json:"reported_os,omitempty"` + UserAgent string `json:"user_agent,omitempty"` + Version string `json:"version,omitempty"` + LocalHostname string `json:"local_hostname,omitempty"` + Mobile *OpenConnectMobileOptions `json:"mobile,omitempty"` + CSD *OpenConnectCSDOptions `json:"csd,omitempty"` + HIP *OpenConnectHIPOptions `json:"hip,omitempty"` + TNCC *OpenConnectTNCCOptions `json:"tncc,omitempty"` + FortinetHostCheck *OpenConnectFortinetHostCheckOptions `json:"fortinet_host_check,omitempty"` + NoUDP bool `json:"no_udp,omitempty"` + DTLSLocalPort uint16 `json:"dtls_local_port,omitempty"` + CompressionDisabled bool `json:"compression_disabled,omitempty"` + CompressionMode string `json:"compression_mode,omitempty" enum:"stateless,all"` + IPv6Disabled bool `json:"ipv6_disabled,omitempty"` + HTTPKeepAliveDisabled bool `json:"http_keepalive_disabled,omitempty"` + XMLPostDisabled bool `json:"xml_post_disabled,omitempty"` + ExternalAuthDisabled bool `json:"external_auth_disabled,omitempty"` + PasswordAuthenticationDisabled bool `json:"password_authentication_disabled,omitempty"` + TCPKeepAliveEnabled bool `json:"tcp_keep_alive_enabled,omitempty"` + PFS bool `json:"pfs,omitempty"` + MTU uint32 `json:"mtu,omitempty"` + BaseMTU uint32 `json:"base_mtu,omitempty"` + DPDInterval badoption.Duration `json:"dpd_interval,omitempty"` + ReconnectTimeout badoption.Duration `json:"reconnect_timeout,omitempty"` + TrojanInterval badoption.Duration `json:"trojan_interval,omitempty"` + QueueLength uint32 `json:"queue_length,omitempty"` + AllowInsecureCrypto bool `json:"allow_insecure_crypto,omitempty"` + TLS OpenConnectTLSOptions `json:"tls,omitempty"` + FormEntries []OpenConnectFormEntryOptions `json:"form_entries,omitempty"` +} + +type OpenConnectTokenOptions struct { + Mode string `json:"mode,omitempty" enum:"totp,hotp,stoken,oidc"` + Secret string `json:"secret,omitempty"` + SecretPath string `json:"secret_path,omitempty"` + PIN string `json:"pin,omitempty"` + Password string `json:"password,omitempty"` + DeviceID string `json:"device_id,omitempty"` + Counter uint64 `json:"counter,omitempty"` +} + +type OpenConnectMobileOptions struct { + PlatformVersion string `json:"platform_version"` + DeviceType string `json:"device_type"` + DeviceUniqueID string `json:"device_unique_id"` +} + +type OpenConnectCSDOptions struct { + WrapperPath string `json:"wrapper_path,omitempty"` +} + +type OpenConnectHIPOptions struct { + WrapperPath string `json:"wrapper_path,omitempty"` +} + +type OpenConnectTNCCOptions struct { + WrapperPath string `json:"wrapper_path,omitempty"` + DeviceID string `json:"device_id,omitempty"` + UserAgent string `json:"user_agent,omitempty"` + MachineIdentificationEnabled bool `json:"machine_identification_enabled,omitempty"` + Certificates []OpenConnectTNCCCertificateOptions `json:"certificates,omitempty"` +} + +type OpenConnectFortinetHostCheckOptions struct { + HostCheck string `json:"hostcheck,omitempty"` + CheckVirtualDesktop string `json:"check_virtual_desktop,omitempty"` +} + +type OpenConnectTNCCCertificateOptions struct { + Certificate badoption.Listable[string] `json:"certificate,omitempty"` + CertificatePath string `json:"certificate_path,omitempty"` +} + +type OpenConnectTLSOptions struct { + Insecure bool `json:"insecure,omitempty"` + ServerName string `json:"server_name,omitempty"` + PeerFingerprint badoption.Listable[string] `json:"peer_fingerprint,omitempty"` + SystemTrustDisabled bool `json:"system_trust_disabled,omitempty"` + CertificateAuthority badoption.Listable[string] `json:"certificate_authority,omitempty"` + CertificateAuthorityPath string `json:"certificate_authority_path,omitempty"` + ClientCertificate badoption.Listable[string] `json:"client_certificate,omitempty"` + ClientCertificatePath string `json:"client_certificate_path,omitempty"` + ClientKey badoption.Listable[string] `json:"client_key,omitempty"` + ClientKeyPath string `json:"client_key_path,omitempty"` + ClientKeyPassword string `json:"client_key_password,omitempty"` + MCACertificate badoption.Listable[string] `json:"mca_certificate,omitempty"` + MCACertificatePath string `json:"mca_certificate_path,omitempty"` + MCAKey badoption.Listable[string] `json:"mca_key,omitempty"` + MCAKeyPath string `json:"mca_key_path,omitempty"` + MCAKeyPassword string `json:"mca_key_password,omitempty"` +} + +type OpenConnectFormEntryOptions struct { + FormID string `json:"form_id,omitempty"` + SubmissionKey string `json:"submission_key,omitempty"` + Name string `json:"name,omitempty"` + Value string `json:"value,omitempty"` + Promote bool `json:"promote,omitempty"` +} + +type OpenConnectDNSServerOptions struct { + Endpoint string `json:"endpoint,omitempty"` + AcceptDefaultResolvers bool `json:"accept_default_resolvers,omitempty"` + AcceptSearchDomain bool `json:"accept_search_domain,omitempty"` +} diff --git a/option/openvpn.go b/option/openvpn.go new file mode 100644 index 0000000000..7a12a4f04a --- /dev/null +++ b/option/openvpn.go @@ -0,0 +1,210 @@ +package option + +import ( + "net/netip" + + "github.com/sagernet/sing/common/auth" + "github.com/sagernet/sing/common/json/badoption" +) + +type OpenVPNEndpointOptions struct { + System bool `json:"system,omitempty"` + Name string `json:"name,omitempty"` + MTU uint32 `json:"mtu,omitempty"` + UDPMapping UDPNATBehavior `json:"udp_mapping,omitempty"` + UDPFiltering UDPNATBehavior `json:"udp_filtering,omitempty"` + UDPNATMax uint32 `json:"udp_nat_max,omitempty"` +} + +type OpenVPNClientEndpointOptions struct { + DialerOptions + ServerOptions + OpenVPNEndpointOptions + Mode string `json:"mode,omitempty" enum:"tls,static_key"` + Network string `json:"network,omitempty" enum:"udp,udp4,udp6,tcp,tcp4,tcp6"` + Servers []OpenVPNRemoteOptions `json:"servers,omitempty"` + RemoteRandom bool `json:"remote_random,omitempty"` + Address badoption.Listable[netip.Prefix] `json:"address,omitempty"` + PeerAddress badoption.Addr `json:"peer_address,omitempty"` + PeerAddressIPv6 badoption.Addr `json:"peer_address_ipv6,omitempty"` + Topology string `json:"topology,omitempty" enum:"net30,p2p,subnet"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + AuthRetry string `json:"auth_retry,omitempty" enum:"none,nointeract,interact"` + StaticChallenge string `json:"static_challenge,omitempty"` + StaticChallengeEcho bool `json:"static_challenge_echo,omitempty"` + StaticKey badoption.Listable[string] `json:"static_key,omitempty"` + StaticKeyPath string `json:"static_key_path,omitempty"` + KeyDirection string `json:"key_direction,omitempty" enum:"server,client"` + TLS *OpenVPNOutboundTLSOptions `json:"tls,omitempty"` + Cipher string `json:"cipher,omitempty"` + DataCiphers badoption.Listable[string] `json:"data_ciphers,omitempty"` + DataCiphersFallback string `json:"data_ciphers_fallback,omitempty"` + Auth string `json:"auth,omitempty"` + MSSFix uint32 `json:"mss_fix,omitempty"` + MSSFixDisabled bool `json:"mss_fix_disabled,omitempty"` + MSSFixMode string `json:"mss_fix_mode,omitempty" enum:"mtu,fixed"` + Fragment uint32 `json:"fragment,omitempty"` + ReplayWindow uint32 `json:"replay_window,omitempty"` + ReplayWindowTime badoption.Duration `json:"replay_window_time,omitempty"` + Compression string `json:"compression,omitempty" enum:"none,no,lz4,lz4-v2,stub,stub-v2,disabled,off"` + CompressionLZO string `json:"compression_lzo,omitempty" enum:"none,no,yes,adaptive,asym,disabled,off"` + AllowCompression string `json:"allow_compression,omitempty" enum:"no,asym,yes"` + RouteNoPull bool `json:"route_no_pull,omitempty"` + PullFilters []OpenVPNPullFilterOptions `json:"pull_filters,omitempty"` + Routes badoption.Listable[netip.Prefix] `json:"routes,omitempty"` + RouteGateway *badoption.Addr `json:"route_gateway,omitempty"` + RouteMetric int `json:"route_metric,omitempty"` + RedirectGateway bool `json:"redirect_gateway,omitempty"` + RedirectGatewayFlags badoption.Listable[string] `json:"redirect_gateway_flags,omitempty"` + RedirectPrivate bool `json:"redirect_private,omitempty"` + BlockIPv6 bool `json:"block_ipv6,omitempty"` + PingInterval badoption.Duration `json:"ping_interval,omitempty"` + PingRestart badoption.Duration `json:"ping_restart,omitempty"` + PingRestartDisabled bool `json:"ping_restart_disabled,omitempty"` + RenegotiateInterval badoption.Duration `json:"renegotiate_interval,omitempty"` + RenegotiateDisabled bool `json:"renegotiate_disabled,omitempty"` + RenegotiateBytes uint64 `json:"renegotiate_bytes,omitempty"` + RenegotiatePackets uint64 `json:"renegotiate_packets,omitempty"` + TLSTimeout badoption.Duration `json:"tls_timeout,omitempty"` + HandshakeWindow badoption.Duration `json:"handshake_window,omitempty"` + ExplicitExitNotify uint32 `json:"explicit_exit_notify,omitempty"` + UDPTimeout UDPTimeoutCompat `json:"udp_timeout,omitempty"` +} + +type OpenVPNServerEndpointOptions struct { + ListenOptions + OpenVPNEndpointOptions + Mode string `json:"mode,omitempty" enum:"tls,static_key"` + Network string `json:"network,omitempty" enum:"tcp,udp"` + Remote string `json:"remote,omitempty"` + RemotePort uint16 `json:"remote_port,omitempty"` + MaxClients int `json:"max_clients,omitempty"` + Address badoption.Listable[netip.Prefix] `json:"address"` + PeerAddress badoption.Addr `json:"peer_address,omitempty"` + PeerAddressIPv6 badoption.Addr `json:"peer_address_ipv6,omitempty"` + Topology string `json:"topology,omitempty" enum:"net30,p2p,subnet"` + DuplicateCN bool `json:"duplicate_cn,omitempty"` + Users []auth.User `json:"users,omitempty"` + StaticKey badoption.Listable[string] `json:"static_key,omitempty"` + StaticKeyPath string `json:"static_key_path,omitempty"` + KeyDirection string `json:"key_direction,omitempty" enum:"server,client"` + TLS *OpenVPNInboundTLSOptions `json:"tls,omitempty"` + Cipher string `json:"cipher,omitempty"` + DataCiphers badoption.Listable[string] `json:"data_ciphers,omitempty"` + DataCiphersFallback string `json:"data_ciphers_fallback,omitempty"` + Auth string `json:"auth,omitempty"` + MSSFix uint32 `json:"mss_fix,omitempty"` + MSSFixDisabled bool `json:"mss_fix_disabled,omitempty"` + MSSFixMode string `json:"mss_fix_mode,omitempty" enum:"mtu,fixed"` + ReplayWindow uint32 `json:"replay_window,omitempty"` + ReplayWindowTime badoption.Duration `json:"replay_window_time,omitempty"` + Push *OpenVPNPushOptions `json:"push,omitempty"` + PingInterval badoption.Duration `json:"ping_interval,omitempty"` + PingRestart badoption.Duration `json:"ping_restart,omitempty"` + RenegotiateInterval badoption.Duration `json:"renegotiate_interval,omitempty"` + RenegotiateDisabled bool `json:"renegotiate_disabled,omitempty"` + RenegotiateBytes uint64 `json:"renegotiate_bytes,omitempty"` + RenegotiatePackets uint64 `json:"renegotiate_packets,omitempty"` + HandshakeWindow badoption.Duration `json:"handshake_window,omitempty"` +} + +type OpenVPNRemoteOptions struct { + ServerOptions + Network string `json:"network,omitempty" enum:"udp,udp4,udp6,tcp,tcp4,tcp6"` +} + +type OpenVPNPullFilterOptions struct { + Action string `json:"action" enum:"accept,ignore,reject"` + Text string `json:"text"` +} + +type OpenVPNOutboundTLSOptions struct { + ServerName string `json:"server_name,omitempty"` + ServerNameType string `json:"server_name_type,omitempty" enum:"subject,name,name-prefix"` + Certificate badoption.Listable[string] `json:"certificate,omitempty"` + CertificatePath string `json:"certificate_path,omitempty"` + ClientCertificate badoption.Listable[string] `json:"client_certificate,omitempty"` + ClientCertificatePath string `json:"client_certificate_path,omitempty"` + ClientKey badoption.Listable[string] `json:"client_key,omitempty"` + ClientKeyPath string `json:"client_key_path,omitempty"` + PeerFingerprint badoption.Listable[string] `json:"peer_fingerprint,omitempty"` + CRLPath string `json:"crl_path,omitempty"` + RemoteCertificateKU badoption.Listable[string] `json:"remote_certificate_ku,omitempty"` + RemoteCertificateEKU string `json:"remote_certificate_eku,omitempty"` + RemoteCertificateTLS string `json:"remote_certificate_tls,omitempty" enum:"server,client,none"` + CertificateProfile string `json:"certificate_profile,omitempty" enum:"legacy,preferred,insecure,suiteb"` + NSCertificateType string `json:"ns_certificate_type,omitempty" enum:"server,client"` + VersionMin string `json:"version_min,omitempty" enum:"1.0,1.1,1.2,1.3"` + VersionMax string `json:"version_max,omitempty" enum:"1.0,1.1,1.2,1.3"` + Cipher string `json:"cipher,omitempty"` + Groups string `json:"groups,omitempty"` + ControlWrap *OpenVPNControlWrapOptions `json:"control_wrap,omitempty"` +} + +type OpenVPNInboundTLSOptions struct { + Certificate badoption.Listable[string] `json:"certificate,omitempty"` + CertificatePath string `json:"certificate_path,omitempty"` + Key badoption.Listable[string] `json:"key,omitempty"` + KeyPath string `json:"key_path,omitempty"` + ClientCertificate badoption.Listable[string] `json:"client_certificate,omitempty"` + ClientCertificatePath string `json:"client_certificate_path,omitempty"` + VerifyClientCertificate string `json:"verify_client_certificate,omitempty" enum:"require,optional,none"` + ClientName string `json:"client_name,omitempty"` + ClientNameType string `json:"client_name_type,omitempty" enum:"subject,name,name-prefix"` + PeerFingerprint badoption.Listable[string] `json:"peer_fingerprint,omitempty"` + CRLPath string `json:"crl_path,omitempty"` + RemoteCertificateKU badoption.Listable[string] `json:"remote_certificate_ku,omitempty"` + RemoteCertificateEKU string `json:"remote_certificate_eku,omitempty"` + RemoteCertificateTLS string `json:"remote_certificate_tls,omitempty" enum:"server,client,none"` + CertificateProfile string `json:"certificate_profile,omitempty" enum:"legacy,preferred,insecure,suiteb"` + NSCertificateType string `json:"ns_certificate_type,omitempty" enum:"server,client"` + VersionMin string `json:"version_min,omitempty" enum:"1.0,1.1,1.2,1.3"` + VersionMax string `json:"version_max,omitempty" enum:"1.0,1.1,1.2,1.3"` + Cipher string `json:"cipher,omitempty"` + Groups string `json:"groups,omitempty"` + ControlWrap *OpenVPNInboundControlWrapOptions `json:"control_wrap,omitempty"` +} + +type OpenVPNControlWrapOptions struct { + Type string `json:"type,omitempty" enum:"tls_auth,tls_crypt,tls_crypt_v2"` + Key badoption.Listable[string] `json:"key,omitempty"` + KeyPath string `json:"key_path,omitempty"` + Direction string `json:"direction,omitempty" enum:"server,client"` +} + +type OpenVPNInboundControlWrapOptions struct { + Type string `json:"type,omitempty" enum:"tls_auth,tls_crypt,tls_crypt_v2"` + Key badoption.Listable[string] `json:"key,omitempty"` + KeyPath string `json:"key_path,omitempty"` + Direction string `json:"direction,omitempty" enum:"server,client"` + ForceCookie bool `json:"force_cookie,omitempty"` +} + +type OpenVPNPushOptions struct { + Routes badoption.Listable[netip.Prefix] `json:"routes,omitempty"` + DNS badoption.Listable[netip.Addr] `json:"dns,omitempty"` + DNSServers []OpenVPNPushDNSServerOptions `json:"dns_servers,omitempty"` + SearchDomains badoption.Listable[string] `json:"search_domains,omitempty"` + DHCPOptions badoption.Listable[string] `json:"dhcp_options,omitempty"` + RedirectGateway bool `json:"redirect_gateway,omitempty"` + RedirectGatewayFlags badoption.Listable[string] `json:"redirect_gateway_flags,omitempty"` + BlockOutsideDNS bool `json:"block_outside_dns,omitempty"` + PingInterval badoption.Duration `json:"ping_interval,omitempty"` + PingRestart badoption.Duration `json:"ping_restart,omitempty"` +} + +type OpenVPNPushDNSServerOptions struct { + Priority int `json:"priority"` + Addresses badoption.Listable[string] `json:"addresses"` + ResolveDomains badoption.Listable[string] `json:"resolve_domains,omitempty"` + DNSSEC string `json:"dnssec,omitempty" enum:"yes,optional,no"` + Transport string `json:"transport,omitempty" enum:"plain,dot,doh"` + SNI string `json:"sni,omitempty"` +} + +type OpenVPNDNSServerOptions struct { + Endpoint string `json:"endpoint,omitempty"` + AcceptDefaultResolvers bool `json:"accept_default_resolvers,omitempty"` + AcceptSearchDomain bool `json:"accept_search_domain,omitempty"` +} diff --git a/option/options.go b/option/options.go index e28ccef856..b70ec5aafa 100644 --- a/option/options.go +++ b/option/options.go @@ -3,7 +3,9 @@ package option import ( "bytes" "context" + "reflect" + "github.com/sagernet/sing-box/schema" E "github.com/sagernet/sing/common/exceptions" F "github.com/sagernet/sing/common/format" "github.com/sagernet/sing/common/json" @@ -12,7 +14,7 @@ import ( type _Options struct { RawMessage json.RawMessage `json:"-"` CommentsSet *json.CommentSet `json:"-"` - Schema string `json:"$schema,omitempty"` + Schema string `json:"$schema,omitempty" examples:"https://sing-box.sagernet.org/schema.json"` Log *LogOptions `json:"log,omitempty"` DNS *DNSOptions `json:"dns,omitempty"` NTP *NTPOptions `json:"ntp,omitempty"` @@ -45,6 +47,17 @@ func (o *Options) UnmarshalJSONContext(ctx context.Context, content []byte) erro return checkOptions(o) } +func (o Options) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + node := schema.StrictObject() + node.SchemaURI = "https://json-schema.org/draft/2020-12/schema" + node.ID = "https://sing-box.sagernet.org/schema.json" + err := builder.FlattenStruct(node, reflect.TypeFor[Options]()) + if err != nil { + return nil, err + } + return node, nil +} + func (o Options) Comments() *json.CommentSet { return o.CommentsSet } @@ -55,7 +68,7 @@ func (o *Options) SetComments(comments *json.CommentSet) { type LogOptions struct { Disabled bool `json:"disabled,omitempty"` - Level string `json:"level,omitempty"` + Level string `json:"level,omitempty" enum:"trace,debug,info,warn,warning,error,fatal,panic"` Output string `json:"output,omitempty"` Timestamp bool `json:"timestamp,omitempty"` DisableColor bool `json:"-"` diff --git a/option/origin_ca.go b/option/origin_ca.go index 5a9f956af7..1a9498220e 100644 --- a/option/origin_ca.go +++ b/option/origin_ca.go @@ -3,6 +3,7 @@ package option import ( "strings" + "github.com/sagernet/sing-box/schema" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/json/badoption" @@ -13,8 +14,8 @@ type CloudflareOriginCACertificateProviderOptions struct { DataDirectory string `json:"data_directory,omitempty"` APIToken string `json:"api_token,omitempty"` OriginCAKey string `json:"origin_ca_key,omitempty"` - RequestType CloudflareOriginCARequestType `json:"request_type,omitempty"` - RequestedValidity CloudflareOriginCARequestValidity `json:"requested_validity,omitempty"` + RequestType CloudflareOriginCARequestType `json:"request_type,omitempty" enum:"origin-rsa,origin-ecc"` + RequestedValidity CloudflareOriginCARequestValidity `json:"requested_validity,omitempty" enum:"0,7,30,90,365,730,1095,5475"` HTTPClient *HTTPClientOptions `json:"http_client,omitempty"` } @@ -41,6 +42,10 @@ func (t *CloudflareOriginCARequestType) UnmarshalJSON(data []byte) error { return nil } +func (t CloudflareOriginCARequestType) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return schema.StringEnum("", "origin-rsa", "origin-ecc"), nil +} + type CloudflareOriginCARequestValidity uint16 const ( @@ -74,3 +79,7 @@ func (v *CloudflareOriginCARequestValidity) UnmarshalJSON(data []byte) error { } return nil } + +func (v CloudflareOriginCARequestValidity) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return &schema.Node{Type: "integer", Enum: []any{0, 7, 30, 90, 365, 730, 1095, 5475}}, nil +} diff --git a/option/outbound.go b/option/outbound.go index aa32db8148..49ea9f6256 100644 --- a/option/outbound.go +++ b/option/outbound.go @@ -2,8 +2,10 @@ package option import ( "context" + "reflect" C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/schema" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/json/badjson" @@ -13,6 +15,7 @@ import ( ) type OutboundOptionsRegistry interface { + OptionTypes() []string CreateOptions(outboundType string) (any, bool) } @@ -59,41 +62,57 @@ func (h *Outbound) UnmarshalJSONContext(ctx context.Context, content []byte) err return nil } +func (h Outbound) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return builder.Define("Outbound", func() (*schema.Node, error) { + registry := service.FromContext[OutboundOptionsRegistry](builder.Context()) + if registry == nil { + return nil, E.New("missing outbound options registry in context") + } + return registryUnion(builder, registry, []string{C.TypeShadowsocksR, C.TypeWireGuard}, true) + }) +} + type DialerOptionsWrapper interface { TakeDialerOptions() DialerOptions ReplaceDialerOptions(options DialerOptions) } type DialerOptions struct { - Detour string `json:"detour,omitempty"` - BindInterface string `json:"bind_interface,omitempty"` - Inet4BindAddress *badoption.Addr `json:"inet4_bind_address,omitempty"` - Inet6BindAddress *badoption.Addr `json:"inet6_bind_address,omitempty"` - BindAddressNoPort bool `json:"bind_address_no_port,omitempty"` - ProtectPath string `json:"protect_path,omitempty"` - RoutingMark FwMark `json:"routing_mark,omitempty"` - ReuseAddr bool `json:"reuse_addr,omitempty"` - NetNs string `json:"netns,omitempty"` - ConnectTimeout badoption.Duration `json:"connect_timeout,omitempty"` - TCPFastOpen bool `json:"tcp_fast_open,omitempty"` - TCPMultiPath bool `json:"tcp_multi_path,omitempty"` - DisableTCPKeepAlive bool `json:"disable_tcp_keep_alive,omitempty"` - TCPKeepAlive badoption.Duration `json:"tcp_keep_alive,omitempty"` - TCPKeepAliveInterval badoption.Duration `json:"tcp_keep_alive_interval,omitempty"` - UDPFragment *bool `json:"udp_fragment,omitempty"` - UDPFragmentDefault bool `json:"-"` - DomainResolver *DomainResolveOptions `json:"domain_resolver,omitempty"` - NetworkStrategy *NetworkStrategy `json:"network_strategy,omitempty"` - NetworkType badoption.Listable[InterfaceType] `json:"network_type,omitempty"` - FallbackNetworkType badoption.Listable[InterfaceType] `json:"fallback_network_type,omitempty"` - FallbackDelay badoption.Duration `json:"fallback_delay,omitempty"` + Detour string `json:"detour,omitempty" reference:"outbound"` + AbstractDialerOptions +} + +type AbstractDialerOptions struct { + BindInterface string `json:"bind_interface,omitempty"` + Inet4BindAddress *badoption.Addr `json:"inet4_bind_address,omitempty"` + Inet6BindAddress *badoption.Addr `json:"inet6_bind_address,omitempty"` + BindAddressNoPort bool `json:"bind_address_no_port,omitempty"` + ProtectPath string `json:"protect_path,omitempty"` + RoutingMark FwMark `json:"routing_mark,omitempty"` + ReuseAddr bool `json:"reuse_addr,omitempty"` + NetNs string `json:"netns,omitempty" reference:"network_namespace"` + ConnectTimeout badoption.Duration `json:"connect_timeout,omitempty"` + TCPFastOpen bool `json:"tcp_fast_open,omitempty"` + TCPMultiPath bool `json:"tcp_multi_path,omitempty"` + DisableTCPKeepAlive bool `json:"disable_tcp_keep_alive,omitempty"` + TCPKeepAlive badoption.Duration `json:"tcp_keep_alive,omitempty"` + TCPKeepAliveInterval badoption.Duration `json:"tcp_keep_alive_interval,omitempty"` + TCPKeepAliveSystemDefaults bool `json:"-"` + UDPBindPort uint16 `json:"-"` + UDPFragment *bool `json:"udp_fragment,omitempty"` + UDPFragmentDefault bool `json:"-"` + DomainResolver *DomainResolveOptions `json:"domain_resolver,omitempty"` + NetworkStrategy *NetworkStrategy `json:"network_strategy,omitempty"` + NetworkType badoption.Listable[InterfaceType] `json:"network_type,omitempty"` + FallbackNetworkType badoption.Listable[InterfaceType] `json:"fallback_network_type,omitempty"` + FallbackDelay badoption.Duration `json:"fallback_delay,omitempty"` // Deprecated: migrated to domain resolver - DomainStrategy DomainStrategy `json:"domain_strategy,omitempty"` + DomainStrategy DomainStrategy `json:"domain_strategy,omitempty" schema:"omit"` } type _DomainResolveOptions struct { - Server string `json:"server"` + Server string `json:"server" reference:"dns_server"` Timeout badoption.Duration `json:"timeout,omitempty"` Strategy DomainStrategy `json:"strategy,omitempty"` DisableCache bool `json:"disable_cache,omitempty"` @@ -136,6 +155,18 @@ func (o *DomainResolveOptions) UnmarshalJSON(bytes []byte) error { return nil } +func (o DomainResolveOptions) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return builder.Define("DomainResolver", func() (*schema.Node, error) { + objectForm := schema.StrictObject() + err := builder.FlattenStruct(objectForm, reflect.TypeFor[DomainResolveOptions]()) + if err != nil { + return nil, err + } + objectForm.Required = []string{"server"} + return schema.AnyOf(schema.TagReferenceNode("dns_server"), objectForm), nil + }) +} + func (o *DialerOptions) TakeDialerOptions() DialerOptions { return *o } diff --git a/option/platform.go b/option/platform.go index e4ecd6fa1e..7044245ad7 100644 --- a/option/platform.go +++ b/option/platform.go @@ -1,6 +1,7 @@ package option import ( + "github.com/sagernet/sing-box/schema" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/json/badoption" @@ -63,6 +64,10 @@ func (r *OnDemandRuleAction) UnmarshalJSON(bytes []byte) error { return nil } +func (r OnDemandRuleAction) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return schema.StringEnum("connect", "disconnect", "evaluate_connection", "ignore"), nil +} + type OnDemandRuleInterfaceType int func (r *OnDemandRuleInterfaceType) MarshalJSON() ([]byte, error) { @@ -103,3 +108,7 @@ func (r *OnDemandRuleInterfaceType) UnmarshalJSON(bytes []byte) error { *r = OnDemandRuleInterfaceType(interfaceTypeValue) return nil } + +func (r OnDemandRuleInterfaceType) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return schema.StringEnum("any", "wifi", "cellular"), nil +} diff --git a/option/redir.go b/option/redir.go index 743a6e1074..1fca7ab9f3 100644 --- a/option/redir.go +++ b/option/redir.go @@ -6,5 +6,8 @@ type RedirectInboundOptions struct { type TProxyInboundOptions struct { ListenOptions - Network NetworkList `json:"network,omitempty"` + Network NetworkList `json:"network,omitempty"` + UDPMapping UDPNATBehavior `json:"udp_mapping,omitempty"` + UDPFiltering UDPNATBehavior `json:"udp_filtering,omitempty"` + UDPNATMax uint32 `json:"udp_nat_max,omitempty"` } diff --git a/option/resolved.go b/option/resolved.go index cb9f579d1f..f17809364c 100644 --- a/option/resolved.go +++ b/option/resolved.go @@ -3,7 +3,9 @@ package option import ( "context" "net/netip" + "reflect" + "github.com/sagernet/sing-box/schema" "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/json/badoption" @@ -39,6 +41,15 @@ func (r *ResolvedServiceOptions) UnmarshalJSONContext(ctx context.Context, bytes return nil } +func (r ResolvedServiceOptions) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + node := schema.StrictObject() + err := builder.FlattenStruct(node, reflect.TypeFor[ResolvedServiceOptions]()) + if err != nil { + return nil, err + } + return node, nil +} + type ResolvedDNSServerOptions struct { Service string `json:"service"` AcceptDefaultResolvers bool `json:"accept_default_resolvers,omitempty"` diff --git a/option/route.go b/option/route.go index 893be8ece2..39f3cfb9d9 100644 --- a/option/route.go +++ b/option/route.go @@ -3,11 +3,11 @@ package option import "github.com/sagernet/sing/common/json/badoption" type RouteOptions struct { - GeoIP *GeoIPOptions `json:"geoip,omitempty"` - Geosite *GeositeOptions `json:"geosite,omitempty"` + GeoIP *GeoIPOptions `json:"geoip,omitempty" schema:"omit"` + Geosite *GeositeOptions `json:"geosite,omitempty" schema:"omit"` Rules []Rule `json:"rules,omitempty"` RuleSet []RuleSet `json:"rule_set,omitempty"` - Final string `json:"final,omitempty"` + Final string `json:"final,omitempty" reference:"outbound"` FindProcess bool `json:"find_process,omitempty"` FindNeighbor bool `json:"find_neighbor,omitempty"` DHCPLeaseFiles badoption.Listable[string] `json:"dhcp_lease_files,omitempty"` @@ -26,11 +26,11 @@ type RouteOptions struct { type GeoIPOptions struct { Path string `json:"path,omitempty"` DownloadURL string `json:"download_url,omitempty"` - DownloadDetour string `json:"download_detour,omitempty"` + DownloadDetour string `json:"download_detour,omitempty" reference:"outbound"` } type GeositeOptions struct { Path string `json:"path,omitempty"` DownloadURL string `json:"download_url,omitempty"` - DownloadDetour string `json:"download_detour,omitempty"` + DownloadDetour string `json:"download_detour,omitempty" reference:"outbound"` } diff --git a/option/rule.go b/option/rule.go index 85057c9b5b..ab58e090ec 100644 --- a/option/rule.go +++ b/option/rule.go @@ -5,6 +5,7 @@ import ( "reflect" C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/schema" "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json" @@ -13,7 +14,7 @@ import ( ) type _Rule struct { - Type string `json:"type,omitempty"` + Type string `json:"type,omitempty" enum:"default,logical"` DefaultOptions DefaultRule `json:"-"` LogicalOptions LogicalRule `json:"-"` } @@ -65,20 +66,91 @@ func (r Rule) IsValid() bool { } } +func (r Rule) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return builder.Define("Rule", func() (*schema.Node, error) { + actionRef, err := builder.Define("RuleAction", func() (*schema.Node, error) { + return routeActionUnion(builder) + }) + if err != nil { + return nil, err + } + nestedRef, err := builder.Define("NestedRule", func() (*schema.Node, error) { + return nestedRuleUnion(builder, reflect.TypeFor[RawDefaultRule](), "NestedRule") + }) + if err != nil { + return nil, err + } + return ruleUnion(builder, reflect.TypeFor[RawDefaultRule](), nestedRef, actionRef) + }) +} + +// ruleUnion builds the top-level rule schema: match fields composed with rule +// actions via unevaluatedProperties, mirroring the badjson.UnmarshallExcluded +// composition in DefaultRule / LogicalRule. +func ruleUnion(builder schema.Builder, matchType reflect.Type, nestedRef *schema.Node, actionRef *schema.Node) (*schema.Node, error) { + defaultMatch := schema.LooseObject() + defaultMatch.Properties.Put("type", schema.StringEnum(C.RuleTypeDefault, "")) + err := builder.FlattenStruct(defaultMatch, matchType) + if err != nil { + return nil, err + } + defaultVariant := &schema.Node{ + Type: "object", + AllOf: []*schema.Node{defaultMatch, actionRef}, + UnevaluatedProperties: false, + } + + logicalMatch := schema.LooseObject() + logicalMatch.Properties.Put("type", schema.StringConst(C.RuleTypeLogical)) + logicalProperties(logicalMatch, nestedRef) + logicalMatch.Required = []string{"type", "mode", "rules"} + logicalVariant := &schema.Node{ + Type: "object", + AllOf: []*schema.Node{logicalMatch, actionRef}, + UnevaluatedProperties: false, + } + + return schema.OneOf(defaultVariant, logicalVariant), nil +} + +// nestedRuleUnion builds a match-only rule schema: nested rules reject rule +// actions, and headless rules never carry them. +func nestedRuleUnion(builder schema.Builder, matchType reflect.Type, selfName string) (*schema.Node, error) { + defaultVariant := schema.StrictObject() + defaultVariant.Properties.Put("type", schema.StringEnum(C.RuleTypeDefault, "")) + err := builder.FlattenStruct(defaultVariant, matchType) + if err != nil { + return nil, err + } + + logicalVariant := schema.StrictObject() + logicalVariant.Properties.Put("type", schema.StringConst(C.RuleTypeLogical)) + logicalProperties(logicalVariant, schema.RefNode(selfName)) + logicalVariant.Required = []string{"type", "mode", "rules"} + + return schema.OneOf(defaultVariant, logicalVariant), nil +} + +func logicalProperties(node *schema.Node, nestedRef *schema.Node) { + node.Properties.Put("mode", schema.StringEnum(C.LogicalTypeAnd, C.LogicalTypeOr)) + node.Properties.Put("rules", &schema.Node{Type: "array", Items: nestedRef}) + node.Properties.Put("invert", schema.BooleanNode()) +} + type RawDefaultRule struct { - Inbound badoption.Listable[string] `json:"inbound,omitempty"` - IPVersion int `json:"ip_version,omitempty"` - Network badoption.Listable[string] `json:"network,omitempty"` + Inbound badoption.Listable[string] `json:"inbound,omitempty" reference:"inbound"` + IPVersion int `json:"ip_version,omitempty" enum:"4,6"` + Network badoption.Listable[string] `json:"network,omitempty" enum:"tcp,udp,icmp"` AuthUser badoption.Listable[string] `json:"auth_user,omitempty"` - Protocol badoption.Listable[string] `json:"protocol,omitempty"` + Protocol badoption.Listable[string] `json:"protocol,omitempty" enum:"tls,http,quic,dns,stun,bittorrent,dtls,ssh,rdp,ntp"` Client badoption.Listable[string] `json:"client,omitempty"` Domain badoption.Listable[string] `json:"domain,omitempty"` DomainSuffix badoption.Listable[string] `json:"domain_suffix,omitempty"` DomainKeyword badoption.Listable[string] `json:"domain_keyword,omitempty"` DomainRegex badoption.Listable[string] `json:"domain_regex,omitempty"` - Geosite badoption.Listable[string] `json:"geosite,omitempty"` - SourceGeoIP badoption.Listable[string] `json:"source_geoip,omitempty"` - GeoIP badoption.Listable[string] `json:"geoip,omitempty"` + Geosite badoption.Listable[string] `json:"geosite,omitempty" schema:"omit"` + SourceGeoIP badoption.Listable[string] `json:"source_geoip,omitempty" schema:"omit"` + GeoIP badoption.Listable[string] `json:"geoip,omitempty" schema:"omit"` SourceIPCIDR badoption.Listable[string] `json:"source_ip_cidr,omitempty"` SourceIPIsPrivate bool `json:"source_ip_is_private,omitempty"` IPCIDR badoption.Listable[string] `json:"ip_cidr,omitempty"` @@ -106,12 +178,12 @@ type RawDefaultRule struct { SourceMACAddress badoption.Listable[string] `json:"source_mac_address,omitempty"` SourceHostname badoption.Listable[string] `json:"source_hostname,omitempty"` PreferredBy badoption.Listable[string] `json:"preferred_by,omitempty"` - RuleSet badoption.Listable[string] `json:"rule_set,omitempty"` + RuleSet badoption.Listable[string] `json:"rule_set,omitempty" reference:"rule_set"` RuleSetIPCIDRMatchSource bool `json:"rule_set_ip_cidr_match_source,omitempty"` Invert bool `json:"invert,omitempty"` // Deprecated: renamed to rule_set_ip_cidr_match_source - Deprecated_RulesetIPCIDRMatchSource bool `json:"rule_set_ipcidr_match_source,omitempty"` + Deprecated_RulesetIPCIDRMatchSource bool `json:"rule_set_ipcidr_match_source,omitempty" schema:"omit"` TunnelSource badoption.Listable[string] `json:"tunnel_source,omitempty"` //E TunnelDestination badoption.Listable[string] `json:"tunnel_destination,omitempty"` //E @@ -141,7 +213,7 @@ func (r DefaultRule) IsValid() bool { } type RawLogicalRule struct { - Mode string `json:"mode"` + Mode string `json:"mode" enum:"and,or"` Rules []Rule `json:"rules,omitempty"` Invert bool `json:"invert,omitempty"` } diff --git a/option/rule_action.go b/option/rule_action.go index 9aea780208..e014abd17c 100644 --- a/option/rule_action.go +++ b/option/rule_action.go @@ -4,9 +4,11 @@ import ( "context" "fmt" "net/netip" + "reflect" "time" C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/schema" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/json/badjson" @@ -14,7 +16,7 @@ import ( ) type _RuleAction struct { - Action string `json:"action,omitempty"` + Action string `json:"action,omitempty" enum:"route,route-options,direct,bypass,reject,hijack-dns,sniff,resolve"` RouteOptions RouteActionOptions `json:"-"` RouteOptionsOptions RouteOptionsActionOptions `json:"-"` DirectOptions DirectActionOptions `json:"-"` @@ -97,8 +99,10 @@ func (r *RuleAction) UnmarshalJSON(data []byte) error { } type _DNSRuleAction struct { - Action string `json:"action,omitempty"` + Action string `json:"action,omitempty" enum:"route,evaluate,respond,route-options,reject,predefined"` + Race bool `json:"race,omitempty"` RouteOptions DNSRouteActionOptions `json:"-"` + EvaluateOptions DNSEvaluateActionOptions `json:"-"` RouteOptionsOptions DNSRouteOptionsActionOptions `json:"-"` RejectOptions RejectActionOptions `json:"-"` PredefinedOptions DNSRouteActionPredefined `json:"-"` @@ -116,7 +120,7 @@ func (r DNSRuleAction) MarshalJSON() ([]byte, error) { r.Action = "" v = r.RouteOptions case C.RuleActionTypeEvaluate: - v = r.RouteOptions + v = r.EvaluateOptions case C.RuleActionTypeRespond: v = nil case C.RuleActionTypeRouteOptions: @@ -145,7 +149,7 @@ func (r *DNSRuleAction) UnmarshalJSONContext(ctx context.Context, data []byte) e r.Action = C.RuleActionTypeRoute v = &r.RouteOptions case C.RuleActionTypeEvaluate: - v = &r.RouteOptions + v = &r.EvaluateOptions case C.RuleActionTypeRespond: v = nil case C.RuleActionTypeRouteOptions: @@ -160,11 +164,15 @@ func (r *DNSRuleAction) UnmarshalJSONContext(ctx context.Context, data []byte) e if v == nil { return json.UnmarshalDisallowUnknownFields(data, &_DNSRuleAction{}) } - return badjson.UnmarshallExcludedContext(ctx, data, (*_DNSRuleAction)(r), v) + err = badjson.UnmarshallExcludedContext(ctx, data, (*_DNSRuleAction)(r), v) + if err != nil { + return err + } + return nil } type RouteActionOptions struct { - Outbound string `json:"outbound,omitempty"` + Outbound string `json:"outbound,omitempty" reference:"outbound"` RawRouteOptionsActionOptions } @@ -184,7 +192,7 @@ type RawRouteOptionsActionOptions struct { TLSFragmentFallbackDelay badoption.Duration `json:"tls_fragment_fallback_delay,omitempty"` TLSRecordFragment bool `json:"tls_record_fragment,omitempty"` TLSSpoof string `json:"tls_spoof,omitempty"` - TLSSpoofMethod string `json:"tls_spoof_method,omitempty"` + TLSSpoofMethod string `json:"tls_spoof_method,omitempty" enum:"wrong-sequence,wrong-checksum,wrong-ack,wrong-md5,wrong-timestamp"` } type RouteOptionsActionOptions RawRouteOptionsActionOptions @@ -204,30 +212,33 @@ func (r *RouteOptionsActionOptions) UnmarshalJSON(data []byte) error { } type DNSRouteActionOptions struct { - Server string `json:"server,omitempty"` - Timeout badoption.Duration `json:"timeout,omitempty"` - Strategy DomainStrategy `json:"strategy,omitempty"` - DisableCache bool `json:"disable_cache,omitempty"` - DisableOptimisticCache bool `json:"disable_optimistic_cache,omitempty"` - RewriteTTL *uint32 `json:"rewrite_ttl,omitempty"` - ClientSubnet *badoption.Prefixable `json:"client_subnet,omitempty"` + Server string `json:"server,omitempty" reference:"dns_server"` + Speculative bool `json:"speculative,omitempty"` + AbstractDNSRouteActionOptions +} - BypassIfFailed bool `json:"bypass_if_failed,omitempty"` //H +type DNSEvaluateActionOptions struct { + Server string `json:"server,omitempty" reference:"dns_server"` + Tag string `json:"tag,omitempty"` + Speculative bool `json:"speculative,omitempty"` + AbstractDNSRouteActionOptions } -type _DNSRouteOptionsActionOptions struct { - Strategy DomainStrategy `json:"strategy,omitempty"` +type AbstractDNSRouteActionOptions struct { Timeout badoption.Duration `json:"timeout,omitempty"` + Strategy DomainStrategy `json:"strategy,omitempty" schema:"omit"` DisableCache bool `json:"disable_cache,omitempty"` DisableOptimisticCache bool `json:"disable_optimistic_cache,omitempty"` RewriteTTL *uint32 `json:"rewrite_ttl,omitempty"` ClientSubnet *badoption.Prefixable `json:"client_subnet,omitempty"` + + BypassIfFailed bool `json:"bypass_if_failed,omitempty"` //H } -type DNSRouteOptionsActionOptions _DNSRouteOptionsActionOptions +type DNSRouteOptionsActionOptions AbstractDNSRouteActionOptions func (r *DNSRouteOptionsActionOptions) UnmarshalJSON(data []byte) error { - err := json.Unmarshal(data, (*_DNSRouteOptionsActionOptions)(r)) + err := json.Unmarshal(data, (*AbstractDNSRouteActionOptions)(r)) if err != nil { return err } @@ -237,9 +248,9 @@ func (r *DNSRouteOptionsActionOptions) UnmarshalJSON(data []byte) error { return nil } -type _DirectActionOptions DialerOptions - -type DirectActionOptions _DirectActionOptions +type DirectActionOptions struct { + AbstractDialerOptions +} func (d DirectActionOptions) Descriptions() []string { var descriptions []string @@ -279,19 +290,8 @@ func (d DirectActionOptions) Descriptions() []string { return descriptions } -func (d *DirectActionOptions) UnmarshalJSON(data []byte) error { - err := json.Unmarshal(data, (*_DirectActionOptions)(d)) - if err != nil { - return err - } - if d.Detour != "" { - return E.New("detour is not available in the current context") - } - return nil -} - type _RejectActionOptions struct { - Method string `json:"method,omitempty"` + Method string `json:"method,omitempty" enum:"default,drop,reply"` NoDrop bool `json:"no_drop,omitempty"` } @@ -325,12 +325,12 @@ func (r *RejectActionOptions) UnmarshalJSON(bytes []byte) error { } type RouteActionSniff struct { - Sniffer badoption.Listable[string] `json:"sniffer,omitempty"` + Sniffer badoption.Listable[string] `json:"sniffer,omitempty" enum:"tls,http,quic,dns,stun,bittorrent,dtls,ssh,rdp,ntp"` Timeout badoption.Duration `json:"timeout,omitempty"` } type RouteActionResolve struct { - Server string `json:"server,omitempty"` + Server string `json:"server,omitempty" reference:"dns_server"` Timeout badoption.Duration `json:"timeout,omitempty"` Strategy DomainStrategy `json:"strategy,omitempty"` DisableCache bool `json:"disable_cache,omitempty"` @@ -345,3 +345,81 @@ type DNSRouteActionPredefined struct { Ns badoption.Listable[DNSRecordOptions] `json:"ns,omitempty"` Extra badoption.Listable[DNSRecordOptions] `json:"extra,omitempty"` } + +type actionVariant struct { + action string + actionOptional bool + structType reflect.Type + build func(variant *schema.Node) error +} + +func actionUnion(builder schema.Builder, variants []actionVariant) (*schema.Node, error) { + variantNodes := make([]*schema.Node, 0, len(variants)) + for _, variant := range variants { + variantNode := schema.LooseObject() + variantNode.Properties.Put("action", schema.StringConst(variant.action)) + if !variant.actionOptional { + variantNode.Required = []string{"action"} + } + if variant.build != nil { + err := variant.build(variantNode) + if err != nil { + return nil, err + } + } + if variant.structType != nil { + err := builder.FlattenStruct(variantNode, variant.structType) + if err != nil { + return nil, err + } + } + variantNodes = append(variantNodes, variantNode) + } + return schema.OneOf(variantNodes...), nil +} + +func rejectProperties(variant *schema.Node) error { + variant.Properties.Put("method", schema.StringEnum( + "", + C.RuleActionRejectMethodDefault, + C.RuleActionRejectMethodDrop, + C.RuleActionRejectMethodReply, + )) + variant.Properties.Put("no_drop", schema.BooleanNode()) + return nil +} + +func routeActionUnion(builder schema.Builder) (*schema.Node, error) { + return actionUnion(builder, []actionVariant{ + {action: C.RuleActionTypeRoute, actionOptional: true, structType: reflect.TypeFor[RouteActionOptions]()}, + {action: C.RuleActionTypeRouteOptions, structType: reflect.TypeFor[RawRouteOptionsActionOptions]()}, + {action: C.RuleActionTypeDirect, structType: reflect.TypeFor[DirectActionOptions]()}, + {action: C.RuleActionTypeBypass, structType: reflect.TypeFor[RouteActionOptions]()}, + {action: C.RuleActionTypeReject, build: rejectProperties}, + {action: C.RuleActionTypeHijackDNS}, + {action: C.RuleActionTypeSniff, structType: reflect.TypeFor[RouteActionSniff]()}, + {action: C.RuleActionTypeResolve, structType: reflect.TypeFor[RouteActionResolve]()}, + }) +} + +func dnsActionUnion(builder schema.Builder) (*schema.Node, error) { + raceProperty := func(variant *schema.Node) error { + variant.Properties.Put("race", schema.BooleanNode()) + return nil + } + rejectWithRace := func(variant *schema.Node) error { + err := raceProperty(variant) + if err != nil { + return err + } + return rejectProperties(variant) + } + return actionUnion(builder, []actionVariant{ + {action: C.RuleActionTypeRoute, actionOptional: true, structType: reflect.TypeFor[DNSRouteActionOptions](), build: raceProperty}, + {action: C.RuleActionTypeEvaluate, structType: reflect.TypeFor[DNSEvaluateActionOptions](), build: raceProperty}, + {action: C.RuleActionTypeRespond, build: raceProperty}, + {action: C.RuleActionTypeRouteOptions, structType: reflect.TypeFor[DNSRouteOptionsActionOptions](), build: raceProperty}, + {action: C.RuleActionTypeReject, build: rejectWithRace}, + {action: C.RuleActionTypePredefined, structType: reflect.TypeFor[DNSRouteActionPredefined](), build: raceProperty}, + }) +} diff --git a/option/rule_dns.go b/option/rule_dns.go index 3f538609c9..970da58a4a 100644 --- a/option/rule_dns.go +++ b/option/rule_dns.go @@ -5,6 +5,7 @@ import ( "reflect" C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/schema" "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json" @@ -13,7 +14,7 @@ import ( ) type _DNSRule struct { - Type string `json:"type,omitempty"` + Type string `json:"type,omitempty" enum:"default,logical"` DefaultOptions DefaultDNSRule `json:"-"` LogicalOptions LogicalDNSRule `json:"-"` } @@ -67,13 +68,79 @@ func (r DNSRule) IsValid() bool { } } +func (r DNSRule) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return builder.Define("DNSRule", func() (*schema.Node, error) { + actionRef, err := builder.Define("DNSRuleAction", func() (*schema.Node, error) { + return dnsActionUnion(builder) + }) + if err != nil { + return nil, err + } + nestedRef, err := builder.Define("NestedDNSRule", func() (*schema.Node, error) { + return nestedRuleUnion(builder, reflect.TypeFor[RawDefaultDNSRule](), "NestedDNSRule") + }) + if err != nil { + return nil, err + } + return ruleUnion(builder, reflect.TypeFor[RawDefaultDNSRule](), nestedRef, actionRef) + }) +} + +type DNSRuleMatchResponse struct { + Enabled bool + Tag string +} + +func (m *DNSRuleMatchResponse) UnmarshalJSON(content []byte) error { + var boolValue bool + err := json.Unmarshal(content, &boolValue) + if err == nil { + m.Enabled = boolValue + m.Tag = "" + return nil + } + var stringValue string + err = json.Unmarshal(content, &stringValue) + if err != nil { + return E.New("invalid match_response value") + } + if stringValue == "" { + return E.New("empty match_response tag") + } + m.Enabled = true + m.Tag = stringValue + return nil +} + +func (m DNSRuleMatchResponse) MarshalJSON() ([]byte, error) { + if m.Tag != "" { + return json.Marshal(m.Tag) + } + return json.Marshal(m.Enabled) +} + +func (m *DNSRuleMatchResponse) IsEnabled() bool { + return m != nil && m.Enabled +} + +func (m *DNSRuleMatchResponse) ResponseTag() string { + if m == nil { + return "" + } + return m.Tag +} + +func (m DNSRuleMatchResponse) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return schema.AnyOf(schema.BooleanNode(), schema.StringNode()), nil +} + type RawDefaultDNSRule struct { - Inbound badoption.Listable[string] `json:"inbound,omitempty"` - IPVersion int `json:"ip_version,omitempty"` + Inbound badoption.Listable[string] `json:"inbound,omitempty" reference:"inbound"` + IPVersion int `json:"ip_version,omitempty" enum:"4,6"` QueryType badoption.Listable[DNSQueryType] `json:"query_type,omitempty"` - Network badoption.Listable[string] `json:"network,omitempty"` + Network badoption.Listable[string] `json:"network,omitempty" enum:"tcp,udp"` AuthUser badoption.Listable[string] `json:"auth_user,omitempty"` - Protocol badoption.Listable[string] `json:"protocol,omitempty"` + Protocol badoption.Listable[string] `json:"protocol,omitempty" enum:"tls,http,quic,dns,stun,bittorrent,dtls,ssh,rdp,ntp"` Domain badoption.Listable[string] `json:"domain,omitempty"` DomainSuffix badoption.Listable[string] `json:"domain_suffix,omitempty"` DomainKeyword badoption.Listable[string] `json:"domain_keyword,omitempty"` @@ -91,7 +158,7 @@ type RawDefaultDNSRule struct { PackageNameRegex badoption.Listable[string] `json:"package_name_regex,omitempty"` User badoption.Listable[string] `json:"user,omitempty"` UserID badoption.Listable[int32] `json:"user_id,omitempty"` - Outbound badoption.Listable[string] `json:"outbound,omitempty"` + Outbound badoption.Listable[string] `json:"outbound,omitempty" reference:"outbound" schema:"omit"` ClashMode string `json:"clash_mode,omitempty"` NetworkType badoption.Listable[InterfaceType] `json:"network_type,omitempty"` NetworkIsExpensive bool `json:"network_is_expensive,omitempty"` @@ -104,9 +171,9 @@ type RawDefaultDNSRule struct { SourceMACAddress badoption.Listable[string] `json:"source_mac_address,omitempty"` SourceHostname badoption.Listable[string] `json:"source_hostname,omitempty"` PreferredBy badoption.Listable[string] `json:"preferred_by,omitempty"` - RuleSet badoption.Listable[string] `json:"rule_set,omitempty"` + RuleSet badoption.Listable[string] `json:"rule_set,omitempty" reference:"rule_set"` RuleSetIPCIDRMatchSource bool `json:"rule_set_ip_cidr_match_source,omitempty"` - MatchResponse bool `json:"match_response,omitempty"` + MatchResponse *DNSRuleMatchResponse `json:"match_response,omitempty"` IPCIDR badoption.Listable[string] `json:"ip_cidr,omitempty"` IPIsPrivate bool `json:"ip_is_private,omitempty"` IPAcceptAny bool `json:"ip_accept_any,omitempty"` @@ -117,13 +184,13 @@ type RawDefaultDNSRule struct { Invert bool `json:"invert,omitempty"` // Deprecated: removed in sing-box 1.12.0 - Geosite badoption.Listable[string] `json:"geosite,omitempty"` - SourceGeoIP badoption.Listable[string] `json:"source_geoip,omitempty"` - GeoIP badoption.Listable[string] `json:"geoip,omitempty"` + Geosite badoption.Listable[string] `json:"geosite,omitempty" schema:"omit"` + SourceGeoIP badoption.Listable[string] `json:"source_geoip,omitempty" schema:"omit"` + GeoIP badoption.Listable[string] `json:"geoip,omitempty" schema:"omit"` // Deprecated: removed in sing-box 1.11.0 - RuleSetIPCIDRAcceptEmpty bool `json:"rule_set_ip_cidr_accept_empty,omitempty"` + RuleSetIPCIDRAcceptEmpty bool `json:"rule_set_ip_cidr_accept_empty,omitempty" schema:"omit"` // Deprecated: renamed to rule_set_ip_cidr_match_source - Deprecated_RulesetIPCIDRMatchSource bool `json:"rule_set_ipcidr_match_source,omitempty"` + Deprecated_RulesetIPCIDRMatchSource bool `json:"rule_set_ipcidr_match_source,omitempty" schema:"omit"` TunnelSource badoption.Listable[string] `json:"tunnel_source,omitempty"` //E TunnelDestination badoption.Listable[string] `json:"tunnel_destination,omitempty"` //E @@ -169,7 +236,7 @@ func (r DefaultDNSRule) IsValid() bool { } type RawLogicalDNSRule struct { - Mode string `json:"mode"` + Mode string `json:"mode" enum:"and,or"` Rules []DNSRule `json:"rules,omitempty"` Invert bool `json:"invert,omitempty"` } diff --git a/option/rule_nested.go b/option/rule_nested.go index a48c17ea39..173e5e3e17 100644 --- a/option/rule_nested.go +++ b/option/rule_nested.go @@ -20,7 +20,7 @@ const ( var ( routeRuleActionKeys = jsonFieldNames(reflect.TypeFor[_RuleAction](), reflect.TypeFor[RouteActionOptions]()) - dnsRuleActionKeys = jsonFieldNames(reflect.TypeFor[_DNSRuleAction](), reflect.TypeFor[DNSRouteActionOptions]()) + dnsRuleActionKeys = jsonFieldNames(reflect.TypeFor[_DNSRuleAction](), reflect.TypeFor[DNSRouteActionOptions](), reflect.TypeFor[DNSEvaluateActionOptions]()) ) func nestedRuleChildContext(ctx context.Context) context.Context { diff --git a/option/rule_set.go b/option/rule_set.go index 419bda1d20..5a236b563d 100644 --- a/option/rule_set.go +++ b/option/rule_set.go @@ -4,8 +4,10 @@ import ( "net/url" "path/filepath" "reflect" + "strings" C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/schema" "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/domain" E "github.com/sagernet/sing/common/exceptions" @@ -18,12 +20,12 @@ import ( ) type _RuleSet struct { - Type string `json:"type,omitempty"` - Tag string `json:"tag"` - Format string `json:"format,omitempty"` - InlineOptions PlainRuleSet `json:"-"` - LocalOptions LocalRuleSet `json:"-"` - RemoteOptions RemoteRuleSet `json:"-"` + Type string `json:"type,omitempty" enum:"inline,local,remote"` + Tag badoption.Listable[string] `json:"tag"` + Format string `json:"format,omitempty" enum:"source,binary"` + InlineOptions PlainRuleSet `json:"-"` + LocalOptions LocalRuleSet `json:"-"` + RemoteOptions RemoteRuleSet `json:"-"` } type RuleSet _RuleSet @@ -61,7 +63,7 @@ func (r *RuleSet) UnmarshalJSON(bytes []byte) error { if err != nil { return err } - if r.Tag == "" { + if len(r.Tag) == 0 || common.Any(r.Tag, func(tag string) bool { return tag == "" }) { return E.New("missing tag") } var v any @@ -99,6 +101,23 @@ func (r *RuleSet) UnmarshalJSON(bytes []byte) error { } else { r.Format = "" } + if len(r.Tag) > 1 { + switch r.Type { + case C.RuleSetTypeInline: + return E.New("inline rule-set does not support multiple tags") + case C.RuleSetTypeLocal: + if !strings.Contains(r.LocalOptions.Path, C.RuleSetTagPlaceholder) { + return E.New("missing ", C.RuleSetTagPlaceholder, " placeholder in path") + } + case C.RuleSetTypeRemote: + if !strings.Contains(r.RemoteOptions.URL, C.RuleSetTagPlaceholder) { + return E.New("missing ", C.RuleSetTagPlaceholder, " placeholder in url") + } + if r.RemoteOptions.InitialPath != "" && !strings.Contains(r.RemoteOptions.InitialPath, C.RuleSetTagPlaceholder) { + return E.New("missing ", C.RuleSetTagPlaceholder, " placeholder in initial_path") + } + } + } return nil } @@ -116,20 +135,60 @@ func ruleSetDefaultFormat(path string) string { } } +func (r RuleSet) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return builder.Define("RuleSet", func() (*schema.Node, error) { + headlessRef, err := builder.Describe(reflect.TypeFor[HeadlessRule]()) + if err != nil { + return nil, err + } + tagNode := schema.ListableOf(schema.StringNode()) + formatNode := schema.StringEnum(C.RuleSetFormatSource, C.RuleSetFormatBinary) + + inlineVariant := schema.StrictObject() + inlineVariant.Properties.Put("type", schema.StringEnum(C.RuleSetTypeInline, "")) + inlineVariant.Properties.Put("tag", tagNode) + inlineVariant.Properties.Put("rules", &schema.Node{Type: "array", Items: headlessRef}) + inlineVariant.Required = []string{"tag"} + + localVariant := schema.StrictObject() + localVariant.Properties.Put("type", schema.StringConst(C.RuleSetTypeLocal)) + localVariant.Properties.Put("tag", tagNode) + localVariant.Properties.Put("format", formatNode) + err = builder.FlattenStruct(localVariant, reflect.TypeFor[LocalRuleSet]()) + if err != nil { + return nil, err + } + localVariant.Required = []string{"type", "tag"} + + remoteVariant := schema.StrictObject() + remoteVariant.Properties.Put("type", schema.StringConst(C.RuleSetTypeRemote)) + remoteVariant.Properties.Put("tag", tagNode) + remoteVariant.Properties.Put("format", formatNode) + err = builder.FlattenStruct(remoteVariant, reflect.TypeFor[RemoteRuleSet]()) + if err != nil { + return nil, err + } + remoteVariant.Required = []string{"type", "tag"} + + return schema.OneOf(inlineVariant, localVariant, remoteVariant), nil + }) +} + type LocalRuleSet struct { Path string `json:"path,omitempty"` } type RemoteRuleSet struct { URL string `json:"url"` + InitialPath string `json:"initial_path,omitempty"` HTTPClient *HTTPClientOptions `json:"http_client,omitempty"` UpdateInterval badoption.Duration `json:"update_interval,omitempty"` // Deprecated: use http_client instead - DownloadDetour string `json:"download_detour,omitempty"` + DownloadDetour string `json:"download_detour,omitempty" reference:"outbound" schema:"omit"` } type _HeadlessRule struct { - Type string `json:"type,omitempty"` + Type string `json:"type,omitempty" enum:"default,logical"` DefaultOptions DefaultHeadlessRule `json:"-"` LogicalOptions LogicalHeadlessRule `json:"-"` } @@ -183,9 +242,15 @@ func (r HeadlessRule) IsValid() bool { } } +func (r HeadlessRule) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return builder.Define("HeadlessRule", func() (*schema.Node, error) { + return nestedRuleUnion(builder, reflect.TypeFor[DefaultHeadlessRule](), "HeadlessRule") + }) +} + type DefaultHeadlessRule struct { QueryType badoption.Listable[DNSQueryType] `json:"query_type,omitempty"` - Network badoption.Listable[string] `json:"network,omitempty"` + Network badoption.Listable[string] `json:"network,omitempty" enum:"tcp,udp,icmp"` Domain badoption.Listable[string] `json:"domain,omitempty"` DomainSuffix badoption.Listable[string] `json:"domain_suffix,omitempty"` DomainKeyword badoption.Listable[string] `json:"domain_keyword,omitempty"` @@ -229,7 +294,7 @@ func (r DefaultHeadlessRule) IsValid() bool { } type LogicalHeadlessRule struct { - Mode string `json:"mode"` + Mode string `json:"mode" enum:"and,or"` Rules []HeadlessRule `json:"rules,omitempty"` Invert bool `json:"invert,omitempty"` } @@ -239,7 +304,7 @@ func (r LogicalHeadlessRule) IsValid() bool { } type _PlainRuleSetCompat struct { - Version uint8 `json:"version"` + Version uint8 `json:"version" enum:"1,2,3,4,5"` Options PlainRuleSet `json:"-"` RawMessage json.RawMessage `json:"-"` } diff --git a/option/schema.go b/option/schema.go new file mode 100644 index 0000000000..de9a6c442d --- /dev/null +++ b/option/schema.go @@ -0,0 +1,71 @@ +package option + +import ( + "reflect" + "slices" + + "github.com/sagernet/sing-box/schema" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json/badjson" +) + +type schemaTypeRegistry interface { + OptionTypes() []string + CreateOptions(itemType string) (any, bool) +} + +func registryUnion(builder schema.Builder, registry schemaTypeRegistry, excludeTypes []string, withTag bool) (*schema.Node, error) { + var variants []*schema.Node + for _, itemType := range registry.OptionTypes() { + if slices.Contains(excludeTypes, itemType) { + continue + } + optionsValue, _ := registry.CreateOptions(itemType) + describer, isDescriber := optionsValue.(schema.Describer) + var variant *schema.Node + var err error + if isDescriber { + variant, err = describer.DescribeSchema(builder) + } else { + variant = schema.StrictObject() + err = builder.FlattenStruct(variant, reflect.TypeOf(optionsValue).Elem()) + } + if err != nil { + return nil, E.Cause(err, itemType) + } + err = prependTypeTag(variant, itemType, withTag) + if err != nil { + return nil, E.Cause(err, itemType) + } + variants = append(variants, variant) + } + return schema.OneOf(variants...), nil +} + +// prependTypeTag merges the polymorphic base fields into a variant produced +// from registry options, matching badjson.UnmarshallExcluded composition. +func prependTypeTag(variant *schema.Node, typeName string, withTag bool) error { + if variant.OneOf != nil { + for _, branch := range variant.OneOf { + err := prependTypeTag(branch, typeName, withTag) + if err != nil { + return err + } + } + return nil + } + if variant.Properties == nil { + return E.New("cannot merge type into non-object variant") + } + newProperties := new(badjson.TypedMap[string, *schema.Node]) + newProperties.Put("type", schema.StringConst(typeName)) + if withTag { + newProperties.Put("tag", schema.StringNode()) + } + for _, entry := range variant.Properties.Entries() { + newProperties.Put(entry.Key, entry.Value) + } + variant.Properties = newProperties + variant.Required = append([]string{"type"}, variant.Required...) + return nil +} diff --git a/option/service.go b/option/service.go index 7d45bc1433..35e3faca5f 100644 --- a/option/service.go +++ b/option/service.go @@ -3,6 +3,7 @@ package option import ( "context" + "github.com/sagernet/sing-box/schema" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/json/badjson" @@ -10,6 +11,7 @@ import ( ) type ServiceOptionsRegistry interface { + OptionTypes() []string CreateOptions(serviceType string) (any, bool) } @@ -45,3 +47,13 @@ func (h *Service) UnmarshalJSONContext(ctx context.Context, content []byte) erro h.Options = options return nil } + +func (h Service) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return builder.Define("Service", func() (*schema.Node, error) { + registry := service.FromContext[ServiceOptionsRegistry](builder.Context()) + if registry == nil { + return nil, E.New("missing service options registry in context") + } + return registryUnion(builder, registry, nil, true) + }) +} diff --git a/option/shadowsocks.go b/option/shadowsocks.go index a1cd32759b..eadcf9456d 100644 --- a/option/shadowsocks.go +++ b/option/shadowsocks.go @@ -3,7 +3,7 @@ package option type ShadowsocksInboundOptions struct { ListenOptions Network NetworkList `json:"network,omitempty"` - Method string `json:"method"` + Method string `json:"method" enum:"none,aes-128-gcm,aes-192-gcm,aes-256-gcm,chacha20-ietf-poly1305,xchacha20-ietf-poly1305,2022-blake3-aes-128-gcm,2022-blake3-aes-256-gcm,2022-blake3-chacha20-poly1305"` Password string `json:"password,omitempty"` Users []ShadowsocksUser `json:"users,omitempty"` Destinations []ShadowsocksDestination `json:"destinations,omitempty"` @@ -27,7 +27,7 @@ type ShadowsocksDestination struct { type ShadowsocksOutboundOptions struct { DialerOptions ServerOptions - Method string `json:"method"` + Method string `json:"method" enum:"none,aes-128-gcm,aes-192-gcm,aes-256-gcm,chacha20-ietf-poly1305,xchacha20-ietf-poly1305,2022-blake3-aes-128-gcm,2022-blake3-aes-256-gcm,2022-blake3-chacha20-poly1305,aes-128-ctr,aes-192-ctr,aes-256-ctr,aes-128-cfb,aes-192-cfb,aes-256-cfb,rc4-md5,chacha20-ietf,xchacha20"` Password string `json:"password"` Plugin string `json:"plugin,omitempty"` PluginOptions string `json:"plugin_opts,omitempty"` diff --git a/option/shadowtls.go b/option/shadowtls.go index 81ef9a4370..7263261835 100644 --- a/option/shadowtls.go +++ b/option/shadowtls.go @@ -3,13 +3,14 @@ package option import ( "encoding/json" + "github.com/sagernet/sing-box/schema" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json/badjson" ) type ShadowTLSInboundOptions struct { ListenOptions - Version int `json:"version,omitempty"` + Version int `json:"version,omitempty" enum:"1,2,3"` Password string `json:"password,omitempty"` Users []ShadowTLSUser `json:"users,omitempty"` Handshake ShadowTLSHandshakeOptions `json:"handshake,omitempty"` @@ -62,6 +63,10 @@ func (w *WildcardSNI) UnmarshalJSON(bytes []byte) error { return nil } +func (w WildcardSNI) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return schema.StringEnum("", "off", "authed", "all"), nil +} + type ShadowTLSUser struct { Name string `json:"name,omitempty"` Password string `json:"password,omitempty"` @@ -75,7 +80,7 @@ type ShadowTLSHandshakeOptions struct { type ShadowTLSOutboundOptions struct { DialerOptions ServerOptions - Version int `json:"version,omitempty"` + Version int `json:"version,omitempty" enum:"1,2,3"` Password string `json:"password,omitempty"` OutboundTLSOptionsContainer } diff --git a/option/simple.go b/option/simple.go index f244ba18b8..43cbb2c6c1 100644 --- a/option/simple.go +++ b/option/simple.go @@ -22,7 +22,7 @@ type HTTPMixedInboundOptions struct { type SOCKSOutboundOptions struct { DialerOptions ServerOptions - Version string `json:"version,omitempty"` + Version string `json:"version,omitempty" enum:"4,4a,5"` Username string `json:"username,omitempty"` Password string `json:"password,omitempty"` Network NetworkList `json:"network,omitempty"` diff --git a/option/snell.go b/option/snell.go index 53c8117d83..6ca0b2e3f8 100644 --- a/option/snell.go +++ b/option/snell.go @@ -1,20 +1,27 @@ package option import ( + "reflect" + + "github.com/sagernet/sing-box/schema" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/json/badjson" ) type _SnellInboundOptions struct { - ListenOptions - Version int `json:"version"` - PSK string `json:"psk"` - Users []SnellUser `json:"users,omitempty"` + Version int `json:"version" enum:"5,6"` + AbstractSnellInboundOptions ObfsOptions SnellObfsServerOptions `json:"-"` V6Options SnellV6Options `json:"-"` } +type AbstractSnellInboundOptions struct { + ListenOptions + PSK string `json:"psk"` + Users []SnellUser `json:"users,omitempty"` +} + type SnellInboundOptions _SnellInboundOptions func (o *SnellInboundOptions) UnmarshalJSON(content []byte) error { @@ -51,18 +58,31 @@ func (o SnellInboundOptions) MarshalJSON() ([]byte, error) { return badjson.MarshallObjects((_SnellInboundOptions)(o), versionOptions) } +func (o SnellInboundOptions) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return schema.DiscriminatedUnion(builder, "version", true, []schema.UnionVariant{ + {Value: 5, StructType: reflect.TypeFor[SnellObfsServerOptions]()}, + {Value: 6, StructType: reflect.TypeFor[SnellV6Options]()}, + }, func(variant *schema.Node) error { + return builder.FlattenStruct(variant, reflect.TypeFor[AbstractSnellInboundOptions]()) + }) +} + type _SnellOutboundOptions struct { - DialerOptions - ServerOptions - Version int `json:"version"` - PSK string `json:"psk"` - UserKey string `json:"userkey,omitempty"` - Reuse bool `json:"reuse,omitempty"` - Network NetworkList `json:"network,omitempty"` + Version int `json:"version" enum:"4,6"` + AbstractSnellOutboundOptions ObfsOptions SnellObfsClientOptions `json:"-"` V6Options SnellV6Options `json:"-"` } +type AbstractSnellOutboundOptions struct { + DialerOptions + ServerOptions + PSK string `json:"psk"` + UserKey string `json:"userkey,omitempty"` + Reuse bool `json:"reuse,omitempty"` + Network NetworkList `json:"network,omitempty"` +} + type SnellOutboundOptions _SnellOutboundOptions func (o *SnellOutboundOptions) UnmarshalJSON(content []byte) error { @@ -99,8 +119,17 @@ func (o SnellOutboundOptions) MarshalJSON() ([]byte, error) { return badjson.MarshallObjects((_SnellOutboundOptions)(o), versionOptions) } +func (o SnellOutboundOptions) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return schema.DiscriminatedUnion(builder, "version", true, []schema.UnionVariant{ + {Value: 4, StructType: reflect.TypeFor[SnellObfsClientOptions]()}, + {Value: 6, StructType: reflect.TypeFor[SnellV6Options]()}, + }, func(variant *schema.Node) error { + return builder.FlattenStruct(variant, reflect.TypeFor[AbstractSnellOutboundOptions]()) + }) +} + type SnellObfsServerOptions struct { - ObfsMode string `json:"obfs_mode,omitempty"` + ObfsMode string `json:"obfs_mode,omitempty" enum:"none,http,tls"` } type SnellUser struct { @@ -109,10 +138,10 @@ type SnellUser struct { } type SnellObfsClientOptions struct { - ObfsMode string `json:"obfs_mode,omitempty"` + ObfsMode string `json:"obfs_mode,omitempty" enum:"none,http,tls"` ObfsHost string `json:"obfs_host,omitempty"` } type SnellV6Options struct { - Mode string `json:"mode,omitempty"` + Mode string `json:"mode,omitempty" enum:"default,unshaped,unsafe-raw"` } diff --git a/option/tailscale.go b/option/tailscale.go index d04f81bf82..a470ee3004 100644 --- a/option/tailscale.go +++ b/option/tailscale.go @@ -3,7 +3,9 @@ package option import ( "net/netip" "net/url" + "reflect" + "github.com/sagernet/sing-box/schema" "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/json/badjson" "github.com/sagernet/sing/common/json/badoption" @@ -56,6 +58,15 @@ func (o *TailscaleSSHServerOptions) UnmarshalJSON(bytes []byte) error { return json.UnmarshalDisallowUnknownFields(bytes, (*_TailscaleSSHServerOptions)(o)) } +func (o TailscaleSSHServerOptions) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + objectForm := schema.StrictObject() + err := builder.FlattenStruct(objectForm, reflect.TypeFor[TailscaleSSHServerOptions]()) + if err != nil { + return nil, err + } + return schema.AnyOf(schema.BooleanNode(), objectForm), nil +} + type TailscaleDNSServerOptions struct { Endpoint string `json:"endpoint,omitempty"` AcceptDefaultResolvers bool `json:"accept_default_resolvers,omitempty"` @@ -127,6 +138,15 @@ func (d *DERPVerifyClientURLOptions) UnmarshalJSON(bytes []byte) error { return nil } +func (d DERPVerifyClientURLOptions) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + objectForm, err := describeHTTPClientObject(builder) + if err != nil { + return nil, err + } + objectForm.Properties.Put("url", schema.StringNode()) + return schema.AnyOf(schema.StringNode(), objectForm), nil +} + type DERPMeshOptions struct { ServerOptions Host string `json:"host,omitempty"` @@ -135,7 +155,7 @@ type DERPMeshOptions struct { } type _DERPSTUNListenOptions struct { - Enabled bool + Enabled bool `json:"enabled,omitempty"` ListenOptions } @@ -165,3 +185,12 @@ func (d *DERPSTUNListenOptions) UnmarshalJSON(bytes []byte) error { } return json.Unmarshal(bytes, (*_DERPSTUNListenOptions)(d)) } + +func (d DERPSTUNListenOptions) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + objectForm := schema.StrictObject() + err := builder.FlattenStruct(objectForm, reflect.TypeFor[DERPSTUNListenOptions]()) + if err != nil { + return nil, err + } + return schema.AnyOf(schema.UnsignedNode(16), objectForm), nil +} diff --git a/option/tls.go b/option/tls.go index 177dd27f07..e91b8774b1 100644 --- a/option/tls.go +++ b/option/tls.go @@ -5,6 +5,7 @@ import ( "encoding/json" "strings" + "github.com/sagernet/sing-box/schema" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json/badoption" ) @@ -13,9 +14,9 @@ type InboundTLSOptions struct { Enabled bool `json:"enabled,omitempty"` ServerName string `json:"server_name,omitempty"` Insecure bool `json:"insecure,omitempty"` - ALPN badoption.Listable[string] `json:"alpn,omitempty"` - MinVersion string `json:"min_version,omitempty"` - MaxVersion string `json:"max_version,omitempty"` + ALPN badoption.Listable[string] `json:"alpn,omitempty" examples:"http/1.1,h2,h3"` + MinVersion string `json:"min_version,omitempty" enum:"1.0,1.1,1.2,1.3"` + MaxVersion string `json:"max_version,omitempty" enum:"1.0,1.1,1.2,1.3"` CipherSuites badoption.Listable[string] `json:"cipher_suites,omitempty"` CurvePreferences badoption.Listable[CurvePreference] `json:"curve_preferences,omitempty"` Certificate badoption.Listable[string] `json:"certificate,omitempty"` @@ -32,7 +33,7 @@ type InboundTLSOptions struct { CertificateProvider *CertificateProviderOptions `json:"certificate_provider,omitempty"` // Deprecated: use certificate_provider - ACME *InboundACMEOptions `json:"acme,omitempty"` + ACME *InboundACMEOptions `json:"acme,omitempty" schema:"omit"` ECH *InboundECHOptions `json:"ech,omitempty"` Reality *InboundRealityOptions `json:"reality,omitempty"` @@ -82,6 +83,10 @@ func (t *ClientAuthType) UnmarshalJSON(data []byte) error { return nil } +func (t ClientAuthType) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return schema.StringEnum("no", "request", "require-any", "verify-if-given", "require-and-verify"), nil +} + type InboundTLSOptionsContainer struct { TLS *InboundTLSOptions `json:"tls,omitempty"` } @@ -101,13 +106,13 @@ func (o *InboundTLSOptionsContainer) ReplaceInboundTLSOptions(options *InboundTL type OutboundTLSOptions struct { Enabled bool `json:"enabled,omitempty"` - Engine string `json:"engine,omitempty"` + Engine string `json:"engine,omitempty" enum:"go,apple,windows"` DisableSNI bool `json:"disable_sni,omitempty"` ServerName string `json:"server_name,omitempty"` Insecure bool `json:"insecure,omitempty"` - ALPN badoption.Listable[string] `json:"alpn,omitempty"` - MinVersion string `json:"min_version,omitempty"` - MaxVersion string `json:"max_version,omitempty"` + ALPN badoption.Listable[string] `json:"alpn,omitempty" examples:"http/1.1,h2,h3"` + MinVersion string `json:"min_version,omitempty" enum:"1.0,1.1,1.2,1.3"` + MaxVersion string `json:"max_version,omitempty" enum:"1.0,1.1,1.2,1.3"` CipherSuites badoption.Listable[string] `json:"cipher_suites,omitempty"` CurvePreferences badoption.Listable[CurvePreference] `json:"curve_preferences,omitempty"` Certificate badoption.Listable[string] `json:"certificate,omitempty"` @@ -121,7 +126,7 @@ type OutboundTLSOptions struct { FragmentFallbackDelay badoption.Duration `json:"fragment_fallback_delay,omitempty"` RecordFragment bool `json:"record_fragment,omitempty"` Spoof string `json:"spoof,omitempty"` - SpoofMethod string `json:"spoof_method,omitempty"` + SpoofMethod string `json:"spoof_method,omitempty" enum:"wrong-sequence,wrong-checksum,wrong-ack,wrong-md5,wrong-timestamp"` KernelTx bool `json:"kernel_tx,omitempty"` KernelRx bool `json:"kernel_rx,omitempty"` HandshakeTimeout badoption.Duration `json:"handshake_timeout,omitempty"` @@ -201,6 +206,10 @@ func (c *CurvePreference) UnmarshalJSON(data []byte) error { return nil } +func (c CurvePreference) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return schema.StringEnum("P256", "P384", "P521", "X25519", "X25519MLKEM768"), nil +} + type InboundRealityOptions struct { Enabled bool `json:"enabled,omitempty"` Handshake InboundRealityHandshakeOptions `json:"handshake,omitempty"` @@ -220,9 +229,9 @@ type InboundECHOptions struct { KeyPath string `json:"key_path,omitempty"` // Deprecated: not supported by stdlib - PQSignatureSchemesEnabled bool `json:"pq_signature_schemes_enabled,omitempty"` + PQSignatureSchemesEnabled bool `json:"pq_signature_schemes_enabled,omitempty" schema:"omit"` // Deprecated: added by fault - DynamicRecordSizingDisabled bool `json:"dynamic_record_sizing_disabled,omitempty"` + DynamicRecordSizingDisabled bool `json:"dynamic_record_sizing_disabled,omitempty" schema:"omit"` } type OutboundECHOptions struct { @@ -232,14 +241,14 @@ type OutboundECHOptions struct { QueryServerName string `json:"query_server_name,omitempty"` // Deprecated: not supported by stdlib - PQSignatureSchemesEnabled bool `json:"pq_signature_schemes_enabled,omitempty"` + PQSignatureSchemesEnabled bool `json:"pq_signature_schemes_enabled,omitempty" schema:"omit"` // Deprecated: added by fault - DynamicRecordSizingDisabled bool `json:"dynamic_record_sizing_disabled,omitempty"` + DynamicRecordSizingDisabled bool `json:"dynamic_record_sizing_disabled,omitempty" schema:"omit"` } type OutboundUTLSOptions struct { Enabled bool `json:"enabled,omitempty"` - Fingerprint string `json:"fingerprint,omitempty"` + Fingerprint string `json:"fingerprint,omitempty" enum:"chrome_psk,chrome_psk_shuffle,chrome_padding_psk_shuffle,chrome_pq,chrome_pq_psk,chrome,firefox,edge,safari,360,qq,ios,android,random,randomized"` } type OutboundRealityOptions struct { diff --git a/option/tls_acme.go b/option/tls_acme.go index 636abc6ece..35e26779e4 100644 --- a/option/tls_acme.go +++ b/option/tls_acme.go @@ -1,7 +1,10 @@ package option import ( + "reflect" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/schema" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/json/badjson" @@ -29,7 +32,7 @@ type ACMEExternalAccountOptions struct { } type _ACMEDNS01ChallengeOptions struct { - Provider string `json:"provider,omitempty"` + Provider string `json:"provider,omitempty" enum:"alidns,cloudflare,acmedns"` AliDNSOptions ACMEDNS01AliDNSOptions `json:"-"` CloudflareOptions ACMEDNS01CloudflareOptions `json:"-"` ACMEDNSOptions ACMEDNS01ACMEDNSOptions `json:"-"` @@ -77,6 +80,20 @@ func (o *ACMEDNS01ChallengeOptions) UnmarshalJSON(bytes []byte) error { return nil } +func acmeDNS01Variants() []schema.UnionVariant { + return []schema.UnionVariant{ + {Value: C.DNSProviderAliDNS, StructType: reflect.TypeFor[ACMEDNS01AliDNSOptions]()}, + {Value: C.DNSProviderCloudflare, StructType: reflect.TypeFor[ACMEDNS01CloudflareOptions]()}, + {Value: C.DNSProviderACMEDNS, StructType: reflect.TypeFor[ACMEDNS01ACMEDNSOptions]()}, + } +} + +func (o ACMEDNS01ChallengeOptions) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return builder.Define("ACMEDNS01Challenge", func() (*schema.Node, error) { + return schema.DiscriminatedUnion(builder, "provider", true, acmeDNS01Variants(), nil) + }) +} + type ACMEDNS01AliDNSOptions struct { AccessKeyID string `json:"access_key_id,omitempty"` AccessKeySecret string `json:"access_key_secret,omitempty"` diff --git a/option/trusttunnel.go b/option/trusttunnel.go new file mode 100644 index 0000000000..98e0e46466 --- /dev/null +++ b/option/trusttunnel.go @@ -0,0 +1,24 @@ +package option + +import ( + "github.com/sagernet/sing/common/auth" +) + +type TrustTunnelInboundOptions struct { + ListenOptions + Users []auth.User `json:"users,omitempty"` + QUICCongestionControl string `json:"quic_congestion_control,omitempty"` + Network NetworkList `json:"network,omitempty"` + InboundTLSOptionsContainer +} + +type TrustTunnelOutboundOptions struct { + DialerOptions + ServerOptions + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + HealthCheck bool `json:"health_check,omitempty"` + QUIC bool `json:"quic,omitempty"` + QUICCongestionControl string `json:"quic_congestion_control,omitempty"` + OutboundTLSOptionsContainer +} diff --git a/option/tuic.go b/option/tuic.go index 51cc0a5b96..e90417e392 100644 --- a/option/tuic.go +++ b/option/tuic.go @@ -5,7 +5,7 @@ import "github.com/sagernet/sing/common/json/badoption" type TUICInboundOptions struct { ListenOptions Users []TUICUser `json:"users,omitempty"` - CongestionControl string `json:"congestion_control,omitempty"` + CongestionControl string `json:"congestion_control,omitempty" enum:"cubic,new_reno,bbr"` AuthTimeout badoption.Duration `json:"auth_timeout,omitempty"` ZeroRTTHandshake bool `json:"zero_rtt_handshake,omitempty"` Heartbeat badoption.Duration `json:"heartbeat,omitempty"` @@ -24,8 +24,8 @@ type TUICOutboundOptions struct { ServerOptions UUID string `json:"uuid,omitempty"` Password string `json:"password,omitempty"` - CongestionControl string `json:"congestion_control,omitempty"` - UDPRelayMode string `json:"udp_relay_mode,omitempty"` + CongestionControl string `json:"congestion_control,omitempty" enum:"cubic,new_reno,bbr"` + UDPRelayMode string `json:"udp_relay_mode,omitempty" enum:"native,quic"` UDPOverStream bool `json:"udp_over_stream,omitempty"` ZeroRTTHandshake bool `json:"zero_rtt_handshake,omitempty"` Heartbeat badoption.Duration `json:"heartbeat,omitempty"` diff --git a/option/tun.go b/option/tun.go index 34e95b7341..aa959c5b16 100644 --- a/option/tun.go +++ b/option/tun.go @@ -4,6 +4,7 @@ import ( "net/netip" "strconv" + "github.com/sagernet/sing-box/schema" E "github.com/sagernet/sing/common/exceptions" F "github.com/sagernet/sing/common/format" "github.com/sagernet/sing/common/json" @@ -12,10 +13,10 @@ import ( type TunInboundOptions struct { InterfaceName string `json:"interface_name,omitempty"` - NetNs string `json:"netns,omitempty"` + NetNs string `json:"netns,omitempty" reference:"network_namespace"` MTU uint32 `json:"mtu,omitempty"` - Address badoption.Listable[netip.Prefix] `json:"address,omitempty"` - DNSMode string `json:"dns_mode,omitempty"` + Address badoption.Listable[netip.Prefix] `json:"address,omitempty" examples:"172.19.0.1/30,fdfe:dcba:9876::1/126"` + DNSMode string `json:"dns_mode,omitempty" enum:"disabled,native,hijack"` DNSAddress badoption.Listable[netip.Addr] `json:"dns_address,omitempty"` AutoRoute bool `json:"auto_route,omitempty"` IPRoute2TableIndex int `json:"iproute2_table_index,omitempty"` @@ -45,26 +46,29 @@ type TunInboundOptions struct { IncludeMACAddress badoption.Listable[string] `json:"include_mac_address,omitempty"` ExcludeMACAddress badoption.Listable[string] `json:"exclude_mac_address,omitempty"` UDPTimeout UDPTimeoutCompat `json:"udp_timeout,omitempty"` - Stack string `json:"stack,omitempty"` + UDPMapping UDPNATBehavior `json:"udp_mapping,omitempty"` + UDPFiltering UDPNATBehavior `json:"udp_filtering,omitempty"` + UDPNATMax uint32 `json:"udp_nat_max,omitempty"` + Stack string `json:"stack,omitempty" enum:"system,gvisor,mixed"` Platform *TunPlatformOptions `json:"platform,omitempty"` InboundOptions // Deprecated: removed - GSO bool `json:"gso,omitempty"` + GSO bool `json:"gso,omitempty" schema:"omit"` // Deprecated: merged to Address - Inet4Address badoption.Listable[netip.Prefix] `json:"inet4_address,omitempty"` + Inet4Address badoption.Listable[netip.Prefix] `json:"inet4_address,omitempty" schema:"omit"` // Deprecated: merged to Address - Inet6Address badoption.Listable[netip.Prefix] `json:"inet6_address,omitempty"` + Inet6Address badoption.Listable[netip.Prefix] `json:"inet6_address,omitempty" schema:"omit"` // Deprecated: merged to RouteAddress - Inet4RouteAddress badoption.Listable[netip.Prefix] `json:"inet4_route_address,omitempty"` + Inet4RouteAddress badoption.Listable[netip.Prefix] `json:"inet4_route_address,omitempty" schema:"omit"` // Deprecated: merged to RouteAddress - Inet6RouteAddress badoption.Listable[netip.Prefix] `json:"inet6_route_address,omitempty"` + Inet6RouteAddress badoption.Listable[netip.Prefix] `json:"inet6_route_address,omitempty" schema:"omit"` // Deprecated: merged to RouteExcludeAddress - Inet4RouteExcludeAddress badoption.Listable[netip.Prefix] `json:"inet4_route_exclude_address,omitempty"` + Inet4RouteExcludeAddress badoption.Listable[netip.Prefix] `json:"inet4_route_exclude_address,omitempty" schema:"omit"` // Deprecated: merged to RouteExcludeAddress - Inet6RouteExcludeAddress badoption.Listable[netip.Prefix] `json:"inet6_route_exclude_address,omitempty"` + Inet6RouteExcludeAddress badoption.Listable[netip.Prefix] `json:"inet6_route_exclude_address,omitempty" schema:"omit"` // Deprecated: removed - EndpointIndependentNat bool `json:"endpoint_independent_nat,omitempty"` + EndpointIndependentNat bool `json:"endpoint_independent_nat,omitempty" schema:"omit"` } type FwMark uint32 @@ -89,3 +93,7 @@ func (f *FwMark) UnmarshalJSON(bytes []byte) error { *f = FwMark(intValue) return nil } + +func (f FwMark) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return schema.AnyOf(schema.UnsignedNode(32), schema.StringNode()), nil +} diff --git a/option/types.go b/option/types.go index 87cf382c65..32ac6ab068 100644 --- a/option/types.go +++ b/option/types.go @@ -1,9 +1,12 @@ package option import ( + "maps" + "slices" "strings" C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/schema" E "github.com/sagernet/sing/common/exceptions" F "github.com/sagernet/sing/common/format" "github.com/sagernet/sing/common/json" @@ -43,6 +46,10 @@ func (v NetworkList) Build() []string { return strings.Split(string(v), "\n") } +func (v NetworkList) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return schema.ListableOf(schema.StringEnum(N.NetworkTCP, N.NetworkUDP)), nil +} + type DomainStrategy C.DomainStrategy func (s DomainStrategy) String() string { @@ -105,6 +112,12 @@ func (s *DomainStrategy) UnmarshalJSON(bytes []byte) error { return nil } +func (s DomainStrategy) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return builder.Define("DomainStrategy", func() (*schema.Node, error) { + return schema.StringEnum("", "as_is", "prefer_ipv4", "prefer_ipv6", "ipv4_only", "ipv6_only"), nil + }) +} + type DNSQueryType uint16 func (t DNSQueryType) String() string { @@ -142,6 +155,15 @@ func (t *DNSQueryType) UnmarshalJSON(bytes []byte) error { return E.New("unknown DNS query type: ", string(bytes)) } +func (t DNSQueryType) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return builder.Define("DNSQueryType", func() (*schema.Node, error) { + return schema.AnyOf( + schema.UnsignedNode(16), + schema.StringEnum(slices.Sorted(maps.Keys(mDNS.StringToType))...), + ), nil + }) +} + func DNSQueryTypeToString(queryType uint16) string { typeName, loaded := mDNS.TypeToString[queryType] if loaded { @@ -170,6 +192,10 @@ func (n *NetworkStrategy) UnmarshalJSON(content []byte) error { return nil } +func (n NetworkStrategy) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return schema.StringEnum(slices.Sorted(maps.Keys(C.StringToNetworkStrategy))...), nil +} + type InterfaceType C.InterfaceType func (t InterfaceType) Build() C.InterfaceType { @@ -193,3 +219,9 @@ func (t *InterfaceType) UnmarshalJSON(content []byte) error { *t = InterfaceType(interfaceType) return nil } + +func (t InterfaceType) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return builder.Define("InterfaceType", func() (*schema.Node, error) { + return schema.StringEnum(slices.Sorted(maps.Keys(C.StringToInterfaceType))...), nil + }) +} diff --git a/option/udp_over_tcp.go b/option/udp_over_tcp.go index b496017a41..014efc8c47 100644 --- a/option/udp_over_tcp.go +++ b/option/udp_over_tcp.go @@ -1,13 +1,16 @@ package option import ( + "reflect" + + "github.com/sagernet/sing-box/schema" "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/uot" ) type _UDPOverTCPOptions struct { Enabled bool `json:"enabled,omitempty"` - Version uint8 `json:"version,omitempty"` + Version uint8 `json:"version,omitempty" enum:"1,2"` } type UDPOverTCPOptions _UDPOverTCPOptions @@ -28,3 +31,12 @@ func (o *UDPOverTCPOptions) UnmarshalJSON(bytes []byte) error { } return json.UnmarshalDisallowUnknownFields(bytes, (*_UDPOverTCPOptions)(o)) } + +func (o UDPOverTCPOptions) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + objectForm := schema.StrictObject() + err := builder.FlattenStruct(objectForm, reflect.TypeFor[UDPOverTCPOptions]()) + if err != nil { + return nil, err + } + return schema.AnyOf(schema.BooleanNode(), objectForm), nil +} diff --git a/option/usbip.go b/option/usbip.go index 32136e9e74..8ce912f17d 100644 --- a/option/usbip.go +++ b/option/usbip.go @@ -1,6 +1,9 @@ package option import ( + "reflect" + + "github.com/sagernet/sing-box/schema" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/json/badjson" @@ -13,7 +16,7 @@ const ( type _USBIPServerServiceOptions struct { ListenOptions - Provider string `json:"provider,omitempty"` + Provider string `json:"provider,omitempty" enum:"default,dynamic"` Options any `json:"-"` } @@ -49,6 +52,15 @@ func (o *USBIPServerServiceOptions) UnmarshalJSON(content []byte) error { return nil } +func (o USBIPServerServiceOptions) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return schema.DiscriminatedUnion(builder, "provider", false, []schema.UnionVariant{ + {Value: USBIPProviderDefault, StructType: reflect.TypeFor[USBIPDefaultProviderOptions](), TypeOptional: true}, + {Value: USBIPProviderDynamic, StructType: reflect.TypeFor[USBIPDynamicProviderOptions]()}, + }, func(variant *schema.Node) error { + return builder.FlattenStruct(variant, reflect.TypeFor[ListenOptions]()) + }) +} + type USBIPClientServiceOptions struct { DialerOptions ServerOptions diff --git a/option/v2ray_transport.go b/option/v2ray_transport.go index 9f0e393623..05b0697ffc 100644 --- a/option/v2ray_transport.go +++ b/option/v2ray_transport.go @@ -2,11 +2,13 @@ package option import ( "net/http" + "reflect" "strings" Xbadoption "github.com/sagernet/sing-box/common/xray/json/badoption" "github.com/sagernet/sing-box/common/xray/utils" C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/schema" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/json/badjson" @@ -14,7 +16,7 @@ import ( ) type _V2RayTransportOptions struct { - Type string `json:"type"` + Type string `json:"type" enum:"http,ws,quic,grpc,httpupgrade"` HTTPOptions V2RayHTTPOptions `json:"-"` WebsocketOptions V2RayWebsocketOptions `json:"-"` QUICOptions V2RayQUICOptions `json:"-"` @@ -78,6 +80,18 @@ func (o *V2RayTransportOptions) UnmarshalJSON(bytes []byte) error { return nil } +func (o V2RayTransportOptions) DescribeSchema(builder schema.Builder) (*schema.Node, error) { + return builder.Define("V2RayTransport", func() (*schema.Node, error) { + return schema.DiscriminatedUnion(builder, "type", true, []schema.UnionVariant{ + {Value: C.V2RayTransportTypeHTTP, StructType: reflect.TypeFor[V2RayHTTPOptions]()}, + {Value: C.V2RayTransportTypeWebsocket, StructType: reflect.TypeFor[V2RayWebsocketOptions]()}, + {Value: C.V2RayTransportTypeQUIC, StructType: reflect.TypeFor[V2RayQUICOptions]()}, + {Value: C.V2RayTransportTypeGRPC, StructType: reflect.TypeFor[V2RayGRPCOptions]()}, + {Value: C.V2RayTransportTypeHTTPUpgrade, StructType: reflect.TypeFor[V2RayHTTPUpgradeOptions]()}, + }, nil) + }) +} + type V2RayHTTPOptions struct { Host badoption.Listable[string] `json:"host,omitempty"` Path string `json:"path,omitempty"` diff --git a/option/vmess.go b/option/vmess.go index 8a38fb70c8..f4dd28284e 100644 --- a/option/vmess.go +++ b/option/vmess.go @@ -18,13 +18,13 @@ type VMessOutboundOptions struct { DialerOptions ServerOptions UUID string `json:"uuid"` - Security string `json:"security"` + Security string `json:"security" enum:"auto,none,zero,aes-128-cfb,aes-128-gcm,chacha20-poly1305"` AlterId int `json:"alter_id,omitempty"` GlobalPadding bool `json:"global_padding,omitempty"` AuthenticatedLength bool `json:"authenticated_length,omitempty"` Network NetworkList `json:"network,omitempty"` OutboundTLSOptionsContainer - PacketEncoding string `json:"packet_encoding,omitempty"` + PacketEncoding string `json:"packet_encoding,omitempty" enum:"packetaddr,xudp"` Multiplex *OutboundMultiplexOptions `json:"multiplex,omitempty"` Transport *V2RayTransportOptions `json:"transport,omitempty"` } diff --git a/option/wireguard.go b/option/wireguard.go index e58f7a5ff4..ea773e7a6c 100644 --- a/option/wireguard.go +++ b/option/wireguard.go @@ -9,15 +9,18 @@ import ( ) type WireGuardEndpointOptions struct { - System bool `json:"system,omitempty"` - Name string `json:"name,omitempty"` - MTU uint32 `json:"mtu,omitempty"` - Address badoption.Listable[netip.Prefix] `json:"address"` - PrivateKey string `json:"private_key"` - ListenPort uint16 `json:"listen_port,omitempty"` - Peers []WireGuardPeer `json:"peers,omitempty"` - UDPTimeout badoption.Duration `json:"udp_timeout,omitempty"` - Workers int `json:"workers,omitempty"` + System bool `json:"system,omitempty"` + Name string `json:"name,omitempty"` + MTU uint32 `json:"mtu,omitempty"` + Address badoption.Listable[netip.Prefix] `json:"address"` + PrivateKey string `json:"private_key"` + ListenPort uint16 `json:"listen_port,omitempty"` + Peers []WireGuardPeer `json:"peers,omitempty"` + UDPTimeout badoption.Duration `json:"udp_timeout,omitempty"` + UDPMapping UDPNATBehavior `json:"udp_mapping,omitempty"` + UDPFiltering UDPNATBehavior `json:"udp_filtering,omitempty"` + UDPNATMax uint32 `json:"udp_nat_max,omitempty"` + Workers int `json:"workers,omitempty"` DialerOptions Noise hiddify.NoiseOptions `json:"noise,omitempty"` //H diff --git a/protocol/anytls/outbound.go b/protocol/anytls/outbound.go index 2f24c2ef8f..92fbbecd78 100644 --- a/protocol/anytls/outbound.go +++ b/protocol/anytls/outbound.go @@ -27,17 +27,20 @@ func RegisterOutbound(registry *outbound.Registry) { type Outbound struct { outbound.Adapter - dialer tls.Dialer - server M.Socksaddr - tlsConfig tls.Config - client *anytls.Client - uotClient *uot.Client - logger log.ContextLogger + ctx context.Context + dialer tls.Dialer + server M.Socksaddr + tlsConfig tls.Config + clientOptions anytls.ClientConfig + client *anytls.Client + uotClient *uot.Client + logger log.ContextLogger } func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.AnyTLSOutboundOptions) (adapter.Outbound, error) { outbound := &Outbound{ Adapter: outbound.NewAdapterWithDialerOptions(C.TypeAnyTLS, tag, []string{N.NetworkTCP, N.NetworkUDP}, options.DialerOptions), + ctx: ctx, server: options.ServerOptions.Build(), logger: logger, } @@ -69,24 +72,31 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL outbound.dialer = tls.NewDialer(outboundDialer, tlsConfig) - client, err := anytls.NewClient(ctx, anytls.ClientConfig{ + outbound.clientOptions = anytls.ClientConfig{ Password: options.Password, IdleSessionCheckInterval: options.IdleSessionCheckInterval.Build(), IdleSessionTimeout: options.IdleSessionTimeout.Build(), MinIdleSession: options.MinIdleSession, DialOut: outbound.dialOut, Logger: logger, - }) - if err != nil { - return nil, err } - outbound.client = client + return outbound, nil +} - outbound.uotClient = &uot.Client{ +func (h *Outbound) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateInitialize { + return nil + } + client, err := anytls.NewClient(h.ctx, h.clientOptions) + if err != nil { + return err + } + h.client = client + h.uotClient = &uot.Client{ Dialer: (anytlsDialer)(client.CreateProxy), Version: uot.Version, } - return outbound, nil + return nil } type anytlsDialer func(ctx context.Context, destination M.Socksaddr) (net.Conn, error) @@ -127,5 +137,5 @@ func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (n } func (h *Outbound) Close() error { - return common.Close(h.client) + return common.Close(common.PtrOrNil(h.client)) } diff --git a/protocol/bridge/backend_darwin.go b/protocol/bridge/backend_darwin.go index 2dd8d03e8f..99580bbd82 100644 --- a/protocol/bridge/backend_darwin.go +++ b/protocol/bridge/backend_darwin.go @@ -121,6 +121,12 @@ func (b *backendDarwin) start() error { if err != nil { return E.Cause(err, "enable pf") } + dropRules := bridgeDropRules(b.tunName, b.inet4Port, b.inet6Port) + err = b.pfDevice.LoadAnchor(b.anchorName, dropRules) + if err != nil { + return E.Cause(err, "initialize bridge pf rules") + } + b.currentRules = dropRules b.batchTUN = tunInterface.(tun.DarwinTUN) b.closed = make(chan struct{}) b.readDone = make(chan struct{}) @@ -324,11 +330,15 @@ func (b *backendDarwin) syncEgress() { default: } egress := b.resolveEgress() - var rules []pfAnchorRule + rules := bridgeDropRules(b.tunName, b.inet4Port, b.inet6Port) + var buildErr error if egress != "" { - rules = buildBridgeAnchorRules(b.logger, b.tunName, egress, b.boundInterface, b.inet4Port, b.inet6Port) + rules, buildErr = buildBridgeAnchorRules(b.tunName, egress, b.boundInterface, b.inet4Port, b.inet6Port) } if slices.Equal(rules, b.currentRules) { + if buildErr != nil { + b.logger.Debug(buildErr) + } return } err := b.pfDevice.LoadAnchor(b.anchorName, rules) @@ -337,11 +347,14 @@ func (b *backendDarwin) syncEgress() { return } b.currentRules = rules - if len(rules) == 0 { + if buildErr != nil || egress == "" { b.logger.Debug("bridge egress unavailable, dropping forwarded traffic") } else { b.logger.Debug("bridge egress ", egress) } + if buildErr != nil { + b.logger.Debug(buildErr) + } } func (b *backendDarwin) enablePf() error { diff --git a/protocol/bridge/backend_windows.go b/protocol/bridge/backend_windows.go index 4ec1c3acb8..ab66fcec52 100644 --- a/protocol/bridge/backend_windows.go +++ b/protocol/bridge/backend_windows.go @@ -156,16 +156,6 @@ func (b *backendWindows) start() error { b.closed = make(chan struct{}) state := b.currentEgressState() - if !(b.inet4Port.IsValid() && state.inet4.IsValid()) { - b.inet4Port = netip.Addr{} - } - if !(b.inet6Port.IsValid() && state.inet6.IsValid()) { - b.inet6Port = netip.Addr{} - b.logger.Debug("bridge IPv6 egress unavailable, disabling IPv6 forwarding") - } - if !b.inet4Port.IsValid() && !b.inet6Port.IsValid() { - return E.New("bridge: no usable egress address; requires an interface with a routable address and Administrator") - } b.egress.Store(state) err := b.acquireReservations() @@ -189,6 +179,11 @@ func (b *backendWindows) start() error { } b.registerMonitors(b.syncEgress) + b.syncEgress() + state = b.egress.Load() + if !state.inet4.IsValid() && !state.inet6.IsValid() { + b.logger.Debug("bridge egress unavailable, dropping forwarded traffic") + } b.logger.Info("bridge started (WinDivert, egress ", b.egressLabel(), ")") return nil } @@ -201,22 +196,14 @@ func (b *backendWindows) egressLabel() string { } func (b *backendWindows) acquireReservations() error { - family := windows.AF_INET - if !b.inet4Port.IsValid() { - family = windows.AF_INET6 - } - reservation, err := acquirePortReservation(family, windows.SOCK_STREAM, windows.IPPROTO_TCP, bridgeReservedPortCount) + reservation, err := acquirePortReservation(windows.AF_INET, windows.SOCK_STREAM, windows.IPPROTO_TCP, bridgeReservedPortCount) if err != nil { return E.Cause(err, "bridge: reserve ports") } b.reservation = reservation b.reservedStart = reservation.startPort - if b.inet4Port.IsValid() { - b.icmp4 = newICMPTable(bridgeICMPFlowTimeout) - } - if b.inet6Port.IsValid() { - b.icmp6 = newICMPTable(bridgeICMPFlowTimeout) - } + b.icmp4 = newICMPTable(bridgeICMPFlowTimeout) + b.icmp6 = newICMPTable(bridgeICMPFlowTimeout) return nil } @@ -225,27 +212,33 @@ func (b *backendWindows) PortSelectorRange() (uint16, uint16) { } func (b *backendWindows) rebuildDivertersLocked(state *egressState) error { - for _, existing := range b.diverters { - existing.handle.Close() - <-existing.done - } - b.diverters = nil + b.closeDivertersLocked() if b.inet4Port.IsValid() && state.inet4.IsValid() { err := b.openFamilyDiverters(state.divertAddresses(false), false) if err != nil { + b.closeDivertersLocked() return err } } if b.inet6Port.IsValid() && state.inet6.IsValid() { err := b.openFamilyDiverters(state.divertAddresses(true), true) if err != nil { + b.closeDivertersLocked() return err } } return nil } +func (b *backendWindows) closeDivertersLocked() { + for _, existing := range b.diverters { + existing.handle.Close() + <-existing.done + } + b.diverters = nil +} + func (b *backendWindows) openFamilyDiverters(addresses []netip.Addr, isV6 bool) error { portHigh := uint16(uint32(b.reservedStart) + uint32(bridgeReservedPortCount) - 1) entries := []struct { @@ -724,6 +717,11 @@ func (b *backendWindows) currentEgressState() *egressState { if finder == nil { return state } + err := finder.Update() + if err != nil { + b.logger.Debug(E.Cause(err, "bridge update interfaces")) + return state + } egressInterface, err := finder.ByName(egressName) if err != nil { return state @@ -810,11 +808,7 @@ func (b *backendWindows) Close() error { b.unregister() } b.egressAccess.Lock() - for _, d := range b.diverters { - d.handle.Close() - <-d.done - } - b.diverters = nil + b.closeDivertersLocked() b.egressAccess.Unlock() if b.injectHandle != nil { b.injectHandle.Close() diff --git a/protocol/bridge/netfilter_linux.go b/protocol/bridge/netfilter_linux.go index 2d1e4cace1..38a8588b4e 100644 --- a/protocol/bridge/netfilter_linux.go +++ b/protocol/bridge/netfilter_linux.go @@ -29,33 +29,71 @@ var ( func enableBridgeForwarding(logger logger.ContextLogger, tunName string, inet4 bool, inet6 bool) []sysctlState { var restore []sysctlState - enable := func(path string) { + enable := func(path string) bool { content, err := os.ReadFile(path) if err != nil { logger.Debug(E.Cause(err, "read ", path)) - return + return false } value := strings.TrimSpace(string(content)) if value == "1" { - return + return false } err = os.WriteFile(path, []byte("1"), 0o644) if err != nil { logger.Debug(E.Cause(err, "enable ", path)) - return + return false } restore = append(restore, sysctlState{name: path, value: value}) + return true } if inet4 { enable("/proc/sys/net/ipv4/ip_forward") } if inet6 { - enable("/proc/sys/net/ipv6/conf/all/forwarding") + if enable("/proc/sys/net/ipv6/conf/all/forwarding") { + restore = append(restore, overruleAcceptRA(logger)...) + } } _ = os.WriteFile("/proc/sys/net/ipv4/conf/"+tunName+"/rp_filter", []byte("2"), 0o644) return restore } +// Writing conf/all/forwarding copies forwarding=1 to conf/default and to every +// existing interface (addrconf_fixup_forwarding), and ipv6_accept_ra() then +// requires accept_ra=2 on a forwarding interface; raise interfaces left at the +// host default of 1 so SLAAC (e.g. on PPPoE WANs) survives forwarding. +// conf/default is included so interfaces created afterwards inherit 2. +func overruleAcceptRA(logger logger.ContextLogger) []sysctlState { + var restore []sysctlState + entries, err := os.ReadDir("/proc/sys/net/ipv6/conf") + if err != nil { + logger.Debug(E.Cause(err, "read /proc/sys/net/ipv6/conf")) + return nil + } + for _, entry := range entries { + if entry.Name() == "all" { + continue + } + path := "/proc/sys/net/ipv6/conf/" + entry.Name() + "/accept_ra" + content, err := os.ReadFile(path) + if err != nil { + continue + } + value := strings.TrimSpace(string(content)) + if value != "1" { + continue + } + err = os.WriteFile(path, []byte("2"), 0o644) + if err != nil { + logger.Debug(E.Cause(err, "overrule ", path)) + continue + } + restore = append(restore, sysctlState{name: path, value: value}) + } + return restore +} + func restoreBridgeForwarding(states []sysctlState) { for _, state := range states { _ = os.WriteFile(state.name, []byte(state.value), 0o644) diff --git a/protocol/bridge/pf_darwin.go b/protocol/bridge/pf_darwin.go index 75de1bd539..2a056107c4 100644 --- a/protocol/bridge/pf_darwin.go +++ b/protocol/bridge/pf_darwin.go @@ -19,6 +19,7 @@ const ( pfRulesetNat = 2 pfActionPass = 0 + pfActionDrop = 1 pfActionScrub = 2 pfActionNat = 4 diff --git a/protocol/bridge/rules_darwin.go b/protocol/bridge/rules_darwin.go index 17fcd56e7c..3fd7b7a772 100644 --- a/protocol/bridge/rules_darwin.go +++ b/protocol/bridge/rules_darwin.go @@ -5,6 +5,7 @@ import ( "net/netip" "slices" "strconv" + "strings" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/logger" @@ -12,26 +13,28 @@ import ( "golang.org/x/sys/unix" ) -func buildBridgeAnchorRules(ruleLogger logger.ContextLogger, tunName string, egress string, boundInterface string, inet4Port netip.Addr, inet6Port netip.Addr) []pfAnchorRule { +func buildBridgeAnchorRules(tunName string, egress string, boundInterface string, inet4Port netip.Addr, inet6Port netip.Addr) ([]pfAnchorRule, error) { + rules := bridgeDropRules(tunName, inet4Port, inet6Port) egressInterface, err := net.InterfaceByName(egress) if err != nil { - return nil + return rules, E.Cause(err, "find bridge egress ", egress) + } + isCellular := strings.HasPrefix(egressInterface.Name, "pdp_ip") + isPhysical := egressInterface.Flags&net.FlagBroadcast != 0 && egressInterface.Flags&net.FlagLoopback == 0 && + egressInterface.Flags&net.FlagPointToPoint == 0 + if boundInterface == "" && !isCellular && !isPhysical { + return rules, E.New("bridge egress ", egress, " is not a physical or cellular interface") } + routeWithoutGateway := isCellular || boundInterface != "" && egressInterface.Flags&net.FlagPointToPoint != 0 // The flowswitch aggregates forwarded TCP into packets larger than the tun // MTU, and pf_route() only fragments when they exceed the egress MTU: a // large-MTU utun target feeds them whole into the overflow described at // bridgeTunMTUDarwin. - if egressInterface.Flags&net.FlagBroadcast == 0 || egressInterface.Flags&net.FlagLoopback != 0 || - egressInterface.Flags&net.FlagPointToPoint != 0 { - ruleLogger.Error("bridge egress ", egress, " is not a physical interface, dropping forwarded traffic") - return nil - } mtu := egressInterface.MTU if mtu < 576 || mtu > bridgeTunMTUDarwin { mtu = bridgeTunMTUDarwin } localPrefixes, inet4Interfaces, inet6Interfaces := collectLocalSegments(egress, boundInterface, inet4Port.IsValid(), inet6Port.IsValid()) - var rules []pfAnchorRule if inet4Port.IsValid() { rules = append(rules, pfScrubRule(egress, inet4Port, uint16(mtu-40))) } @@ -57,20 +60,16 @@ func buildBridgeAnchorRules(ruleLogger logger.ContextLogger, tunName string, egr // leaves via the egress and the nat rule applies there. if inet4Port.IsValid() { gateway := interfaceGateway(egressInterface.Index, true) - if gateway.IsValid() { + if gateway.IsValid() || routeWithoutGateway { rules = append(rules, pfRouteToRule(tunName, egress, gateway, inet4Port)) rules = append(rules, pfReplyToRule(tunName, egress, inet4Port)) - } else { - ruleLogger.Debug("no IPv4 gateway on ", egress, ", relying on the default route") } } if inet6Port.IsValid() { gateway := interfaceGateway(egressInterface.Index, false) - if gateway.IsValid() { + if gateway.IsValid() || routeWithoutGateway { rules = append(rules, pfRouteToRule(tunName, egress, gateway, inet6Port)) rules = append(rules, pfReplyToRule(tunName, egress, inet6Port)) - } else { - ruleLogger.Debug("no IPv6 gateway on ", egress, ", relying on the default route") } } // pf rules are last-match: the pass rules below override the route-to pin @@ -83,6 +82,17 @@ func buildBridgeAnchorRules(ruleLogger logger.ContextLogger, tunName string, egr } rules = append(rules, pfPassInRule(tunName, port, prefix)) } + return rules, nil +} + +func bridgeDropRules(tunName string, inet4Port netip.Addr, inet6Port netip.Addr) []pfAnchorRule { + var rules []pfAnchorRule + if inet4Port.IsValid() { + rules = append(rules, pfDropInRule(tunName, inet4Port)) + } + if inet6Port.IsValid() { + rules = append(rules, pfDropInRule(tunName, inet6Port)) + } return rules } @@ -196,11 +206,24 @@ func pfPassInRule(tunName string, port netip.Addr, destination netip.Prefix) pfA return pfAnchorRule{RulesetIndex: pfRulesetFilter, Rule: rule} } +func pfDropInRule(tunName string, port netip.Addr) pfAnchorRule { + rule := pfRule{ + Action: pfActionDrop, + Direction: pfDirectionIn, + AF: pfFamily(port.Is4()), + } + copy(rule.IfName[:], tunName) + rule.Src.Addr = pfHostAddress(port) + return pfAnchorRule{RulesetIndex: pfRulesetFilter, Rule: rule} +} + func pfRouteToRule(tunName string, egress string, gateway netip.Addr, port netip.Addr) pfAnchorRule { anchorRule := pfPassInRule(tunName, port, netip.Prefix{}) anchorRule.Rule.RouteAction = pfRouteActionRouteTo copy(anchorRule.Rule.TagName[:], bridgeTagName(tunName)) - anchorRule.Pool = pfPoolAddr{Addr: pfHostAddress(gateway)} + if gateway.IsValid() { + anchorRule.Pool.Addr = pfHostAddress(gateway) + } copy(anchorRule.Pool.IfName[:], egress) return anchorRule } diff --git a/protocol/bridge/service.go b/protocol/bridge/service.go index 6fc56e9f83..dfa1d1ee12 100644 --- a/protocol/bridge/service.go +++ b/protocol/bridge/service.go @@ -28,7 +28,7 @@ type serviceBase struct { access sync.Mutex egressName string closed bool - applyEgress func() + applyEgress func() error } func (s *serviceBase) FileDescriptor() int { @@ -50,8 +50,7 @@ func (s *serviceBase) SetEgress(interfaceName string) error { return os.ErrClosed } s.egressName = interfaceName - s.applyEgress() - return nil + return s.applyEgress() } func (s *serviceBase) syncEgress() { @@ -60,7 +59,10 @@ func (s *serviceBase) syncEgress() { if s.closed { return } - s.applyEgress() + err := s.applyEgress() + if err != nil { + s.logger.Debug(E.Cause(err, "update bridge egress")) + } } func (s *serviceBase) startNetworkMonitor() { diff --git a/protocol/bridge/service_darwin.go b/protocol/bridge/service_darwin.go index 8e5ad474c6..b280d54bc6 100644 --- a/protocol/bridge/service_darwin.go +++ b/protocol/bridge/service_darwin.go @@ -101,29 +101,36 @@ func (s *Service) start() error { return E.Cause(err, "enable pf") } s.pfToken = token + dropRules := bridgeDropRules(s.tunName, s.inet4Port, s.inet6Port) + err = s.pfDevice.LoadAnchor(s.anchorName, dropRules) + if err != nil { + return E.Cause(err, "initialize bridge pf rules") + } + s.currentRules = dropRules s.startNetworkMonitor() return nil } -func (s *Service) syncEgressLocked() { - var rules []pfAnchorRule +func (s *Service) syncEgressLocked() error { + rules := bridgeDropRules(s.tunName, s.inet4Port, s.inet6Port) + var buildErr error if s.egressName != "" { - rules = buildBridgeAnchorRules(s.logger, s.tunName, s.egressName, s.boundInterface, s.inet4Port, s.inet6Port) + rules, buildErr = buildBridgeAnchorRules(s.tunName, s.egressName, s.boundInterface, s.inet4Port, s.inet6Port) } if slices.Equal(rules, s.currentRules) { - return + return buildErr } err := s.pfDevice.LoadAnchor(s.anchorName, rules) if err != nil { - s.logger.Debug(E.Cause(err, "apply bridge egress ", s.egressName)) - return + return E.Cause(err, "apply bridge egress ", s.egressName) } s.currentRules = rules - if len(rules) == 0 { + if buildErr != nil || s.egressName == "" { s.logger.Debug("bridge egress unavailable, dropping forwarded traffic") } else { s.logger.Debug("bridge egress ", s.egressName) } + return buildErr } func (s *Service) Close() error { diff --git a/protocol/bridge/service_linux.go b/protocol/bridge/service_linux.go index 9892986680..52e9958cbd 100644 --- a/protocol/bridge/service_linux.go +++ b/protocol/bridge/service_linux.go @@ -116,13 +116,13 @@ func (s *Service) start(bridgeName string) error { return nil } -func (s *Service) syncEgressLocked() { +func (s *Service) syncEgressLocked() error { flushBridgeRouteTable(s.routeTable) if s.egressName == "" { for _, family := range activeBridgeFamilies(s.inet6Port) { blackholeBridgeDefault(s.routeTable, family) } - return + return nil } link, err := netlink.LinkByName(s.egressName) if err != nil { @@ -130,12 +130,13 @@ func (s *Service) syncEgressLocked() { blackholeBridgeDefault(s.routeTable, family) } s.logger.Debug("bridge egress ", s.egressName, " absent, dropping forwarded traffic") - return + return nil } for _, family := range activeBridgeFamilies(s.inet6Port) { s.syncEgressFamilyLocked(family, link.Attrs().Index) } s.updateClampLocked(link.Attrs().MTU) + return nil } // Unlike the in-process backend this copies routes from every table: on Android diff --git a/protocol/direct/inbound.go b/protocol/direct/inbound.go index fcb4671d89..42135dc160 100644 --- a/protocol/direct/inbound.go +++ b/protocol/direct/inbound.go @@ -77,6 +77,10 @@ func (i *Inbound) Start(stage adapter.StartStage) error { return i.listener.Start() } +func (i *Inbound) InterfaceUpdated() { + i.udpNat.Purge() +} + func (i *Inbound) Close() error { return i.listener.Close() } diff --git a/protocol/direct/outbound.go b/protocol/direct/outbound.go index fdf45988b5..1480fac934 100644 --- a/protocol/direct/outbound.go +++ b/protocol/direct/outbound.go @@ -29,10 +29,11 @@ func RegisterOutbound(registry *outbound.Registry) { } var ( - _ N.ParallelDialer = (*Outbound)(nil) - _ dialer.ParallelNetworkDialer = (*Outbound)(nil) - _ dialer.DirectDialer = (*Outbound)(nil) - _ adapter.FlowOutbound = (*Outbound)(nil) + _ N.ParallelDialer = (*Outbound)(nil) + _ dialer.ParallelNetworkDialer = (*Outbound)(nil) + _ dialer.DirectDialer = (*Outbound)(nil) + _ adapter.FlowOutbound = (*Outbound)(nil) + _ adapter.InterfaceUpdateListener = (*Outbound)(nil) ) type Outbound struct { @@ -71,7 +72,9 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL domainStrategy: C.DomainStrategy(options.DomainStrategy), fallbackDelay: time.Duration(options.FallbackDelay), dialer: outboundDialer.(dialer.ParallelInterfaceDialer), - isEmpty: reflect.DeepEqual(options.DialerOptions, option.DialerOptions{UDPFragmentDefault: true}), + isEmpty: reflect.DeepEqual(options.DialerOptions, option.DialerOptions{ + AbstractDialerOptions: option.AbstractDialerOptions{UDPFragmentDefault: true}, + }), } //nolint:staticcheck if options.ProxyProtocol != 0 { @@ -88,30 +91,43 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL func (h *Outbound) Start(stage adapter.StartStage) error { switch stage { case adapter.StartStatePostStart, adapter.StartStateStarted: - h.fetchMyAddresses() + if len(h.myAddresses.Load()) == 0 { + h.fetchMyAddresses() + } } return nil } func (h *Outbound) fetchMyAddresses() { - if len(h.myAddresses.Load()) > 0 { - return - } myInterfaceNames := h.network.InterfaceMonitor().MyInterfaces() if len(myInterfaceNames) == 0 { return } - var myAddresses []netip.Prefix + var ( + myAddresses []netip.Prefix + found bool + ) for _, myInterfaceName := range myInterfaceNames { myInterface, err := h.network.InterfaceFinder().ByName(myInterfaceName) if err != nil { continue } + found = true myAddresses = append(myAddresses, myInterface.Addresses...) } + if !found { + return + } h.myAddresses.Store(myAddresses) } +func (h *Outbound) InterfaceUpdated() { + h.fetchMyAddresses() + if h.icmpPort != nil { + h.icmpPort.Close() + } +} + func (h *Outbound) isMyLoopbackAddress(addresses ...netip.Addr) bool { for _, prefix := range h.myAddresses.Load() { for _, address := range addresses { diff --git a/protocol/dns/handle.go b/protocol/dns/handle.go index d7d89ca8a9..72197140c9 100644 --- a/protocol/dns/handle.go +++ b/protocol/dns/handle.go @@ -40,28 +40,30 @@ func HandleStreamDNSRequest(ctx context.Context, router adapter.DNSRouter, conn return err } metadataInQuery := metadata - go func() error { - response, err := router.Exchange(adapter.WithContext(ctx, &metadataInQuery), &message, adapter.DNSQueryOptions{}) + router.ExchangeAsync(adapter.WithContext(ctx, &metadataInQuery), &message, adapter.DNSQueryOptions{}, func(response *mDNS.Msg, err error) { if err != nil { conn.Close() - return err + return } - responseLength := response.Len() - responseBuffer := buf.NewSize(3 + responseLength) - defer responseBuffer.Release() - responseBuffer.Resize(2, 0) - n, err := response.PackBuffer(responseBuffer.FreeBytes()) - if err != nil { - return err - } - responseBuffer.Truncate(len(n)) - binary.BigEndian.PutUint16(responseBuffer.ExtendHeader(2), uint16(len(n))) - _, err = conn.Write(responseBuffer.Bytes()) - return err - }() + go writeStreamResponse(conn, response) + }) return nil } +func writeStreamResponse(conn net.Conn, response *mDNS.Msg) { + responseLength := response.Len() + responseBuffer := buf.NewSize(3 + responseLength) + defer responseBuffer.Release() + responseBuffer.Resize(2, 0) + n, err := response.PackBuffer(responseBuffer.FreeBytes()) + if err != nil { + return + } + responseBuffer.Truncate(len(n)) + binary.BigEndian.PutUint16(responseBuffer.ExtendHeader(2), uint16(len(n))) + conn.Write(responseBuffer.Bytes()) +} + func NewDNSPacketConnection(ctx context.Context, router adapter.DNSRouter, conn N.PacketConn, cachedPackets []*N.PacketBuffer, metadata adapter.InboundContext) error { metadata.Destination = M.Socksaddr{} var reader N.PacketReader = conn @@ -123,24 +125,22 @@ func NewDNSPacketConnection(ctx context.Context, router adapter.DNSRouter, conn timeout.Update() } metadataInQuery := metadata - go func() error { - response, err := router.Exchange(adapter.WithContext(ctx, &metadataInQuery), &message, adapter.DNSQueryOptions{}) + router.ExchangeAsync(adapter.WithContext(ctx, &metadataInQuery), &message, adapter.DNSQueryOptions{}, func(response *mDNS.Msg, err error) { if err != nil { cancel(err) - return err + return } timeout.Update() - responseBuffer, err := dns.TruncateDNSMessage(&message, response, 1024) - if err != nil { - cancel(err) - return err + responseBuffer, truncateErr := dns.TruncateDNSMessage(&message, response, 1024) + if truncateErr != nil { + cancel(truncateErr) + return } - err = conn.WritePacket(responseBuffer, destination) - if err != nil { - cancel(err) + writeErr := conn.WritePacket(responseBuffer, destination) + if writeErr != nil { + cancel(writeErr) } - return err - }() + }) } }) group.Cleanup(func() { @@ -193,24 +193,22 @@ func newDNSPacketConnection(ctx context.Context, router adapter.DNSRouter, conn timeout.Update() } metadataInQuery := metadata - go func() error { - response, err := router.Exchange(adapter.WithContext(ctx, &metadataInQuery), &message, adapter.DNSQueryOptions{}) + router.ExchangeAsync(adapter.WithContext(ctx, &metadataInQuery), &message, adapter.DNSQueryOptions{}, func(response *mDNS.Msg, err error) { if err != nil { cancel(err) - return err + return } timeout.Update() - responseBuffer, err := dns.TruncateDNSMessage(&message, response, 1024) - if err != nil { - cancel(err) - return err + responseBuffer, truncateErr := dns.TruncateDNSMessage(&message, response, 1024) + if truncateErr != nil { + cancel(truncateErr) + return } - err = conn.WritePacket(responseBuffer, destination) - if err != nil { - cancel(err) + writeErr := conn.WritePacket(responseBuffer, destination) + if writeErr != nil { + cancel(writeErr) } - return err - }() + }) } }) group.Cleanup(func() { diff --git a/protocol/group/balancer/sticky_session.go b/protocol/group/balancer/sticky_session.go index bf6f18459f..aa95abadec 100644 --- a/protocol/group/balancer/sticky_session.go +++ b/protocol/group/balancer/sticky_session.go @@ -22,11 +22,11 @@ type StickySession struct { mu sync.Mutex delayAcceptableRatio float64 - lruCache *freelru.ShardedLRU[uint64, int] + lruCache *freelru.Cache[uint64, int] } func NewStickySession(outbounds []adapter.Outbound, options option.BalancerOutboundOptions) *StickySession { - lruCache := common.Must1(freelru.NewSharded[uint64, int](1000, maphash.NewHasher[uint64]().Hash32)) + lruCache := common.Must1(freelru.New[uint64, int](1000, maphash.NewHasher[uint64]().Hash32, true)) lruCache.SetLifetime(options.TTL.Build()) cOutbounds := convertOutbounds(outbounds) return &StickySession{ diff --git a/protocol/group/urltest.go b/protocol/group/urltest.go index 69e310370a..d97235b784 100644 --- a/protocol/group/urltest.go +++ b/protocol/group/urltest.go @@ -28,7 +28,10 @@ func RegisterURLTest(registry *outbound.Registry) { outbound.Register[option.URLTestOutboundOptions](registry, C.TypeURLTest, NewURLTest) } -var _ adapter.OutboundGroup = (*URLTest)(nil) +var ( + _ adapter.OutboundGroup = (*URLTest)(nil) + _ adapter.InterfaceUpdateListener = (*URLTest)(nil) +) type URLTest struct { outbound.Adapter @@ -114,6 +117,17 @@ func (s *URLTest) CheckOutbounds() { s.group.CheckOutbounds(true) } +func (s *URLTest) InterfaceUpdated() { + group := s.group + if group == nil { + return + } + if group.pause.IsDevicePaused() || group.pause.IsNetworkPaused() { + return + } + go group.CheckOutbounds(true) +} + func (s *URLTest) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { s.group.Touch() var outbound adapter.Outbound diff --git a/protocol/hysteria2/inbound.go b/protocol/hysteria2/inbound.go index b706b49202..5da8fd37ea 100644 --- a/protocol/hysteria2/inbound.go +++ b/protocol/hysteria2/inbound.go @@ -7,6 +7,7 @@ import ( "net/http/httputil" "net/netip" "net/url" + "os" "time" "github.com/sagernet/sing-box/adapter" @@ -26,6 +27,7 @@ import ( M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" "github.com/sagernet/sing/service" + "github.com/sagernet/sing/service/filemanager" ) func RegisterInbound(registry *inbound.Registry) { @@ -73,7 +75,12 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo if options.Masquerade != nil && options.Masquerade.Type != "" { switch options.Masquerade.Type { case C.Hysterai2MasqueradeTypeFile: - masqueradeHandler = http.FileServer(http.Dir(options.Masquerade.FileOptions.Directory)) + masqueradeDirectory := filemanager.BasePath(ctx, os.ExpandEnv(options.Masquerade.FileOptions.Directory)) + _, err = filemanager.ReadDir(ctx, masqueradeDirectory) + if err != nil && !os.IsNotExist(err) { + return nil, E.Cause(err, "read masquerade directory") + } + masqueradeHandler = http.FileServer(http.Dir(masqueradeDirectory)) case C.Hysterai2MasqueradeTypeProxy: masqueradeURL, err := url.Parse(options.Masquerade.ProxyOptions.URL) if err != nil { diff --git a/protocol/naive/inbound.go b/protocol/naive/inbound.go index c9d6a1aed8..a70ff5bc0f 100644 --- a/protocol/naive/inbound.go +++ b/protocol/naive/inbound.go @@ -221,7 +221,7 @@ func (n *Inbound) newConnection(ctx context.Context, waitForClose bool, conn net } else { done := make(chan struct{}) wrapper := v2rayhttp.NewHTTP2Wrapper(conn) - n.router.RouteConnectionEx(ctx, conn, metadata, N.OnceClose(func(it error) { + n.router.RouteConnectionEx(ctx, wrapper, metadata, N.OnceClose(func(it error) { close(done) })) <-done diff --git a/protocol/naive/outbound.go b/protocol/naive/outbound.go index 8249a1fefe..1e753c33de 100644 --- a/protocol/naive/outbound.go +++ b/protocol/naive/outbound.go @@ -6,7 +6,6 @@ import ( "context" "encoding/pem" "net" - "os" "strings" "github.com/sagernet/cronet-go" @@ -25,6 +24,7 @@ import ( N "github.com/sagernet/sing/common/network" "github.com/sagernet/sing/common/uot" "github.com/sagernet/sing/service" + "github.com/sagernet/sing/service/filemanager" mDNS "github.com/miekg/dns" ) @@ -109,7 +109,7 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL if len(options.TLS.Certificate) > 0 { trustedRootCertificates = strings.Join(options.TLS.Certificate, "\n") } else if options.TLS.CertificatePath != "" { - content, err := os.ReadFile(options.TLS.CertificatePath) + content, err := filemanager.ReadFile(ctx, options.TLS.CertificatePath) if err != nil { return nil, E.Cause(err, "read certificate") } @@ -146,7 +146,7 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL if len(options.TLS.ECH.Config) > 0 { echConfig = []byte(strings.Join(options.TLS.ECH.Config, "\n")) } else if options.TLS.ECH.ConfigPath != "" { - content, err := os.ReadFile(options.TLS.ECH.ConfigPath) + content, err := filemanager.ReadFile(ctx, options.TLS.ECH.ConfigPath) if err != nil { return nil, E.Cause(err, "read ECH config") } diff --git a/protocol/openconnect/client.go b/protocol/openconnect/client.go new file mode 100644 index 0000000000..4c139f63e2 --- /dev/null +++ b/protocol/openconnect/client.go @@ -0,0 +1,610 @@ +package openconnect + +import ( + "context" + "crypto/tls" + "net" + "net/netip" + "net/url" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/endpoint" + "github.com/sagernet/sing-box/common/dialer" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + openconnecttransport "github.com/sagernet/sing-box/transport/openconnect" + "github.com/sagernet/sing-openconnect" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/buf" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/service" + + "go4.org/netipx" +) + +var ( + _ adapter.OutboundWithPreferredRoutes = (*Endpoint)(nil) + _ adapter.FlowOutbound = (*Endpoint)(nil) + _ adapter.InterfaceUpdateListener = (*Endpoint)(nil) + _ dialer.PacketDialerWithDestination = (*Endpoint)(nil) + _ tun.Port = (*Endpoint)(nil) +) + +type Endpoint struct { + endpointBase + loopContext context.Context + cancelLoop context.CancelFunc + dnsRouter adapter.DNSRouter + client *openconnect.Client + device openconnecttransport.Device + server string + flavor string + stateAccess sync.Mutex + state atomic.Pointer[clientState] + dnsTransportAccess sync.Mutex + dnsTransport *DNSTransport + deviceStarted bool + readLoopDone chan struct{} + statusAccess sync.Mutex + statusUpdated chan struct{} + terminalError string + authFormLoopDone chan struct{} + activeTransportLoopDone chan struct{} + hotpCounter atomic.Uint64 +} + +type clientState struct { + started bool + tunnelConfigured bool + localAddresses []netip.Prefix + routeSet *netipx.IPSet + preferredDomains map[string]bool + configuration openconnecttransport.Configuration + tunnelInfo adapter.OpenConnectTunnelInfo +} + +func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.OpenConnectEndpointOptions) (adapter.Endpoint, error) { + tcpKeepAliveEnabled := options.TCPKeepAliveEnabled || options.TCPKeepAlive != 0 || options.TCPKeepAliveInterval != 0 + if tcpKeepAliveEnabled && options.DisableTCPKeepAlive { + return nil, E.New("tcp_keep_alive_enabled conflicts with disable_tcp_keep_alive") + } + if !tcpKeepAliveEnabled { + options.DisableTCPKeepAlive = true + } else if options.TCPKeepAlive == 0 && options.TCPKeepAliveInterval == 0 { + options.TCPKeepAliveSystemDefaults = true + } + options.UDPBindPort = options.DTLSLocalPort + loopContext, cancelLoop := context.WithCancel(ctx) + openConnectEndpoint := &Endpoint{ + endpointBase: endpointBase{ + Adapter: endpoint.NewAdapterWithDialerOptions(C.TypeOpenConnect, tag, []string{N.NetworkTCP, N.NetworkUDP, N.NetworkICMP}, options.DialerOptions), + router: router, + logger: logger, + }, + loopContext: loopContext, + cancelLoop: cancelLoop, + dnsRouter: service.FromContext[adapter.DNSRouter](ctx), + statusUpdated: make(chan struct{}), + } + openConnectEndpoint.state.Store(new(clientState)) + success := false + defer func() { + if success { + return + } + if openConnectEndpoint.device != nil { + _ = openConnectEndpoint.device.Close() + } + cancelLoop() + }() + server := options.Server + if !strings.Contains(server, "://") { + server = "https://" + server + } + serverURL, err := url.Parse(server) + if err != nil { + return nil, E.Cause(err, "parse server") + } + serverPort := serverURL.Port() + if serverPort == "" { + serverPort = "443" + } + openConnectEndpoint.server = net.JoinHostPort(serverURL.Hostname(), serverPort) + openConnectEndpoint.flavor = options.Flavor + if openConnectEndpoint.flavor == "" { + openConnectEndpoint.flavor = openconnect.FlavorAnyConnect + } + serverAddress, serverAddressErr := netip.ParseAddr(serverURL.Hostname()) + remoteIsDomain := serverURL.Hostname() != "" && serverAddressErr != nil && !serverAddress.IsValid() + outboundDialer, err := dialer.NewWithOptions(dialer.Options{ + Context: ctx, + Options: options.DialerOptions, + RemoteIsDomain: remoteIsDomain, + ResolverOnDetour: true, + NewDialer: true, + }) + if err != nil { + return nil, err + } + udpTimeout := C.UDPTimeout + if options.UDPTimeout != 0 { + udpTimeout = time.Duration(options.UDPTimeout) + } + networkManager := service.FromContext[adapter.NetworkManager](ctx) + device, err := openconnecttransport.NewDevice(openconnecttransport.DeviceOptions{ + Context: ctx, + Logger: logger, + System: options.System, + Handler: openConnectEndpoint, + UDPTimeout: udpTimeout, + ICMPTimeout: C.ICMPTimeout, + UDPMapping: tun.NATMapping(options.UDPMapping), + UDPFiltering: tun.NATFiltering(options.UDPFiltering), + UDPNATMax: options.UDPNATMax, + InterfaceFinder: networkManager.InterfaceFinder(), + Name: options.Name, + MTU: openconnecttransport.DefaultMTU, + Configuration: openconnecttransport.Configuration{ + MTU: openconnecttransport.DefaultMTU, + }, + }) + if err != nil { + return nil, err + } + openConnectEndpoint.device = device + device.SetPacketWriter(openConnectEndpoint.writePacketBuffers) + clientOptions, err := openConnectEndpoint.buildClientOptions(options, outboundDialer) + if err != nil { + return nil, err + } + client, err := openconnect.NewClient(clientOptions) + if err != nil { + return nil, err + } + openConnectEndpoint.client = client + success = true + return openConnectEndpoint, nil +} + +func (e *Endpoint) buildClientOptions(options option.OpenConnectEndpointOptions, outboundDialer N.Dialer) (openconnect.ClientOptions, error) { + var tlsConfig *tls.Config + if options.TLS.Insecure { + tlsConfig = &tls.Config{InsecureSkipVerify: true} + } + certificateAuthority, err := materialSource("tls.certificate_authority", options.TLS.CertificateAuthority, options.TLS.CertificateAuthorityPath) + if err != nil { + return openconnect.ClientOptions{}, err + } + clientCertificate, err := materialSource("tls.client_certificate", options.TLS.ClientCertificate, options.TLS.ClientCertificatePath) + if err != nil { + return openconnect.ClientOptions{}, err + } + clientKey, err := materialSource("tls.client_key", options.TLS.ClientKey, options.TLS.ClientKeyPath) + if err != nil { + return openconnect.ClientOptions{}, err + } + mcaCertificate, err := materialSource("tls.mca_certificate", options.TLS.MCACertificate, options.TLS.MCACertificatePath) + if err != nil { + return openconnect.ClientOptions{}, err + } + mcaKey, err := materialSource("tls.mca_key", options.TLS.MCAKey, options.TLS.MCAKeyPath) + if err != nil { + return openconnect.ClientOptions{}, err + } + var tokenOptions *openconnect.TokenOptions + if options.Token != nil { + tokenOptions = &openconnect.TokenOptions{ + Mode: options.Token.Mode, + Secret: options.Token.Secret, + SecretPath: options.Token.SecretPath, + PIN: options.Token.PIN, + Password: options.Token.Password, + DeviceID: options.Token.DeviceID, + Counter: options.Token.Counter, + } + if tokenOptions.Mode == openconnect.TokenModeHOTP { + e.hotpCounter.Store(tokenOptions.Counter) + tokenOptions.UpdateCounter = func(_ context.Context, counter uint64) error { + e.hotpCounter.Store(counter) + return nil + } + } + } + var csdOptions *openconnect.CSDOptions + var mobileOptions *openconnect.MobileOptions + if options.Mobile != nil { + mobileOptions = &openconnect.MobileOptions{ + PlatformVersion: options.Mobile.PlatformVersion, + DeviceType: options.Mobile.DeviceType, + DeviceUniqueID: options.Mobile.DeviceUniqueID, + } + } + if options.CSD != nil { + csdOptions = &openconnect.CSDOptions{WrapperPath: options.CSD.WrapperPath} + } + var hipOptions *openconnect.HIPOptions + if options.HIP != nil { + hipOptions = &openconnect.HIPOptions{WrapperPath: options.HIP.WrapperPath} + } + var tnccOptions *openconnect.TNCCOptions + if options.TNCC != nil { + tnccCertificates := make([]openconnect.Material, 0, len(options.TNCC.Certificates)) + for i, certificateOptions := range options.TNCC.Certificates { + certificate, certificateErr := materialSource("tncc.certificates["+strconv.Itoa(i)+"].certificate", certificateOptions.Certificate, certificateOptions.CertificatePath) + if certificateErr != nil { + return openconnect.ClientOptions{}, certificateErr + } + tnccCertificates = append(tnccCertificates, certificate) + } + tnccOptions = &openconnect.TNCCOptions{ + WrapperPath: options.TNCC.WrapperPath, + DeviceID: options.TNCC.DeviceID, + UserAgent: options.TNCC.UserAgent, + MachineIdentificationEnabled: options.TNCC.MachineIdentificationEnabled, + Certificates: tnccCertificates, + } + } + var fortinetHostCheckOptions *openconnect.FortinetHostCheckOptions + if options.FortinetHostCheck != nil { + fortinetHostCheckOptions = &openconnect.FortinetHostCheckOptions{ + HostCheck: options.FortinetHostCheck.HostCheck, + CheckVirtualDesktop: options.FortinetHostCheck.CheckVirtualDesktop, + } + } + formEntries := common.Map(options.FormEntries, func(entry option.OpenConnectFormEntryOptions) openconnect.FormEntry { + return openconnect.FormEntry{ + FormID: entry.FormID, + SubmissionKey: entry.SubmissionKey, + Name: entry.Name, + Value: entry.Value, + Promote: entry.Promote, + } + }) + return openconnect.ClientOptions{ + Context: e.loopContext, + Server: options.Server, + Flavor: options.Flavor, + Username: options.Username, + Password: options.Password, + AuthGroup: options.AuthGroup, + Cookie: options.Cookie, + Token: tokenOptions, + ReportedOS: options.ReportedOS, + UserAgent: options.UserAgent, + Version: options.Version, + LocalHostname: options.LocalHostname, + Mobile: mobileOptions, + CSD: csdOptions, + HIP: hipOptions, + TNCC: tnccOptions, + FortinetHostCheck: fortinetHostCheckOptions, + NoUDP: options.NoUDP, + DTLSLocalPort: options.DTLSLocalPort, + CompressionDisabled: options.CompressionDisabled, + CompressionMode: options.CompressionMode, + IPv6Disabled: options.IPv6Disabled, + HTTPKeepAliveDisabled: options.HTTPKeepAliveDisabled, + XMLPostDisabled: options.XMLPostDisabled, + ExternalAuthDisabled: options.ExternalAuthDisabled, + PasswordAuthenticationDisabled: options.PasswordAuthenticationDisabled, + PFS: options.PFS, + MTU: options.MTU, + BaseMTU: options.BaseMTU, + DPDInterval: time.Duration(options.DPDInterval), + ReconnectTimeout: time.Duration(options.ReconnectTimeout), + TrojanInterval: time.Duration(options.TrojanInterval), + QueueLength: options.QueueLength, + AllowInsecureCrypto: options.AllowInsecureCrypto, + TLSConfig: openconnect.ClientTLSOptions{ + Config: tlsConfig, + ServerName: options.TLS.ServerName, + PeerFingerprints: options.TLS.PeerFingerprint, + SystemTrustDisabled: options.TLS.SystemTrustDisabled, + CertificateAuthority: certificateAuthority, + Certificate: clientCertificate, + Key: clientKey, + KeyPassword: options.TLS.ClientKeyPassword, + MCACertificate: mcaCertificate, + MCAKey: mcaKey, + MCAKeyPassword: options.TLS.MCAKeyPassword, + }, + FormEntries: formEntries, + Dialer: outboundDialer, + Logger: e.logger, + OnTunnelConfiguration: e.handleTunnelConfiguration, + }, nil +} + +func (e *Endpoint) handleTunnelConfiguration(event openconnect.TunnelConfigurationEvent) error { + configuration := configurationFromClientEvent(event) + defer e.notifyStatusUpdated() + e.stateAccess.Lock() + defer e.stateAccess.Unlock() + e.updateState(func(state *clientState) { + state.tunnelConfigured = false + }) + routeSet, err := buildIPSet(configuration.Routes, configuration.ExcludedRoutes) + if err != nil { + return E.Cause(err, "build route set") + } + err = e.device.UpdateConfiguration(openconnecttransport.Configuration{ + MTU: configuration.MTU, + Addresses: configuration.Addresses, + }) + if err != nil { + return E.Cause(err, "update device configuration") + } + if !e.deviceStarted { + err = e.device.Start() + if err != nil { + return E.Cause(err, "start device") + } + e.deviceStarted = true + } + preferredDomains := buildPreferredDomains(configuration) + var ipv4Addresses []netip.Prefix + var ipv6Addresses []netip.Prefix + for _, address := range configuration.Addresses { + if address.Addr().Is4() { + ipv4Addresses = append(ipv4Addresses, address) + } else if address.Addr().Is6() { + ipv6Addresses = append(ipv6Addresses, address) + } + } + e.updateState(func(state *clientState) { + connectedSince := state.tunnelInfo.ConnectedSince + if event.Reason == openconnect.TunnelConfigurationEventInitial || + event.Reason == openconnect.TunnelConfigurationEventReestablishment || + connectedSince.IsZero() { + connectedSince = time.Now() + } + state.tunnelConfigured = true + state.localAddresses = configuration.Addresses + state.routeSet = routeSet + state.preferredDomains = preferredDomains + state.configuration = configuration + state.tunnelInfo = adapter.OpenConnectTunnelInfo{ + Server: e.server, + Flavor: e.flavor, + Transport: state.tunnelInfo.Transport, + IPv4: ipv4Addresses, + IPv6: ipv6Addresses, + DNS: configuration.DNS, + MTU: configuration.MTU, + ConnectedSince: connectedSince, + } + }) + e.dnsTransportAccess.Lock() + dnsTransport := e.dnsTransport + e.dnsTransportAccess.Unlock() + if dnsTransport != nil { + dnsTransport.updateConfiguration(configuration) + } + return nil +} + +func (e *Endpoint) updateState(update func(state *clientState)) { + newState := *e.state.Load() + update(&newState) + e.state.Store(&newState) +} + +func (e *Endpoint) Start(stage adapter.StartStage) error { + if stage != adapter.StartStatePostStart { + return nil + } + err := e.client.Start() + if err != nil { + return err + } + e.stateAccess.Lock() + e.updateState(func(state *clientState) { + state.started = true + }) + e.readLoopDone = make(chan struct{}) + e.authFormLoopDone = make(chan struct{}) + e.activeTransportLoopDone = make(chan struct{}) + e.stateAccess.Unlock() + go e.readLoop() + go e.watchAuthForms() + go e.watchActiveTransport() + return nil +} + +func (e *Endpoint) readLoop() { + defer close(e.readLoopDone) + for { + packetBuffers, err := e.client.ReadDataPackets(e.loopContext) + if err != nil { + if E.IsClosedOrCanceled(err) || e.loopContext.Err() != nil { + return + } + e.logger.Error(E.Cause(err, "client terminated")) + e.setTerminalError(err) + return + } + err = e.device.WriteInboundBuffers(packetBuffers) + buf.ReleaseMulti(packetBuffers) + if err != nil { + err = E.Cause(err, "write packet to device") + e.logger.Error(err) + e.setTerminalError(err) + return + } + } +} + +func (e *Endpoint) Close() error { + e.stateAccess.Lock() + e.updateState(func(state *clientState) { + state.started = false + }) + readLoopDone := e.readLoopDone + authFormLoopDone := e.authFormLoopDone + activeTransportLoopDone := e.activeTransportLoopDone + e.stateAccess.Unlock() + e.cancelLoop() + err := E.Errors(e.client.Close(), e.device.Close()) + if readLoopDone != nil { + <-readLoopDone + } + if authFormLoopDone != nil { + <-authFormLoopDone + } + if activeTransportLoopDone != nil { + <-activeTransportLoopDone + } + e.notifyStatusUpdated() + return err +} + +func (e *Endpoint) InterfaceUpdated() { + e.client.RestartSession() +} + +func (e *Endpoint) PreMatchFlow(network string, destination netip.Addr) adapter.PreMatchAction { + return adapter.PreMatchFlow +} + +func (e *Endpoint) PortAddresses() (netip.Addr, netip.Addr) { + return e.device.PortAddresses() +} + +func (e *Endpoint) PortMTU() uint32 { + return e.device.PortMTU() +} + +func (e *Endpoint) AttachReturn(returnPath tun.Return) error { + return e.device.AttachReturn(returnPath) +} + +func (e *Endpoint) DetachReturn(returnPath tun.Return) error { + return e.device.DetachReturn(returnPath) +} + +func (e *Endpoint) JudgeFlow(network uint8, source netip.AddrPort, destination netip.AddrPort, firstPacket []byte) tun.FlowVerdict { + return judgeOpenConnectFlow(e.router, e.Tag(), e.Type(), e.state.Load().localAddresses, network, source, destination, firstPacket) +} + +func (e *Endpoint) NewDNSPacket(payload []byte, source M.Socksaddr, destination M.Socksaddr, writer N.PacketWriter) { + e.newDNSPacket(log.ContextWithNewID(e.loopContext), e, payload, source, destination, writer) +} + +func (e *Endpoint) ready() bool { + state := e.state.Load() + return state.started && state.tunnelConfigured +} + +func (e *Endpoint) WritePackets(packets [][]byte) error { + if !e.ready() { + return E.New("endpoint is not ready yet") + } + err := e.client.WriteDataPackets(packets) + if E.IsMulti(err, openconnect.ErrDataChannelNotReady) { + return E.New("endpoint is not ready yet") + } + return err +} + +func (e *Endpoint) writePacketBuffers(packetBuffers []*buf.Buffer) error { + if !e.ready() { + buf.ReleaseMulti(packetBuffers) + return nil + } + err := e.client.WriteDataPacketBuffers(packetBuffers) + if E.IsMulti(err, openconnect.ErrDataChannelNotReady) { + return nil + } + return err +} + +func (e *Endpoint) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + e.newConnection(ctx, e, e.state.Load().localAddresses, conn, source, destination, onClose) +} + +func (e *Endpoint) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + e.newPacketConnection(ctx, e, e.state.Load().localAddresses, conn, source, destination, onClose) +} + +func (e *Endpoint) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + switch network { + case N.NetworkTCP: + e.logger.InfoContext(ctx, "outbound connection to ", destination) + case N.NetworkUDP: + e.logger.InfoContext(ctx, "outbound packet connection to ", destination) + } + if !e.ready() || !e.client.Ready() { + return nil, E.New("endpoint is not ready yet") + } + if destination.IsDomain() { + destinationAddresses, err := e.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{}) + if err != nil { + return nil, err + } + return N.DialSerial(ctx, e.device, network, destination, destinationAddresses) + } + if !destination.Addr.IsValid() { + return nil, E.New("invalid destination: ", destination) + } + return e.device.DialContext(ctx, network, destination) +} + +func (e *Endpoint) ListenPacketWithDestination(ctx context.Context, destination M.Socksaddr) (net.PacketConn, netip.Addr, error) { + e.logger.InfoContext(ctx, "outbound packet connection to ", destination) + if !e.ready() || !e.client.Ready() { + return nil, netip.Addr{}, E.New("endpoint is not ready yet") + } + if destination.IsDomain() { + destinationAddresses, err := e.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{}) + if err != nil { + return nil, netip.Addr{}, err + } + return N.ListenSerial(ctx, e.device, destination, destinationAddresses) + } + packetConn, err := e.device.ListenPacket(ctx, destination) + if err != nil { + return nil, netip.Addr{}, err + } + if destination.IsIP() { + return packetConn, destination.Addr, nil + } + return packetConn, netip.Addr{}, nil +} + +func (e *Endpoint) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + packetConn, destinationAddress, err := e.ListenPacketWithDestination(ctx, destination) + if err != nil { + return nil, err + } + if destinationAddress.IsValid() && destination != M.SocksaddrFrom(destinationAddress, destination.Port) { + return bufio.NewNATPacketConn(bufio.NewPacketConn(packetConn), M.SocksaddrFrom(destinationAddress, destination.Port), destination), nil + } + return packetConn, nil +} + +func (e *Endpoint) PreferredDomain(metadata *adapter.InboundContext, domain string) bool { + state := e.state.Load() + if !state.started || !state.tunnelConfigured || !e.client.Ready() { + return false + } + canonicalDomain := canonicalOpenConnectDomain(domain) + return openConnectDomainMatchesAny(canonicalDomain, state.preferredDomains) +} + +func (e *Endpoint) PreferredAddress(metadata *adapter.InboundContext, address netip.Addr) bool { + state := e.state.Load() + if !state.started || !state.tunnelConfigured || state.routeSet == nil || !e.client.Ready() { + return false + } + return state.routeSet.Contains(address) +} diff --git a/protocol/openconnect/dns_transport.go b/protocol/openconnect/dns_transport.go new file mode 100644 index 0000000000..eb97ff0ba0 --- /dev/null +++ b/protocol/openconnect/dns_transport.go @@ -0,0 +1,373 @@ +package openconnect + +import ( + "context" + "net/netip" + "os" + "strings" + "sync" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/dns/transport" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + openconnecttransport "github.com/sagernet/sing-box/transport/openconnect" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/service" + + mDNS "github.com/miekg/dns" +) + +func RegisterDNSTransport(registry *dns.TransportRegistry) { + dns.RegisterTransport[option.OpenConnectDNSServerOptions](registry, C.DNSTypeOpenConnect, NewDNSTransport) +} + +type DNSTransport struct { + dns.TransportAdapter + logger logger.ContextLogger + endpointTag string + acceptDefaultResolvers bool + acceptSearchDomain bool + endpointManager adapter.EndpointManager + endpoint *Endpoint + dialer N.Dialer + access sync.RWMutex + closed bool + routes []openConnectDNSRoute + searchDomains []string + defaultResolvers []adapter.DNSTransport +} + +type openConnectDNSRoute struct { + domain string + resolvers []adapter.DNSTransport +} + +func NewDNSTransport(ctx context.Context, logger log.ContextLogger, tag string, options option.OpenConnectDNSServerOptions) (adapter.DNSTransport, error) { + if options.Endpoint == "" { + return nil, E.New("missing endpoint tag") + } + return &DNSTransport{ + TransportAdapter: dns.NewTransportAdapter(C.DNSTypeOpenConnect, tag, nil), + logger: logger, + endpointTag: options.Endpoint, + acceptDefaultResolvers: options.AcceptDefaultResolvers, + acceptSearchDomain: options.AcceptSearchDomain, + endpointManager: service.FromContext[adapter.EndpointManager](ctx), + }, nil +} + +func (t *DNSTransport) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateInitialize { + return nil + } + rawEndpoint, loaded := t.endpointManager.Get(t.endpointTag) + if !loaded { + return E.New("endpoint not found: ", t.endpointTag) + } + openConnectEndpoint, isOpenConnect := rawEndpoint.(*Endpoint) + if !isOpenConnect { + return E.New("endpoint is not OpenConnect: ", t.endpointTag) + } + openConnectEndpoint.dnsTransportAccess.Lock() + if openConnectEndpoint.dnsTransport != nil && openConnectEndpoint.dnsTransport.Tag() != t.Tag() { + openConnectEndpoint.dnsTransportAccess.Unlock() + return E.New("only one DNS server is allowed for an endpoint") + } + openConnectEndpoint.dnsTransport = t + t.endpoint = openConnectEndpoint + t.dialer = openConnectEndpoint + state := openConnectEndpoint.state.Load() + if state.started && state.tunnelConfigured && openConnectEndpoint.client.Ready() { + t.updateConfiguration(state.configuration) + } + openConnectEndpoint.dnsTransportAccess.Unlock() + return nil +} + +func (t *DNSTransport) updateConfiguration(configuration openconnecttransport.Configuration) { + resolverByAddress := make(map[netip.Addr]adapter.DNSTransport) + resolverFor := func(address netip.Addr) adapter.DNSTransport { + if !address.IsValid() { + return nil + } + resolver, loaded := resolverByAddress[address] + if loaded { + return resolver + } + resolver = transport.NewUDPRaw( + t.logger, + dns.NewTransportAdapter(C.DNSTypeUDP, t.Tag()+"/"+address.String(), nil), + t.dialer, + M.SocksaddrFrom(address, 53), + ) + resolverByAddress[address] = resolver + return resolver + } + resolversFor := func(addresses []netip.Addr) []adapter.DNSTransport { + resolvers := make([]adapter.DNSTransport, 0, len(addresses)) + resolverSet := make(map[adapter.DNSTransport]bool) + for _, address := range addresses { + resolver := resolverFor(address) + if resolver != nil && !resolverSet[resolver] { + resolverSet[resolver] = true + resolvers = append(resolvers, resolver) + } + } + return resolvers + } + defaultResolvers := resolversFor(configuration.DNS) + routes := make([]openConnectDNSRoute, 0, len(configuration.SplitDNS)+len(configuration.SearchDomains)+len(configuration.SplitDNSRules)) + routeIndex := make(map[string]int) + for _, rule := range configuration.SplitDNSRules { + resolvers := resolversFor(rule.Servers) + for _, domain := range rule.Domains { + canonicalDomain := canonicalOpenConnectDomain(domain) + if canonicalDomain != "" { + fqdn := mDNS.Fqdn(canonicalDomain) + index, loaded := routeIndex[fqdn] + if loaded { + resolverSet := make(map[adapter.DNSTransport]bool) + for _, resolver := range routes[index].resolvers { + resolverSet[resolver] = true + } + for _, resolver := range resolvers { + if !resolverSet[resolver] { + routes[index].resolvers = append(routes[index].resolvers, resolver) + } + } + } else { + routeIndex[fqdn] = len(routes) + routes = append(routes, openConnectDNSRoute{domain: fqdn, resolvers: resolvers}) + } + } + } + } + for _, domain := range append(append([]string(nil), configuration.SplitDNS...), configuration.SearchDomains...) { + canonicalDomain := canonicalOpenConnectDomain(domain) + if canonicalDomain != "" { + fqdn := mDNS.Fqdn(canonicalDomain) + _, loaded := routeIndex[fqdn] + if !loaded { + routeIndex[fqdn] = len(routes) + routes = append(routes, openConnectDNSRoute{domain: fqdn, resolvers: defaultResolvers}) + } + } + } + searchDomains := make([]string, 0, len(configuration.SearchDomains)) + searchDomainSet := make(map[string]bool) + for _, domain := range configuration.SearchDomains { + canonicalDomain := canonicalOpenConnectDomain(domain) + if canonicalDomain != "" { + fqdn := mDNS.Fqdn(canonicalDomain) + if !searchDomainSet[fqdn] { + searchDomainSet[fqdn] = true + searchDomains = append(searchDomains, fqdn) + } + } + } + if !t.acceptDefaultResolvers || !configuration.TunnelAllDNS && (len(configuration.SplitDNS) > 0 || len(configuration.SplitDNSRules) > 0) { + defaultResolvers = nil + } + + t.access.Lock() + if t.closed { + t.access.Unlock() + for _, resolver := range resolverByAddress { + _ = resolver.Close() + } + return + } + oldResolvers := t.collectResolversLocked() + t.routes = routes + t.searchDomains = searchDomains + t.defaultResolvers = defaultResolvers + activeResolvers := t.collectResolversLocked() + t.access.Unlock() + + for _, resolver := range oldResolvers { + _ = resolver.Close() + } + activeResolverSet := make(map[adapter.DNSTransport]bool, len(activeResolvers)) + for _, resolver := range activeResolvers { + activeResolverSet[resolver] = true + } + for _, resolver := range resolverByAddress { + if !activeResolverSet[resolver] { + _ = resolver.Close() + } + } + if len(resolverByAddress) > 0 { + t.logger.Info("updated ", len(routes), " DNS routes and ", len(resolverByAddress), " resolvers") + } else { + t.logger.Info("cleared DNS configuration") + } +} + +func (t *DNSTransport) Reset() { + t.access.RLock() + resolvers := t.collectResolversLocked() + t.access.RUnlock() + for _, resolver := range resolvers { + resolver.Reset() + } +} + +func (t *DNSTransport) Close() error { + if t.endpoint != nil { + t.endpoint.dnsTransportAccess.Lock() + if t.endpoint.dnsTransport == t { + t.endpoint.dnsTransport = nil + } + t.endpoint.dnsTransportAccess.Unlock() + } + t.access.Lock() + resolvers := t.collectResolversLocked() + t.closed = true + t.routes = nil + t.searchDomains = nil + t.defaultResolvers = nil + t.access.Unlock() + var closeErr error + for _, resolver := range resolvers { + closeErr = E.Errors(closeErr, resolver.Close()) + } + return closeErr +} + +func (t *DNSTransport) PreferredDomain(domain string) bool { + canonicalDomain := mDNS.Fqdn(canonicalOpenConnectDomain(domain)) + t.access.RLock() + routes := t.routes + t.access.RUnlock() + for _, route := range routes { + if mDNS.IsSubDomain(route.domain, canonicalDomain) { + return true + } + } + return false +} + +func (t *DNSTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + done := make(chan struct{}) + var response *mDNS.Msg + var err error + t.ExchangeAsync(ctx, message, func(callbackResponse *mDNS.Msg, callbackErr error) { + response = callbackResponse + err = callbackErr + close(done) + }) + <-done + return response, err +} + +func (t *DNSTransport) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { + if len(message.Question) != 1 { + callback(nil, os.ErrInvalid) + return + } + t.access.RLock() + searchDomains := append([]string(nil), t.searchDomains...) + t.access.RUnlock() + if t.acceptSearchDomain && len(searchDomains) > 0 && mDNS.CountLabel(message.Question[0].Name) == 1 { + t.exchangeWithSearchDomains(ctx, message, searchDomains, callback) + return + } + t.exchangeOnce(ctx, message, callback) +} + +func (t *DNSTransport) exchangeWithSearchDomains(ctx context.Context, message *mDNS.Msg, searchDomains []string, callback func(response *mDNS.Msg, err error)) { + originalQuestion := message.Question[0] + singleLabel := strings.TrimSuffix(originalQuestion.Name, ".") + exchangers := make([]transport.AsyncExchanger, 0, len(searchDomains)+1) + for _, searchDomain := range searchDomains { + expandedName := singleLabel + "." + searchDomain + exchangers = append(exchangers, func(exchangeCtx context.Context, exchangeCallback func(response *mDNS.Msg, err error)) { + question := originalQuestion + question.Name = expandedName + rewritten := *message + rewritten.Question = []mDNS.Question{question} + t.exchangeOnce(exchangeCtx, &rewritten, func(response *mDNS.Msg, err error) { + if err == nil { + restoreOpenConnectDNSQuestion(response, expandedName, originalQuestion) + } + exchangeCallback(response, err) + }) + }) + } + exchangers = append(exchangers, func(exchangeCtx context.Context, exchangeCallback func(response *mDNS.Msg, err error)) { + t.exchangeOnce(exchangeCtx, message, exchangeCallback) + }) + transport.ExchangeSequential(ctx, exchangers, func(response *mDNS.Msg, err error) bool { + return err == nil && response.Rcode != mDNS.RcodeNameError + }, callback) +} + +func restoreOpenConnectDNSQuestion(response *mDNS.Msg, expandedName string, originalQuestion mDNS.Question) { + response.Question = []mDNS.Question{originalQuestion} + for _, record := range response.Answer { + if strings.EqualFold(record.Header().Name, expandedName) { + record.Header().Name = originalQuestion.Name + } + } +} + +func (t *DNSTransport) exchangeOnce(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { + question := message.Question[0] + t.access.RLock() + routes := t.routes + defaultResolvers := t.defaultResolvers + t.access.RUnlock() + var matchedResolvers []adapter.DNSTransport + matchedDomainLength := -1 + for _, route := range routes { + if len(route.domain) > matchedDomainLength && mDNS.IsSubDomain(route.domain, question.Name) { + matchedDomainLength = len(route.domain) + matchedResolvers = route.resolvers + } + } + if matchedDomainLength != -1 { + if len(matchedResolvers) == 0 { + callback(nil, dns.RcodeNameError) + return + } + transport.ExchangeSequential(ctx, openConnectDNSExchangers(matchedResolvers, message), nil, callback) + return + } + if len(defaultResolvers) == 0 { + callback(nil, dns.RcodeNameError) + return + } + transport.ExchangeSequential(ctx, openConnectDNSExchangers(defaultResolvers, message), nil, callback) +} + +func openConnectDNSExchangers(resolvers []adapter.DNSTransport, message *mDNS.Msg) []transport.AsyncExchanger { + return common.Map(resolvers, func(resolver adapter.DNSTransport) transport.AsyncExchanger { + return func(ctx context.Context, callback func(response *mDNS.Msg, err error)) { + resolver.ExchangeAsync(ctx, message, callback) + } + }) +} + +func (t *DNSTransport) collectResolversLocked() []adapter.DNSTransport { + resolverSet := make(map[adapter.DNSTransport]bool) + for _, route := range t.routes { + for _, resolver := range route.resolvers { + resolverSet[resolver] = true + } + } + for _, resolver := range t.defaultResolvers { + resolverSet[resolver] = true + } + resolvers := make([]adapter.DNSTransport, 0, len(resolverSet)) + for resolver := range resolverSet { + resolvers = append(resolvers, resolver) + } + return resolvers +} diff --git a/protocol/openconnect/endpoint.go b/protocol/openconnect/endpoint.go new file mode 100644 index 0000000000..2199936527 --- /dev/null +++ b/protocol/openconnect/endpoint.go @@ -0,0 +1,262 @@ +package openconnect + +import ( + "context" + "net" + "net/netip" + "slices" + "strings" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/endpoint" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + openconnecttransport "github.com/sagernet/sing-box/transport/openconnect" + "github.com/sagernet/sing-openconnect" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/bufio" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + + "go4.org/netipx" +) + +func RegisterEndpoint(registry *endpoint.Registry) { + endpoint.Register[option.OpenConnectEndpointOptions](registry, C.TypeOpenConnect, NewEndpoint) +} + +type endpointBase struct { + endpoint.Adapter + router adapter.Router + logger log.ContextLogger +} + +func (e *endpointBase) SupportsFlow(network string) bool { + return slices.Contains(e.Network(), network) +} + +func (e *endpointBase) newConnection(ctx context.Context, endpoint adapter.Endpoint, localAddresses []netip.Prefix, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + var metadata adapter.InboundContext + metadata.Inbound = endpoint.Tag() + metadata.InboundType = endpoint.Type() + metadata.Source = source + if isEndpointLocalAddress(localAddresses, destination.Addr) { + metadata.OriginDestination = destination + destination.Addr = loopbackAddressFor(destination.Addr) + } + metadata.Destination = destination + e.logger.InfoContext(ctx, "inbound connection from ", source) + e.logger.InfoContext(ctx, "inbound connection to ", metadata.Destination) + e.router.RouteConnectionEx(ctx, conn, metadata, onClose) +} + +func (e *endpointBase) newPacketConnection(ctx context.Context, endpoint adapter.Endpoint, localAddresses []netip.Prefix, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + var metadata adapter.InboundContext + metadata.Inbound = endpoint.Tag() + metadata.InboundType = endpoint.Type() + metadata.Source = source + if isEndpointLocalAddress(localAddresses, destination.Addr) { + metadata.OriginDestination = destination + destination.Addr = loopbackAddressFor(destination.Addr) + conn = bufio.NewNATPacketConn(bufio.NewNetPacketConn(conn), metadata.OriginDestination, destination) + } + metadata.Destination = destination + e.logger.InfoContext(ctx, "inbound packet connection from ", source) + e.logger.InfoContext(ctx, "inbound packet connection to ", metadata.Destination) + e.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose) +} + +func (e *endpointBase) newDNSPacket(ctx context.Context, endpoint adapter.Endpoint, payload []byte, source M.Socksaddr, destination M.Socksaddr, writer N.PacketWriter) { + var metadata adapter.InboundContext + metadata.Inbound = endpoint.Tag() + metadata.InboundType = endpoint.Type() + metadata.Network = N.NetworkUDP + metadata.Source = source + metadata.Destination = destination + metadata.Protocol = C.ProtocolDNS + e.logger.InfoContext(ctx, "inbound DNS packet from ", source) + e.router.HijackDNSPacket(ctx, payload, writer, metadata) +} + +func isEndpointLocalAddress(localAddresses []netip.Prefix, address netip.Addr) bool { + for _, localPrefix := range localAddresses { + if address == localPrefix.Addr() { + return true + } + } + return false +} + +func loopbackAddressFor(address netip.Addr) netip.Addr { + if address.Is4() { + return netip.AddrFrom4([4]uint8{127, 0, 0, 1}) + } + return netip.IPv6Loopback() +} + +func judgeOpenConnectFlow(router adapter.Router, tag string, endpointType string, localAddresses []netip.Prefix, network uint8, source netip.AddrPort, destination netip.AddrPort, firstPacket []byte) tun.FlowVerdict { + for _, localPrefix := range localAddresses { + if destination.Addr() == localPrefix.Addr() { + return tun.FlowVerdict{Action: tun.ActionAccept} + } + } + return adapter.JudgeFlow(router, tag, endpointType, network, source, destination, firstPacket) +} + +func materialSource(name string, inlineValues []string, path string) (openconnect.Material, error) { + material := openconnect.Material{Path: path} + if len(inlineValues) > 0 { + material.Content = []byte(strings.Join(inlineValues, "\n")) + } + return material, material.Validate(name) +} + +func configurationFromClientEvent(event openconnect.TunnelConfigurationEvent) openconnecttransport.Configuration { + configuration := event.Configuration + mtu := configuration.MTU + if mtu == 0 { + mtu = openconnecttransport.DefaultMTU + } + routes := common.Map(configuration.Routes, func(route openconnect.TunnelRoute) openconnecttransport.Route { + return openconnecttransport.Route{ + Prefix: route.Prefix, + Gateway: route.Gateway, + Metric: route.Metric, + } + }) + excludedRoutes := common.Map(configuration.ExcludedRoutes, func(route openconnect.TunnelRoute) openconnecttransport.Route { + return openconnecttransport.Route{ + Prefix: route.Prefix, + Gateway: route.Gateway, + Metric: route.Metric, + } + }) + if configuration.RemoteAddress.IsValid() { + remoteAddress := configuration.RemoteAddress.Unmap() + if remoteAddress.Is6() { + remoteAddress = remoteAddress.WithZone("") + } + remoteAddressExcluded := false + for _, route := range excludedRoutes { + if route.Prefix.Contains(remoteAddress) { + remoteAddressExcluded = true + break + } + } + if !remoteAddressExcluded { + excludedRoutes = append(excludedRoutes, openconnecttransport.Route{ + Prefix: netip.PrefixFrom(remoteAddress, remoteAddress.BitLen()), + }) + } + } + dnsAddresses := append([]netip.Addr(nil), configuration.DNS...) + for _, rule := range configuration.SplitDNSRules { + dnsAddresses = append(dnsAddresses, rule.Servers...) + } + for _, dnsAddress := range dnsAddresses { + if !dnsAddress.IsValid() { + continue + } + dnsAddressExcluded := false + for _, route := range excludedRoutes { + if route.Prefix.Contains(dnsAddress) { + dnsAddressExcluded = true + break + } + } + if dnsAddressExcluded { + continue + } + dnsAddressIncluded := false + for _, route := range routes { + if route.Prefix.Contains(dnsAddress) { + dnsAddressIncluded = true + break + } + } + if !dnsAddressIncluded { + routes = append(routes, openconnecttransport.Route{ + Prefix: netip.PrefixFrom(dnsAddress, dnsAddress.BitLen()), + }) + } + } + splitDNSRules := common.Map(configuration.SplitDNSRules, func(rule openconnect.TunnelSplitDNSRule) openconnecttransport.SplitDNSRule { + return openconnecttransport.SplitDNSRule{ + Domains: rule.Domains, + Servers: rule.Servers, + } + }) + return openconnecttransport.Configuration{ + MTU: mtu, + Addresses: configuration.Addresses, + Routes: routes, + ExcludedRoutes: excludedRoutes, + DNS: configuration.DNS, + NBNS: configuration.NBNS, + SearchDomains: configuration.SearchDomains, + SplitDNS: configuration.SplitDNS, + SplitDNSRules: splitDNSRules, + ProxyAutoConfigURL: configuration.ProxyAutoConfigURL, + Banner: configuration.Banner, + TunnelAllDNS: configuration.TunnelAllDNS, + ClientBypassProtocol: configuration.ClientBypassProtocol, + IdleTimeout: configuration.IdleTimeout, + AuthenticationExpiration: configuration.AuthenticationExpiration, + } +} + +func buildIPSet(routes []openconnecttransport.Route, excludedRoutes []openconnecttransport.Route) (*netipx.IPSet, error) { + var builder netipx.IPSetBuilder + for _, route := range routes { + builder.AddPrefix(route.Prefix) + } + for _, route := range excludedRoutes { + builder.RemovePrefix(route.Prefix) + } + return builder.IPSet() +} + +func buildPreferredDomains(configuration openconnecttransport.Configuration) map[string]bool { + preferredDomains := make(map[string]bool) + for _, domain := range configuration.SearchDomains { + canonicalDomain := canonicalOpenConnectDomain(domain) + if canonicalDomain != "" { + preferredDomains[canonicalDomain] = true + } + } + for _, domain := range configuration.SplitDNS { + canonicalDomain := canonicalOpenConnectDomain(domain) + if canonicalDomain != "" { + preferredDomains[canonicalDomain] = true + } + } + for _, rule := range configuration.SplitDNSRules { + for _, domain := range rule.Domains { + canonicalDomain := canonicalOpenConnectDomain(domain) + if canonicalDomain != "" { + preferredDomains[canonicalDomain] = true + } + } + } + return preferredDomains +} + +func canonicalOpenConnectDomain(domain string) string { + return strings.ToLower(strings.Trim(strings.TrimSpace(domain), ".")) +} + +func openConnectDomainMatchesAny(domain string, suffixes map[string]bool) bool { + for domain != "" { + if suffixes[domain] { + return true + } + dotIndex := strings.IndexByte(domain, '.') + if dotIndex == -1 { + break + } + domain = domain[dotIndex+1:] + } + return false +} diff --git a/protocol/openconnect/status.go b/protocol/openconnect/status.go new file mode 100644 index 0000000000..b7be1dc56d --- /dev/null +++ b/protocol/openconnect/status.go @@ -0,0 +1,175 @@ +package openconnect + +import ( + "net/http" + "slices" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-openconnect" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/service" +) + +var _ adapter.OpenConnectEndpoint = (*Endpoint)(nil) + +func (e *Endpoint) OpenConnectStatus() adapter.OpenConnectStatus { + var status adapter.OpenConnectStatus + clientState := e.state.Load() + authChallenge := e.client.PendingAuthChallenge() + e.statusAccess.Lock() + status.Error = e.terminalError + e.statusAccess.Unlock() + if authChallenge != nil { + challenge := &adapter.OpenConnectAuthChallenge{ + ID: authChallenge.ID, + Banner: authChallenge.Banner, + Message: authChallenge.Message, + Error: authChallenge.Error, + } + if authChallenge.Form != nil { + challenge.Form = &adapter.OpenConnectAuthForm{ + Fields: common.Map(authChallenge.Form.Fields, func(field openconnect.AuthFormField) adapter.OpenConnectAuthFormField { + return adapter.OpenConnectAuthFormField{ + SubmissionKey: field.SubmissionKey, + Name: field.Name, + Label: field.Label, + Kind: field.Kind, + Value: field.Value, + Options: common.Map(field.Options, func(choice openconnect.AuthFormChoice) adapter.OpenConnectAuthFormChoice { + return adapter.OpenConnectAuthFormChoice{ + Value: choice.Value, + Label: choice.Label, + } + }), + } + }), + } + } + if authChallenge.Browser != nil { + var cacheID string + cacheFile := service.FromContext[adapter.CacheFile](e.loopContext) + if cacheFile != nil { + cacheID = cacheFile.CacheID() + } + challenge.Browser = &adapter.OpenConnectBrowserRequest{ + URL: authChallenge.Browser.URL, + FinalURL: authChallenge.Browser.FinalURL, + CookieNames: slices.Clone(authChallenge.Browser.CookieNames), + EarlyCookieNames: slices.Clone(authChallenge.Browser.EarlyCookieNames), + HeaderNames: slices.Clone(authChallenge.Browser.HeaderNames), + CallbackURLPrefixes: slices.Clone(authChallenge.Browser.CallbackURLPrefixes), + CacheID: cacheID, + } + } + status.AuthChallenge = challenge + } + switch { + case status.AuthChallenge != nil: + status.State = adapter.OpenConnectStateAuthPending + case status.Error != "": + status.State = adapter.OpenConnectStateError + case clientState.started && clientState.tunnelConfigured && e.client.Ready(): + status.State = adapter.OpenConnectStateConnected + tunnelInfo := clientState.tunnelInfo + tunnelInfo.IPv4 = slices.Clone(tunnelInfo.IPv4) + tunnelInfo.IPv6 = slices.Clone(tunnelInfo.IPv6) + tunnelInfo.DNS = slices.Clone(tunnelInfo.DNS) + status.TunnelInfo = &tunnelInfo + default: + status.State = adapter.OpenConnectStateConnecting + } + return status +} + +func (e *Endpoint) StatusUpdated() <-chan struct{} { + e.statusAccess.Lock() + defer e.statusAccess.Unlock() + return e.statusUpdated +} + +func (e *Endpoint) CompleteAuthChallenge(challengeID string, response adapter.OpenConnectAuthResponse) error { + var authResponse openconnect.AuthResponse + if response.Form != nil { + authResponse.Form = &openconnect.AuthFormResponse{Values: response.Form.Values} + } + if response.Browser != nil { + browserResult := &openconnect.BrowserResult{ + FinalURL: response.Browser.FinalURL, + Cookies: common.Map(response.Browser.Cookies, func(cookie adapter.OpenConnectBrowserCookie) openconnect.BrowserCookie { + return openconnect.BrowserCookie{Name: cookie.Name, Value: cookie.Value} + }), + Header: make(http.Header), + } + for _, header := range response.Browser.Headers { + for _, value := range header.Values { + browserResult.Header.Add(header.Name, value) + } + } + authResponse.Browser = browserResult + } + return e.client.CompleteAuthChallenge(challengeID, authResponse) +} + +func (e *Endpoint) CancelAuthChallenge(challengeID string) error { + return e.client.CancelAuthChallenge(challengeID) +} + +func (e *Endpoint) notifyStatusUpdated() { + e.statusAccess.Lock() + e.notifyStatusUpdatedLocked() + e.statusAccess.Unlock() +} + +func (e *Endpoint) notifyStatusUpdatedLocked() { + close(e.statusUpdated) + e.statusUpdated = make(chan struct{}) +} + +func (e *Endpoint) setTerminalError(err error) { + e.statusAccess.Lock() + e.terminalError = err.Error() + e.notifyStatusUpdatedLocked() + e.statusAccess.Unlock() +} + +func (e *Endpoint) watchAuthForms() { + defer close(e.authFormLoopDone) + var loggedAuthChallengeID string + for { + authChallengeUpdated := e.client.AuthChallengeUpdated() + authChallenge := e.client.PendingAuthChallenge() + if authChallenge != nil && authChallenge.ID != loggedAuthChallengeID { + loggedAuthChallengeID = authChallenge.ID + if authChallenge.Browser != nil { + e.logger.Info("waiting for browser authentication") + } else { + e.logger.Info("waiting for authentication") + } + } + e.notifyStatusUpdated() + select { + case <-e.loopContext.Done(): + return + case <-authChallengeUpdated: + } + } +} + +func (e *Endpoint) watchActiveTransport() { + defer close(e.activeTransportLoopDone) + for { + transportUpdated := e.client.ActiveTransportUpdated() + transport := e.client.ActiveTransport() + e.stateAccess.Lock() + e.updateState(func(state *clientState) { + state.tunnelInfo.Transport = transport + }) + e.stateAccess.Unlock() + e.notifyStatusUpdated() + select { + case <-e.loopContext.Done(): + return + case <-transportUpdated: + } + } +} diff --git a/protocol/openvpn/client.go b/protocol/openvpn/client.go new file mode 100644 index 0000000000..69d44258c4 --- /dev/null +++ b/protocol/openvpn/client.go @@ -0,0 +1,849 @@ +package openvpn + +import ( + "context" + "net" + "net/netip" + "slices" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/endpoint" + "github.com/sagernet/sing-box/common/dialer" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + ovpntransport "github.com/sagernet/sing-box/transport/openvpn" + ovpn "github.com/sagernet/sing-openvpn" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing-tun/gtcpip/header" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/buf" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/service" + + "go4.org/netipx" +) + +var ( + _ adapter.OutboundWithPreferredRoutes = (*ClientEndpoint)(nil) + _ adapter.FlowOutbound = (*ClientEndpoint)(nil) + _ adapter.InterfaceUpdateListener = (*ClientEndpoint)(nil) + _ dialer.PacketDialerWithDestination = (*ClientEndpoint)(nil) + _ tun.Port = (*ClientEndpoint)(nil) +) + +type ClientEndpoint struct { + endpointBase + ctx context.Context + loopContext context.Context + cancelLoop context.CancelFunc + dnsRouter adapter.DNSRouter + outboundDialer N.Dialer + queryOptions adapter.DNSQueryOptions + client *ovpn.Client + device ovpntransport.Device + stateAccess sync.Mutex + state atomic.Pointer[clientState] + dnsTransport *DNSTransport + deviceStarted bool + readLoopDone chan struct{} + statusAccess sync.Mutex + statusUpdated chan struct{} + terminalError string + challengeLoopDone chan struct{} +} + +type clientState struct { + started bool + tunnelConfigured bool + localAddresses []netip.Prefix + routeSet *netipx.IPSet + blockIPv6 bool + configuration ovpntransport.Configuration + preferredDomains []string + tunnelInfo adapter.OpenVPNTunnelInfo +} + +func NewClientEndpoint(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.OpenVPNClientEndpointOptions) (adapter.Endpoint, error) { + loopContext, cancelLoop := context.WithCancel(ctx) + clientEndpoint := &ClientEndpoint{ + endpointBase: endpointBase{ + Adapter: endpoint.NewAdapterWithDialerOptions(C.TypeOpenVPNClient, tag, []string{N.NetworkTCP, N.NetworkUDP, N.NetworkICMP}, options.DialerOptions), + router: router, + logger: logger, + }, + ctx: ctx, + loopContext: loopContext, + cancelLoop: cancelLoop, + dnsRouter: service.FromContext[adapter.DNSRouter](ctx), + statusUpdated: make(chan struct{}), + } + success := false + defer func() { + if success { + return + } + if clientEndpoint.device != nil { + _ = clientEndpoint.device.Close() + } + cancelLoop() + }() + clientOptions, err := clientEndpoint.buildClientOptions(options) + if err != nil { + return nil, err + } + clientEndpoint.state.Store(&clientState{localAddresses: clientOptions.Tunnel.LocalAddress}) + outboundDialer, err := dialer.NewWithOptions(dialer.Options{ + Context: ctx, + Options: options.DialerOptions, + RemoteIsDomain: openVPNClientRemoteIsDomain(options), + ResolverOnDetour: true, + NewDialer: true, + }) + if err != nil { + return nil, err + } + var queryOptions adapter.DNSQueryOptions + resolveDialer, isResolveDialer := outboundDialer.(dialer.ResolveDialer) + if isResolveDialer { + queryOptions = resolveDialer.QueryOptions() + } + clientEndpoint.outboundDialer = outboundDialer + clientEndpoint.queryOptions = queryOptions + udpTimeout := C.UDPTimeout + if options.UDPTimeout != 0 { + udpTimeout = time.Duration(options.UDPTimeout) + } + device, err := ovpntransport.NewDevice(ovpntransport.DeviceOptions{ + Context: ctx, + Logger: logger, + System: options.System, + Handler: clientEndpoint, + UDPTimeout: udpTimeout, + ICMPTimeout: C.ICMPTimeout, + UDPMapping: tun.NATMapping(options.UDPMapping), + UDPFiltering: tun.NATFiltering(options.UDPFiltering), + UDPNATMax: options.UDPNATMax, + InterfaceFinder: service.FromContext[adapter.NetworkManager](ctx).InterfaceFinder(), + Name: options.Name, + MTU: options.MTU, + Configuration: ovpntransport.Configuration{ + MTU: options.MTU, + Address: clientOptions.Tunnel.LocalAddress, + }, + }) + if err != nil { + return nil, err + } + clientEndpoint.device = device + device.SetPacketWriter(clientEndpoint.writePacketBuffers) + client, err := ovpn.NewClient(clientOptions) + if err != nil { + return nil, err + } + clientEndpoint.client = client + success = true + return clientEndpoint, nil +} + +func (c *ClientEndpoint) buildClientOptions(options option.OpenVPNClientEndpointOptions) (ovpn.ClientOptions, error) { + mode := options.Mode + if mode == "" { + mode = ovpn.ModeTLS + } + switch mode { + case ovpn.ModeTLS, ovpn.ModeStaticKey: + default: + return ovpn.ClientOptions{}, E.New("unsupported mode: ", mode, " (expected \"tls\" or \"static_key\")") + } + if options.Server != "" && len(options.Servers) > 0 { + return ovpn.ClientOptions{}, E.New("`server` is conflict with `servers`") + } + if options.Server == "" && len(options.Servers) == 0 { + return ovpn.ClientOptions{}, E.New("missing `server` or `servers`") + } + protocol, remotes := buildClientRemoteOptions(options) + tunnelOptions, err := buildClientTunnelOptions(options, mode == ovpn.ModeStaticKey) + if err != nil { + return ovpn.ClientOptions{}, err + } + if mode == ovpn.ModeStaticKey { + return c.buildStaticKeyClientOptions(options, protocol, remotes, tunnelOptions) + } + if options.TLS == nil { + return ovpn.ClientOptions{}, E.New("missing `tls` options") + } + if len(options.StaticKey) > 0 || options.StaticKeyPath != "" { + return ovpn.ClientOptions{}, E.New("`static_key` and `static_key_path` are only supported in `static_key` mode") + } + if options.KeyDirection != "" { + return ovpn.ClientOptions{}, E.New("`key_direction` is only supported in `static_key` mode; use `tls.control_wrap.direction` for `tls_auth`") + } + if options.Cipher != "" { + return ovpn.ClientOptions{}, E.New("`cipher` is only supported in `static_key` mode; use `data_ciphers` or `data_ciphers_fallback` in TLS mode") + } + certificateAuthority, err := materialSource("tls.certificate", options.TLS.Certificate, options.TLS.CertificatePath) + if err != nil { + return ovpn.ClientOptions{}, err + } + clientCertificate, err := materialSource("tls.client_certificate", options.TLS.ClientCertificate, options.TLS.ClientCertificatePath) + if err != nil { + return ovpn.ClientOptions{}, err + } + clientKey, err := materialSource("tls.client_key", options.TLS.ClientKey, options.TLS.ClientKeyPath) + if err != nil { + return ovpn.ClientOptions{}, err + } + keyDirection := -1 + var controlAuth ovpn.Material + var controlCrypt ovpn.Material + var controlCryptV2 ovpn.Material + controlWrap := options.TLS.ControlWrap + if controlWrap != nil && (controlWrap.Type != "" || len(controlWrap.Key) > 0 || controlWrap.KeyPath != "" || controlWrap.Direction != "") { + controlKey, controlErr := requiredMaterialSource("tls.control_wrap.key", controlWrap.Key, controlWrap.KeyPath) + if controlErr != nil { + return ovpn.ClientOptions{}, controlErr + } + switch controlWrap.Type { + case "tls_auth": + keyDirection, err = keyDirectionValue(controlWrap.Direction) + if err != nil { + return ovpn.ClientOptions{}, err + } + controlAuth = controlKey + case "tls_crypt": + if controlWrap.Direction != "" { + return ovpn.ClientOptions{}, E.New("`tls.control_wrap.direction` is only supported by `tls_auth`") + } + controlCrypt = controlKey + case "tls_crypt_v2": + if controlWrap.Direction != "" { + return ovpn.ClientOptions{}, E.New("`tls.control_wrap.direction` is only supported by `tls_auth`") + } + controlCryptV2 = controlKey + case "": + return ovpn.ClientOptions{}, E.New("missing control wrap type") + default: + return ovpn.ClientOptions{}, E.New("unknown control wrap type: ", controlWrap.Type) + } + } + pullFilters := common.Map(options.PullFilters, func(filterOptions option.OpenVPNPullFilterOptions) ovpn.PullFilter { + return ovpn.PullFilter{ + Action: filterOptions.Action, + Text: filterOptions.Text, + } + }) + remoteCertificateTLS := options.TLS.RemoteCertificateTLS + switch remoteCertificateTLS { + case "", "server", "client", "none": + default: + return ovpn.ClientOptions{}, E.New("invalid `tls.remote_certificate_tls`: ", remoteCertificateTLS) + } + if options.TLS.RemoteCertificateEKU != "" && remoteCertificateTLS != "" { + return ovpn.ClientOptions{}, E.New("`tls.remote_certificate_eku` is conflict with `tls.remote_certificate_tls`") + } + if remoteCertificateTLS == "" && options.TLS.RemoteCertificateEKU == "" { + remoteCertificateTLS = "server" + } else if remoteCertificateTLS == "none" { + remoteCertificateTLS = "" + } + clientTLSOptions := ovpn.ClientTLSOptions{ + CertificateAuthority: certificateAuthority, + Certificate: clientCertificate, + Key: clientKey, + Auth: controlAuth, + Crypt: controlCrypt, + CryptV2: controlCryptV2, + VerifyX509Type: options.TLS.ServerNameType, + PeerFingerprint: options.TLS.PeerFingerprint, + CRLVerify: options.TLS.CRLPath, + RemoteCertificateKU: options.TLS.RemoteCertificateKU, + RemoteCertificateEKU: options.TLS.RemoteCertificateEKU, + RemoteCertificateTLS: remoteCertificateTLS, + NSCertificateType: options.TLS.NSCertificateType, + VersionMin: options.TLS.VersionMin, + VersionMax: options.TLS.VersionMax, + CertificateProfile: options.TLS.CertificateProfile, + Cipher: options.TLS.Cipher, + Groups: options.TLS.Groups, + } + if options.TLS.ServerName != "" { + clientTLSOptions.VerifyX509Name = options.TLS.ServerName + if options.TLS.ServerNameType == "" { + clientTLSOptions.VerifyX509Type = "name" + } + } + return ovpn.ClientOptions{ + Context: c.loopContext, + Mode: mode, + Transport: ovpn.ClientTransportOptions{ + Remotes: remotes, + RemoteRandom: options.RemoteRandom, + Protocol: protocol, + ExplicitExitNotify: options.ExplicitExitNotify, + DialContextWithAddressIndex: c.transportDialContextWithAddressIndex, + }, + DataChannel: buildClientDataChannelOptions(options), + TLS: clientTLSOptions, + Authentication: ovpn.ClientAuthenticationOptions{ + Username: options.Username, + Password: options.Password, + AuthRetry: options.AuthRetry, + StaticChallenge: options.StaticChallenge, + StaticChallengeEcho: options.StaticChallengeEcho, + }, + Pull: ovpn.ClientPullOptions{ + Enabled: true, + Filters: pullFilters, + RouteNoPull: options.RouteNoPull, + }, + Tunnel: tunnelOptions, + Timing: buildClientTimingOptions(options), + KeyDirection: keyDirection, + OnTunnelConfiguration: c.handleTunnelConfiguration, + Logger: c.logger, + }, nil +} + +func (c *ClientEndpoint) buildStaticKeyClientOptions(options option.OpenVPNClientEndpointOptions, protocol string, remotes []ovpn.Remote, tunnelOptions ovpn.ClientTunnelOptions) (ovpn.ClientOptions, error) { + if options.TLS != nil { + return ovpn.ClientOptions{}, E.New("`tls` options are not supported in `static_key` mode") + } + if options.Username != "" || options.Password != "" || (options.AuthRetry != "" && options.AuthRetry != "none") || options.StaticChallenge != "" || options.StaticChallengeEcho { + return ovpn.ClientOptions{}, E.New("username/password authentication is not supported in `static_key` mode") + } + if options.RouteNoPull || len(options.PullFilters) > 0 { + return ovpn.ClientOptions{}, E.New("pull options are not supported in `static_key` mode") + } + if options.RenegotiateInterval != 0 || options.RenegotiateDisabled || options.RenegotiateBytes != 0 || options.RenegotiatePackets != 0 || options.TLSTimeout != 0 || options.HandshakeWindow != 0 { + return ovpn.ClientOptions{}, E.New("TLS timing and renegotiation options are not supported in `static_key` mode") + } + if len(options.DataCiphers) > 0 || options.DataCiphersFallback != "" { + return ovpn.ClientOptions{}, E.New("`data_ciphers` and `data_ciphers_fallback` are not supported in `static_key` mode; use `cipher`") + } + staticKey, err := requiredMaterialSource("static_key", options.StaticKey, options.StaticKeyPath) + if err != nil { + return ovpn.ClientOptions{}, err + } + keyDirection, err := keyDirectionValue(options.KeyDirection) + if err != nil { + return ovpn.ClientOptions{}, err + } + return ovpn.ClientOptions{ + Context: c.loopContext, + Mode: ovpn.ModeStaticKey, + Transport: ovpn.ClientTransportOptions{ + Remotes: remotes, + RemoteRandom: options.RemoteRandom, + Protocol: protocol, + ExplicitExitNotify: options.ExplicitExitNotify, + DialContextWithAddressIndex: c.transportDialContextWithAddressIndex, + }, + DataChannel: buildClientDataChannelOptions(options), + Tunnel: tunnelOptions, + Timing: buildClientTimingOptions(options), + StaticKey: staticKey, + KeyDirection: keyDirection, + OnTunnelConfiguration: c.handleTunnelConfiguration, + Logger: c.logger, + }, nil +} + +func buildClientRemoteOptions(options option.OpenVPNClientEndpointOptions) (string, []ovpn.Remote) { + protocol := options.Network + if protocol == "" { + protocol = N.NetworkUDP + } + if options.Server != "" { + return protocol, []ovpn.Remote{{ + Host: options.Server, + Port: options.ServerPort, + Protocol: protocol, + }} + } + remotes := make([]ovpn.Remote, 0, len(options.Servers)) + for _, remoteOptions := range options.Servers { + remoteProtocol := remoteOptions.Network + if remoteProtocol == "" { + remoteProtocol = protocol + } + remotes = append(remotes, ovpn.Remote{ + Host: remoteOptions.Server, + Port: remoteOptions.ServerPort, + Protocol: remoteProtocol, + }) + } + return protocol, remotes +} + +func buildClientDataChannelOptions(options option.OpenVPNClientEndpointOptions) ovpn.ClientDataChannelOptions { + return ovpn.ClientDataChannelOptions{ + MTU: options.MTU, + MSSFix: options.MSSFix, + MSSFixDisabled: options.MSSFixDisabled, + MSSFixMode: options.MSSFixMode, + Fragment: options.Fragment, + Cipher: options.Cipher, + Ciphers: options.DataCiphers, + FallbackCipher: options.DataCiphersFallback, + Auth: options.Auth, + Compression: options.Compression, + CompressionLZO: options.CompressionLZO, + AllowCompression: options.AllowCompression, + ReplayWindow: options.ReplayWindow, + ReplayWindowTime: time.Duration(options.ReplayWindowTime), + PacketHeadroom: ovpntransport.PacketHeadroom, + } +} + +func buildClientTunnelOptions(options option.OpenVPNClientEndpointOptions, requirePeerAddress bool) (ovpn.ClientTunnelOptions, error) { + vpnGateway := netip.Addr(options.PeerAddress) + if vpnGateway.IsValid() && !vpnGateway.Is4() { + return ovpn.ClientTunnelOptions{}, E.New("`peer_address` must be an IPv4 address") + } + vpnGatewayIPv6 := netip.Addr(options.PeerAddressIPv6) + if vpnGatewayIPv6.IsValid() && !vpnGatewayIPv6.Is6() { + return ovpn.ClientTunnelOptions{}, E.New("`peer_address_ipv6` must be an IPv6 address") + } + var hasIPv4 bool + var hasIPv6 bool + for addressIndex, address := range options.Address { + if !address.IsValid() { + return ovpn.ClientTunnelOptions{}, E.New("`address[", addressIndex, "]` is invalid") + } + if address.Addr().Is4() { + hasIPv4 = true + } else { + hasIPv6 = true + } + } + if requirePeerAddress { + if len(options.Address) == 0 { + return ovpn.ClientTunnelOptions{}, E.New("missing `address` in `static_key` mode") + } + if hasIPv4 && !vpnGateway.IsValid() { + return ovpn.ClientTunnelOptions{}, E.New("missing `peer_address` for the IPv4 tunnel address in `static_key` mode") + } + if hasIPv6 && !vpnGatewayIPv6.IsValid() { + return ovpn.ClientTunnelOptions{}, E.New("missing `peer_address_ipv6` for the IPv6 tunnel address in `static_key` mode") + } + if vpnGateway.IsValid() && !hasIPv4 { + return ovpn.ClientTunnelOptions{}, E.New("`peer_address` requires an IPv4 tunnel `address` in `static_key` mode") + } + if vpnGatewayIPv6.IsValid() && !hasIPv6 { + return ovpn.ClientTunnelOptions{}, E.New("`peer_address_ipv6` requires an IPv6 tunnel `address` in `static_key` mode") + } + } + tunnelRoutes := common.Map(options.Routes, func(route netip.Prefix) ovpn.TunnelRoute { + return ovpn.TunnelRoute{Prefix: route} + }) + return ovpn.ClientTunnelOptions{ + DevType: "tun", + Topology: options.Topology, + RedirectGateway: options.RedirectGateway, + RedirectGatewayFlags: options.RedirectGatewayFlags, + RedirectPrivate: options.RedirectPrivate, + BlockIPv6: options.BlockIPv6, + RouteMetric: options.RouteMetric, + RouteGateway: options.RouteGateway.Build(netip.Addr{}), + Routes: tunnelRoutes, + LocalAddress: options.Address, + VPNGateway: vpnGateway, + VPNGatewayIPv6: vpnGatewayIPv6, + }, nil +} + +func buildClientTimingOptions(options option.OpenVPNClientEndpointOptions) ovpn.ClientTimingOptions { + return ovpn.ClientTimingOptions{ + RenegotiationInterval: time.Duration(options.RenegotiateInterval), + RenegotiationDisabled: options.RenegotiateDisabled, + RenegotiationBytes: options.RenegotiateBytes, + RenegotiationPackets: options.RenegotiatePackets, + PingInterval: time.Duration(options.PingInterval), + PingRestart: time.Duration(options.PingRestart), + PingRestartDisabled: options.PingRestartDisabled, + TLSTimeout: time.Duration(options.TLSTimeout), + HandWindow: time.Duration(options.HandshakeWindow), + } +} + +func (c *ClientEndpoint) transportDialContextWithAddressIndex(ctx context.Context, network string, address string, addressIndex int) (net.Conn, error) { + destination := M.ParseSocksaddr(address) + if destination.IsDomain() { + destinationAddresses, lookupErr := c.dnsRouter.Lookup(ctx, destination.Fqdn, c.queryOptions) + if lookupErr != nil { + return nil, lookupErr + } + if addressIndex < 0 || addressIndex >= len(destinationAddresses) { + return nil, ovpn.ErrRemoteAddressExhausted + } + destination = M.SocksaddrFrom(destinationAddresses[addressIndex], destination.Port) + } else if addressIndex != 0 { + return nil, ovpn.ErrRemoteAddressExhausted + } + connection, err := c.outboundDialer.DialContext(ctx, network, destination) + if err != nil { + return nil, err + } + if N.NetworkName(network) == N.NetworkUDP { + tuneOpenVPNUDPSocket(connection) + } + c.stateAccess.Lock() + c.updateState(func(state *clientState) { + state.tunnelInfo.Server = address + state.tunnelInfo.Network = N.NetworkName(network) + }) + c.stateAccess.Unlock() + return connection, nil +} + +func (c *ClientEndpoint) handleTunnelConfiguration(event ovpn.TunnelConfigurationEvent) error { + defer c.notifyStatusUpdated() + c.stateAccess.Lock() + configuration := configurationFromClientEvent(event, c.logger) + c.updateState(func(state *clientState) { + state.tunnelConfigured = false + }) + deviceConfiguration := ovpntransport.Configuration{ + MTU: configuration.MTU, + Address: configuration.Address, + BlockIPv6: configuration.BlockIPv6, + } + err := c.device.UpdateConfiguration(deviceConfiguration) + if err != nil { + c.stateAccess.Unlock() + return E.Cause(err, "update device configuration") + } + if !c.deviceStarted { + err = c.device.Start() + if err != nil { + c.stateAccess.Unlock() + return E.Cause(err, "start device") + } + c.deviceStarted = true + } + routeSet, err := buildIPSet(configuration.Routes, configuration.ExcludedRoutes) + if err != nil { + c.stateAccess.Unlock() + return E.Cause(err, "build route set") + } + preferredDomains := slices.Clone(configuration.DNSRoutes) + preferredDomains = append(preferredDomains, configuration.SearchDomains...) + if len(configuration.DNSServers) > 0 { + servers := slices.Clone(configuration.DNSServers) + slices.SortFunc(servers, func(left ovpntransport.DNSServer, right ovpntransport.DNSServer) int { + return left.Priority - right.Priority + }) + preferredDomains = append(preferredDomains, servers[0].ResolveDomains...) + } + c.updateState(func(state *clientState) { + state.tunnelConfigured = true + state.localAddresses = configuration.Address + state.routeSet = routeSet + state.blockIPv6 = configuration.BlockIPv6 + state.configuration = configuration + state.preferredDomains = preferredDomains + state.tunnelInfo.Cipher = event.Configuration.SelectedCipher + state.tunnelInfo.IPv4 = event.Configuration.LocalIPv4 + state.tunnelInfo.IPv6 = event.Configuration.LocalIPv6 + state.tunnelInfo.DNS = event.Configuration.DNS + state.tunnelInfo.MTU = configuration.MTU + if event.Reason == ovpn.TunnelConfigurationEventInitial || state.tunnelInfo.ConnectedSince.IsZero() { + state.tunnelInfo.ConnectedSince = time.Now() + } + }) + dnsTransport := c.dnsTransport + c.stateAccess.Unlock() + if dnsTransport != nil { + dnsTransport.onReconfiguration(configuration) + } + return nil +} + +func (c *ClientEndpoint) updateState(update func(state *clientState)) { + newState := *c.state.Load() + update(&newState) + c.state.Store(&newState) +} + +func (c *ClientEndpoint) installDNSTransport(dnsTransport *DNSTransport) error { + c.stateAccess.Lock() + defer c.stateAccess.Unlock() + if c.dnsTransport != nil && c.dnsTransport != dnsTransport && c.dnsTransport.Tag() != dnsTransport.Tag() { + return E.New("only one DNS server is allowed for an endpoint") + } + err := dnsTransport.updateResolvers(c.state.Load().configuration) + if err != nil { + return err + } + c.dnsTransport = dnsTransport + return nil +} + +func (c *ClientEndpoint) uninstallDNSTransport(dnsTransport *DNSTransport) { + c.stateAccess.Lock() + if c.dnsTransport == dnsTransport { + c.dnsTransport = nil + } + c.stateAccess.Unlock() +} + +func (c *ClientEndpoint) Start(stage adapter.StartStage) error { + if stage != adapter.StartStatePostStart { + return nil + } + err := c.client.Start() + if err != nil { + return err + } + c.stateAccess.Lock() + c.updateState(func(state *clientState) { + state.started = true + }) + c.readLoopDone = make(chan struct{}) + c.challengeLoopDone = make(chan struct{}) + c.stateAccess.Unlock() + go c.readLoop() + go c.watchChallenges() + return nil +} + +func (c *ClientEndpoint) readLoop() { + defer close(c.readLoopDone) + for { + packetBuffers, err := c.client.ReadDataPackets(c.loopContext) + if err != nil { + if E.IsClosedOrCanceled(err) || c.loopContext.Err() != nil { + return + } + c.logger.Error(E.Cause(err, "client terminated")) + c.setTerminalError(err) + return + } + err = c.device.WriteInboundBuffers(packetBuffers) + buf.ReleaseMulti(packetBuffers) + if err != nil { + err = E.Cause(err, "write packet to device") + c.logger.Error(err) + c.setTerminalError(err) + return + } + } +} + +func (c *ClientEndpoint) Close() error { + c.stateAccess.Lock() + c.updateState(func(state *clientState) { + state.started = false + }) + readLoopDone := c.readLoopDone + challengeLoopDone := c.challengeLoopDone + c.stateAccess.Unlock() + c.cancelLoop() + err := E.Errors(c.client.Close(), c.device.Close()) + if readLoopDone != nil { + <-readLoopDone + } + if challengeLoopDone != nil { + <-challengeLoopDone + } + c.notifyStatusUpdated() + return err +} + +func (c *ClientEndpoint) InterfaceUpdated() { + c.client.RestartSession() +} + +func (c *ClientEndpoint) PreMatchFlow(network string, destination netip.Addr) adapter.PreMatchAction { + return adapter.PreMatchFlow +} + +func (c *ClientEndpoint) PortAddresses() (netip.Addr, netip.Addr) { + return c.device.PortAddresses() +} + +func (c *ClientEndpoint) PortMTU() uint32 { + return c.device.PortMTU() +} + +func (c *ClientEndpoint) AttachReturn(returnPath tun.Return) error { + return c.device.AttachReturn(returnPath) +} + +func (c *ClientEndpoint) DetachReturn(returnPath tun.Return) error { + return c.device.DetachReturn(returnPath) +} + +func (c *ClientEndpoint) JudgeFlow(network uint8, source netip.AddrPort, destination netip.AddrPort, firstPacket []byte) tun.FlowVerdict { + return judgeOpenVPNFlow(c.router, c.Tag(), c.Type(), c.state.Load().localAddresses, network, source, destination, firstPacket) +} + +func (c *ClientEndpoint) NewDNSPacket(payload []byte, source M.Socksaddr, destination M.Socksaddr, writer N.PacketWriter) { + c.newDNSPacket(log.ContextWithNewID(c.ctx), c, payload, source, destination, writer) +} + +func (c *ClientEndpoint) ready() bool { + state := c.state.Load() + return state.started && state.tunnelConfigured +} + +func (c *ClientEndpoint) WritePackets(packets [][]byte) error { + state := c.state.Load() + if !state.started || !state.tunnelConfigured { + return E.New("endpoint is not ready yet") + } + if state.blockIPv6 { + outboundPackets := packets[:0] + for _, packet := range packets { + if header.IPVersion(packet) != header.IPv6Version { + outboundPackets = append(outboundPackets, packet) + } + } + packets = outboundPackets + if len(packets) == 0 { + return nil + } + } + packetBuffers := make([]*buf.Buffer, len(packets)) + for i, packet := range packets { + packetBuffers[i] = buf.As(packet) + } + err := c.client.WriteDataPacketBuffers(packetBuffers) + if E.IsMulti(err, ovpn.ErrDataChannelNotReady) { + return E.New("endpoint is not ready yet") + } + return err +} + +func (c *ClientEndpoint) writePacketBuffers(packetBuffers []*buf.Buffer) error { + state := c.state.Load() + if !state.started || !state.tunnelConfigured { + buf.ReleaseMulti(packetBuffers) + return nil + } + if state.blockIPv6 { + outboundBuffers := packetBuffers[:0] + for _, packetBuffer := range packetBuffers { + if header.IPVersion(packetBuffer.Bytes()) == header.IPv6Version { + packetBuffer.Release() + continue + } + outboundBuffers = append(outboundBuffers, packetBuffer) + } + packetBuffers = outboundBuffers + if len(packetBuffers) == 0 { + return nil + } + } + err := c.client.WriteDataPacketBuffers(packetBuffers) + if E.IsMulti(err, ovpn.ErrDataChannelNotReady) { + return nil + } + return err +} + +func (c *ClientEndpoint) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + c.newConnection(ctx, c, c.state.Load().localAddresses, conn, source, destination, onClose) +} + +func (c *ClientEndpoint) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + c.newPacketConnection(ctx, c, c.state.Load().localAddresses, conn, source, destination, onClose) +} + +func (c *ClientEndpoint) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + switch network { + case N.NetworkTCP: + c.logger.InfoContext(ctx, "outbound connection to ", destination) + case N.NetworkUDP: + c.logger.InfoContext(ctx, "outbound packet connection to ", destination) + } + if !c.ready() || !c.client.Ready() { + return nil, E.New("endpoint is not ready yet") + } + if destination.IsDomain() { + destinationAddresses, err := c.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{}) + if err != nil { + return nil, err + } + return N.DialSerial(ctx, c.device, network, destination, destinationAddresses) + } + if !destination.Addr.IsValid() { + return nil, E.New("invalid destination: ", destination) + } + return c.device.DialContext(ctx, network, destination) +} + +func (c *ClientEndpoint) ListenPacketWithDestination(ctx context.Context, destination M.Socksaddr) (net.PacketConn, netip.Addr, error) { + c.logger.InfoContext(ctx, "outbound packet connection to ", destination) + if !c.ready() || !c.client.Ready() { + return nil, netip.Addr{}, E.New("endpoint is not ready yet") + } + if destination.IsDomain() { + destinationAddresses, err := c.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{}) + if err != nil { + return nil, netip.Addr{}, err + } + return N.ListenSerial(ctx, c.device, destination, destinationAddresses) + } + packetConn, err := c.device.ListenPacket(ctx, destination) + if err != nil { + return nil, netip.Addr{}, err + } + if destination.IsIP() { + return packetConn, destination.Addr, nil + } + return packetConn, netip.Addr{}, nil +} + +func (c *ClientEndpoint) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + packetConn, destinationAddress, err := c.ListenPacketWithDestination(ctx, destination) + if err != nil { + return nil, err + } + if destinationAddress.IsValid() && destination != M.SocksaddrFrom(destinationAddress, destination.Port) { + return bufio.NewNATPacketConn(bufio.NewPacketConn(packetConn), M.SocksaddrFrom(destinationAddress, destination.Port), destination), nil + } + return packetConn, nil +} + +func (c *ClientEndpoint) PreferredDomain(metadata *adapter.InboundContext, domain string) bool { + state := c.state.Load() + if !state.started || !state.tunnelConfigured || !c.client.Ready() { + return false + } + for _, preferredDomain := range state.preferredDomains { + if openVPNDomainMatches(preferredDomain, domain) { + return true + } + } + return false +} + +func (c *ClientEndpoint) PreferredAddress(metadata *adapter.InboundContext, address netip.Addr) bool { + state := c.state.Load() + if !state.started || !state.tunnelConfigured || state.routeSet == nil || !c.client.Ready() { + return false + } + return state.routeSet.Contains(address) +} + +func openVPNDomainMatches(suffix string, domain string) bool { + normalizedSuffix := strings.ToLower(strings.TrimSpace(suffix)) + if normalizedSuffix == "." { + return true + } + normalizedSuffix = strings.TrimSuffix(normalizedSuffix, ".") + normalizedDomain := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(domain)), ".") + if normalizedSuffix == "" { + return false + } + return normalizedDomain == normalizedSuffix || strings.HasSuffix(normalizedDomain, "."+normalizedSuffix) +} diff --git a/protocol/openvpn/dns_transport.go b/protocol/openvpn/dns_transport.go new file mode 100644 index 0000000000..188da2af5c --- /dev/null +++ b/protocol/openvpn/dns_transport.go @@ -0,0 +1,427 @@ +package openvpn + +import ( + "context" + "net" + "net/http" + "net/netip" + "net/url" + "os" + "slices" + "strconv" + "strings" + "sync" + + "github.com/sagernet/sing-box/adapter" + boxTLS "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + boxDNS "github.com/sagernet/sing-box/dns" + dnsTransport "github.com/sagernet/sing-box/dns/transport" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + ovpntransport "github.com/sagernet/sing-box/transport/openvpn" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/service" + + mDNS "github.com/miekg/dns" + "golang.org/x/net/http2" +) + +func RegisterDNSTransport(registry *boxDNS.TransportRegistry) { + boxDNS.RegisterTransport[option.OpenVPNDNSServerOptions](registry, C.DNSTypeOpenVPN, NewDNSTransport) +} + +type DNSTransport struct { + boxDNS.TransportAdapter + ctx context.Context + logger logger.ContextLogger + endpointTag string + acceptDefaultResolvers bool + acceptSearchDomain bool + endpointManager adapter.EndpointManager + endpoint *ClientEndpoint + dialer N.Dialer + updateAccess sync.Mutex + access sync.RWMutex + closed bool + routes map[string][]adapter.DNSTransport + searchDomains []string + defaultResolvers []adapter.DNSTransport +} + +func NewDNSTransport(ctx context.Context, logger log.ContextLogger, tag string, options option.OpenVPNDNSServerOptions) (adapter.DNSTransport, error) { + if options.Endpoint == "" { + return nil, E.New("missing endpoint tag") + } + return &DNSTransport{ + TransportAdapter: boxDNS.NewTransportAdapter(C.DNSTypeOpenVPN, tag, nil), + ctx: ctx, + logger: logger, + endpointTag: options.Endpoint, + acceptDefaultResolvers: options.AcceptDefaultResolvers, + acceptSearchDomain: options.AcceptSearchDomain, + endpointManager: service.FromContext[adapter.EndpointManager](ctx), + }, nil +} + +func (t *DNSTransport) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateInitialize { + return nil + } + rawEndpoint, loaded := t.endpointManager.Get(t.endpointTag) + if !loaded { + return E.New("endpoint not found: ", t.endpointTag) + } + endpoint, isOpenVPN := rawEndpoint.(*ClientEndpoint) + if !isOpenVPN { + return E.New("endpoint is not an OpenVPN client: ", t.endpointTag) + } + t.endpoint = endpoint + t.dialer = endpoint + err := endpoint.installDNSTransport(t) + if err != nil { + t.endpoint = nil + t.dialer = nil + return err + } + return nil +} + +func (t *DNSTransport) onReconfiguration(configuration ovpntransport.Configuration) { + err := t.updateResolvers(configuration) + if err != nil && !E.IsClosed(err) { + t.logger.Error(E.Cause(err, "update DNS resolvers")) + } +} + +func (t *DNSTransport) updateResolvers(configuration ovpntransport.Configuration) error { + t.updateAccess.Lock() + defer t.updateAccess.Unlock() + t.access.RLock() + closed := t.closed + t.access.RUnlock() + if closed { + return net.ErrClosed + } + routes := make(map[string][]adapter.DNSTransport) + searchDomains := normalizeOpenVPNDomains(configuration.SearchDomains) + var defaultResolvers []adapter.DNSTransport + var newResolvers []adapter.DNSTransport + servers := slices.Clone(configuration.DNSServers) + slices.SortFunc(servers, func(left ovpntransport.DNSServer, right ovpntransport.DNSServer) int { + return left.Priority - right.Priority + }) + var selectedResolvers []adapter.DNSTransport + if len(servers) > 0 { + server := servers[0] + if server.DNSSEC == "yes" { + return t.failResolverUpdate(newResolvers, E.New("DNSSEC validation is required but is not supported")) + } + for _, address := range server.Addresses { + resolver, err := t.createResolver(server, address) + if err != nil { + return t.failResolverUpdate(newResolvers, err) + } + selectedResolvers = append(selectedResolvers, resolver) + newResolvers = append(newResolvers, resolver) + } + if len(selectedResolvers) == 0 { + return t.failResolverUpdate(newResolvers, E.New("DNS server ", server.Priority, " has no addresses")) + } + if len(server.ResolveDomains) == 0 { + defaultResolvers = slices.Clone(selectedResolvers) + } else { + for _, domain := range server.ResolveDomains { + normalizedDomain := normalizeOpenVPNDomain(domain) + if normalizedDomain != "" { + routes[normalizedDomain] = slices.Clone(selectedResolvers) + } + } + } + } else { + for _, address := range configuration.DNS { + resolver := dnsTransport.NewUDPRaw(t.logger, t.TransportAdapter, t.dialer, M.SocksaddrFrom(address, 53)) + selectedResolvers = append(selectedResolvers, resolver) + newResolvers = append(newResolvers, resolver) + } + if len(configuration.DNSRoutes) > 0 { + if len(selectedResolvers) == 0 { + return t.failResolverUpdate(newResolvers, E.New("DOMAIN-ROUTE requires traditional pushed DNS servers")) + } + for _, domain := range configuration.DNSRoutes { + normalizedDomain := normalizeOpenVPNDomain(domain) + if normalizedDomain != "" { + routes[normalizedDomain] = slices.Clone(selectedResolvers) + } + } + } else { + defaultResolvers = slices.Clone(selectedResolvers) + } + } + if len(searchDomains) > 0 && len(selectedResolvers) == 0 { + return t.failResolverUpdate(newResolvers, E.New("search domains require pushed DNS servers")) + } + for _, searchDomain := range searchDomains { + routes[searchDomain] = slices.Clone(selectedResolvers) + } + + t.access.Lock() + oldResolvers := t.collectResolversLocked() + t.routes = routes + t.searchDomains = searchDomains + t.defaultResolvers = defaultResolvers + t.access.Unlock() + closeErr := closeDNSTransports(oldResolvers) + t.logger.Info("updated ", len(routes), " DNS routes, ", len(searchDomains), " search domains and ", len(defaultResolvers), " default resolvers") + return closeErr +} + +func (t *DNSTransport) failResolverUpdate(newResolvers []adapter.DNSTransport, updateErr error) error { + newCloseErr := closeDNSTransports(newResolvers) + t.access.Lock() + oldResolvers := t.collectResolversLocked() + t.routes = nil + t.searchDomains = nil + t.defaultResolvers = nil + t.access.Unlock() + oldCloseErr := closeDNSTransports(oldResolvers) + return E.Errors(updateErr, newCloseErr, oldCloseErr) +} + +func (t *DNSTransport) createResolver(server ovpntransport.DNSServer, address netip.AddrPort) (adapter.DNSTransport, error) { + transportType := strings.ToLower(server.Transport) + if transportType == "" { + transportType = "plain" + } + port := address.Port() + switch transportType { + case "plain": + if port == 0 { + port = 53 + } + return dnsTransport.NewUDPRaw(t.logger, t.TransportAdapter, t.dialer, M.SocksaddrFrom(address.Addr(), port)), nil + case "dot", "doh": + default: + return nil, E.New("unsupported DNS transport: ", server.Transport) + } + serverName := server.SNI + if serverName == "" { + serverName = address.Addr().String() + } + if transportType == "dot" { + if port == 0 { + port = 853 + } + tlsConfig, err := boxTLS.NewClient(t.ctx, t.logger, serverName, option.OutboundTLSOptions{ + Enabled: true, + ServerName: serverName, + }) + if err != nil { + return nil, err + } + return dnsTransport.NewTLSRaw(t.logger, t.TransportAdapter, t.dialer, M.SocksaddrFrom(address.Addr(), port), tlsConfig), nil + } + if port == 0 { + port = 443 + } + tlsConfig, err := boxTLS.NewClient(t.ctx, t.logger, serverName, option.OutboundTLSOptions{ + Enabled: true, + ServerName: serverName, + ALPN: []string{http2.NextProtoTLS, "http/1.1"}, + }) + if err != nil { + return nil, err + } + host := serverName + if port != 443 { + host = net.JoinHostPort(host, strconv.Itoa(int(port))) + } else if strings.Contains(host, ":") { + host = "[" + host + "]" + } + destination := &url.URL{Scheme: "https", Host: host, Path: "/dns-query"} + return dnsTransport.NewHTTPSRaw(t.TransportAdapter, t.logger, t.dialer, destination, http.Header{}, M.SocksaddrFrom(address.Addr(), port), tlsConfig), nil +} + +func (t *DNSTransport) Reset() { + t.access.RLock() + resolvers := t.collectResolversLocked() + t.access.RUnlock() + for _, resolver := range resolvers { + resolver.Reset() + } +} + +func (t *DNSTransport) Close() error { + if t.endpoint != nil { + t.endpoint.uninstallDNSTransport(t) + } + t.updateAccess.Lock() + t.access.Lock() + resolvers := t.collectResolversLocked() + t.closed = true + t.routes = nil + t.searchDomains = nil + t.defaultResolvers = nil + t.access.Unlock() + t.endpoint = nil + t.dialer = nil + t.updateAccess.Unlock() + return closeDNSTransports(resolvers) +} + +func (t *DNSTransport) Raw() bool { + return true +} + +func (t *DNSTransport) PreferredDomain(domain string) bool { + t.access.RLock() + defer t.access.RUnlock() + for route := range t.routes { + if openVPNDomainMatches(route, domain) { + return true + } + } + return false +} + +func (t *DNSTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + done := make(chan struct{}) + var response *mDNS.Msg + var err error + t.ExchangeAsync(ctx, message, func(callbackResponse *mDNS.Msg, callbackErr error) { + response = callbackResponse + err = callbackErr + close(done) + }) + <-done + return response, err +} + +func (t *DNSTransport) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { + if len(message.Question) != 1 { + callback(nil, os.ErrInvalid) + return + } + t.access.RLock() + searchDomains := slices.Clone(t.searchDomains) + t.access.RUnlock() + if t.acceptSearchDomain && len(searchDomains) > 0 && mDNS.CountLabel(message.Question[0].Name) == 1 { + t.exchangeWithSearchDomains(ctx, message, searchDomains, callback) + return + } + t.exchangeOnce(ctx, message, t.acceptDefaultResolvers, callback) +} + +func (t *DNSTransport) exchangeWithSearchDomains(ctx context.Context, message *mDNS.Msg, searchDomains []string, callback func(response *mDNS.Msg, err error)) { + originalQuestion := message.Question[0] + singleLabel := strings.TrimSuffix(originalQuestion.Name, ".") + exchangers := make([]dnsTransport.AsyncExchanger, 0, len(searchDomains)+1) + for _, searchDomain := range searchDomains { + expandedName := singleLabel + "." + searchDomain + exchangers = append(exchangers, func(exchangeCtx context.Context, exchangeCallback func(response *mDNS.Msg, err error)) { + question := originalQuestion + question.Name = expandedName + rewritten := *message + rewritten.Question = []mDNS.Question{question} + t.exchangeOnce(exchangeCtx, &rewritten, false, func(response *mDNS.Msg, err error) { + if err == nil { + restoreOpenVPNOriginalQuestion(response, expandedName, originalQuestion) + } + exchangeCallback(response, err) + }) + }) + } + exchangers = append(exchangers, func(exchangeCtx context.Context, exchangeCallback func(response *mDNS.Msg, err error)) { + t.exchangeOnce(exchangeCtx, message, t.acceptDefaultResolvers, exchangeCallback) + }) + dnsTransport.ExchangeSequential(ctx, exchangers, func(response *mDNS.Msg, err error) bool { + return err == nil && response.Rcode != mDNS.RcodeNameError + }, callback) +} + +func (t *DNSTransport) exchangeOnce(ctx context.Context, message *mDNS.Msg, allowDefaultResolvers bool, callback func(response *mDNS.Msg, err error)) { + question := message.Question[0] + t.access.RLock() + var matchedResolvers []adapter.DNSTransport + matchedLength := -1 + for route, resolvers := range t.routes { + if openVPNDomainMatches(route, question.Name) && len(route) > matchedLength { + matchedLength = len(route) + matchedResolvers = resolvers + } + } + defaultResolvers := slices.Clone(t.defaultResolvers) + t.access.RUnlock() + if len(matchedResolvers) > 0 { + dnsTransport.ExchangeSequential(ctx, openVPNResolverExchangers(matchedResolvers, message), nil, callback) + return + } + if allowDefaultResolvers && len(defaultResolvers) > 0 { + dnsTransport.ExchangeSequential(ctx, openVPNResolverExchangers(defaultResolvers, message), nil, callback) + return + } + callback(nil, boxDNS.RcodeNameError) +} + +func openVPNResolverExchangers(resolvers []adapter.DNSTransport, message *mDNS.Msg) []dnsTransport.AsyncExchanger { + return common.Map(resolvers, func(resolver adapter.DNSTransport) dnsTransport.AsyncExchanger { + return func(ctx context.Context, callback func(response *mDNS.Msg, err error)) { + resolver.ExchangeAsync(ctx, message, callback) + } + }) +} + +func (t *DNSTransport) collectResolversLocked() []adapter.DNSTransport { + var resolvers []adapter.DNSTransport + for _, routeResolvers := range t.routes { + resolvers = append(resolvers, routeResolvers...) + } + resolvers = append(resolvers, t.defaultResolvers...) + return common.Uniq(resolvers) +} + +func closeDNSTransports(resolvers []adapter.DNSTransport) error { + var err error + for _, resolver := range common.Uniq(resolvers) { + err = E.Append(err, resolver.Close(), func(closeErr error) error { + return E.Cause(closeErr, "close DNS resolver") + }) + } + return err +} + +func normalizeOpenVPNDomain(domain string) string { + normalized := strings.TrimSpace(strings.ToLower(domain)) + if normalized == "." { + return normalized + } + normalized = strings.TrimSuffix(normalized, ".") + if normalized == "" { + return "" + } + return normalized + "." +} + +func normalizeOpenVPNDomains(domains []string) []string { + normalized := make([]string, 0, len(domains)) + for _, domain := range domains { + normalizedDomain := normalizeOpenVPNDomain(domain) + if normalizedDomain != "" && normalizedDomain != "." && !slices.Contains(normalized, normalizedDomain) { + normalized = append(normalized, normalizedDomain) + } + } + return normalized +} + +func restoreOpenVPNOriginalQuestion(response *mDNS.Msg, expandedName string, originalQuestion mDNS.Question) { + response.Question = []mDNS.Question{originalQuestion} + for _, resourceRecord := range response.Answer { + if strings.EqualFold(resourceRecord.Header().Name, expandedName) { + resourceRecord.Header().Name = originalQuestion.Name + } + } +} diff --git a/protocol/openvpn/endpoint.go b/protocol/openvpn/endpoint.go new file mode 100644 index 0000000000..0aa7ff67ae --- /dev/null +++ b/protocol/openvpn/endpoint.go @@ -0,0 +1,379 @@ +package openvpn + +import ( + "context" + "crypto/subtle" + "net" + "net/netip" + "slices" + "strings" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/endpoint" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + ovpntransport "github.com/sagernet/sing-box/transport/openvpn" + ovpn "github.com/sagernet/sing-openvpn" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing-tun/gtcpip/header" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/auth" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + + "go4.org/netipx" +) + +func RegisterEndpoint(registry *endpoint.Registry) { + endpoint.Register[option.OpenVPNClientEndpointOptions](registry, C.TypeOpenVPNClient, NewClientEndpoint) + endpoint.Register[option.OpenVPNServerEndpointOptions](registry, C.TypeOpenVPNServer, NewServerEndpoint) +} + +type endpointBase struct { + endpoint.Adapter + router adapter.Router + logger log.ContextLogger +} + +func (e *endpointBase) SupportsFlow(network string) bool { + return slices.Contains(e.Network(), network) +} + +func (e *endpointBase) newConnection(ctx context.Context, endpoint adapter.Endpoint, localAddresses []netip.Prefix, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + var metadata adapter.InboundContext + metadata.Inbound = endpoint.Tag() + metadata.InboundType = endpoint.Type() + metadata.Source = source + if isEndpointLocalAddress(localAddresses, destination.Addr) { + metadata.OriginDestination = destination + destination.Addr = loopbackAddressFor(destination.Addr) + } + metadata.Destination = destination + e.logger.InfoContext(ctx, "inbound connection from ", source) + e.logger.InfoContext(ctx, "inbound connection to ", metadata.Destination) + e.router.RouteConnectionEx(ctx, conn, metadata, onClose) +} + +func (e *endpointBase) newPacketConnection(ctx context.Context, endpoint adapter.Endpoint, localAddresses []netip.Prefix, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + var metadata adapter.InboundContext + metadata.Inbound = endpoint.Tag() + metadata.InboundType = endpoint.Type() + metadata.Source = source + if isEndpointLocalAddress(localAddresses, destination.Addr) { + metadata.OriginDestination = destination + destination.Addr = loopbackAddressFor(destination.Addr) + conn = bufio.NewNATPacketConn(bufio.NewNetPacketConn(conn), metadata.OriginDestination, destination) + } + metadata.Destination = destination + e.logger.InfoContext(ctx, "inbound packet connection from ", source) + e.logger.InfoContext(ctx, "inbound packet connection to ", metadata.Destination) + e.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose) +} + +func (e *endpointBase) newDNSPacket(ctx context.Context, endpoint adapter.Endpoint, payload []byte, source M.Socksaddr, destination M.Socksaddr, writer N.PacketWriter) { + var metadata adapter.InboundContext + metadata.Inbound = endpoint.Tag() + metadata.InboundType = endpoint.Type() + metadata.Network = N.NetworkUDP + metadata.Source = source + metadata.Destination = destination + metadata.Protocol = C.ProtocolDNS + e.logger.InfoContext(ctx, "inbound DNS packet from ", source) + e.router.HijackDNSPacket(ctx, payload, writer, metadata) +} + +func isEndpointLocalAddress(localAddresses []netip.Prefix, address netip.Addr) bool { + for _, localPrefix := range localAddresses { + if address == localPrefix.Addr() { + return true + } + } + return false +} + +func loopbackAddressFor(address netip.Addr) netip.Addr { + if address.Is4() { + return netip.AddrFrom4([4]uint8{127, 0, 0, 1}) + } + return netip.IPv6Loopback() +} + +func judgeOpenVPNFlow(router adapter.Router, tag string, endpointType string, localAddresses []netip.Prefix, network uint8, source netip.AddrPort, destination netip.AddrPort, firstPacket []byte) tun.FlowVerdict { + for _, localPrefix := range localAddresses { + if destination.Addr() == localPrefix.Addr() { + return tun.FlowVerdict{Action: tun.ActionAccept} + } + } + return adapter.JudgeFlow(router, tag, endpointType, network, source, destination, firstPacket) +} + +func keyDirectionValue(direction string) (int, error) { + switch direction { + case "": + return -1, nil + case "server": + return 0, nil + case "client": + return 1, nil + default: + return 0, E.New("unsupported key direction: ", direction, " (expected \"server\" or \"client\")") + } +} + +func openVPNClientRemoteIsDomain(options option.OpenVPNClientEndpointOptions) bool { + if options.Server != "" && options.ServerIsDomain() { + return true + } + for _, remoteOptions := range options.Servers { + if remoteOptions.Build().IsDomain() { + return true + } + } + return false +} + +func materialSource(name string, inlineValues []string, path string) (ovpn.Material, error) { + material := ovpn.Material{Path: path} + if len(inlineValues) > 0 { + material.Content = []byte(strings.Join(inlineValues, "\n")) + } + return material, material.Validate(name) +} + +func requiredMaterialSource(name string, inlineValues []string, path string) (ovpn.Material, error) { + material, err := materialSource(name, inlineValues, path) + if err != nil { + return ovpn.Material{}, err + } + if !material.IsSet() { + return ovpn.Material{}, E.New("missing `", name, "` or `", name, "_path`") + } + return material, nil +} + +func configurationFromClientEvent(event ovpn.TunnelConfigurationEvent, logger log.ContextLogger) ovpntransport.Configuration { + configuration := event.Configuration + var addresses []netip.Prefix + addresses = append(addresses, configuration.LocalIPv4...) + addresses = append(addresses, configuration.LocalIPv6...) + mtu := configuration.TunMTU + if mtu == 0 { + mtu = ovpntransport.DefaultMTU + } + var routes []ovpntransport.Route + inet4DefaultRoute := netip.PrefixFrom(netip.IPv4Unspecified(), 0) + inet6DefaultRoute := netip.PrefixFrom(netip.IPv6Unspecified(), 0) + var hasInet4DefaultRoute bool + var hasInet6DefaultRoute bool + for _, route := range configuration.IPv4Routes { + routes = append(routes, ovpntransport.Route{ + Prefix: route.Prefix, + Gateway: route.Gateway, + Metric: route.Metric, + }) + if route.Prefix == inet4DefaultRoute { + hasInet4DefaultRoute = true + } + } + for _, route := range configuration.IPv6Routes { + routes = append(routes, ovpntransport.Route{ + Prefix: route.Prefix, + Gateway: route.Gateway, + Metric: route.Metric, + }) + if route.Prefix == inet6DefaultRoute { + hasInet6DefaultRoute = true + } + } + var excludedRoutes []ovpntransport.Route + for _, route := range configuration.ExcludedIPv4Routes { + excludedRoutes = append(excludedRoutes, ovpntransport.Route{Prefix: route.Prefix, Gateway: route.Gateway, Metric: route.Metric}) + } + for _, route := range configuration.ExcludedIPv6Routes { + excludedRoutes = append(excludedRoutes, ovpntransport.Route{Prefix: route.Prefix, Gateway: route.Gateway, Metric: route.Metric}) + } + if configuration.RedirectGateway { + if !hasOpenVPNFlag(configuration.RedirectGatewayFlags, "!ipv4") && !hasInet4DefaultRoute { + if hasOpenVPNFlag(configuration.RedirectGatewayFlags, "def1") { + for _, prefix := range []netip.Prefix{ + netip.PrefixFrom(netip.IPv4Unspecified(), 1), + netip.MustParsePrefix("128.0.0.0/1"), + } { + if !openVPNRoutesContainPrefix(routes, prefix) { + routes = append(routes, ovpntransport.Route{Prefix: prefix, Gateway: configuration.VPNGateway, Metric: configuration.RouteMetric}) + } + } + } else { + routes = append(routes, ovpntransport.Route{ + Prefix: inet4DefaultRoute, + Gateway: configuration.VPNGateway, + Metric: configuration.RouteMetric, + }) + } + } + if hasOpenVPNFlag(configuration.RedirectGatewayFlags, "ipv6") && !hasInet6DefaultRoute { + for _, prefix := range []netip.Prefix{ + netip.MustParsePrefix("::/3"), + netip.MustParsePrefix("2000::/4"), + netip.MustParsePrefix("3000::/4"), + netip.MustParsePrefix("fc00::/7"), + } { + if !openVPNRoutesContainPrefix(routes, prefix) { + routes = append(routes, ovpntransport.Route{Prefix: prefix, Gateway: configuration.VPNGatewayIPv6, Metric: configuration.RouteMetric}) + } + } + } + } + if configuration.BlockIPv6 && !hasInet6DefaultRoute { + routes = append(routes, ovpntransport.Route{ + Prefix: inet6DefaultRoute, + Gateway: configuration.VPNGatewayIPv6, + Metric: configuration.RouteMetric, + }) + } + var dnsAddresses []netip.Addr + if len(configuration.DNSServers) > 0 { + servers := slices.Clone(configuration.DNSServers) + slices.SortFunc(servers, func(left ovpn.TunnelDNSServer, right ovpn.TunnelDNSServer) int { + return left.Priority - right.Priority + }) + for _, address := range servers[0].Addresses { + if address.Addr().IsValid() && !slices.Contains(dnsAddresses, address.Addr()) { + dnsAddresses = append(dnsAddresses, address.Addr()) + } + } + } else { + dnsAddresses = slices.Clone(configuration.DNS) + } + for _, dnsAddress := range dnsAddresses { + if openVPNRoutesContainAddress(routes, dnsAddress) || openVPNRoutesContainAddress(excludedRoutes, dnsAddress) { + continue + } + gateway := configuration.VPNGateway + if dnsAddress.Is6() { + gateway = configuration.VPNGatewayIPv6 + } + routes = append(routes, ovpntransport.Route{ + Prefix: netip.PrefixFrom(dnsAddress, dnsAddress.BitLen()), + Gateway: gateway, + Metric: configuration.RouteMetric, + }) + } + var ignoredOptions []string + var notApplicableOptions []string + for _, flag := range configuration.RedirectGatewayFlags { + switch strings.ToLower(flag) { + case "!ipv4", "ipv6", "def1", "local", "autolocal": + case "bypass-dhcp", "bypass-dns": + notApplicableOptions = append(notApplicableOptions, "redirect-gateway "+flag) + default: + if flag != "" { + ignoredOptions = append(ignoredOptions, "redirect-gateway "+flag) + } + } + } + if configuration.BlockOutsideDNS { + ignoredOptions = append(ignoredOptions, "block-outside-dns") + } + for _, dhcpOption := range configuration.DHCPOptions { + fields := strings.Fields(dhcpOption) + if len(fields) == 0 || slices.ContainsFunc([]string{"DNS", "DNS6", "DOMAIN", "ADAPTER_DOMAIN_SUFFIX", "DOMAIN-SEARCH", "DOMAIN-ROUTE"}, func(optionName string) bool { + return strings.EqualFold(fields[0], optionName) + }) { + continue + } + ignoredOptions = append(ignoredOptions, "dhcp-option "+strings.TrimSpace(dhcpOption)) + } + if len(ignoredOptions) > 0 && logger != nil { + logger.Debug("ignored pushed options: ", strings.Join(ignoredOptions, ", ")) + } + if len(notApplicableOptions) > 0 && logger != nil { + logger.Debug("pushed options are not applicable: ", strings.Join(notApplicableOptions, ", ")) + } + return ovpntransport.Configuration{ + MTU: mtu, + Address: addresses, + Routes: routes, + ExcludedRoutes: excludedRoutes, + DNS: configuration.DNS, + DNSServers: common.Map(configuration.DNSServers, func(server ovpn.TunnelDNSServer) ovpntransport.DNSServer { + return ovpntransport.DNSServer{ + Priority: server.Priority, + Addresses: slices.Clone(server.Addresses), + ResolveDomains: slices.Clone(server.ResolveDomains), + DNSSEC: server.DNSSEC, + Transport: server.Transport, + SNI: server.SNI, + } + }), + SearchDomains: slices.Clone(configuration.SearchDomains), + DNSRoutes: slices.Clone(configuration.DNSRoutes), + Topology: configuration.Topology, + BlockIPv6: configuration.BlockIPv6, + } +} + +func openVPNRoutesContainAddress(routes []ovpntransport.Route, address netip.Addr) bool { + for _, route := range routes { + if route.Prefix.Contains(address) { + return true + } + } + return false +} + +func openVPNRoutesContainPrefix(routes []ovpntransport.Route, prefix netip.Prefix) bool { + for _, route := range routes { + if route.Prefix == prefix { + return true + } + } + return false +} + +func buildIPSet(routes []ovpntransport.Route, excludedRoutes []ovpntransport.Route) (*netipx.IPSet, error) { + var builder netipx.IPSetBuilder + for _, route := range routes { + builder.AddPrefix(route.Prefix) + } + for _, route := range excludedRoutes { + builder.RemovePrefix(route.Prefix) + } + return builder.IPSet() +} + +func hasOpenVPNFlag(flags []string, flag string) bool { + for _, value := range flags { + if strings.EqualFold(value, flag) { + return true + } + } + return false +} + +func packetSourceAddress(packet []byte, inet4Address netip.Addr, inet6Address netip.Addr) netip.Addr { + if header.IPVersion(packet) == header.IPv6Version { + return inet6Address + } + return inet4Address +} + +func authenticatorFromUsers(users []auth.User) ovpn.UserPassAuthenticator { + if len(users) == 0 { + return nil + } + passwordByUsername := make(map[string]string, len(users)) + for _, user := range users { + passwordByUsername[user.Username] = user.Password + } + return func(ctx context.Context, username string, password string) error { + expectedPassword, found := passwordByUsername[username] + if !found || subtle.ConstantTimeCompare([]byte(expectedPassword), []byte(password)) != 1 { + return E.New("invalid username or password") + } + return nil + } +} diff --git a/protocol/openvpn/server.go b/protocol/openvpn/server.go new file mode 100644 index 0000000000..24e0369892 --- /dev/null +++ b/protocol/openvpn/server.go @@ -0,0 +1,771 @@ +package openvpn + +import ( + "context" + "net" + "net/netip" + "slices" + "strconv" + "sync/atomic" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/endpoint" + "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-box/common/listener" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + ovpntransport "github.com/sagernet/sing-box/transport/openvpn" + ovpn "github.com/sagernet/sing-openvpn" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/service" +) + +var ( + _ adapter.FlowOutbound = (*ServerEndpoint)(nil) + _ dialer.PacketDialerWithDestination = (*ServerEndpoint)(nil) +) + +type ServerEndpoint struct { + endpointBase + ctx context.Context + loopContext context.Context + cancelLoop context.CancelFunc + options option.OpenVPNServerEndpointOptions + serverOptions ovpn.ServerOptions + dnsRouter adapter.DNSRouter + listener *listener.Listener + server *ovpn.Server + device ovpntransport.Device + localAddresses []netip.Prefix + started atomic.Bool + readLoopDone chan struct{} +} + +type udpEgressPacketConn struct { + *tun.UDPEgressConn +} + +func (c *udpEgressPacketConn) ReadFrom(buffer []byte) (int, net.Addr, error) { + dataLength, source, err := c.ReadFromUDPAddrPort(buffer) + if err != nil { + return 0, nil, err + } + return dataLength, net.UDPAddrFromAddrPort(source), nil +} + +func (c *udpEgressPacketConn) WriteTo(buffer []byte, destination net.Addr) (int, error) { + destinationAddress := M.SocksaddrFromNet(destination) + if !destinationAddress.IsIP() { + return 0, E.New("invalid UDP destination: ", destination) + } + return c.WriteToUDPAddrPort(buffer, destinationAddress.AddrPort()) +} + +func NewServerEndpoint(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.OpenVPNServerEndpointOptions) (adapter.Endpoint, error) { + if options.MTU == 0 { + options.MTU = ovpntransport.DefaultMTU + } + loopContext, cancelLoop := context.WithCancel(ctx) + serverEndpoint := &ServerEndpoint{ + endpointBase: endpointBase{ + Adapter: endpoint.NewAdapter(C.TypeOpenVPNServer, tag, []string{N.NetworkTCP, N.NetworkUDP, N.NetworkICMP}, nil), + router: router, + logger: logger, + }, + ctx: ctx, + loopContext: loopContext, + cancelLoop: cancelLoop, + options: options, + dnsRouter: service.FromContext[adapter.DNSRouter](ctx), + localAddresses: options.Address, + } + serverOptions, err := buildServerOptions(options) + if err != nil { + cancelLoop() + return nil, err + } + serverOptions.Context = loopContext + if serverOptions.Mode == ovpn.ModeTLS { + serverOptions.Authentication.Authenticator = authenticatorFromUsers(options.Users) + serverOptions.Authentication.DuplicateCN = options.DuplicateCN + } + serverOptions.Logger = logger + serverEndpoint.serverOptions = serverOptions + udpTimeout := C.UDPTimeout + if options.UDPTimeout != 0 { + udpTimeout = time.Duration(options.UDPTimeout) + } + device, err := ovpntransport.NewDevice(ovpntransport.DeviceOptions{ + Context: ctx, + Logger: logger, + System: options.System, + Handler: serverEndpoint, + UDPTimeout: udpTimeout, + ICMPTimeout: C.ICMPTimeout, + UDPMapping: tun.NATMapping(options.UDPMapping), + UDPFiltering: tun.NATFiltering(options.UDPFiltering), + UDPNATMax: options.UDPNATMax, + InterfaceFinder: service.FromContext[adapter.NetworkManager](ctx).InterfaceFinder(), + Name: options.Name, + MTU: options.MTU, + Configuration: ovpntransport.Configuration{ + MTU: options.MTU, + Address: options.Address, + Topology: options.Topology, + }, + }) + if err != nil { + cancelLoop() + return nil, err + } + serverEndpoint.device = device + device.SetPacketWriter(serverEndpoint.writePacketBuffersByDestination) + return serverEndpoint, nil +} + +func validateServerAddresses(addresses []netip.Prefix) error { + var hasIPv4 bool + var hasIPv6 bool + for addressIndex, prefix := range addresses { + if !prefix.IsValid() { + return E.New("server address[", addressIndex, "] is invalid") + } + if prefix.Addr().Is4() { + if hasIPv4 { + return E.New("multiple IPv4 server address pools are not supported") + } + hasIPv4 = true + } else { + if hasIPv6 { + return E.New("multiple IPv6 server address pools are not supported") + } + hasIPv6 = true + } + } + return nil +} + +func validateServerTopology(topology string) error { + switch topology { + case "", "subnet", "p2p", "net30": + return nil + default: + return E.New("invalid topology ", topology, ", allowed values: subnet, p2p, net30") + } +} + +func (s *ServerEndpoint) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + protocol := s.serverOptions.Transport.Protocol + s.listener = listener.New(listener.Options{ + Context: s.ctx, + Logger: s.logger, + Network: []string{protocol}, + Listen: s.options.ListenOptions, + }) + var ( + streamListener net.Listener + packetConn net.PacketConn + err error + ) + if protocol == N.NetworkTCP { + streamListener, err = s.listener.ListenTCP() + } else { + var listenConfig net.ListenConfig + var egressEnabled bool + listenAddress := s.options.Listen.Build(netip.AddrFrom4([4]byte{127, 0, 0, 1})) + if listenAddress.IsUnspecified() && s.options.BindInterface == "" && s.options.RoutingMark == 0 && s.options.NetNs == "" { + udpDialer, dialerErr := dialer.NewDefault(s.ctx, option.DialerOptions{ + AbstractDialerOptions: option.AbstractDialerOptions{ + ReuseAddr: s.options.ReuseAddr, + UDPFragment: s.options.UDPFragment, + UDPFragmentDefault: s.options.UDPFragmentDefault, + }, + }) + if dialerErr != nil { + return dialerErr + } + listenConfig.Control, egressEnabled = udpDialer.UDPListenerControl() + } + packetConn, err = s.listener.ListenUDPWithConfig(listenConfig) + if err == nil { + tuneOpenVPNUDPSocket(packetConn) + if egressEnabled { + udpConn := packetConn.(*net.UDPConn) + networkManager := service.FromContext[adapter.NetworkManager](s.ctx) + egressPool := tun.NewUDPEgressPool(tun.UDPEgressPoolOptions{ + Logger: s.logger, + Network: M.NetworkFromNetAddr(N.NetworkUDP, listenAddress), + Control: listenConfig.Control, + InterfaceFinder: networkManager.InterfaceFinder(), + InterfaceMonitor: networkManager.InterfaceMonitor(), + ExcludeInterface: s.options.Name, + IsExempt: func() bool { + return networkManager.AutoRedirectOutputMark() != 0 + }, + }) + listenPort := udpConn.LocalAddr().(*net.UDPAddr).AddrPort().Port() + if egressPool.SetEgressPort(listenPort) { + packetConn = &udpEgressPacketConn{tun.NewUDPEgressConn(udpConn, egressPool)} + } else { + egressPool.Close() + } + } + } + } + if err != nil { + return err + } + serverOptions := s.serverOptions + if streamListener != nil { + serverOptions.Transport.ListenAddress = streamListener.Addr().String() + } else if packetConn != nil { + serverOptions.Transport.ListenAddress = packetConn.LocalAddr().String() + } + serverOptions.Transport.Listener = streamListener + serverOptions.Transport.PacketConn = packetConn + server, err := ovpn.NewServer(serverOptions) + if err != nil { + if packetConn != nil { + _ = packetConn.Close() + } + s.listener.Close() + return err + } + s.server = server + err = s.device.Start() + if err != nil { + s.listener.Close() + server.Close() + return err + } + err = server.Start() + if err != nil { + s.device.Close() + s.listener.Close() + server.Close() + return err + } + s.started.Store(true) + s.readLoopDone = make(chan struct{}) + go s.readLoop() + return nil +} + +func buildServerOptions(options option.OpenVPNServerEndpointOptions) (ovpn.ServerOptions, error) { + mode := options.Mode + if mode == "" { + mode = ovpn.ModeTLS + } + switch mode { + case ovpn.ModeTLS, ovpn.ModeStaticKey: + default: + return ovpn.ServerOptions{}, E.New("unsupported mode: ", mode, " (expected \"tls\" or \"static_key\")") + } + if len(options.Address) == 0 { + return ovpn.ServerOptions{}, E.New("missing server address") + } + err := validateServerAddresses(options.Address) + if err != nil { + return ovpn.ServerOptions{}, err + } + err = validateServerTopology(options.Topology) + if err != nil { + return ovpn.ServerOptions{}, err + } + protocol := options.Network + if protocol == "" { + protocol = N.NetworkUDP + } + switch protocol { + case N.NetworkTCP, N.NetworkUDP: + default: + return ovpn.ServerOptions{}, E.New("unsupported network: ", protocol) + } + if mode == ovpn.ModeStaticKey { + return buildStaticKeyServerOptions(options, protocol) + } + if options.TLS == nil { + return ovpn.ServerOptions{}, E.New("missing `tls` options") + } + if len(options.StaticKey) > 0 || options.StaticKeyPath != "" || options.KeyDirection != "" || options.Cipher != "" || options.Remote != "" || options.RemotePort != 0 || netip.Addr(options.PeerAddress).IsValid() || netip.Addr(options.PeerAddressIPv6).IsValid() { + return ovpn.ServerOptions{}, E.New("static-key server options require `mode: static_key`") + } + tlsOptions, keyDirection, err := buildServerTLSOptions(*options.TLS) + if err != nil { + return ovpn.ServerOptions{}, err + } + serverOptions := ovpn.ServerOptions{ + Mode: ovpn.ModeTLS, + KeyDirection: keyDirection, + Transport: ovpn.ServerTransportOptions{ + Protocol: protocol, + }, + Resources: ovpn.ServerResourceOptions{ + MaxClients: options.MaxClients, + }, + DataChannel: ovpn.ServerDataChannelOptions{ + MTU: options.MTU, + MSSFix: options.MSSFix, + MSSFixDisabled: options.MSSFixDisabled, + MSSFixMode: options.MSSFixMode, + Ciphers: []string(options.DataCiphers), + FallbackCipher: options.DataCiphersFallback, + Auth: options.Auth, + ReplayWindow: options.ReplayWindow, + ReplayWindowTime: time.Duration(options.ReplayWindowTime), + PacketHeadroom: ovpntransport.PacketHeadroom, + }, + TLS: tlsOptions, + Timing: ovpn.ServerTimingOptions{ + RenegotiationInterval: time.Duration(options.RenegotiateInterval), + RenegotiationDisabled: options.RenegotiateDisabled, + RenegotiationBytes: options.RenegotiateBytes, + RenegotiationPackets: options.RenegotiatePackets, + HandWindow: time.Duration(options.HandshakeWindow), + PingInterval: time.Duration(options.PingInterval), + PingRestart: time.Duration(options.PingRestart), + }, + } + err = applyServerPushOptions(&serverOptions, options) + if err != nil { + return ovpn.ServerOptions{}, err + } + return serverOptions, nil +} + +func buildStaticKeyServerOptions(options option.OpenVPNServerEndpointOptions, protocol string) (ovpn.ServerOptions, error) { + if options.TLS != nil { + return ovpn.ServerOptions{}, E.New("`tls` options are not supported in `static_key` mode") + } + if len(options.Users) > 0 || options.DuplicateCN { + return ovpn.ServerOptions{}, E.New("user authentication is not supported in `static_key` mode") + } + if options.Push != nil { + return ovpn.ServerOptions{}, E.New("push options are not supported in `static_key` mode") + } + if options.RenegotiateInterval != 0 || options.RenegotiateDisabled || options.RenegotiateBytes != 0 || options.RenegotiatePackets != 0 || options.HandshakeWindow != 0 { + return ovpn.ServerOptions{}, E.New("TLS timing and renegotiation options are not supported in `static_key` mode") + } + if len(options.DataCiphers) > 0 || options.DataCiphersFallback != "" { + return ovpn.ServerOptions{}, E.New("`data_ciphers` and `data_ciphers_fallback` are not supported in `static_key` mode; use `cipher`") + } + staticKey, err := requiredMaterialSource("static_key", options.StaticKey, options.StaticKeyPath) + if err != nil { + return ovpn.ServerOptions{}, err + } + keyDirection, err := keyDirectionValue(options.KeyDirection) + if err != nil { + return ovpn.ServerOptions{}, err + } + vpnGateway := netip.Addr(options.PeerAddress) + if vpnGateway.IsValid() && !vpnGateway.Is4() { + return ovpn.ServerOptions{}, E.New("`peer_address` must be an IPv4 address") + } + vpnGatewayIPv6 := netip.Addr(options.PeerAddressIPv6) + if vpnGatewayIPv6.IsValid() && !vpnGatewayIPv6.Is6() { + return ovpn.ServerOptions{}, E.New("`peer_address_ipv6` must be an IPv6 address") + } + var hasIPv4 bool + var hasIPv6 bool + for _, address := range options.Address { + hasIPv4 = hasIPv4 || address.Addr().Is4() + hasIPv6 = hasIPv6 || address.Addr().Is6() + } + if hasIPv4 && !vpnGateway.IsValid() { + return ovpn.ServerOptions{}, E.New("missing `peer_address` for the IPv4 static-key tunnel") + } + if hasIPv6 && !vpnGatewayIPv6.IsValid() { + return ovpn.ServerOptions{}, E.New("missing `peer_address_ipv6` for the IPv6 static-key tunnel") + } + if vpnGateway.IsValid() && !hasIPv4 { + return ovpn.ServerOptions{}, E.New("`peer_address` requires an IPv4 tunnel `address` in `static_key` mode") + } + if vpnGatewayIPv6.IsValid() && !hasIPv6 { + return ovpn.ServerOptions{}, E.New("`peer_address_ipv6` requires an IPv6 tunnel `address` in `static_key` mode") + } + remoteAddress := "" + if protocol == N.NetworkUDP { + if options.Remote == "" || options.RemotePort == 0 { + return ovpn.ServerOptions{}, E.New("`remote` and `remote_port` are required for a UDP static-key server") + } + remoteAddress = net.JoinHostPort(options.Remote, strconv.Itoa(int(options.RemotePort))) + } else if options.Remote != "" || options.RemotePort != 0 { + return ovpn.ServerOptions{}, E.New("`remote` and `remote_port` are only used by a UDP static-key server") + } + topology := options.Topology + if topology == "" { + topology = "p2p" + } + return ovpn.ServerOptions{ + Mode: ovpn.ModeStaticKey, + StaticKey: staticKey, + KeyDirection: keyDirection, + Transport: ovpn.ServerTransportOptions{ + Protocol: protocol, + RemoteAddress: remoteAddress, + }, + Resources: ovpn.ServerResourceOptions{MaxClients: options.MaxClients}, + DataChannel: ovpn.ServerDataChannelOptions{ + MTU: options.MTU, + MSSFix: options.MSSFix, + MSSFixDisabled: options.MSSFixDisabled, + MSSFixMode: options.MSSFixMode, + Cipher: options.Cipher, + Auth: options.Auth, + ReplayWindow: options.ReplayWindow, + ReplayWindowTime: time.Duration(options.ReplayWindowTime), + PacketHeadroom: ovpntransport.PacketHeadroom, + }, + Timing: ovpn.ServerTimingOptions{ + PingInterval: time.Duration(options.PingInterval), + PingRestart: time.Duration(options.PingRestart), + }, + Tunnel: ovpn.ServerTunnelOptions{ + Topology: topology, + LocalAddress: slices.Clone(options.Address), + VPNGateway: vpnGateway, + VPNGatewayIPv6: vpnGatewayIPv6, + }, + }, nil +} + +func buildServerTLSOptions(options option.OpenVPNInboundTLSOptions) (ovpn.ServerTLSOptions, int, error) { + switch options.VerifyClientCertificate { + case "", "require", "optional", "none": + default: + return ovpn.ServerTLSOptions{}, 0, E.New("invalid client certificate policy ", options.VerifyClientCertificate, ", allowed values: require, optional, none") + } + certificate, err := requiredMaterialSource("tls.certificate", options.Certificate, options.CertificatePath) + if err != nil { + return ovpn.ServerTLSOptions{}, 0, err + } + key, err := requiredMaterialSource("tls.key", options.Key, options.KeyPath) + if err != nil { + return ovpn.ServerTLSOptions{}, 0, err + } + certificateAuthority, err := materialSource("tls.client_certificate", options.ClientCertificate, options.ClientCertificatePath) + if err != nil { + return ovpn.ServerTLSOptions{}, 0, err + } + remoteCertificateTLS := options.RemoteCertificateTLS + switch remoteCertificateTLS { + case "", "server", "client", "none": + default: + return ovpn.ServerTLSOptions{}, 0, E.New("invalid `tls.remote_certificate_tls`: ", remoteCertificateTLS) + } + if options.RemoteCertificateEKU != "" && remoteCertificateTLS != "" { + return ovpn.ServerTLSOptions{}, 0, E.New("`tls.remote_certificate_eku` is conflict with `tls.remote_certificate_tls`") + } + if remoteCertificateTLS == "" && options.RemoteCertificateEKU == "" { + remoteCertificateTLS = "client" + } else if remoteCertificateTLS == "none" { + remoteCertificateTLS = "" + } + clientNameType := options.ClientNameType + if options.ClientName != "" && clientNameType == "" { + clientNameType = "name" + } + tlsOptions := ovpn.ServerTLSOptions{ + CertificateAuthority: certificateAuthority, + Certificate: certificate, + Key: key, + VerifyClientCertificate: options.VerifyClientCertificate, + VerifyX509Name: options.ClientName, + VerifyX509Type: clientNameType, + PeerFingerprint: options.PeerFingerprint, + CRLVerify: options.CRLPath, + RemoteCertificateKU: options.RemoteCertificateKU, + RemoteCertificateEKU: options.RemoteCertificateEKU, + RemoteCertificateTLS: remoteCertificateTLS, + NSCertificateType: options.NSCertificateType, + CertificateProfile: options.CertificateProfile, + VersionMin: options.VersionMin, + VersionMax: options.VersionMax, + Cipher: options.Cipher, + Groups: options.Groups, + } + keyDirection := -1 + controlWrap := options.ControlWrap + if controlWrap != nil && (controlWrap.Type != "" || len(controlWrap.Key) > 0 || controlWrap.KeyPath != "" || controlWrap.Direction != "" || controlWrap.ForceCookie) { + wrapKey, wrapErr := requiredMaterialSource("tls.control_wrap.key", controlWrap.Key, controlWrap.KeyPath) + if wrapErr != nil { + return ovpn.ServerTLSOptions{}, 0, wrapErr + } + switch controlWrap.Type { + case "tls_auth": + if controlWrap.ForceCookie { + return ovpn.ServerTLSOptions{}, 0, E.New("`tls.control_wrap.force_cookie` is only supported by `tls_crypt_v2`") + } + keyDirection, err = keyDirectionValue(controlWrap.Direction) + if err != nil { + return ovpn.ServerTLSOptions{}, 0, err + } + tlsOptions.Auth = wrapKey + case "tls_crypt", "tls_crypt_v2": + if controlWrap.Direction != "" { + return ovpn.ServerTLSOptions{}, 0, E.New("`tls.control_wrap.direction` is only supported by `tls_auth`") + } + if controlWrap.Type == "tls_crypt" { + if controlWrap.ForceCookie { + return ovpn.ServerTLSOptions{}, 0, E.New("`tls.control_wrap.force_cookie` is only supported by `tls_crypt_v2`") + } + tlsOptions.Crypt = wrapKey + } else { + tlsOptions.CryptV2 = wrapKey + tlsOptions.CryptV2ForceCookie = controlWrap.ForceCookie + } + case "": + return ovpn.ServerTLSOptions{}, 0, E.New("missing control wrap type") + default: + return ovpn.ServerTLSOptions{}, 0, E.New("unknown control wrap type: ", controlWrap.Type) + } + } + return tlsOptions, keyDirection, nil +} + +func applyServerPushOptions(serverOptions *ovpn.ServerOptions, options option.OpenVPNServerEndpointOptions) error { + topology := options.Topology + if topology == "" { + topology = "subnet" + } + localAddresses := make([]netip.Prefix, 0, len(options.Address)) + for _, prefix := range options.Address { + if !prefix.IsValid() { + continue + } + if prefix.Addr().Is4() { + localAddresses = append(localAddresses, netip.PrefixFrom(prefix.Addr(), 32)) + } else { + localAddresses = append(localAddresses, netip.PrefixFrom(prefix.Addr(), 128)) + } + } + serverOptions.Tunnel = ovpn.ServerTunnelOptions{ + AddressPools: slices.Clone(options.Address), + Topology: topology, + LocalAddress: localAddresses, + } + if options.Push == nil { + return nil + } + serverOptions.Push.Routes = slices.Clone(options.Push.Routes) + serverOptions.Push.DNS = slices.Clone(options.Push.DNS) + serverOptions.Push.SearchDomains = slices.Clone(options.Push.SearchDomains) + serverOptions.Push.DHCPOptions = slices.Clone(options.Push.DHCPOptions) + for serverIndex, server := range options.Push.DNSServers { + addresses := make([]netip.AddrPort, 0, len(server.Addresses)) + for addressIndex, addressValue := range server.Addresses { + address, err := netip.ParseAddr(addressValue) + if err == nil { + addresses = append(addresses, netip.AddrPortFrom(address, 0)) + continue + } + addressPort, addressPortErr := netip.ParseAddrPort(addressValue) + if addressPortErr != nil || addressPort.Port() == 0 { + return E.New("invalid push.dns_servers[", serverIndex, "].addresses[", addressIndex, "]: ", addressValue) + } + addresses = append(addresses, addressPort) + } + serverOptions.Push.DNSServers = append(serverOptions.Push.DNSServers, ovpn.TunnelDNSServer{ + Priority: server.Priority, + Addresses: addresses, + ResolveDomains: slices.Clone(server.ResolveDomains), + DNSSEC: server.DNSSEC, + Transport: server.Transport, + SNI: server.SNI, + }) + } + serverOptions.Push.BlockOutsideDNS = options.Push.BlockOutsideDNS + serverOptions.Push.PingInterval = time.Duration(options.Push.PingInterval) + serverOptions.Push.PingRestart = time.Duration(options.Push.PingRestart) + if options.Push.RedirectGateway { + serverOptions.Push.RedirectGateway = true + if len(options.Push.RedirectGatewayFlags) > 0 { + serverOptions.Push.RedirectGatewayFlags = slices.Clone(options.Push.RedirectGatewayFlags) + } else { + serverOptions.Push.RedirectGatewayFlags = []string{"def1"} + } + } + return nil +} + +func (s *ServerEndpoint) readLoop() { + defer close(s.readLoopDone) + for { + serverPacketBuffers, err := s.server.ReadDataPackets(s.loopContext) + if err != nil { + if E.IsClosedOrCanceled(err) || s.loopContext.Err() != nil { + return + } + s.logger.Error(E.Cause(err, "server terminated")) + return + } + packetBuffers := make([]*buf.Buffer, len(serverPacketBuffers)) + for i, packetBuffer := range serverPacketBuffers { + packetBuffers[i] = packetBuffer.Buffer + } + err = s.device.WriteInboundBuffers(packetBuffers) + buf.ReleaseMulti(packetBuffers) + if err != nil { + s.logger.Error(E.Cause(err, "write packet to device")) + return + } + } +} + +func (s *ServerEndpoint) Close() error { + s.started.Store(false) + s.cancelLoop() + var serverErr error + if s.server != nil { + serverErr = s.server.Close() + } + if s.readLoopDone != nil { + <-s.readLoopDone + } + var deviceErr error + if s.device != nil { + deviceErr = s.device.Close() + } + var listenerErr error + if s.listener != nil { + listenerErr = s.listener.Close() + } + return E.Errors(serverErr, deviceErr, listenerErr) +} + +func (s *ServerEndpoint) PreMatchFlow(network string, destination netip.Addr) adapter.PreMatchAction { + return adapter.PreMatchFlow +} + +func (s *ServerEndpoint) PortAddresses() (netip.Addr, netip.Addr) { + return s.device.PortAddresses() +} + +func (s *ServerEndpoint) PortMTU() uint32 { + return s.device.PortMTU() +} + +func (s *ServerEndpoint) AttachReturn(returnPath tun.Return) error { + return s.device.AttachReturn(returnPath) +} + +func (s *ServerEndpoint) DetachReturn(returnPath tun.Return) error { + return s.device.DetachReturn(returnPath) +} + +func (s *ServerEndpoint) JudgeFlow(network uint8, source netip.AddrPort, destination netip.AddrPort, firstPacket []byte) tun.FlowVerdict { + return judgeOpenVPNFlow(s.router, s.Tag(), s.Type(), s.localAddresses, network, source, destination, firstPacket) +} + +func (s *ServerEndpoint) NewDNSPacket(payload []byte, source M.Socksaddr, destination M.Socksaddr, writer N.PacketWriter) { + s.newDNSPacket(log.ContextWithNewID(s.ctx), s, payload, source, destination, writer) +} + +func (s *ServerEndpoint) WritePackets(packets [][]byte) error { + if !s.started.Load() { + return E.New("endpoint is not ready yet") + } + packetBuffers := make([]*buf.Buffer, len(packets)) + for i, packet := range packets { + packetBuffers[i] = buf.As(packet) + } + routeMisses, err := s.server.WriteDataPacketBuffersByDestination(packetBuffers) + if len(routeMisses) > 0 { + s.writeRouteMisses(routeMisses) + } + return err +} + +func (s *ServerEndpoint) writePacketBuffersByDestination(packetBuffers []*buf.Buffer) error { + routeMisses, err := s.server.WriteDataPacketBuffersByDestination(packetBuffers) + if len(routeMisses) > 0 { + s.writeRouteMisses(routeMisses) + } + return err +} + +func (s *ServerEndpoint) writeRouteMisses(routeMisses []*ovpn.RouteMissError) { + returnPath, headroom := s.device.ReturnPath() + if returnPath == nil { + return + } + inet4Address, inet6Address := s.PortAddresses() + replies := make([][]byte, 0, len(routeMisses)) + for _, routeMiss := range routeMisses { + sourceAddress := packetSourceAddress(routeMiss.Packet, inet4Address, inet6Address) + reply, built := tun.BuildUnreachable(routeMiss.Packet, sourceAddress, headroom) + if built { + replies = append(replies, reply) + } + } + if len(replies) > 0 { + returnPath.ReturnPackets(replies) + } +} + +func (s *ServerEndpoint) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + s.newConnection(ctx, s, s.localAddresses, conn, source, destination, onClose) +} + +func (s *ServerEndpoint) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + s.newPacketConnection(ctx, s, s.localAddresses, conn, source, destination, onClose) +} + +func (s *ServerEndpoint) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + switch network { + case N.NetworkTCP: + s.logger.InfoContext(ctx, "outbound connection to ", destination) + case N.NetworkUDP: + s.logger.InfoContext(ctx, "outbound packet connection to ", destination) + } + if !s.started.Load() { + return nil, E.New("endpoint is not ready yet") + } + if destination.IsDomain() { + destinationAddresses, err := s.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{}) + if err != nil { + return nil, err + } + return N.DialSerial(ctx, s.device, network, destination, destinationAddresses) + } + if !destination.Addr.IsValid() { + return nil, E.New("invalid destination: ", destination) + } + return s.device.DialContext(ctx, network, destination) +} + +func (s *ServerEndpoint) ListenPacketWithDestination(ctx context.Context, destination M.Socksaddr) (net.PacketConn, netip.Addr, error) { + s.logger.InfoContext(ctx, "outbound packet connection to ", destination) + if !s.started.Load() { + return nil, netip.Addr{}, E.New("endpoint is not ready yet") + } + if destination.IsDomain() { + destinationAddresses, err := s.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{}) + if err != nil { + return nil, netip.Addr{}, err + } + return N.ListenSerial(ctx, s.device, destination, destinationAddresses) + } + packetConn, err := s.device.ListenPacket(ctx, destination) + if err != nil { + return nil, netip.Addr{}, err + } + if destination.IsIP() { + return packetConn, destination.Addr, nil + } + return packetConn, netip.Addr{}, nil +} + +func (s *ServerEndpoint) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + packetConn, _, err := s.ListenPacketWithDestination(ctx, destination) + return packetConn, err +} diff --git a/protocol/openvpn/socket_buffer.go b/protocol/openvpn/socket_buffer.go new file mode 100644 index 0000000000..e0263bfaa2 --- /dev/null +++ b/protocol/openvpn/socket_buffer.go @@ -0,0 +1,21 @@ +//go:build !linux + +package openvpn + +import "github.com/sagernet/sing/common" + +const openVPNUDPSocketBufferSize = 7 << 20 + +type openVPNUDPSocketBufferSetter interface { + SetReadBuffer(bytes int) error + SetWriteBuffer(bytes int) error +} + +func tuneOpenVPNUDPSocket(connection any) { + bufferSetter, loaded := common.Cast[openVPNUDPSocketBufferSetter](connection) + if !loaded { + return + } + _ = bufferSetter.SetReadBuffer(openVPNUDPSocketBufferSize) + _ = bufferSetter.SetWriteBuffer(openVPNUDPSocketBufferSize) +} diff --git a/protocol/openvpn/socket_buffer_linux.go b/protocol/openvpn/socket_buffer_linux.go new file mode 100644 index 0000000000..bdc22b47f2 --- /dev/null +++ b/protocol/openvpn/socket_buffer_linux.go @@ -0,0 +1,28 @@ +package openvpn + +import ( + "syscall" + + "github.com/sagernet/sing/common" + + "golang.org/x/sys/unix" +) + +const openVPNUDPSocketBufferSize = 7 << 20 + +func tuneOpenVPNUDPSocket(connection any) { + syscallConnection, loaded := common.Cast[syscall.Conn](connection) + if !loaded { + return + } + rawConnection, err := syscallConnection.SyscallConn() + if err != nil { + return + } + _ = rawConnection.Control(func(fd uintptr) { + _ = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_RCVBUF, openVPNUDPSocketBufferSize) + _ = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_SNDBUF, openVPNUDPSocketBufferSize) + _ = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_RCVBUFFORCE, openVPNUDPSocketBufferSize) + _ = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_SNDBUFFORCE, openVPNUDPSocketBufferSize) + }) +} diff --git a/protocol/openvpn/status.go b/protocol/openvpn/status.go new file mode 100644 index 0000000000..b39a22e48a --- /dev/null +++ b/protocol/openvpn/status.go @@ -0,0 +1,114 @@ +package openvpn + +import ( + "slices" + + "github.com/sagernet/sing-box/adapter" + ovpn "github.com/sagernet/sing-openvpn" +) + +var _ adapter.OpenVPNEndpoint = (*ClientEndpoint)(nil) + +func (c *ClientEndpoint) OpenVPNStatus() adapter.OpenVPNStatus { + var status adapter.OpenVPNStatus + challenge := c.client.PendingChallenge() + state := c.state.Load() + c.statusAccess.Lock() + status.Error = c.terminalError + c.statusAccess.Unlock() + switch { + case challenge != nil: + status.State = adapter.OpenVPNStateAuthPending + status.Challenge = &adapter.OpenVPNChallenge{ + ID: challenge.ID, + Kind: string(challenge.Kind), + Username: challenge.Username, + Message: challenge.Message, + URL: challenge.URL, + SecretMessage: challenge.SecretMessage, + Echo: challenge.Echo, + PreviousError: challenge.PreviousError, + Deadline: challenge.Deadline, + } + case status.Error != "": + status.State = adapter.OpenVPNStateError + case state.started && state.tunnelConfigured && c.client.Ready(): + status.State = adapter.OpenVPNStateConnected + tunnelInfo := state.tunnelInfo + tunnelInfo.IPv4 = slices.Clone(tunnelInfo.IPv4) + tunnelInfo.IPv6 = slices.Clone(tunnelInfo.IPv6) + tunnelInfo.DNS = slices.Clone(tunnelInfo.DNS) + status.TunnelInfo = &tunnelInfo + default: + status.State = adapter.OpenVPNStateConnecting + } + return status +} + +func (c *ClientEndpoint) StatusUpdated() <-chan struct{} { + c.statusAccess.Lock() + defer c.statusAccess.Unlock() + return c.statusUpdated +} + +func (c *ClientEndpoint) CompleteChallenge(challengeID string, response adapter.OpenVPNChallengeResponse) error { + return c.client.CompleteChallenge(challengeID, ovpn.ChallengeResponse{ + Username: response.Username, + Password: response.Password, + Secret: response.Secret, + }) +} + +func (c *ClientEndpoint) CancelChallenge(challengeID string) error { + return c.client.CancelChallenge(challengeID) +} + +func (c *ClientEndpoint) notifyStatusUpdated() { + c.statusAccess.Lock() + c.notifyStatusUpdatedLocked() + c.statusAccess.Unlock() +} + +func (c *ClientEndpoint) notifyStatusUpdatedLocked() { + close(c.statusUpdated) + c.statusUpdated = make(chan struct{}) +} + +func (c *ClientEndpoint) setTerminalError(err error) { + c.statusAccess.Lock() + c.terminalError = err.Error() + c.notifyStatusUpdatedLocked() + c.statusAccess.Unlock() +} + +func (c *ClientEndpoint) watchChallenges() { + defer close(c.challengeLoopDone) + var loggedChallengeID string + for { + challengeUpdated := c.client.ChallengeUpdated() + challenge := c.client.PendingChallenge() + if challenge != nil && challenge.ID != loggedChallengeID { + loggedChallengeID = challenge.ID + c.logChallenge(challenge) + } + c.notifyStatusUpdated() + select { + case <-c.loopContext.Done(): + return + case <-challengeUpdated: + } + } +} + +func (c *ClientEndpoint) logChallenge(challenge *ovpn.Challenge) { + switch challenge.Kind { + case ovpn.ChallengeCredentials: + c.logger.Info("waiting for credentials") + case ovpn.ChallengeSecret: + c.logger.Info("waiting for challenge response: ", challenge.Message) + case ovpn.ChallengeMessage: + c.logger.Info("authentication message: ", challenge.Message) + case ovpn.ChallengeOpenURL: + c.logger.Info("waiting for authentication: ", challenge.URL) + } +} diff --git a/protocol/redirect/tproxy.go b/protocol/redirect/tproxy.go index 5f24162629..6ce7330c8b 100644 --- a/protocol/redirect/tproxy.go +++ b/protocol/redirect/tproxy.go @@ -13,12 +13,13 @@ import ( C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-tun" "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/buf" "github.com/sagernet/sing/common/control" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" - "github.com/sagernet/sing/common/udpnat2" + "github.com/sagernet/sing/service" ) func RegisterTProxy(registry *inbound.Registry) { @@ -31,7 +32,7 @@ type TProxy struct { router adapter.Router logger log.ContextLogger listener *listener.Listener - udpNat *udpnat.Service + udpNat *tun.UDPNat } func NewTProxy(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.TProxyInboundOptions) (adapter.Inbound, error) { @@ -47,7 +48,16 @@ func NewTProxy(ctx context.Context, router adapter.Router, logger log.ContextLog } else { udpTimeout = C.UDPTimeout } - tproxy.udpNat = udpnat.New(tproxy, tproxy.preparePacketConnection, udpTimeout, false) + networkManager := service.FromContext[adapter.NetworkManager](ctx) + tproxy.udpNat = tun.NewUDPNat(tun.UDPNatOptions{ + Handler: tproxy, + Prepare: tproxy.preparePacketConnection, + Timeout: udpTimeout, + Mapping: tun.NATMapping(options.UDPMapping), + Filtering: tun.NATFiltering(options.UDPFiltering), + MaxSize: options.UDPNATMax, + InterfaceFinder: networkManager.InterfaceFinder(), + }) tproxy.listener = listener.New(listener.Options{ Context: ctx, Logger: logger, @@ -64,10 +74,23 @@ func (t *TProxy) Start(stage adapter.StartStage) error { if stage != adapter.StartStateStart { return nil } - return t.listener.Start() + err := t.udpNat.Start() + if err != nil { + return err + } + err = t.listener.Start() + if err != nil { + _ = t.udpNat.Close() + } + return err +} + +func (t *TProxy) InterfaceUpdated() { + t.udpNat.Purge() } func (t *TProxy) Close() error { + _ = t.udpNat.Close() return t.listener.Close() } diff --git a/protocol/shadowsocks/outbound.go b/protocol/shadowsocks/outbound.go index 9b9d9252fe..ebf21f6a28 100644 --- a/protocol/shadowsocks/outbound.go +++ b/protocol/shadowsocks/outbound.go @@ -26,6 +26,8 @@ func RegisterOutbound(registry *outbound.Registry) { outbound.Register[option.ShadowsocksOutboundOptions](registry, C.TypeShadowsocks, NewOutbound) } +var _ adapter.OutboundWithMultiplex = (*Outbound)(nil) + type Outbound struct { outbound.Adapter logger logger.ContextLogger @@ -124,6 +126,10 @@ func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (n } } +func (h *Outbound) MultiplexEnabled() bool { + return h.multiplexDialer != nil +} + func (h *Outbound) InterfaceUpdated() { if h.multiplexDialer != nil { h.multiplexDialer.Reset() diff --git a/protocol/snell/outbound.go b/protocol/snell/outbound.go index 74543f8f5b..c24a92c020 100644 --- a/protocol/snell/outbound.go +++ b/protocol/snell/outbound.go @@ -32,9 +32,12 @@ type Outbound struct { serverAddr M.Socksaddr } +var _ adapter.InterfaceUpdateListener = (*Outbound)(nil) + type snellClient interface { snellprotocol.Method DialContext(ctx context.Context, destination M.Socksaddr) (net.Conn, error) + Reset() Close() error } @@ -136,6 +139,10 @@ func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (n return packetConn, nil } +func (h *Outbound) InterfaceUpdated() { + h.client.Reset() +} + func (h *Outbound) Close() error { return h.client.Close() } diff --git a/protocol/ssh/outbound.go b/protocol/ssh/outbound.go index e265f79979..7e244bc2ee 100644 --- a/protocol/ssh/outbound.go +++ b/protocol/ssh/outbound.go @@ -25,6 +25,7 @@ import ( M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" "github.com/sagernet/sing/common/uot" + "github.com/sagernet/sing/service/filemanager" "golang.org/x/crypto/ssh" ) @@ -93,7 +94,7 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL privateKey = []byte(strings.Join(options.PrivateKey, "\n")) } else { var err error - privateKey, err = os.ReadFile(os.ExpandEnv(options.PrivateKeyPath)) + privateKey, err = filemanager.ReadFile(ctx, os.ExpandEnv(options.PrivateKeyPath)) if err != nil { return nil, E.Cause(err, "read private key") } diff --git a/protocol/tailscale/dns_transport.go b/protocol/tailscale/dns_transport.go index 25b5aabe07..08876ea9df 100644 --- a/protocol/tailscale/dns_transport.go +++ b/protocol/tailscale/dns_transport.go @@ -4,7 +4,6 @@ package tailscale import ( "context" - "errors" "net" "net/http" "net/netip" @@ -268,7 +267,7 @@ func (t *DNSTransport) PreferredDomain(domain string) bool { return true } for suffix := range routes { - if strings.HasSuffix(domain, suffix) { + if mDNS.IsSubDomain(suffix, domain) { return true } } @@ -276,48 +275,64 @@ func (t *DNSTransport) PreferredDomain(domain string) bool { } func (t *DNSTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + done := make(chan struct{}) + var ( + response *mDNS.Msg + err error + ) + t.ExchangeAsync(ctx, message, func(callbackResponse *mDNS.Msg, callbackErr error) { + response = callbackResponse + err = callbackErr + close(done) + }) + <-done + return response, err +} + +func (t *DNSTransport) ExchangeAsync(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { if len(message.Question) != 1 { - return nil, os.ErrInvalid + callback(nil, os.ErrInvalid) + return } if t.acceptSearchDomain && mDNS.CountLabel(message.Question[0].Name) == 1 { - return t.exchangeWithSearchDomains(ctx, message) + t.exchangeWithSearchDomains(ctx, message, callback) + return } t.access.RLock() acceptDefaultResolvers := t.acceptDefaultResolvers t.access.RUnlock() - return t.exchangeOnce(ctx, message, acceptDefaultResolvers) + t.exchangeOnce(ctx, message, acceptDefaultResolvers, callback) } -func (t *DNSTransport) exchangeWithSearchDomains(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { +func (t *DNSTransport) exchangeWithSearchDomains(ctx context.Context, message *mDNS.Msg, callback func(response *mDNS.Msg, err error)) { t.access.RLock() searchDomains := t.searchDomains t.access.RUnlock() + if len(searchDomains) == 0 { + callback(nil, dns.RcodeNameError) + return + } originalQuestion := message.Question[0] singleLabel := strings.TrimSuffix(originalQuestion.Name, ".") - var lastErr error + domainExchangers := make([]transport.AsyncExchanger, 0, len(searchDomains)) for _, searchDomain := range searchDomains { expandedName := singleLabel + "." + searchDomain - question := originalQuestion - question.Name = expandedName - rewritten := *message - rewritten.Question = []mDNS.Question{question} - response, err := t.exchangeOnce(ctx, &rewritten, false) - if err == nil { - if response.Rcode == mDNS.RcodeNameError { - continue - } - restoreOriginalQuestion(response, expandedName, originalQuestion) - return response, nil - } - if errors.Is(err, dns.RcodeNameError) { - continue - } - lastErr = err - } - if lastErr != nil { - return nil, lastErr + domainExchangers = append(domainExchangers, func(exchangeCtx context.Context, exchangeCallback func(response *mDNS.Msg, err error)) { + question := originalQuestion + question.Name = expandedName + rewritten := *message + rewritten.Question = []mDNS.Question{question} + t.exchangeOnce(exchangeCtx, &rewritten, false, func(response *mDNS.Msg, err error) { + if err == nil { + restoreOriginalQuestion(response, expandedName, originalQuestion) + } + exchangeCallback(response, err) + }) + }) } - return nil, dns.RcodeNameError + transport.ExchangeSequential(ctx, domainExchangers, func(response *mDNS.Msg, err error) bool { + return err == nil && response.Rcode != mDNS.RcodeNameError + }, callback) } // RFC 1035 §4.1.1 requires the response Question to match the request byte-for-byte, @@ -331,7 +346,7 @@ func restoreOriginalQuestion(response *mDNS.Msg, expandedName string, originalQu } } -func (t *DNSTransport) exchangeOnce(ctx context.Context, message *mDNS.Msg, allowDefaultResolvers bool) (*mDNS.Msg, error) { +func (t *DNSTransport) exchangeOnce(ctx context.Context, message *mDNS.Msg, allowDefaultResolvers bool, callback func(response *mDNS.Msg, err error)) { question := message.Question[0] t.access.RLock() @@ -348,58 +363,53 @@ func (t *DNSTransport) exchangeOnce(ctx context.Context, message *mDNS.Msg, allo return addr.Is4() }) if len(addresses4) > 0 { - return dns.FixedResponse(message.Id, question, addresses4, C.DefaultDNSTTL), nil + callback(dns.FixedResponse(message.Id, question, addresses4, C.DefaultDNSTTL), nil) + return } case mDNS.TypeAAAA: addresses6 := common.Filter(addresses, func(addr netip.Addr) bool { return addr.Is6() }) if len(addresses6) > 0 { - return dns.FixedResponse(message.Id, question, addresses6, C.DefaultDNSTTL), nil + callback(dns.FixedResponse(message.Id, question, addresses6, C.DefaultDNSTTL), nil) + return } } } for domainSuffix, transports := range routes { - if strings.HasSuffix(question.Name, domainSuffix) { + if mDNS.IsSubDomain(domainSuffix, question.Name) { if len(transports) == 0 { - return &mDNS.Msg{ + callback(&mDNS.Msg{ MsgHdr: mDNS.MsgHdr{ Id: message.Id, Rcode: mDNS.RcodeNameError, Response: true, }, Question: []mDNS.Question{question}, - }, nil - } - var lastErr error - for _, dnsTransport := range transports { - response, err := dnsTransport.Exchange(ctx, message) - if err != nil { - lastErr = err - continue - } - return response, nil + }, nil) + return } - return nil, lastErr + transport.ExchangeSequential(ctx, resolverExchangers(transports, message), nil, callback) + return } } if allowDefaultResolvers { if len(defaultResolvers) > 0 { - var lastErr error - for _, resolver := range defaultResolvers { - response, err := resolver.Exchange(ctx, message) - if err != nil { - lastErr = err - continue - } - return response, nil - } - return nil, lastErr + transport.ExchangeSequential(ctx, resolverExchangers(defaultResolvers, message), nil, callback) } else { - return nil, E.New("missing default resolvers") + callback(nil, E.New("missing default resolvers")) } + return } - return nil, dns.RcodeNameError + callback(nil, dns.RcodeNameError) +} + +func resolverExchangers(resolvers []adapter.DNSTransport, message *mDNS.Msg) []transport.AsyncExchanger { + return common.Map(resolvers, func(resolver adapter.DNSTransport) transport.AsyncExchanger { + return func(ctx context.Context, callback func(response *mDNS.Msg, err error)) { + resolver.ExchangeAsync(ctx, message, callback) + } + }) } func (t *DNSTransport) collectResolversLocked() []adapter.DNSTransport { diff --git a/protocol/tailscale/endpoint.go b/protocol/tailscale/endpoint.go index 777984b75b..40748de02d 100644 --- a/protocol/tailscale/endpoint.go +++ b/protocol/tailscale/endpoint.go @@ -31,6 +31,7 @@ import ( "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" R "github.com/sagernet/sing-box/route/rule" + "github.com/sagernet/sing-box/protocol/tailscale/tailssh" "github.com/sagernet/sing-tun" "github.com/sagernet/sing-tun/ping" "github.com/sagernet/sing/common" @@ -67,6 +68,7 @@ import ( var ( _ adapter.OutboundWithPreferredRoutes = (*Endpoint)(nil) _ adapter.FlowOutbound = (*Endpoint)(nil) + _ adapter.InterfaceUpdateListener = (*Endpoint)(nil) _ dialer.PacketDialerWithDestination = (*Endpoint)(nil) ) @@ -92,6 +94,9 @@ type Endpoint struct { icmpForwarder *tun.ICMPForwarder filter *atomic.Pointer[filter.Filter] onReconfigHook wgengine.ReconfigListener + sshReconfigHook wgengine.ReconfigListener + sshServerInstance *tailssh.Server + sshServerOptions *option.TailscaleSSHServerOptions cfg *wgcfg.Config dnsCfg *tsDNS.Config @@ -136,6 +141,12 @@ func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextL } stateDirectory = filemanager.BasePath(ctx, os.ExpandEnv(stateDirectory)) stateDirectory, _ = filepath.Abs(stateDirectory) + if options.SSHServer != nil && options.SSHServer.Enabled { + err := adapter.CheckSecurityFeature(ctx, "Tailscale `ssh_server`") + if err != nil { + return nil, err + } + } for _, advertiseRoute := range options.AdvertiseRoutes { if advertiseRoute.Addr().IsUnspecified() && advertiseRoute.Bits() == 0 { return nil, E.New("`advertise_routes` cannot be default, use `advertise_exit_node` instead.") @@ -182,6 +193,7 @@ func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextL queryOptions: dialerQueryOptions, network: service.FromContext[adapter.NetworkManager](ctx), platformInterface: service.FromContext[adapter.PlatformInterface](ctx), + sshServerOptions: options.SSHServer, server: &tsnet.Server{ Dir: stateDirectory, Hostname: hostname, @@ -231,6 +243,10 @@ func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextL func (t *Endpoint) Start(stage adapter.StartStage) error { switch stage { case adapter.StartStateInitialize: + mkdirErr := filemanager.MkdirAll(t.ctx, t.server.Dir, 0o700) + if mkdirErr != nil { + return E.Cause(mkdirErr, "create state directory") + } // PeerDNSQueryHandler hook was removed from tsnet.Server upstream; // peer DNS queries no longer route through dnsRouter. See // peerDNSQueryHandler below, kept unused pending a replacement hook. @@ -243,7 +259,7 @@ func (t *Endpoint) Start(stage adapter.StartStage) error { } func (t *Endpoint) start() error { - if t.platformInterface != nil { + if t.platformInterface != nil && t.platformInterface.UsePlatformNetworkInterfaces() { err := t.network.UpdateInterfaces() if err != nil { return err @@ -302,7 +318,9 @@ func (t *Endpoint) start() error { return err } systemDialer, err := dialer.NewDefault(t.ctx, option.DialerOptions{ - BindInterface: tunName, + AbstractDialerOptions: option.AbstractDialerOptions{ + BindInterface: tunName, + }, }) if err != nil { _ = systemTun.Close() @@ -325,6 +343,31 @@ func (t *Endpoint) start() error { return nil } +func (t *Endpoint) listenPacket(ctx context.Context, network string, address string) (nettype.PacketConn, error) { + listenConfig := net.ListenConfig{ + Control: control.Append(t.network.AutoDetectInterfaceFunc(), control.DisableUDPNetReset()), + } + packetConn, err := listenConfig.ListenPacket(ctx, network, address) + if err != nil { + return nil, err + } + udpConn := packetConn.(*net.UDPConn) + egressPool := tun.NewUDPEgressPool(tun.UDPEgressPoolOptions{ + Logger: t.logger, + Network: network, + InterfaceFinder: t.network.InterfaceFinder(), + InterfaceMonitor: t.network.InterfaceMonitor(), + IsExempt: func() bool { + return t.network.AutoRedirectOutputMark() != 0 + }, + }) + if !egressPool.SetEgressPort(udpConn.LocalAddr().(*net.UDPAddr).AddrPort().Port()) { + egressPool.Close() + return udpConn, nil + } + return tun.NewUDPEgressConn(udpConn, egressPool), nil +} + func (t *Endpoint) postStart() error { err := t.server.Start() if err != nil { @@ -396,17 +439,20 @@ func (t *Endpoint) postStart() error { } } + sshEnabled := t.sshServerOptions != nil && t.sshServerOptions.Enabled localBackend := t.server.ExportLocalBackend() perfs := &ipn.MaskedPrefs{ Prefs: ipn.Prefs{ RouteAll: t.acceptRoutes, AdvertiseRoutes: t.advertiseRoutes, + RunSSH: sshEnabled, }, RouteAllSet: true, ExitNodeIPSet: true, AdvertiseRoutesSet: true, RelayServerPortSet: true, RelayServerStaticEndpointsSet: true, + RunSSHSet: true, } if t.advertiseExitNode { perfs.AdvertiseRoutes = append(perfs.AdvertiseRoutes, tsaddr.ExitRoutes()...) @@ -422,6 +468,18 @@ func (t *Endpoint) postStart() error { return E.Cause(err, "update prefs") } t.filter = localBackend.ExportFilter() + if sshEnabled { + sshServer, err := tailssh.New(t.ctx, t.server, t.platformInterface, t.sshServerOptions, t.logger) + if err != nil { + return E.Cause(err, "create SSH server") + } + err = sshServer.Start() + if err != nil { + return E.Cause(err, "start SSH server") + } + t.sshReconfigHook = sshServer.OnReconfig + t.sshServerInstance = sshServer + } go t.watchState() t.started.Store(true) return nil @@ -436,7 +494,7 @@ func (t *Endpoint) watchState() { authURL := localBackend.StatusWithoutPeers().AuthURL if authURL != "" { t.logger.Info("Waiting for authentication: ", authURL) - if t.platformInterface != nil { + if t.platformInterface != nil && t.platformInterface.UsePlatformNotification() { err := t.platformInterface.SendNotification(&adapter.Notification{ Identifier: "tailscale-authentication", TypeName: "Tailscale Authentication Notifications", @@ -532,6 +590,8 @@ func (t *Endpoint) Close() error { var err error t.started.Store(false) if t.serverStarted { + err = common.Close(common.PtrOrNil(t.sshServerInstance)) + t.sshServerInstance = nil err = common.Close(common.PtrOrNil(t.server)) t.serverStarted = false } @@ -550,6 +610,16 @@ func (t *Endpoint) Close() error { return err } +func (t *Endpoint) InterfaceUpdated() { + if !t.started.Load() { + return + } + netMon, loaded := t.server.Sys().NetMon.GetOK() + if loaded && netMon != nil { + netMon.InjectEvent() + } +} + func (t *Endpoint) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { switch network { case N.NetworkTCP: @@ -784,12 +854,17 @@ func (t *Endpoint) NewConnectionEx(ctx context.Context, conn net.Conn, source M. metadata.Inbound = t.Tag() metadata.InboundType = t.Type() metadata.Source = source - addr4, addr6 := t.server.TailscaleIPs() - switch destination.Addr { - case addr4: - destination.Addr = netip.AddrFrom4([4]uint8{127, 0, 0, 1}) - case addr6: - destination.Addr = netip.IPv6Loopback() + destinationAddress := tsaddr.UnmapVia(destination.Addr) + if destinationAddress != destination.Addr { + destination.Addr = destinationAddress + } else { + addr4, addr6 := t.server.TailscaleIPs() + switch destination.Addr { + case addr4: + destination.Addr = netip.AddrFrom4([4]uint8{127, 0, 0, 1}) + case addr6: + destination.Addr = netip.IPv6Loopback() + } } metadata.Destination = destination t.logger.InfoContext(ctx, "inbound connection from ", source) @@ -802,16 +877,22 @@ func (t *Endpoint) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, metadata.Inbound = t.Tag() metadata.InboundType = t.Type() metadata.Source = source - addr4, addr6 := t.server.TailscaleIPs() - switch destination.Addr { - case addr4: - metadata.OriginDestination = destination - destination.Addr = netip.AddrFrom4([4]uint8{127, 0, 0, 1}) - conn = bufio.NewNATPacketConn(bufio.NewNetPacketConn(conn), metadata.OriginDestination, destination) - case addr6: - metadata.OriginDestination = destination - destination.Addr = netip.IPv6Loopback() - conn = bufio.NewNATPacketConn(bufio.NewNetPacketConn(conn), metadata.OriginDestination, destination) + originDestination := destination + destinationAddress := tsaddr.UnmapVia(destination.Addr) + if destinationAddress != destination.Addr { + destination.Addr = destinationAddress + } else { + addr4, addr6 := t.server.TailscaleIPs() + switch destination.Addr { + case addr4: + destination.Addr = netip.AddrFrom4([4]uint8{127, 0, 0, 1}) + case addr6: + destination.Addr = netip.IPv6Loopback() + } + } + if destination != originDestination { + metadata.OriginDestination = originDestination + conn = bufio.NewNATPacketConn(bufio.NewNetPacketConn(conn), originDestination, destination) } metadata.Destination = destination t.logger.InfoContext(ctx, "inbound packet connection from ", source) @@ -869,6 +950,9 @@ func (t *Endpoint) onReconfig(cfg *wgcfg.Config, routerCfg *router.Config, dnsCf if t.onReconfigHook != nil { t.onReconfigHook(cfg, routerCfg, dnsCfg) } + if t.sshReconfigHook != nil { + t.sshReconfigHook(cfg, routerCfg, dnsCfg) + } } func addressFromAddr(destination netip.Addr) tcpip.Address { diff --git a/protocol/tailscale/tailssh/server.go b/protocol/tailscale/tailssh/server.go index fc73004be9..99682fecdd 100644 --- a/protocol/tailscale/tailssh/server.go +++ b/protocol/tailscale/tailssh/server.go @@ -15,7 +15,6 @@ import ( "net/http" "net/netip" "net/url" - "os" "path" "path/filepath" "strings" @@ -28,6 +27,7 @@ import ( E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/logger" M "github.com/sagernet/sing/common/metadata" + "github.com/sagernet/sing/service/filemanager" tsDNS "github.com/sagernet/tailscale/net/dns" "github.com/sagernet/tailscale/tailcfg" "github.com/sagernet/tailscale/tsnet" @@ -93,7 +93,7 @@ type activeSession struct { cancel context.CancelFunc } -func New(tsnetServer *tsnet.Server, platformInterface adapter.PlatformInterface, options *option.TailscaleSSHServerOptions, logger logger.ContextLogger) (*Server, error) { +func New(ctx context.Context, tsnetServer *tsnet.Server, platformInterface adapter.PlatformInterface, options *option.TailscaleSSHServerOptions, logger logger.ContextLogger) (*Server, error) { s := &Server{ tsnetServer: tsnetServer, platformInterface: platformInterface, @@ -104,7 +104,7 @@ func New(tsnetServer *tsnet.Server, platformInterface adapter.PlatformInterface, done: make(chan struct{}), activeConns: make(map[*activeSession]struct{}), } - s.serverCtx, s.serverCancel = context.WithCancel(context.Background()) + s.serverCtx, s.serverCancel = context.WithCancel(ctx) hostSigner, err := s.loadOrGenerateHostKey() if err != nil { return nil, err @@ -132,7 +132,7 @@ func (s *Server) loadOrGenerateHostKey() (gossh.Signer, error) { if isPrivilegedUser() { systemKey := systemHostKeyPath() if systemKey != "" { - keyData, err := os.ReadFile(systemKey) + keyData, err := filemanager.ReadFile(s.serverCtx, systemKey) if err == nil { signer, parseErr := gossh.ParsePrivateKey(keyData) if parseErr == nil { @@ -144,7 +144,7 @@ func (s *Server) loadOrGenerateHostKey() (gossh.Signer, error) { } } keyPath := filepath.Join(s.tsnetServer.Dir, "ssh_host_ed25519_key") - keyData, err := os.ReadFile(keyPath) + keyData, err := filemanager.ReadFile(s.serverCtx, keyPath) if err == nil { signer, parseErr := gossh.ParsePrivateKey(keyData) if parseErr == nil { @@ -163,11 +163,11 @@ func (s *Server) loadOrGenerateHostKey() (gossh.Signer, error) { } pemData := pem.EncodeToMemory(keyBytes) dir := filepath.Dir(keyPath) - err = os.MkdirAll(dir, 0o700) + err = filemanager.MkdirAll(s.serverCtx, dir, 0o700) if err != nil { return nil, err } - err = os.WriteFile(keyPath, pemData, 0o600) + err = filemanager.WriteFile(s.serverCtx, keyPath, pemData, 0o600) if err != nil { return nil, err } @@ -557,7 +557,7 @@ func (s *Server) handleSession(session gliderssh.Session) { session.Exit(1) return } - err = verifyShellIdentity(localUser) + err = verifyShellIdentity(s.platformInterface, localUser) if err != nil { s.logger.Warn("shell rejected for ", localUser.Username, ": ", err) fmt.Fprintf(session.Stderr(), "%s\r\n", err) @@ -566,17 +566,13 @@ func (s *Server) handleSession(session gliderssh.Session) { } var agentSocketPath string if connInfo.action.AllowAgentForwarding && !s.disableForwarding && gliderssh.AgentRequested(session) { - agentListener, listenErr := gliderssh.NewAgentListener() - if listenErr == nil { + agentListener, err := newAgentListener(localUser) + if err == nil { defer agentListener.Close() agentSocketPath = agentListener.Addr().String() - // The agent socket is created as the server identity; hand it to the - // target user so SSH_AUTH_SOCK is reachable after privileges drop. - prepareErr := prepareAgentSocket(agentSocketPath, localUser.Uid, localUser.Gid) - if prepareErr != nil { - s.logger.Warn("prepare agent socket: ", prepareErr) - } go gliderssh.ForwardAgentConnections(agentListener, session) + } else { + s.logger.Warn("create agent listener: ", err) } } env := s.buildEnvironment(session, connInfo, localUser) @@ -749,7 +745,7 @@ func (s *Server) handleSFTP(ctx context.Context, session gliderssh.Session, conn s.serveBuiltinSFTP(ctx, session, localUser) return } - err = verifyShellIdentity(localUser) + err = verifyShellIdentity(s.platformInterface, localUser) if err != nil { s.logger.Warn("sftp rejected for ", localUser.Username, ": ", err) fmt.Fprintf(session.Stderr(), "%s\r\n", err) @@ -759,7 +755,7 @@ func (s *Server) handleSFTP(ctx context.Context, session gliderssh.Session, conn env := s.buildEnvironment(session, connInfo, localUser) sftpSession, err := s.backend.OpenSession(shellRequest{ User: localUser, - Command: sftpCommand(sftpPath), + Command: sftpCommand(sftpPath, localUser.Shell), Env: env, }) if err != nil { @@ -812,8 +808,11 @@ func (s *Server) buildEnvironment(session gliderssh.Session, connInfo *sshConnIn "USER="+localUser.Username, "HOME="+localUser.HomeDir, "SHELL="+localUser.Shell, - "PATH="+defaultPathEnv(), ) + defaultPath := defaultPathEnv(s.platformInterface) + if defaultPath != "" { + env = append(env, "PATH="+defaultPath) + } env = append(env, platformEnvironment(localUser)...) remoteAddr := session.RemoteAddr() localAddr := session.LocalAddr() diff --git a/protocol/tailscale/tailssh/server_helper_unix.go b/protocol/tailscale/tailssh/server_helper_unix.go index 75f4d9ab3b..880759b036 100644 --- a/protocol/tailscale/tailssh/server_helper_unix.go +++ b/protocol/tailscale/tailssh/server_helper_unix.go @@ -3,6 +3,7 @@ package tailssh import ( + "net" "os" "path/filepath" "strconv" @@ -22,7 +23,7 @@ func requestedUserMatchesProcess(localUser *adapter.PlatformUser) (bool, error) // verifyShellIdentity is a no-op on Unix: spawned shells and sftp-server drop to the // requested user via setCredential, so the child already runs as that user. -func verifyShellIdentity(_ *adapter.PlatformUser) error { +func verifyShellIdentity(_ adapter.PlatformInterface, _ *adapter.PlatformUser) error { return nil } @@ -30,7 +31,7 @@ func systemHostKeyPath() string { return "/etc/ssh/ssh_host_ed25519_key" } -func defaultPathEnv() string { +func defaultPathEnv(_ adapter.PlatformInterface) string { return "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" } @@ -38,31 +39,40 @@ func userSocketDirectories(localUser *adapter.PlatformUser) []string { return gliderssh.UserSocketDirectories(localUser.HomeDir, strconv.Itoa(localUser.Uid)) } -// prepareAgentSocket hands the agent-forwarding socket to the target user so -// SSH_AUTH_SOCK stays reachable after the shell drops privileges. No-op when the -// shell runs as the server identity. -func prepareAgentSocket(socketPath string, uid, gid int) error { - if uid < 0 || uid == os.Getuid() { - return nil +func newAgentListener(localUser *adapter.PlatformUser) (net.Listener, error) { + listener, err := gliderssh.NewAgentListener() + if err != nil { + return nil, err + } + socketPath := listener.Addr().String() + if localUser.Uid < 0 || localUser.Uid == os.Getuid() { + return listener, nil } - err := os.Chown(socketPath, uid, gid) + err = os.Chown(socketPath, localUser.Uid, localUser.Gid) if err != nil { - return err + listener.Close() + return nil, err } err = os.Chmod(socketPath, 0o600) if err != nil { - return err + listener.Close() + return nil, err } // Make the MkdirTemp parent traversable so the dropped-privilege child can // reach the socket. - return os.Chmod(filepath.Dir(socketPath), 0o755) + err = os.Chmod(filepath.Dir(socketPath), 0o755) + if err != nil { + listener.Close() + return nil, err + } + return listener, nil } func platformEnvironment(_ *adapter.PlatformUser) []string { return nil } -func sftpCommand(sftpPath string) string { +func sftpCommand(sftpPath, _ string) string { return sftpPath + " 2>/dev/null" } diff --git a/protocol/tailscale/tailssh/server_helper_windows.go b/protocol/tailscale/tailssh/server_helper_windows.go index c94bedb204..4792fc7114 100644 --- a/protocol/tailscale/tailssh/server_helper_windows.go +++ b/protocol/tailscale/tailssh/server_helper_windows.go @@ -3,6 +3,9 @@ package tailssh import ( + "crypto/rand" + "fmt" + "net" "os" "os/user" "strings" @@ -12,6 +15,7 @@ import ( E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/tailscale/util/winutil" + winio "github.com/tailscale/go-winio" "golang.org/x/sys/windows" ) @@ -19,10 +23,6 @@ func isPrivilegedUser() bool { return winutil.IsCurrentProcessElevated() } -// requestedUserMatchesProcess reports whether the ACL-mapped user is the same Windows -// account the sing-box process runs as. Windows has no impersonation wired up, so a -// session always runs with the process identity; this is the only case where the -// identity it runs as equals the requested one. func requestedUserMatchesProcess(localUser *adapter.PlatformUser) (bool, error) { tokenUser, err := windows.GetCurrentProcessToken().GetTokenUser() if err != nil { @@ -36,9 +36,13 @@ func requestedUserMatchesProcess(localUser *adapter.PlatformUser) (bool, error) return strings.EqualFold(tokenUser.User.Sid.String(), requested.Uid), nil } -// verifyShellIdentity refuses a spawned shell/SFTP session whose ACL-mapped user differs -// from the process identity it would actually run as, since Windows has no impersonation. -func verifyShellIdentity(localUser *adapter.PlatformUser) error { +func verifyShellIdentity(platformInterface adapter.PlatformInterface, localUser *adapter.PlatformUser) error { + if platformInterface != nil && platformInterface.UsePlatformShell() { + _, loaded := platformInterface.(windowsUserTokenProvider) + if loaded { + return nil + } + } match, err := requestedUserMatchesProcess(localUser) if err != nil { return err @@ -53,7 +57,10 @@ func systemHostKeyPath() string { return "" } -func defaultPathEnv() string { +func defaultPathEnv(platformInterface adapter.PlatformInterface) string { + if platformInterface != nil && platformInterface.UsePlatformShell() { + return "" + } systemRoot := os.Getenv("SystemRoot") return systemRoot + `\system32;` + systemRoot + `;` + systemRoot + `\System32\Wbem` } @@ -62,10 +69,22 @@ func userSocketDirectories(localUser *adapter.PlatformUser) []string { return []string{localUser.HomeDir, os.TempDir()} } -// prepareAgentSocket is a no-op on Windows: shells run as the server identity, so -// the agent socket needs no ownership change. -func prepareAgentSocket(_ string, _, _ int) error { - return nil +func newAgentListener(localUser *adapter.PlatformUser) (net.Listener, error) { + requestedUser, err := user.Lookup(localUser.Username) + if err != nil { + return nil, E.Cause(err, "lookup requested user") + } + pipePath := `\\.\pipe\sing-box-tailssh-agent-` + rand.Text() + securityDescriptor := fmt.Sprintf(`D:P(A;;GA;;;SY)(A;;GRGW;;;%s)`, requestedUser.Uid) + listener, err := winio.ListenPipe(pipePath, &winio.PipeConfig{ + SecurityDescriptor: securityDescriptor, + InputBufferSize: 64 * 1024, + OutputBufferSize: 64 * 1024, + }) + if err != nil { + return nil, E.Cause(err, "listen on agent pipe") + } + return listener, nil } func platformEnvironment(localUser *adapter.PlatformUser) []string { @@ -80,8 +99,11 @@ func platformEnvironment(localUser *adapter.PlatformUser) []string { return env } -func sftpCommand(sftpPath string) string { - return sftpPath +func sftpCommand(sftpPath, shell string) string { + if isPowerShell(shell) { + return `& "` + sftpPath + `"` + } + return `"` + sftpPath + `"` } func sshSignalToSyscall(sig gliderssh.Signal) int { diff --git a/protocol/tailscale/tailssh/session_windows.go b/protocol/tailscale/tailssh/session_windows.go index c66000997b..0b6db19ce4 100644 --- a/protocol/tailscale/tailssh/session_windows.go +++ b/protocol/tailscale/tailssh/session_windows.go @@ -16,18 +16,45 @@ import ( "github.com/sagernet/tailscale/util/winutil" "github.com/sagernet/tailscale/util/winutil/conpty" + "github.com/tailscale/go-winio" "golang.org/x/sys/windows" ) -func selectShellBackend(_ adapter.PlatformInterface) shellBackend { - return &windowsShellBackend{} +const ( + seAssignPrimaryToken = "SeAssignPrimaryTokenPrivilege" + seIncreaseQuota = "SeIncreaseQuotaPrivilege" +) + +type windowsUserTokenProvider interface { + AcquireWindowsUserToken(user *adapter.PlatformUser) (windows.Token, io.Closer, error) +} + +func selectShellBackend(platformInterface adapter.PlatformInterface) shellBackend { + backend := &windowsShellBackend{} + if platformInterface != nil && platformInterface.UsePlatformShell() { + backend.userTokenProvider, _ = platformInterface.(windowsUserTokenProvider) + } + return backend } -func CheckServerSupport(_ adapter.PlatformInterface) (string, error) { +func CheckServerSupport(platformInterface adapter.PlatformInterface) (string, error) { + if platformInterface != nil && platformInterface.UsePlatformShell() { + _, loaded := platformInterface.(windowsUserTokenProvider) + if !loaded { + return "", E.New("platform shell does not provide Windows user tokens") + } + err := platformInterface.CheckPlatformShell() + if err != nil { + return "", err + } + } return "", nil } -func lookupSFTPServer(_ adapter.PlatformInterface) (string, error) { +func lookupSFTPServer(platformInterface adapter.PlatformInterface) (string, error) { + if platformInterface != nil && platformInterface.UsePlatformShell() { + return platformInterface.LookupSFTPServer() + } sftpPath, err := exec.LookPath("sftp-server") if err != nil { return "", E.New("sftp-server not found") @@ -35,20 +62,48 @@ func lookupSFTPServer(_ adapter.PlatformInterface) (string, error) { return sftpPath, nil } -type windowsShellBackend struct{} +type windowsShellBackend struct { + userTokenProvider windowsUserTokenProvider +} -func (b *windowsShellBackend) OpenSession(request shellRequest) (shellSession, error) { +func (b *windowsShellBackend) OpenSession(request shellRequest) (session shellSession, err error) { + var ( + userToken windows.Token + userResource io.Closer + ) + if b.userTokenProvider != nil { + userToken, userResource, err = b.userTokenProvider.AcquireWindowsUserToken(request.User) + if err != nil { + return nil, err + } + defer func() { + if err != nil { + err = E.Errors(err, userResource.Close()) + } + }() + userEnvironment, err := userToken.Environ(false) + if err != nil { + return nil, E.Cause(err, "query user environment") + } + request.Env = mergeWindowsEnvironment(userEnvironment, request.Env) + } shell := request.User.Shell if request.Term != "" { - session, err := openConPTYSession(request, shell) - if err == nil { - return session, nil + session, err = openConPTYSession(request, shell, userToken) + if err != nil && !errors.Is(err, conpty.ErrUnsupported) { + return nil, err } - if !errors.Is(err, conpty.ErrUnsupported) { + } + if request.Term == "" || err != nil { + session, err = openPipeSession(request, shell, userToken) + if err != nil { return nil, err } } - return openPipeSession(request, shell) + if userResource != nil { + session = &windowsUserShellSession{shellSession: session, resource: userResource} + } + return session, nil } func (b *windowsShellBackend) Close() error { @@ -59,15 +114,51 @@ func buildCommandLine(shell, command string) string { if command == "" { return `"` + shell + `"` } - base := strings.ToLower(filepath.Base(shell)) - switch base { - case "pwsh.exe", "powershell.exe": + if isPowerShell(shell) { // -NoProfile/-NonInteractive keep the invoking user's PowerShell profile from // writing into the (binary) SFTP/stdout stream and corrupting it. return `"` + shell + `" -NoLogo -NoProfile -NonInteractive -Command ` + command + } + return `"` + shell + `" /c ` + command +} + +func isPowerShell(shell string) bool { + switch strings.ToLower(filepath.Base(shell)) { + case "pwsh", "pwsh.exe", "powershell", "powershell.exe": + return true default: - return `"` + shell + `" /c ` + command + return false + } +} + +func mergeWindowsEnvironment(baseEnvironment, overrideEnvironment []string) []string { + environment := make([]string, 0, len(baseEnvironment)+len(overrideEnvironment)) + variableIndex := make(map[string]int, len(baseEnvironment)+len(overrideEnvironment)) + for _, variables := range [][]string{baseEnvironment, overrideEnvironment} { + for _, variable := range variables { + name := windowsEnvironmentName(variable) + index, loaded := variableIndex[name] + if loaded { + environment[index] = variable + } else { + variableIndex[name] = len(environment) + environment = append(environment, variable) + } + } + } + return environment +} + +func windowsEnvironmentName(variable string) string { + start := 0 + if strings.HasPrefix(variable, "=") { + start = 1 + } + separator := strings.IndexByte(variable[start:], '=') + if separator == -1 { + return strings.ToLower(variable) } + return strings.ToLower(variable[:start+separator]) } // clampConsoleDimension keeps a client-supplied window dimension within the @@ -83,7 +174,7 @@ func clampConsoleDimension(value uint16) int16 { return int16(value) } -func createShellProcess(shell string, request shellRequest, startupInfo *windows.StartupInfo, inheritHandles bool, createProcessFlags uint32) (windows.Handle, error) { +func createShellProcess(shell string, request shellRequest, startupInfo *windows.StartupInfo, inheritHandles bool, createProcessFlags uint32, userToken windows.Token) (windows.Handle, error) { cmdLine := buildCommandLine(shell, request.Command) cmdLine16, err := windows.UTF16PtrFromString(cmdLine) if err != nil { @@ -105,28 +196,40 @@ func createShellProcess(shell string, request shellRequest, startupInfo *windows // NewEnvBlock requires the variables sorted case-insensitively by name. envCopy := slices.Clone(request.Env) slices.SortFunc(envCopy, func(a, b string) int { - aName, _, _ := strings.Cut(a, "=") - bName, _, _ := strings.Cut(b, "=") - return strings.Compare(strings.ToLower(aName), strings.ToLower(bName)) + return strings.Compare(windowsEnvironmentName(a), windowsEnvironmentName(b)) }) envBlock := winutil.NewEnvBlock(envCopy) var processInfo windows.ProcessInformation - // request.User only sets HomeDir and Env here; the child inherits the sing-box - // process identity because Windows impersonation is not implemented. Sessions - // whose requested user differs from the process identity are refused before - // reaching this point (verifyShellIdentity in handleSession/handleSFTP). - err = windows.CreateProcess( - exe16, - cmdLine16, - nil, - nil, - inheritHandles, - createProcessFlags|windows.CREATE_NEW_PROCESS_GROUP, - envBlock, - dir16, - startupInfo, - &processInfo, - ) + if userToken == 0 { + err = windows.CreateProcess( + exe16, + cmdLine16, + nil, + nil, + inheritHandles, + createProcessFlags|windows.CREATE_NEW_PROCESS_GROUP, + envBlock, + dir16, + startupInfo, + &processInfo, + ) + } else { + err = winio.RunWithPrivileges([]string{seAssignPrimaryToken, seIncreaseQuota}, func() error { + return windows.CreateProcessAsUser( + userToken, + exe16, + cmdLine16, + nil, + nil, + inheritHandles, + createProcessFlags|windows.CREATE_NEW_PROCESS_GROUP, + envBlock, + dir16, + startupInfo, + &processInfo, + ) + }) + } if err != nil { return 0, E.Cause(err, "create process") } @@ -134,6 +237,15 @@ func createShellProcess(shell string, request shellRequest, startupInfo *windows return processInfo.Process, nil } +type windowsUserShellSession struct { + shellSession + resource io.Closer +} + +func (s *windowsUserShellSession) Close() error { + return E.Errors(s.shellSession.Close(), s.resource.Close()) +} + type conptyShellSession struct { console *conpty.PseudoConsole input io.WriteCloser @@ -143,7 +255,7 @@ type conptyShellSession struct { exitCode uint32 } -func openConPTYSession(request shellRequest, shell string) (shellSession, error) { +func openConPTYSession(request shellRequest, shell string, userToken windows.Token) (shellSession, error) { cols := request.Cols rows := request.Rows if cols == 0 { @@ -171,7 +283,7 @@ func openConPTYSession(request shellRequest, shell string) (shellSession, error) console.Close() return nil, E.Cause(err, "resolve startup info") } - process, err := createShellProcess(shell, request, startupInfo, inheritHandles, createProcessFlags) + process, err := createShellProcess(shell, request, startupInfo, inheritHandles, createProcessFlags, userToken) startupInfoBuilder.Close() if err != nil { console.Close() @@ -202,7 +314,11 @@ func (s *conptyShellSession) waitProcess() { } func (s *conptyShellSession) Read(p []byte) (int, error) { - return s.output.Read(p) + n, err := s.output.Read(p) + if errors.Is(err, os.ErrClosed) { + return n, io.EOF + } + return n, err } func (s *conptyShellSession) Write(p []byte) (int, error) { @@ -261,7 +377,7 @@ type pipeShellSession struct { exitCode uint32 } -func openPipeSession(request shellRequest, shell string) (shellSession, error) { +func openPipeSession(request shellRequest, shell string, userToken windows.Token) (shellSession, error) { var stdinR, stdinW windows.Handle err := windows.CreatePipe(&stdinR, &stdinW, nil, 0) if err != nil { @@ -304,7 +420,7 @@ func openPipeSession(request shellRequest, shell string) (shellSession, error) { windows.CloseHandle(stdoutR) return nil, E.Cause(err, "resolve startup info") } - process, err := createShellProcess(shell, request, startupInfo, inheritHandles, createProcessFlags) + process, err := createShellProcess(shell, request, startupInfo, inheritHandles, createProcessFlags, userToken) startupInfoBuilder.Close() if err != nil { windows.CloseHandle(stdinW) diff --git a/protocol/tor/outbound.go b/protocol/tor/outbound.go index 6f0c3fd6d9..ef46c417cd 100644 --- a/protocol/tor/outbound.go +++ b/protocol/tor/outbound.go @@ -20,8 +20,8 @@ import ( "github.com/sagernet/sing/common/logger" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" - "github.com/sagernet/sing/common/rw" "github.com/sagernet/sing/protocol/socks" + "github.com/sagernet/sing/service/filemanager" "github.com/cretz/bine/control" "github.com/cretz/bine/tor" @@ -35,6 +35,7 @@ type Outbound struct { outbound.Adapter ctx context.Context logger logger.ContextLogger + dialer N.Dialer proxy *proxybridge.Bridge startConf *tor.StartConf options map[string]string @@ -46,60 +47,93 @@ type Outbound struct { func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.TorOutboundOptions) (adapter.Outbound, error) { var startConf tor.StartConf startConf.DataDir = os.ExpandEnv(options.DataDirectory) - startConf.TempDataDirBase = os.TempDir() - startConf.ExtraArgs = options.ExtraArgs - if options.DataDirectory != "" { + if startConf.DataDir != "" { + startConf.DataDir = filemanager.BasePath(ctx, startConf.DataDir) + } + startConf.TempDataDirBase = filemanager.TempPath(ctx) + if startConf.DataDir != "" { dataDirAbs, _ := filepath.Abs(startConf.DataDir) - if geoIPPath := filepath.Join(dataDirAbs, "geoip"); rw.IsFile(geoIPPath) && !common.Contains(options.ExtraArgs, "--GeoIPFile") { + geoIPPath := filepath.Join(dataDirAbs, "geoip") + geoIPInfo, err := filemanager.Stat(ctx, geoIPPath) + if err == nil && !geoIPInfo.IsDir() && !common.Contains(options.ExtraArgs, "--GeoIPFile") { options.ExtraArgs = append(options.ExtraArgs, "--GeoIPFile", geoIPPath) } - if geoIP6Path := filepath.Join(dataDirAbs, "geoip6"); rw.IsFile(geoIP6Path) && !common.Contains(options.ExtraArgs, "--GeoIPv6File") { + geoIP6Path := filepath.Join(dataDirAbs, "geoip6") + geoIP6Info, err := filemanager.Stat(ctx, geoIP6Path) + if err == nil && !geoIP6Info.IsDir() && !common.Contains(options.ExtraArgs, "--GeoIPv6File") { options.ExtraArgs = append(options.ExtraArgs, "--GeoIPv6File", geoIP6Path) } + torrcFile := filepath.Join(startConf.DataDir, "torrc") + torrcInfo, err := filemanager.Stat(ctx, torrcFile) + if err != nil && !os.IsNotExist(err) { + return nil, err + } else if err == nil && torrcInfo.IsDir() { + return nil, E.New("Tor configuration path is a directory: ", torrcFile) + } + startConf.TorrcFile = torrcFile } + startConf.ExtraArgs = options.ExtraArgs if options.ExecutablePath != "" { - startConf.ExePath = options.ExecutablePath - startConf.ProcessCreator = nil - startConf.UseEmbeddedControlConn = false - } - if startConf.DataDir != "" { - torrcFile := filepath.Join(startConf.DataDir, "torrc") - err := rw.MkdirParent(torrcFile) + err := adapter.CheckSecurityFeature(ctx, "Tor `executable_path`") if err != nil { return nil, err } - if !rw.IsFile(torrcFile) { - err := os.WriteFile(torrcFile, []byte(""), 0o600) - if err != nil { - return nil, err - } - } - startConf.TorrcFile = torrcFile + startConf.ExePath = options.ExecutablePath + startConf.ProcessCreator = nil + startConf.UseEmbeddedControlConn = false } outboundDialer, err := dialer.New(ctx, options.DialerOptions, false) if err != nil { return nil, err } - proxy, err := proxybridge.New(ctx, logger, "proxy", outboundDialer) - if err != nil { - return nil, err - } return &Outbound{ Adapter: outbound.NewAdapterWithDialerOptions(C.TypeTor, tag, []string{N.NetworkTCP}, options.DialerOptions), ctx: ctx, logger: logger, - proxy: proxy, + dialer: outboundDialer, startConf: &startConf, options: options.Options, }, nil } -func (t *Outbound) Start() error { - err := t.start() - if err != nil { - t.Close() +func (t *Outbound) Start(stage adapter.StartStage) error { + switch stage { + case adapter.StartStateInitialize: + if t.startConf.DataDir == "" { + return nil + } + err := filemanager.MkdirAll(t.ctx, t.startConf.DataDir, 0o755) + if err != nil { + return err + } + torrcInfo, err := filemanager.Stat(t.ctx, t.startConf.TorrcFile) + if os.IsNotExist(err) { + err = filemanager.WriteFile(t.ctx, t.startConf.TorrcFile, []byte(""), 0o600) + if err != nil { + return err + } + } else if err != nil { + return err + } else if torrcInfo.IsDir() { + return E.New("Tor configuration path is a directory: ", t.startConf.TorrcFile) + } + case adapter.StartStateStart: + proxy, err := proxybridge.New(t.ctx, t.logger, "proxy", t.dialer) + if err != nil { + return err + } + t.proxy = proxy + err = proxy.Start() + if err != nil { + return err + } + err = t.start() + if err != nil { + t.Close() + return err + } } - return err + return nil } var torLogEvents = []control.EventCode{ diff --git a/protocol/trojan/outbound.go b/protocol/trojan/outbound.go index 26c7c81fe8..c25af9bb12 100644 --- a/protocol/trojan/outbound.go +++ b/protocol/trojan/outbound.go @@ -26,6 +26,8 @@ func RegisterOutbound(registry *outbound.Registry) { outbound.Register[option.TrojanOutboundOptions](registry, C.TypeTrojan, NewOutbound) } +var _ adapter.OutboundWithMultiplex = (*Outbound)(nil) + type Outbound struct { outbound.Adapter logger logger.ContextLogger @@ -107,6 +109,10 @@ func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (n } } +func (h *Outbound) MultiplexEnabled() bool { + return h.multiplexDialer != nil +} + func (h *Outbound) InterfaceUpdated() { if h.transport != nil { h.transport.Close() diff --git a/protocol/trusttunnel/inbound.go b/protocol/trusttunnel/inbound.go new file mode 100644 index 0000000000..03f1703a19 --- /dev/null +++ b/protocol/trusttunnel/inbound.go @@ -0,0 +1,157 @@ +package trusttunnel + +import ( + "context" + "net" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/inbound" + "github.com/sagernet/sing-box/common/listener" + "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/auth" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + + "github.com/xchacha20-poly1305/sing-trusttunnel" +) + +func RegistryInbound(registry *inbound.Registry) { + inbound.Register[option.TrustTunnelInboundOptions](registry, C.TypeTrustTunnel, NewInbound) +} + +type Inbound struct { + inbound.Adapter + ctx context.Context + logger log.ContextLogger + router adapter.ConnectionRouterEx + listener *listener.Listener + service *trusttunnel.Service + tlsConfig tls.ServerConfig + network []string +} + +func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.TrustTunnelInboundOptions) (adapter.Inbound, error) { + network := options.Network.Build() + if common.Contains(network, N.NetworkUDP) { + if options.TLS == nil || !options.TLS.Enabled { + return nil, C.ErrTLSRequired + } + } + if len(options.Users) == 0 { + return nil, E.New("missing users") + } + if invalidIndex := common.Index(options.Users, func(it auth.User) bool { + return it.Username == "" || it.Password == "" + }); invalidIndex >= 0 { + return nil, E.New("missing username or password of user ", invalidIndex) + } + inbound := &Inbound{ + Adapter: inbound.NewAdapter(C.TypeTrustTunnel, tag), + ctx: ctx, + logger: logger, + router: router, + listener: listener.New(listener.Options{ + Context: ctx, + Logger: logger, + Listen: options.ListenOptions, + }), + network: network, + } + inbound.service = trusttunnel.NewService(trusttunnel.ServiceOptions{ + Ctx: ctx, + Logger: logger, + Handler: inbound, + ICMPHandler: nil, + QUICCongestionControl: options.QUICCongestionControl, + }) + if options.TLS != nil { + tlsConfig, err := tls.NewServer(ctx, logger, common.PtrValueOrDefault(options.TLS)) + if err != nil { + return nil, err + } + inbound.tlsConfig = tlsConfig + } + inbound.service.UpdateUsers(options.Users) + return inbound, nil +} + +func (h *Inbound) Start(stage adapter.StartStage) (err error) { + if stage != adapter.StartStateStart { + return + } + if h.tlsConfig != nil { + err = h.tlsConfig.Start() + if err != nil { + err = E.Cause(err, "start TLS config") + return + } + } + var ( + tcpListener net.Listener + udpConn net.PacketConn + ) + if common.Contains(h.network, N.NetworkTCP) { + tcpListener, err = h.listener.ListenTCP() + if err != nil { + _ = common.Close(h.listener) + return + } + } + if common.Contains(h.network, N.NetworkUDP) { + udpConn, err = h.listener.ListenUDP() + if err != nil { + _ = common.Close(h.tlsConfig, tcpListener) + return + } + } + err = h.service.Start(tcpListener, udpConn, h.tlsConfig) + if err != nil { + _ = common.Close(h.tlsConfig, tcpListener, udpConn) + return + } + return +} + +func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + ctx = log.ContextWithNewID(ctx) + username, _ := auth.UserFromContext[string](ctx) + metadata := adapter.InboundContext{ + Inbound: h.Tag(), + InboundType: h.Type(), + //nolint:staticcheck + InboundDetour: h.listener.ListenOptions().Detour, + OriginDestination: h.listener.UDPAddr(), + Source: source, + Destination: destination, + User: username, + } + h.router.RouteConnectionEx(ctx, conn, metadata, onClose) +} + +func (h *Inbound) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { + ctx = log.ContextWithNewID(ctx) + username, _ := auth.UserFromContext[string](ctx) + metadata := adapter.InboundContext{ + Inbound: h.Tag(), + InboundType: h.Type(), + //nolint:staticcheck + InboundDetour: h.listener.ListenOptions().Detour, + //nolint:staticcheck + Source: source, + Destination: destination, + User: username, + } + h.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose) +} + +func (h *Inbound) Close() error { + return common.Close( + h.service, + h.tlsConfig, + ) +} diff --git a/protocol/trusttunnel/outbound.go b/protocol/trusttunnel/outbound.go new file mode 100644 index 0000000000..78d3cdf467 --- /dev/null +++ b/protocol/trusttunnel/outbound.go @@ -0,0 +1,136 @@ +package trusttunnel + +import ( + "context" + "net" + "net/netip" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/outbound" + "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-box/common/tls" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/auth" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/service" + + "github.com/xchacha20-poly1305/sing-trusttunnel" +) + +func init() { + trusttunnel.ErrQUICNotIncluded = C.ErrQUICNotIncluded +} + +func RegisterOutbound(registry *outbound.Registry) { + outbound.Register[option.TrustTunnelOutboundOptions](registry, C.TypeTrustTunnel, NewOutbound) +} + +type Outbound struct { + outbound.Adapter + ctx context.Context + logger log.ContextLogger + client *trusttunnel.Client + dnsRouter adapter.DNSRouter +} + +func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.TrustTunnelOutboundOptions) (adapter.Outbound, error) { + if options.TLS == nil || !options.TLS.Enabled { + return nil, C.ErrTLSRequired + } + if options.Username == "" || options.Password == "" { + return nil, E.New("require auth") + } + detour, err := dialer.New(ctx, options.DialerOptions, options.ServerIsDomain()) + if err != nil { + return nil, err + } + server := options.ServerOptions.Build() + tlsConfig, err := tls.NewClient(ctx, logger, server.String(), *options.TLS) + if err != nil { + return nil, err + } + dnsRouter := service.FromContext[adapter.DNSRouter](ctx) + client, err := trusttunnel.NewClient(trusttunnel.ClientOptions{ + Ctx: ctx, + Detour: detour, + Server: server, + Auth: auth.User{ + Username: options.Username, + Password: options.Password, + }, + TLSConfig: tlsConfig, + QUIC: options.QUIC, + QUICCongestionControl: options.QUICCongestionControl, + HealthCheck: options.HealthCheck, + ResolveFunc: func(fqdn string) (netip.Addr, error) { + addresses, lookupErr := dnsRouter.Lookup(ctx, fqdn, adapter.DNSQueryOptions{}) + if lookupErr != nil { + return netip.Addr{}, lookupErr + } + return addresses[0], nil + }, + }) + if err != nil { + return nil, err + } + return &Outbound{ + Adapter: outbound.NewAdapterWithDialerOptions(C.TypeTrustTunnel, tag, []string{N.NetworkTCP, N.NetworkUDP}, options.DialerOptions), + ctx: ctx, + logger: logger, + client: client, + dnsRouter: dnsRouter, + }, nil +} + +func (h *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + switch network { + case N.NetworkTCP: + ctx, metadata := adapter.ExtendContext(ctx) + metadata.Outbound = h.Tag() + metadata.Destination = destination + h.logger.InfoContext(ctx, "outbound connection to ", destination) + return h.client.Dial(ctx, destination) + case N.NetworkUDP: + if destination.IsDomain() { + addresses, err := h.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{}) + if err != nil { + return nil, err + } + destination = M.Socksaddr{ + Addr: addresses[0], + Port: destination.Port, + } + } + packetConn, err := h.ListenPacket(ctx, destination) + if err != nil { + return nil, err + } + return bufio.NewBindPacketConn(packetConn, destination), nil + default: + return nil, E.Extend(N.ErrUnknownNetwork, network) + } +} + +func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + ctx, metadata := adapter.ExtendContext(ctx) + metadata.Outbound = h.Tag() + metadata.Destination = destination + h.logger.InfoContext(ctx, "outbound packet connection to ", destination) + return h.client.ListenPacket(ctx) +} + +func (h *Outbound) InterfaceUpdated() { + h.client.ResetConnections() +} + +func (h *Outbound) Close() error { + return common.Close( + common.PtrOrNil(h.client), + ) +} diff --git a/protocol/tun/inbound.go b/protocol/tun/inbound.go index 814a711ba8..19178d4a4a 100644 --- a/protocol/tun/inbound.go +++ b/protocol/tun/inbound.go @@ -18,6 +18,7 @@ import ( "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" "github.com/sagernet/sing-tun" + "github.com/sagernet/sing-tun/gtcpip/header" "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json/badoption" @@ -42,6 +43,9 @@ type Inbound struct { logger log.ContextLogger tunOptions tun.Options udpTimeout time.Duration + udpMapping tun.NATMapping + udpFiltering tun.NATFiltering + udpNATMax uint32 dnsHijackAddress []netip.Addr stack string tunIf tun.Tun @@ -230,6 +234,9 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo EXP_MultiPendingPackets: multiPendingPackets, }, udpTimeout: udpTimeout, + udpMapping: tun.NATMapping(options.UDPMapping), + udpFiltering: tun.NATFiltering(options.UDPFiltering), + udpNATMax: options.UDPNATMax, stack: options.Stack, platformInterface: platformInterface, platformOptions: common.PtrValueOrDefault(options.Platform), @@ -335,7 +342,7 @@ func (t *Inbound) Start(stage adapter.StartStage) error { outboundManager := service.FromContext[adapter.OutboundManager](t.ctx) endpointManager := service.FromContext[adapter.EndpointManager](t.ctx) for _, outbound := range outboundManager.Outbounds() { - if _, isFlowOutbound := outbound.(adapter.FlowOutbound); isFlowOutbound { + if _, isFlowOutbound := outbound.(adapter.FlowOutbound); isFlowOutbound && common.Contains(outbound.Network(), N.NetworkTCP) { if C.IsLinux { t.tunOptions.GSO = true } else { @@ -345,7 +352,7 @@ func (t *Inbound) Start(stage adapter.StartStage) error { } } for _, endpoint := range endpointManager.Endpoints() { - if _, isFlowOutbound := endpoint.(adapter.FlowOutbound); isFlowOutbound { + if _, isFlowOutbound := endpoint.(adapter.FlowOutbound); isFlowOutbound && common.Contains(endpoint.Network(), N.NetworkTCP) { if C.IsLinux { t.tunOptions.GSO = true } else { @@ -367,7 +374,7 @@ func (t *Inbound) Start(stage adapter.StartStage) error { t.tunOptions.NetNs = manager.ResolvePath(t.tunOptions.NetNs) } } - if t.platformInterface == nil { + if t.platformInterface == nil || C.IsWindows { t.routeAddressSet = common.FlatMap(t.routeRuleSet, adapter.RuleSet.ExtractIPSet) for _, routeRuleSet := range t.routeRuleSet { ipSets := routeRuleSet.ExtractIPSet() @@ -432,12 +439,16 @@ func (t *Inbound) Start(stage adapter.StartStage) error { } t.logger.Trace("creating stack") t.tunIf = tunInterface - var ( - forwarderBindInterface bool - includeAllNetworks bool - ) if t.platformInterface != nil { - forwarderBindInterface = true + err = t.platformInterface.ProcessPlatformOptions(t.platformOptions) + if err != nil { + closeError := t.tunIf.Close() + t.tunIf = nil + return E.Errors(E.Cause(err, "process platform options"), closeError) + } + } + var includeAllNetworks bool + if t.platformInterface != nil && t.platformInterface.UnderNetworkExtension() { includeAllNetworks = t.platformInterface.NetworkExtensionIncludeAllNetworks() } tunStack, err := tun.NewStack(t.stack, tun.StackOptions{ @@ -446,9 +457,12 @@ func (t *Inbound) Start(stage adapter.StartStage) error { TunOptions: t.tunOptions, UDPTimeout: t.udpTimeout, ICMPTimeout: C.ICMPTimeout, + UDPMapping: t.udpMapping, + UDPFiltering: t.udpFiltering, + UDPNATMax: t.udpNATMax, Handler: t, Logger: t.logger, - ForwarderBindInterface: forwarderBindInterface, + ForwarderBindInterface: C.IsDarwin, InterfaceFinder: t.networkManager.InterfaceFinder(), IncludeAllNetworks: includeAllNetworks, }) @@ -493,6 +507,13 @@ func (t *Inbound) updateRouteAddressSet(it adapter.RuleSet) { t.routeExcludeAddressSet = nil } +func (t *Inbound) InterfaceUpdated() { + tunStack := t.tunStack + if tunStack != nil { + tunStack.ResetNetwork() + } +} + func (t *Inbound) Close() error { return common.Close( t.tunStack, @@ -503,11 +524,27 @@ func (t *Inbound) Close() error { func (t *Inbound) JudgeFlow(network uint8, source netip.AddrPort, destination netip.AddrPort, firstPacket []byte) tun.FlowVerdict { if slices.Contains(t.dnsHijackAddress, destination.Addr()) { + if network == uint8(header.UDPProtocolNumber) { + return tun.FlowVerdict{Action: tun.ActionHijackDNS} + } return tun.FlowVerdict{Action: tun.ActionAccept} } return adapter.JudgeFlow(t.router, t.tag, C.TypeTun, network, source, destination, firstPacket) } +func (t *Inbound) NewDNSPacket(payload []byte, source M.Socksaddr, destination M.Socksaddr, writer N.PacketWriter) { + ctx := log.ContextWithNewID(t.ctx) + var metadata adapter.InboundContext + metadata.Inbound = t.tag + metadata.InboundType = C.TypeTun + metadata.Network = N.NetworkUDP + metadata.Source = source + metadata.Destination = destination + metadata.Protocol = C.ProtocolDNS + t.logger.InfoContext(ctx, "inbound DNS packet from ", source) + t.router.HijackDNSPacket(ctx, payload, writer, metadata) +} + func (t *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { ctx = log.ContextWithNewID(ctx) var metadata adapter.InboundContext @@ -578,3 +615,7 @@ func (t *autoRedirectHandler) NewConnectionEx(ctx context.Context, conn net.Conn func (t *autoRedirectHandler) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) { panic("unexcepted") } + +func (t *autoRedirectHandler) NewDNSPacket(payload []byte, source M.Socksaddr, destination M.Socksaddr, writer N.PacketWriter) { + (*Inbound)(t).NewDNSPacket(payload, source, destination, writer) +} diff --git a/protocol/vless/outbound.go b/protocol/vless/outbound.go index cca3bca93d..0922be9520 100644 --- a/protocol/vless/outbound.go +++ b/protocol/vless/outbound.go @@ -34,6 +34,8 @@ func RegisterOutbound(registry *outbound.Registry) { outbound.Register[option.VLESSOutboundOptions](registry, C.TypeVLESS, NewOutbound) } +var _ adapter.OutboundWithMultiplex = (*Outbound)(nil) + type Outbound struct { outbound.Adapter logger logger.ContextLogger @@ -144,6 +146,10 @@ func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (n } } +func (h *Outbound) MultiplexEnabled() bool { + return h.multiplexDialer != nil +} + func (h *Outbound) InterfaceUpdated() { if h.transport != nil { h.transport.Close() diff --git a/protocol/vmess/outbound.go b/protocol/vmess/outbound.go index 703f06b1bb..1baf3b3501 100644 --- a/protocol/vmess/outbound.go +++ b/protocol/vmess/outbound.go @@ -27,6 +27,8 @@ func RegisterOutbound(registry *outbound.Registry) { outbound.Register[option.VMessOutboundOptions](registry, C.TypeVMess, NewOutbound) } +var _ adapter.OutboundWithMultiplex = (*Outbound)(nil) + type Outbound struct { outbound.Adapter logger logger.ContextLogger @@ -105,6 +107,10 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL return outbound, nil } +func (h *Outbound) MultiplexEnabled() bool { + return h.multiplexDialer != nil +} + func (h *Outbound) InterfaceUpdated() { if h.transport != nil { h.transport.Close() diff --git a/protocol/wireguard/endpoint.go b/protocol/wireguard/endpoint.go index 187bd7be6c..37a5c20a60 100644 --- a/protocol/wireguard/endpoint.go +++ b/protocol/wireguard/endpoint.go @@ -26,6 +26,7 @@ import ( var ( _ adapter.OutboundWithPreferredRoutes = (*Endpoint)(nil) + _ adapter.InterfaceUpdateListener = (*Endpoint)(nil) _ dialer.PacketDialerWithDestination = (*Endpoint)(nil) ) @@ -73,17 +74,33 @@ func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextL } else { udpTimeout = C.UDPTimeout } + networkManager := service.FromContext[adapter.NetworkManager](ctx) wgEndpoint, err := wireguard.NewEndpoint(wireguard.EndpointOptions{ - Context: ctx, - Logger: logger, - System: options.System, - Handler: ep, - UDPTimeout: udpTimeout, - ICMPTimeout: C.ICMPTimeout, - Dialer: outboundDialer, + Context: ctx, + Logger: logger, + System: options.System, + Handler: ep, + UDPTimeout: udpTimeout, + ICMPTimeout: C.ICMPTimeout, + UDPMapping: tun.NATMapping(options.UDPMapping), + UDPFiltering: tun.NATFiltering(options.UDPFiltering), + UDPNATMax: options.UDPNATMax, + InterfaceFinder: networkManager.InterfaceFinder(), + EgressPoolOptions: tun.UDPEgressPoolOptions{ + Logger: logger, + InterfaceFinder: networkManager.InterfaceFinder(), + InterfaceMonitor: networkManager.InterfaceMonitor(), + ExcludeInterface: options.Name, + IsExempt: func() bool { + return networkManager.AutoRedirectOutputMark() != 0 + }, + }, + Dialer: outboundDialer, CreateDialer: func(interfaceName string) N.Dialer { return common.Must1(dialer.NewDefault(ctx, option.DialerOptions{ - BindInterface: interfaceName, + AbstractDialerOptions: option.AbstractDialerOptions{ + BindInterface: interfaceName, + }, })) }, Name: options.Name, @@ -137,6 +154,16 @@ func (w *Endpoint) Close() error { return w.endpoint.Close() } +func (w *Endpoint) InterfaceUpdated() { + if !w.started.Load() { + return + } + err := w.endpoint.BindUpdate() + if err != nil { + w.logger.Error(E.Cause(err, "update bind")) + } +} + func (w *Endpoint) PreMatchFlow(network string, destination netip.Addr) adapter.PreMatchAction { return adapter.PreMatchFlow } @@ -166,6 +193,19 @@ func (w *Endpoint) JudgeFlow(network uint8, source netip.AddrPort, destination n return adapter.JudgeFlow(w.router, w.Tag(), w.Type(), network, source, destination, firstPacket) } +func (w *Endpoint) NewDNSPacket(payload []byte, source M.Socksaddr, destination M.Socksaddr, writer N.PacketWriter) { + ctx := log.ContextWithNewID(w.ctx) + var metadata adapter.InboundContext + metadata.Inbound = w.Tag() + metadata.InboundType = w.Type() + metadata.Network = N.NetworkUDP + metadata.Source = source + metadata.Destination = destination + metadata.Protocol = C.ProtocolDNS + w.logger.InfoContext(ctx, "inbound DNS packet from ", source) + w.router.HijackDNSPacket(ctx, payload, writer, metadata) +} + func (w *Endpoint) WritePackets(packets [][]byte) error { if !w.started.Load() { return E.New("WireGuard is not ready yet") diff --git a/protocol/wireguard/outbound.go b/protocol/wireguard/outbound.go index 972058e075..82781b9902 100644 --- a/protocol/wireguard/outbound.go +++ b/protocol/wireguard/outbound.go @@ -84,7 +84,9 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL Dialer: outboundDialer, CreateDialer: func(interfaceName string) N.Dialer { return common.Must1(dialer.NewDefault(ctx, option.DialerOptions{ - BindInterface: interfaceName, + AbstractDialerOptions: option.AbstractDialerOptions{ + BindInterface: interfaceName, + }, })) }, Name: options.InterfaceName, diff --git a/release/DEFAULT_BUILD_TAGS b/release/DEFAULT_BUILD_TAGS index e06bc120e0..a588309b2a 100644 --- a/release/DEFAULT_BUILD_TAGS +++ b/release/DEFAULT_BUILD_TAGS @@ -1 +1 @@ -with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,with_ocm,with_cloudflared,with_naive_outbound,badlinkname,tfogo_checklinkname0 \ No newline at end of file +with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,with_ocm,with_cloudflared,with_naive_outbound,with_usbip,with_openvpn,with_openconnect,badlinkname,tfogo_checklinkname0 diff --git a/release/DEFAULT_BUILD_TAGS_OTHERS b/release/DEFAULT_BUILD_TAGS_OTHERS index a28e900e9d..60b1be3ebe 100644 --- a/release/DEFAULT_BUILD_TAGS_OTHERS +++ b/release/DEFAULT_BUILD_TAGS_OTHERS @@ -1 +1 @@ -with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,with_ocm,with_cloudflared,badlinkname,tfogo_checklinkname0 \ No newline at end of file +with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,with_ocm,with_cloudflared,with_usbip,with_openvpn,with_openconnect,badlinkname,tfogo_checklinkname0 diff --git a/release/DEFAULT_BUILD_TAGS_WINDOWS b/release/DEFAULT_BUILD_TAGS_WINDOWS index af4fe41620..6a5d4d03f0 100644 --- a/release/DEFAULT_BUILD_TAGS_WINDOWS +++ b/release/DEFAULT_BUILD_TAGS_WINDOWS @@ -1 +1 @@ -with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,with_ocm,with_cloudflared,with_naive_outbound,with_purego,badlinkname,tfogo_checklinkname0 \ No newline at end of file +with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,with_ocm,with_cloudflared,with_naive_outbound,with_purego,with_usbip,with_openvpn,with_openconnect,badlinkname,tfogo_checklinkname0 diff --git a/release/LDFLAGS b/release/LDFLAGS index 8f613f9714..5aa5c957a1 100644 --- a/release/LDFLAGS +++ b/release/LDFLAGS @@ -1 +1 @@ --X internal/godebug.defaultGODEBUG=multipathtcp=0 -checklinkname=0 \ No newline at end of file +-X runtime.godebugDefault=multipathtcp=0,tlssha1=1,tlsunsafeekm=1 -checklinkname=0 diff --git a/route/dns.go b/route/dns.go index dee4a756a7..5870715257 100644 --- a/route/dns.go +++ b/route/dns.go @@ -10,12 +10,9 @@ import ( "github.com/sagernet/sing-box/dns" dnsOutbound "github.com/sagernet/sing-box/protocol/dns" R "github.com/sagernet/sing-box/route/rule" - "github.com/sagernet/sing/common/buf" E "github.com/sagernet/sing/common/exceptions" - "github.com/sagernet/sing/common/logger" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" - "github.com/sagernet/sing/common/udpnat2" mDNS "github.com/miekg/dns" ) @@ -36,24 +33,6 @@ func (r *Router) hijackDNSStream(ctx context.Context, conn net.Conn, metadata ad } func (r *Router) hijackDNSPacket(ctx context.Context, conn N.PacketConn, packetBuffers []*N.PacketBuffer, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) error { - if natConn, isNatConn := conn.(udpnat.Conn); isNatConn { - metadata.Destination = M.Socksaddr{} - for _, packet := range packetBuffers { - buffer := packet.Buffer - destination := packet.Destination - N.PutPacketBuffer(packet) - go ExchangeDNSPacket(ctx, r.dns, r.logger, natConn, buffer, metadata, destination) - } - natConn.SetHandler(&dnsHijacker{ - router: r.dns, - logger: r.logger, - conn: conn, - ctx: ctx, - metadata: metadata, - onClose: onClose, - }) - return nil - } err := dnsOutbound.NewDNSPacketConnection(ctx, r.dns, conn, packetBuffers, metadata) N.CloseOnHandshakeFailure(conn, onClose, err) if err != nil && !E.IsClosedOrCanceled(err) { @@ -62,48 +41,29 @@ func (r *Router) hijackDNSPacket(ctx context.Context, conn N.PacketConn, packetB return nil } -func ExchangeDNSPacket(ctx context.Context, router adapter.DNSRouter, logger logger.ContextLogger, conn N.PacketConn, buffer *buf.Buffer, metadata adapter.InboundContext, destination M.Socksaddr) { - err := exchangeDNSPacket(ctx, router, conn, buffer, metadata, destination) - if err != nil && !R.IsRejected(err) && !E.IsClosedOrCanceled(err) { - logger.ErrorContext(ctx, E.Cause(err, "process DNS packet")) - } -} - -func exchangeDNSPacket(ctx context.Context, router adapter.DNSRouter, conn N.PacketConn, buffer *buf.Buffer, metadata adapter.InboundContext, destination M.Socksaddr) error { +func (r *Router) HijackDNSPacket(ctx context.Context, payload []byte, writer N.PacketWriter, metadata adapter.InboundContext) { var message mDNS.Msg - err := message.Unpack(buffer.Bytes()) - buffer.Release() + err := message.Unpack(payload) if err != nil { - return E.Cause(err, "unpack request") - } - response, err := router.Exchange(adapter.WithContext(ctx, &metadata), &message, adapter.DNSQueryOptions{}) - if err != nil { - return err - } - responseBuffer, err := dns.TruncateDNSMessage(&message, response, 1024) - if err != nil { - return err + r.logger.ErrorContext(ctx, E.Cause(err, "process DNS packet: unpack request")) + return } - err = conn.WritePacket(responseBuffer, destination) - return err -} - -type dnsHijacker struct { - router adapter.DNSRouter - logger logger.ContextLogger - conn N.PacketConn - ctx context.Context - metadata adapter.InboundContext - onClose N.CloseHandlerFunc -} - -func (h *dnsHijacker) NewPacketEx(buffer *buf.Buffer, destination M.Socksaddr) { - go ExchangeDNSPacket(h.ctx, h.router, h.logger, h.conn, buffer, h.metadata, destination) + destination := metadata.Destination + metadata.Destination = M.Socksaddr{} + r.dns.ExchangeAsync(adapter.WithContext(ctx, &metadata), &message, adapter.DNSQueryOptions{}, func(response *mDNS.Msg, exchangeErr error) { + if exchangeErr == nil { + exchangeErr = r.writeDNSPacketResponse(&message, response, writer, destination) + } + if exchangeErr != nil && !R.IsRejected(exchangeErr) && !E.IsClosedOrCanceled(exchangeErr) { + r.logger.ErrorContext(ctx, E.Cause(exchangeErr, "process DNS packet")) + } + }) } -func (h *dnsHijacker) Close() error { - if h.onClose != nil { - h.onClose(nil) +func (r *Router) writeDNSPacketResponse(message *mDNS.Msg, response *mDNS.Msg, writer N.PacketWriter, destination M.Socksaddr) error { + responseBuffer, err := dns.TruncateDNSMessage(message, response, 1024) + if err != nil { + return err } - return nil + return writer.WritePacket(responseBuffer, destination) } diff --git a/route/network.go b/route/network.go index 1393ca90a0..0156f6c508 100644 --- a/route/network.go +++ b/route/network.go @@ -105,7 +105,7 @@ func NewNetworkManager(ctx context.Context, logger logger.ContextLogger, options return nil, E.New("`auto_detect_interface` is required by `default_network_strategy`") } } - usePlatformDefaultInterfaceMonitor := nm.platformInterface != nil + usePlatformDefaultInterfaceMonitor := nm.platformInterface != nil && nm.platformInterface.UsePlatformDefaultInterfaceMonitor() enforceInterfaceMonitor := options.AutoDetectInterface if !usePlatformDefaultInterfaceMonitor { networkMonitor, err := tun.NewNetworkUpdateMonitor(logger) @@ -496,7 +496,7 @@ func (r *NetworkManager) notifyInterfaceUpdate(defaultInterface *control.Interfa vpnStatus = "disabled" } options = append(options, "vpn "+vpnStatus) - } else if r.platformInterface != nil { + } else if r.platformInterface != nil && r.platformInterface.UsePlatformNetworkInterfaces() { networkInterface := common.Find(r.networkInterfaces.Load(), func(it adapter.NetworkInterface) bool { return it.Interface.Index == defaultInterface.Index }) diff --git a/route/platform_searcher.go b/route/platform_searcher.go index 20fbda3f32..718385f4ee 100644 --- a/route/platform_searcher.go +++ b/route/platform_searcher.go @@ -44,6 +44,9 @@ func (s *platformSearcher) FindProcessInfo(ctx context.Context, network string, return s.platform.FindConnectionOwner(request) } +func (s *platformSearcher) ResetCache() { +} + func (s *platformSearcher) Close() error { return nil } diff --git a/route/route.go b/route/route.go index 89fb597a34..3d0d5f7787 100644 --- a/route/route.go +++ b/route/route.go @@ -391,6 +391,11 @@ func (r *Router) PreMatch(metadata adapter.InboundContext, firstPacket []byte) a return adapter.PreMatchResult{Action: adapter.PreMatchDrop} } return adapter.PreMatchResult{Action: adapter.PreMatchReject} + case *R.RuleActionHijackDNS: + if metadata.Network != N.NetworkUDP { + return continueResult + } + return adapter.PreMatchResult{Action: adapter.PreMatchHijackDNS} case *R.RuleActionResolve: resolveErr := r.actionResolve(adapter.WithContext(ctx, &metadata), &metadata, action) if resolveErr != nil { diff --git a/route/router.go b/route/router.go index bf4117cc5e..682111d43f 100644 --- a/route/router.go +++ b/route/router.go @@ -42,7 +42,7 @@ type Router struct { ruleSetMap map[string]adapter.RuleSet ruleSetUpdater *R.RuleSetUpdater processSearcher process.Searcher - processCache freelru.Cache[processCacheKey, processCacheEntry] + processCache *freelru.Cache[processCacheKey, processCacheEntry] neighborResolver adapter.NeighborResolver pauseManager pause.Manager trackers []adapter.ConnectionTracker @@ -84,15 +84,17 @@ func (r *Router) Initialize(rules []option.Rule, ruleSets []option.RuleSet) erro r.rules = append(r.rules, rule) } for i, options := range ruleSets { - if _, exists := r.ruleSetMap[options.Tag]; exists { - return E.New("duplicate rule-set tag: ", options.Tag) - } - ruleSet, err := R.NewRuleSet(r.ctx, r.logger, options) - if err != nil { - return E.Cause(err, "parse rule-set[", i, "]") + for _, tag := range options.Tag { + if _, exists := r.ruleSetMap[tag]; exists { + return E.New("duplicate rule-set tag: ", tag) + } + ruleSet, err := R.NewRuleSet(r.ctx, r.logger, tag, options) + if err != nil { + return E.Cause(err, "parse rule-set[", i, "]") + } + r.ruleSets = append(r.ruleSets, ruleSet) + r.ruleSetMap[tag] = ruleSet } - r.ruleSets = append(r.ruleSets, ruleSet) - r.ruleSetMap[options.Tag] = ruleSet } return nil } @@ -190,7 +192,7 @@ func (r *Router) Start(stage adapter.StartStage) error { } } if r.processSearcher != nil { - processCache := common.Must1(freelru.NewSharded[processCacheKey, processCacheEntry](256, maphash.NewHasher[processCacheKey]().Hash32)) + processCache := common.Must1(freelru.New[processCacheKey, processCacheEntry](256, maphash.NewHasher[processCacheKey]().Hash32, true)) processCache.SetLifetime(200 * time.Millisecond) r.processCache = processCache } @@ -286,4 +288,10 @@ func (r *Router) NeighborResolver() adapter.NeighborResolver { func (r *Router) ResetNetwork() { r.httpClientManager.ResetNetwork() r.dns.ResetNetwork() + if r.processCache != nil { + r.processCache.Purge() + } + if r.processSearcher != nil { + r.processSearcher.ResetCache() + } } diff --git a/route/rule/match_state.go b/route/rule/match_state.go index feac8418a2..f345d81c42 100644 --- a/route/rule/match_state.go +++ b/route/rule/match_state.go @@ -11,6 +11,22 @@ const ( ruleMatchDestinationPort ) +type ruleGroupMatch struct { + required ruleMatchState + satisfied ruleMatchState +} + +func (g ruleGroupMatch) done() bool { + return g.required&^g.satisfied == 0 +} + +func (g ruleGroupMatch) mergeWith(other ruleGroupMatch) ruleGroupMatch { + return ruleGroupMatch{ + required: g.required | other.required, + satisfied: g.satisfied | other.satisfied, + } +} + type ruleMatchStateSet uint16 func singleRuleMatchState(state ruleMatchState) ruleMatchStateSet { diff --git a/route/rule/rule_abstract.go b/route/rule/rule_abstract.go index d7b844adbb..ce56a58d3a 100644 --- a/route/rule/rule_abstract.go +++ b/route/rule/rule_abstract.go @@ -18,7 +18,7 @@ type abstractDefaultRule struct { destinationIPCIDRItems []RuleItem destinationPortItems []RuleItem allItems []RuleItem - ruleSetItem RuleItem + ruleSetItem *RuleSetItem invert bool action adapter.RuleAction } @@ -52,123 +52,98 @@ func (r *abstractDefaultRule) Close() error { } func (r *abstractDefaultRule) Match(metadata *adapter.InboundContext) bool { - return !r.matchStates(metadata).isEmpty() -} - -func (r *abstractDefaultRule) destinationIPCIDRMatchesSource(metadata *adapter.InboundContext) bool { - return !metadata.IgnoreDestinationIPCIDRMatch && metadata.IPCIDRMatchSource && len(r.destinationIPCIDRItems) > 0 + if len(r.allItems) == 0 { + return true + } + matched := r.matchInner(metadata) + if r.invert { + if matched && metadata.IgnoreDestinationIPCIDRMatch && !metadata.DidMatch && len(r.destinationIPCIDRItems) > 0 { + return true + } + return !matched + } + return matched } -func (r *abstractDefaultRule) destinationIPCIDRMatchesDestination(metadata *adapter.InboundContext) bool { - return !metadata.IgnoreDestinationIPCIDRMatch && !metadata.IPCIDRMatchSource && len(r.destinationIPCIDRItems) > 0 +func (r *abstractDefaultRule) matchInner(metadata *adapter.InboundContext) bool { + groups := r.evaluateGroups(metadata) + for _, item := range r.items { + metadata.DidMatch = true + if !item.Match(metadata) { + return false + } + } + if r.ruleSetItem != nil { + metadata.DidMatch = true + return r.ruleSetItem.matchWithOuterGroups(metadata, groups) + } + return groups.done() } -func (r *abstractDefaultRule) requiresSourceAddressMatch(metadata *adapter.InboundContext) bool { - return len(r.sourceAddressItems) > 0 || r.destinationIPCIDRMatchesSource(metadata) +func (r *abstractDefaultRule) evaluateForMerge(metadata *adapter.InboundContext) (ruleGroupMatch, bool) { + groups := r.evaluateGroups(metadata) + for _, item := range r.items { + metadata.DidMatch = true + if !item.Match(metadata) { + return ruleGroupMatch{}, false + } + } + return groups, true } -func (r *abstractDefaultRule) requiresDestinationAddressMatch(metadata *adapter.InboundContext) bool { - return len(r.destinationAddressItems) > 0 || r.destinationIPCIDRMatchesDestination(metadata) +func (r *abstractDefaultRule) destinationIPCIDRMatchesSource(metadata *adapter.InboundContext) bool { + return !metadata.IgnoreDestinationIPCIDRMatch && metadata.IPCIDRMatchSource && len(r.destinationIPCIDRItems) > 0 } -func (r *abstractDefaultRule) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { - return r.matchStatesWithBase(metadata, 0) +func (r *abstractDefaultRule) destinationIPCIDRMatchesDestination(metadata *adapter.InboundContext) bool { + return !metadata.IgnoreDestinationIPCIDRMatch && !metadata.IPCIDRMatchSource && len(r.destinationIPCIDRItems) > 0 } -func (r *abstractDefaultRule) matchStatesWithBase(metadata *adapter.InboundContext, inheritedBase ruleMatchState) ruleMatchStateSet { - if len(r.allItems) == 0 { - return emptyRuleMatchState().withBase(inheritedBase) - } - evaluationBase := inheritedBase - if r.invert { - evaluationBase = 0 - } - baseState := evaluationBase +func (r *abstractDefaultRule) evaluateGroups(metadata *adapter.InboundContext) ruleGroupMatch { + var groups ruleGroupMatch if len(r.sourceAddressItems) > 0 { metadata.DidMatch = true + groups.required |= ruleMatchSourceAddress if matchAnyItem(r.sourceAddressItems, metadata) { - baseState |= ruleMatchSourceAddress + groups.satisfied |= ruleMatchSourceAddress } } - if r.destinationIPCIDRMatchesSource(metadata) && !baseState.has(ruleMatchSourceAddress) { + if r.destinationIPCIDRMatchesSource(metadata) { metadata.DidMatch = true - if matchAnyItem(r.destinationIPCIDRItems, metadata) { - baseState |= ruleMatchSourceAddress + groups.required |= ruleMatchSourceAddress + if !groups.satisfied.has(ruleMatchSourceAddress) && matchAnyItem(r.destinationIPCIDRItems, metadata) { + groups.satisfied |= ruleMatchSourceAddress } - } else if r.destinationIPCIDRMatchesSource(metadata) { - metadata.DidMatch = true } if len(r.sourcePortItems) > 0 { metadata.DidMatch = true + groups.required |= ruleMatchSourcePort if matchAnyItem(r.sourcePortItems, metadata) { - baseState |= ruleMatchSourcePort + groups.satisfied |= ruleMatchSourcePort } } if len(r.destinationAddressItems) > 0 { metadata.DidMatch = true + groups.required |= ruleMatchDestinationAddress if matchAnyItem(r.destinationAddressItems, metadata) { - baseState |= ruleMatchDestinationAddress + groups.satisfied |= ruleMatchDestinationAddress } } - if r.destinationIPCIDRMatchesDestination(metadata) && !baseState.has(ruleMatchDestinationAddress) { + if r.destinationIPCIDRMatchesDestination(metadata) { metadata.DidMatch = true - if matchAnyItem(r.destinationIPCIDRItems, metadata) { - baseState |= ruleMatchDestinationAddress + groups.required |= ruleMatchDestinationAddress + if !groups.satisfied.has(ruleMatchDestinationAddress) && matchAnyItem(r.destinationIPCIDRItems, metadata) { + groups.satisfied |= ruleMatchDestinationAddress } - } else if r.destinationIPCIDRMatchesDestination(metadata) { - metadata.DidMatch = true } if len(r.destinationPortItems) > 0 { metadata.DidMatch = true + groups.required |= ruleMatchDestinationPort if matchAnyItem(r.destinationPortItems, metadata) { - baseState |= ruleMatchDestinationPort + groups.satisfied |= ruleMatchDestinationPort } } - for _, item := range r.items { - metadata.DidMatch = true - if !item.Match(metadata) { - return r.invertedFailure(inheritedBase) - } - } - var stateSet ruleMatchStateSet - if r.ruleSetItem != nil { - metadata.DidMatch = true - stateSet = matchRuleItemStatesWithBase(r.ruleSetItem, metadata, baseState) - } else { - stateSet = singleRuleMatchState(baseState) - } - stateSet = stateSet.filter(func(state ruleMatchState) bool { - if r.requiresSourceAddressMatch(metadata) && !state.has(ruleMatchSourceAddress) { - return false - } - if len(r.sourcePortItems) > 0 && !state.has(ruleMatchSourcePort) { - return false - } - if r.requiresDestinationAddressMatch(metadata) && !state.has(ruleMatchDestinationAddress) { - return false - } - if len(r.destinationPortItems) > 0 && !state.has(ruleMatchDestinationPort) { - return false - } - return true - }) - if stateSet.isEmpty() { - return r.invertedFailure(inheritedBase) - } - if r.invert { - if metadata.IgnoreDestinationIPCIDRMatch && stateSet == emptyRuleMatchState() && !metadata.DidMatch && len(r.destinationIPCIDRItems) > 0 { - return emptyRuleMatchState().withBase(inheritedBase) - } - return 0 - } - return stateSet -} - -func (r *abstractDefaultRule) invertedFailure(base ruleMatchState) ruleMatchStateSet { - if r.invert { - return emptyRuleMatchState().withBase(base) - } - return 0 + return groups } func (r *abstractDefaultRule) Action() adapter.RuleAction { @@ -226,50 +201,31 @@ func (r *abstractLogicalRule) Close() error { } func (r *abstractLogicalRule) Match(metadata *adapter.InboundContext) bool { - return !r.matchStates(metadata).isEmpty() -} - -func (r *abstractLogicalRule) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { - return r.matchStatesWithBase(metadata, 0) -} - -func (r *abstractLogicalRule) matchStatesWithBase(metadata *adapter.InboundContext, base ruleMatchState) ruleMatchStateSet { - evaluationBase := base - if r.invert { - evaluationBase = 0 - } - var stateSet ruleMatchStateSet + var matched bool if r.mode == C.LogicalTypeAnd { - stateSet = emptyRuleMatchState().withBase(evaluationBase) + matched = true for _, rule := range r.rules { nestedMetadata := *metadata nestedMetadata.ResetRuleCache() - nestedStateSet := matchHeadlessRuleStatesWithBase(rule, &nestedMetadata, evaluationBase) - if nestedStateSet.isEmpty() { - if r.invert { - return emptyRuleMatchState().withBase(base) - } - return 0 + if !rule.Match(&nestedMetadata) { + matched = false + break } - stateSet = stateSet.combine(nestedStateSet) } } else { for _, rule := range r.rules { nestedMetadata := *metadata nestedMetadata.ResetRuleCache() - stateSet = stateSet.merge(matchHeadlessRuleStatesWithBase(rule, &nestedMetadata, evaluationBase)) - } - if stateSet.isEmpty() { - if r.invert { - return emptyRuleMatchState().withBase(base) + if rule.Match(&nestedMetadata) { + matched = true + break } - return 0 } } if r.invert { - return 0 + return !matched } - return stateSet + return matched } func (r *abstractLogicalRule) Action() adapter.RuleAction { diff --git a/route/rule/rule_action.go b/route/rule/rule_action.go index 97265907fd..a26c4ff89e 100644 --- a/route/rule/rule_action.go +++ b/route/rule/rule_action.go @@ -75,7 +75,9 @@ func NewRuleAction(ctx context.Context, logger logger.ContextLogger, action opti RuleActionRouteOptions: routeOptions, }, nil case C.RuleActionTypeDirect: - directDialer, err := dialer.New(ctx, option.DialerOptions(action.DirectOptions), false) + directDialer, err := dialer.New(ctx, option.DialerOptions{ + AbstractDialerOptions: action.DirectOptions.AbstractDialerOptions, + }, false) if err != nil { return nil, err } @@ -129,7 +131,8 @@ func NewDNSRuleAction(logger logger.ContextLogger, action option.DNSRuleAction) return nil case C.RuleActionTypeRoute: return &RuleActionDNSRoute{ - Server: action.RouteOptions.Server, + Server: action.RouteOptions.Server, + Speculative: action.RouteOptions.Speculative, RuleActionDNSRouteOptions: RuleActionDNSRouteOptions{ Strategy: C.DomainStrategy(action.RouteOptions.Strategy), Timeout: time.Duration(action.RouteOptions.Timeout), @@ -142,14 +145,16 @@ func NewDNSRuleAction(logger logger.ContextLogger, action option.DNSRuleAction) } case C.RuleActionTypeEvaluate: return &RuleActionEvaluate{ - Server: action.RouteOptions.Server, + Server: action.EvaluateOptions.Server, + Tag: action.EvaluateOptions.Tag, + Speculative: action.EvaluateOptions.Speculative, RuleActionDNSRouteOptions: RuleActionDNSRouteOptions{ - Strategy: C.DomainStrategy(action.RouteOptions.Strategy), - Timeout: time.Duration(action.RouteOptions.Timeout), - DisableCache: action.RouteOptions.DisableCache, - DisableOptimisticCache: action.RouteOptions.DisableOptimisticCache, - RewriteTTL: action.RouteOptions.RewriteTTL, - ClientSubnet: netip.Prefix(common.PtrValueOrDefault(action.RouteOptions.ClientSubnet)), + Strategy: C.DomainStrategy(action.EvaluateOptions.Strategy), + Timeout: time.Duration(action.EvaluateOptions.Timeout), + DisableCache: action.EvaluateOptions.DisableCache, + DisableOptimisticCache: action.EvaluateOptions.DisableOptimisticCache, + RewriteTTL: action.EvaluateOptions.RewriteTTL, + ClientSubnet: netip.Prefix(common.PtrValueOrDefault(action.EvaluateOptions.ClientSubnet)), }, } case C.RuleActionTypeRespond: @@ -291,7 +296,8 @@ func (r *RuleActionRouteOptions) Descriptions() []string { } type RuleActionDNSRoute struct { - Server string + Server string + Speculative bool RuleActionDNSRouteOptions } @@ -300,11 +306,13 @@ func (r *RuleActionDNSRoute) Type() string { } func (r *RuleActionDNSRoute) String() string { - return formatDNSRouteAction("route", r.Server, r.RuleActionDNSRouteOptions) + return formatDNSRouteAction("route", r.Server, r.Speculative, r.RuleActionDNSRouteOptions) } type RuleActionEvaluate struct { - Server string + Server string + Tag string + Speculative bool RuleActionDNSRouteOptions } @@ -313,7 +321,7 @@ func (r *RuleActionEvaluate) Type() string { } func (r *RuleActionEvaluate) String() string { - return formatDNSRouteAction("evaluate", r.Server, r.RuleActionDNSRouteOptions) + return formatDNSRouteAction("evaluate", r.Server, r.Speculative, r.RuleActionDNSRouteOptions) } type RuleActionRespond struct{} @@ -326,9 +334,12 @@ func (r *RuleActionRespond) String() string { return "respond" } -func formatDNSRouteAction(action string, server string, options RuleActionDNSRouteOptions) string { +func formatDNSRouteAction(action string, server string, speculative bool, options RuleActionDNSRouteOptions) string { var descriptions []string descriptions = append(descriptions, server) + if speculative { + descriptions = append(descriptions, "speculative") + } if options.DisableCache { descriptions = append(descriptions, "disable-cache") } diff --git a/route/rule/rule_default.go b/route/rule/rule_default.go index 02409c335a..b6c4931b07 100644 --- a/route/rule/rule_default.go +++ b/route/rule/rule_default.go @@ -47,10 +47,6 @@ type DefaultRule struct { abstractDefaultRule } -func (r *DefaultRule) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { - return r.abstractDefaultRule.matchStates(metadata) -} - type RuleItem interface { Match(metadata *adapter.InboundContext) bool String() string @@ -319,10 +315,6 @@ type LogicalRule struct { abstractLogicalRule } -func (r *LogicalRule) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { - return r.abstractLogicalRule.matchStates(metadata) -} - func NewLogicalRule(ctx context.Context, logger log.ContextLogger, options option.LogicalRule) (*LogicalRule, error) { action, err := NewRuleAction(ctx, logger, options.RuleAction) if err != nil { diff --git a/route/rule/rule_dns.go b/route/rule/rule_dns.go index 438c6fbdaf..36a65f0292 100644 --- a/route/rule/rule_dns.go +++ b/route/rule/rule_dns.go @@ -28,11 +28,18 @@ func NewDNSRule(ctx context.Context, logger log.ContextLogger, options option.DN if err != nil { return nil, err } + if options.DefaultOptions.Race && !options.DefaultOptions.MatchResponse.IsEnabled() { + return nil, E.New("`race` requires `match_response`") + } switch options.DefaultOptions.Action { - case "", C.RuleActionTypeRoute, C.RuleActionTypeEvaluate: + case "", C.RuleActionTypeRoute: if options.DefaultOptions.RouteOptions.Server == "" && checkServer { return nil, E.New("missing server field") } + case C.RuleActionTypeEvaluate: + if options.DefaultOptions.EvaluateOptions.Server == "" && checkServer { + return nil, E.New("missing server field") + } } return NewDefaultDNSRule(ctx, logger, options.DefaultOptions, legacyDNSMode) case C.RuleTypeLogical: @@ -47,10 +54,14 @@ func NewDNSRule(ctx context.Context, logger log.ContextLogger, options option.DN return nil, err } switch options.LogicalOptions.Action { - case "", C.RuleActionTypeRoute, C.RuleActionTypeEvaluate: + case "", C.RuleActionTypeRoute: if options.LogicalOptions.RouteOptions.Server == "" && checkServer { return nil, E.New("missing server field") } + case C.RuleActionTypeEvaluate: + if options.LogicalOptions.EvaluateOptions.Server == "" && checkServer { + return nil, E.New("missing server field") + } } return NewLogicalDNSRule(ctx, logger, options.LogicalOptions, legacyDNSMode) default: @@ -62,6 +73,16 @@ func validateDNSRuleAction(action option.DNSRuleAction) error { if action.Action == C.RuleActionTypeReject && action.RejectOptions.Method == C.RuleActionRejectMethodReply { return E.New("reject method `reply` is not supported for DNS rules") } + if action.Race { + switch action.Action { + case "", C.RuleActionTypeRoute, C.RuleActionTypeRespond, C.RuleActionTypeReject, C.RuleActionTypePredefined: + default: + return E.New("`race` requires a final action") + } + if action.RouteOptions.Speculative { + return E.New("`race` and `speculative` cannot be combined on the same rule") + } + } return nil } @@ -69,11 +90,9 @@ var _ adapter.DNSRule = (*DefaultDNSRule)(nil) type DefaultDNSRule struct { abstractDefaultRule - matchResponse bool -} - -func (r *DefaultDNSRule) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { - return r.abstractDefaultRule.matchStates(metadata) + matchResponse bool + matchResponseTag string + race bool } func NewDefaultDNSRule(ctx context.Context, logger log.ContextLogger, options option.DefaultDNSRule, legacyDNSMode bool) (*DefaultDNSRule, error) { @@ -82,7 +101,9 @@ func NewDefaultDNSRule(ctx context.Context, logger log.ContextLogger, options op invert: options.Invert, action: NewDNSRuleAction(logger, options.DNSRuleAction), }, - matchResponse: options.MatchResponse, + matchResponse: options.MatchResponse.IsEnabled(), + matchResponseTag: options.MatchResponse.ResponseTag(), + race: options.Race, } if len(options.Inbound) > 0 { item := NewInboundRule(options.Inbound) @@ -370,17 +391,11 @@ func (r *DefaultDNSRule) WithAddressLimit() bool { if len(r.destinationIPCIDRItems) > 0 { return true } - if r.ruleSetItem != nil { - ruleSet, isRuleSet := r.ruleSetItem.(*RuleSetItem) - if isRuleSet && ruleSet.ContainsDestinationIPCIDRRule() { - return true - } - } - return false + return r.ruleSetItem != nil && r.ruleSetItem.ContainsDestinationIPCIDRRule() } func (r *DefaultDNSRule) Match(metadata *adapter.InboundContext) bool { - return !r.matchStatesForMatch(metadata).isEmpty() + return r.matchForMatch(metadata) } func (r *DefaultDNSRule) LegacyPreMatch(metadata *adapter.InboundContext) bool { @@ -389,82 +404,103 @@ func (r *DefaultDNSRule) LegacyPreMatch(metadata *adapter.InboundContext) bool { } metadata.IgnoreDestinationIPCIDRMatch = true defer func() { metadata.IgnoreDestinationIPCIDRMatch = false }() - return !r.abstractDefaultRule.matchStates(metadata).isEmpty() + return r.abstractDefaultRule.Match(metadata) +} + +func (r *DefaultDNSRule) MatchResponseTag() string { + return r.matchResponseTag +} + +func (r *DefaultDNSRule) MatchResponseTags() []string { + if r.matchResponseTag == "" { + return nil + } + return []string{r.matchResponseTag} +} + +func (r *DefaultDNSRule) MatchResponseAnonymous() bool { + return r.matchResponse && r.matchResponseTag == "" +} + +func (r *DefaultDNSRule) Race() bool { + return r.race } -func (r *DefaultDNSRule) matchStatesForMatch(metadata *adapter.InboundContext) ruleMatchStateSet { +func (r *DefaultDNSRule) matchForMatch(metadata *adapter.InboundContext) bool { if r.matchResponse { - if metadata.DNSResponse == nil { - return r.abstractDefaultRule.invertedFailure(0) + response := metadata.DNSResponse + if r.matchResponseTag != "" { + response = metadata.NamedDNSResponses[r.matchResponseTag] + } + if response == nil { + return r.invert } matchMetadata := *metadata + matchMetadata.DNSResponse = response matchMetadata.DestinationAddressMatchFromResponse = true - return r.abstractDefaultRule.matchStates(&matchMetadata) + return r.abstractDefaultRule.Match(&matchMetadata) } - return r.abstractDefaultRule.matchStates(metadata) + return r.abstractDefaultRule.Match(metadata) } func (r *DefaultDNSRule) MatchAddressLimit(metadata *adapter.InboundContext, response *dns.Msg) bool { matchMetadata := *metadata matchMetadata.DNSResponse = response matchMetadata.DestinationAddressMatchFromResponse = true - return !r.abstractDefaultRule.matchStates(&matchMetadata).isEmpty() + return r.abstractDefaultRule.Match(&matchMetadata) } var _ adapter.DNSRule = (*LogicalDNSRule)(nil) type LogicalDNSRule struct { abstractLogicalRule + matchResponseTags []string + matchResponseAnonymous bool + race bool } -func (r *LogicalDNSRule) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { - return r.abstractLogicalRule.matchStates(metadata) +func (r *LogicalDNSRule) MatchResponseTag() string { + return "" } -func matchDNSHeadlessRuleStatesForMatch(rule adapter.HeadlessRule, metadata *adapter.InboundContext) ruleMatchStateSet { - switch typedRule := rule.(type) { - case *DefaultDNSRule: - return typedRule.matchStatesForMatch(metadata) - case *LogicalDNSRule: - return typedRule.matchStatesForMatch(metadata) - default: - return matchHeadlessRuleStates(typedRule, metadata) - } +func (r *LogicalDNSRule) MatchResponseTags() []string { + return r.matchResponseTags +} + +func (r *LogicalDNSRule) MatchResponseAnonymous() bool { + return r.matchResponseAnonymous } -func (r *LogicalDNSRule) matchStatesForMatch(metadata *adapter.InboundContext) ruleMatchStateSet { - var stateSet ruleMatchStateSet +func (r *LogicalDNSRule) Race() bool { + return r.race +} + +func (r *LogicalDNSRule) matchForMatch(metadata *adapter.InboundContext) bool { + var matched bool if r.mode == C.LogicalTypeAnd { - stateSet = emptyRuleMatchState() + matched = true for _, rule := range r.rules { nestedMetadata := *metadata nestedMetadata.ResetRuleCache() - nestedStateSet := matchDNSHeadlessRuleStatesForMatch(rule, &nestedMetadata) - if nestedStateSet.isEmpty() { - if r.invert { - return emptyRuleMatchState() - } - return 0 + if !rule.Match(&nestedMetadata) { + matched = false + break } - stateSet = stateSet.combine(nestedStateSet) } } else { for _, rule := range r.rules { nestedMetadata := *metadata nestedMetadata.ResetRuleCache() - stateSet = stateSet.merge(matchDNSHeadlessRuleStatesForMatch(rule, &nestedMetadata)) - } - if stateSet.isEmpty() { - if r.invert { - return emptyRuleMatchState() + if rule.Match(&nestedMetadata) { + matched = true + break } - return 0 } } if r.invert { - return 0 + return !matched } - return stateSet + return matched } func NewLogicalDNSRule(ctx context.Context, logger log.ContextLogger, options option.LogicalDNSRule, legacyDNSMode bool) (*LogicalDNSRule, error) { @@ -474,6 +510,7 @@ func NewLogicalDNSRule(ctx context.Context, logger log.ContextLogger, options op invert: options.Invert, action: NewDNSRuleAction(logger, options.DNSRuleAction), }, + race: options.Race, } switch options.Mode { case C.LogicalTypeAnd: @@ -494,6 +531,16 @@ func NewLogicalDNSRule(ctx context.Context, logger log.ContextLogger, options op } r.rules[i] = rule } + for _, subRule := range r.rules { + if dnsRule, isDNSRule := subRule.(adapter.DNSRule); isDNSRule { + r.matchResponseTags = append(r.matchResponseTags, dnsRule.MatchResponseTags()...) + r.matchResponseAnonymous = r.matchResponseAnonymous || dnsRule.MatchResponseAnonymous() + } + } + r.matchResponseTags = common.Uniq(r.matchResponseTags) + if r.race && len(r.matchResponseTags) == 0 && !r.matchResponseAnonymous { + return nil, E.New("`race` requires `match_response` in sub-rules") + } return r, nil } @@ -503,35 +550,28 @@ func (r *LogicalDNSRule) Action() adapter.RuleAction { func (r *LogicalDNSRule) WithAddressLimit() bool { for _, rawRule := range r.rules { - switch rule := rawRule.(type) { - case *DefaultDNSRule: - if rule.WithAddressLimit() { - return true - } - case *LogicalDNSRule: - if rule.WithAddressLimit() { - return true - } + if dnsRule, isDNSRule := rawRule.(adapter.DNSRule); isDNSRule && dnsRule.WithAddressLimit() { + return true } } return false } func (r *LogicalDNSRule) Match(metadata *adapter.InboundContext) bool { - return !r.matchStatesForMatch(metadata).isEmpty() + return r.matchForMatch(metadata) } func (r *LogicalDNSRule) LegacyPreMatch(metadata *adapter.InboundContext) bool { metadata.IgnoreDestinationIPCIDRMatch = true defer func() { metadata.IgnoreDestinationIPCIDRMatch = false }() - return !r.abstractLogicalRule.matchStates(metadata).isEmpty() + return r.abstractLogicalRule.Match(metadata) } func (r *LogicalDNSRule) MatchAddressLimit(metadata *adapter.InboundContext, response *dns.Msg) bool { matchMetadata := *metadata matchMetadata.DNSResponse = response matchMetadata.DestinationAddressMatchFromResponse = true - return !r.abstractLogicalRule.matchStates(&matchMetadata).isEmpty() + return r.abstractLogicalRule.Match(&matchMetadata) } func (r *LogicalDNSRule) BypassIfFailed() bool { diff --git a/route/rule/rule_headless.go b/route/rule/rule_headless.go index bef30d46c1..76a4449b76 100644 --- a/route/rule/rule_headless.go +++ b/route/rule/rule_headless.go @@ -34,10 +34,6 @@ type DefaultHeadlessRule struct { abstractDefaultRule } -func (r *DefaultHeadlessRule) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { - return r.abstractDefaultRule.matchStates(metadata) -} - func NewDefaultHeadlessRule(ctx context.Context, options option.DefaultHeadlessRule) (*DefaultHeadlessRule, error) { networkManager := service.FromContext[adapter.NetworkManager](ctx) rule := &DefaultHeadlessRule{ @@ -226,10 +222,6 @@ type LogicalHeadlessRule struct { abstractLogicalRule } -func (r *LogicalHeadlessRule) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { - return r.abstractLogicalRule.matchStates(metadata) -} - func NewLogicalHeadlessRule(ctx context.Context, options option.LogicalHeadlessRule) (*LogicalHeadlessRule, error) { r := &LogicalHeadlessRule{ abstractLogicalRule{ diff --git a/route/rule/rule_item_rule_set.go b/route/rule/rule_item_rule_set.go index 0136494353..505650b171 100644 --- a/route/rule/rule_item_rule_set.go +++ b/route/rule/rule_item_rule_set.go @@ -52,23 +52,68 @@ func (r *RuleSetItem) Close() error { } func (r *RuleSetItem) Match(metadata *adapter.InboundContext) bool { - return !r.matchStates(metadata).isEmpty() + for _, ruleSet := range r.setList { + nestedMetadata := r.nestedMetadata(metadata) + if ruleSet.Match(&nestedMetadata) { + return true + } + } + return false } -func (r *RuleSetItem) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { - return r.matchStatesWithBase(metadata, 0) +func (r *RuleSetItem) matchWithOuterGroups(metadata *adapter.InboundContext, outerGroups ruleGroupMatch) bool { + outerDone := outerGroups.done() + for _, ruleSet := range r.setList { + nestedMetadata := r.nestedMetadata(metadata) + if provider, isProvider := ruleSet.(mergeableRuleProvider); isProvider { + branch := provider.mergeableRule() + if branch != nil { + branchGroups, branchMatched := branch.evaluateForMerge(&nestedMetadata) + if branchMatched && outerGroups.mergeWith(branchGroups).done() { + return true + } + continue + } + } + if outerDone && ruleSet.Match(&nestedMetadata) { + return true + } + } + return false } -func (r *RuleSetItem) matchStatesWithBase(metadata *adapter.InboundContext, base ruleMatchState) ruleMatchStateSet { - var stateSet ruleMatchStateSet - for _, ruleSet := range r.setList { +func (r *RuleSetItem) nestedMetadata(metadata *adapter.InboundContext) adapter.InboundContext { + nestedMetadata := *metadata + nestedMetadata.ResetRuleMatchCache() + nestedMetadata.IPCIDRMatchSource = r.ipCidrMatchSource + nestedMetadata.IPCIDRAcceptEmpty = r.ipCidrAcceptEmpty + return nestedMetadata +} + +type mergeableRuleProvider interface { + mergeableRule() *DefaultHeadlessRule +} + +func mergeableRuleIn(rules []adapter.HeadlessRule) *DefaultHeadlessRule { + if len(rules) != 1 { + return nil + } + rule, isDefault := rules[0].(*DefaultHeadlessRule) + if !isDefault || rule.invert || rule.ruleSetItem != nil { + return nil + } + return rule +} + +func matchAnyHeadlessRule(rules []adapter.HeadlessRule, metadata *adapter.InboundContext) bool { + for _, rule := range rules { nestedMetadata := *metadata nestedMetadata.ResetRuleMatchCache() - nestedMetadata.IPCIDRMatchSource = r.ipCidrMatchSource - nestedMetadata.IPCIDRAcceptEmpty = r.ipCidrAcceptEmpty - stateSet = stateSet.merge(matchHeadlessRuleStatesWithBase(ruleSet, &nestedMetadata, base)) + if rule.Match(&nestedMetadata) { + return true + } } - return stateSet + return false } func (r *RuleSetItem) ContainsDestinationIPCIDRRule() bool { diff --git a/route/rule/rule_item_rule_set_test.go b/route/rule/rule_item_rule_set_test.go index d6457fbd14..a49980d08e 100644 --- a/route/rule/rule_item_rule_set_test.go +++ b/route/rule/rule_item_rule_set_test.go @@ -24,6 +24,9 @@ func (r *ruleSetItemTestRouter) PreMatch(adapter.InboundContext, []byte) adapter return adapter.PreMatchResult{} } +func (r *ruleSetItemTestRouter) HijackDNSPacket(context.Context, []byte, N.PacketWriter, adapter.InboundContext) { +} + func (r *ruleSetItemTestRouter) RouteConnection(context.Context, net.Conn, adapter.InboundContext) error { return nil } diff --git a/route/rule/rule_set.go b/route/rule/rule_set.go index 6720e788b7..4eb24654d9 100644 --- a/route/rule/rule_set.go +++ b/route/rule/rule_set.go @@ -15,12 +15,12 @@ import ( "go4.org/netipx" ) -func NewRuleSet(ctx context.Context, logger logger.ContextLogger, options option.RuleSet) (adapter.RuleSet, error) { +func NewRuleSet(ctx context.Context, logger logger.ContextLogger, tag string, options option.RuleSet) (adapter.RuleSet, error) { switch options.Type { case C.RuleSetTypeInline, C.RuleSetTypeLocal, "": - return NewLocalRuleSet(ctx, logger, options) + return NewLocalRuleSet(ctx, logger, tag, options) case C.RuleSetTypeRemote: - return NewRemoteRuleSet(ctx, logger, options) + return NewRemoteRuleSet(ctx, logger, tag, options) default: return nil, E.New("unknown rule-set type: ", options.Type) } diff --git a/route/rule/rule_set_local.go b/route/rule/rule_set_local.go index 64655658b4..9d5d21e83e 100644 --- a/route/rule/rule_set_local.go +++ b/route/rule/rule_set_local.go @@ -2,7 +2,6 @@ package rule import ( "context" - "os" "path/filepath" "strings" "sync" @@ -39,11 +38,11 @@ type LocalRuleSet struct { refs atomic.Int32 } -func NewLocalRuleSet(ctx context.Context, logger logger.Logger, options option.RuleSet) (*LocalRuleSet, error) { +func NewLocalRuleSet(ctx context.Context, logger logger.Logger, tag string, options option.RuleSet) (*LocalRuleSet, error) { ruleSet := &LocalRuleSet{ ctx: ctx, logger: logger, - tag: options.Tag, + tag: tag, fileFormat: options.Format, } if options.Type == C.RuleSetTypeInline { @@ -55,7 +54,7 @@ func NewLocalRuleSet(ctx context.Context, logger logger.Logger, options option.R return nil, err } } else { - filePath := filemanager.BasePath(ctx, options.LocalOptions.Path) + filePath := filemanager.BasePath(ctx, strings.ReplaceAll(options.LocalOptions.Path, C.RuleSetTagPlaceholder, tag)) filePath, _ = filepath.Abs(filePath) err := ruleSet.reloadFile(filePath) if err != nil { @@ -66,7 +65,7 @@ func NewLocalRuleSet(ctx context.Context, logger logger.Logger, options option.R Callback: func(path string) { uErr := ruleSet.reloadFile(path) if uErr != nil { - logger.Error(E.Cause(uErr, "reload rule-set ", options.Tag)) + logger.Error(E.Cause(uErr, "reload rule-set ", tag)) } }, }) @@ -100,7 +99,7 @@ func (s *LocalRuleSet) reloadFile(path string) error { var ruleSet option.PlainRuleSetCompat switch s.fileFormat { case C.RuleSetFormatSource, "": - content, err := os.ReadFile(path) + content, err := filemanager.ReadFile(s.ctx, path) if err != nil { return err } @@ -110,10 +109,11 @@ func (s *LocalRuleSet) reloadFile(path string) error { } case C.RuleSetFormatBinary: - setFile, err := os.Open(path) + setFile, err := filemanager.Open(s.ctx, path) if err != nil { return err } + defer setFile.Close() ruleSet, err = srs.Read(setFile, false) if err != nil { return err @@ -199,19 +199,9 @@ func (s *LocalRuleSet) Close() error { } func (s *LocalRuleSet) Match(metadata *adapter.InboundContext) bool { - return !s.matchStates(metadata).isEmpty() + return matchAnyHeadlessRule(s.rules, metadata) } -func (s *LocalRuleSet) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { - return s.matchStatesWithBase(metadata, 0) -} - -func (s *LocalRuleSet) matchStatesWithBase(metadata *adapter.InboundContext, base ruleMatchState) ruleMatchStateSet { - var stateSet ruleMatchStateSet - for _, rule := range s.rules { - nestedMetadata := *metadata - nestedMetadata.ResetRuleMatchCache() - stateSet = stateSet.merge(matchHeadlessRuleStatesWithBase(rule, &nestedMetadata, base)) - } - return stateSet +func (s *LocalRuleSet) mergeableRule() *DefaultHeadlessRule { + return mergeableRuleIn(s.rules) } diff --git a/route/rule/rule_set_remote.go b/route/rule/rule_set_remote.go index 3802482cda..8e706d1ea0 100644 --- a/route/rule/rule_set_remote.go +++ b/route/rule/rule_set_remote.go @@ -5,6 +5,7 @@ import ( "context" "io" "net/http" + "path/filepath" "runtime" "strings" "sync" @@ -23,6 +24,7 @@ import ( "github.com/sagernet/sing/common/logger" "github.com/sagernet/sing/common/x/list" "github.com/sagernet/sing/service" + "github.com/sagernet/sing/service/filemanager" "github.com/sagernet/sing/service/pause" "go4.org/netipx" @@ -35,6 +37,9 @@ type RemoteRuleSet struct { cancel context.CancelFunc logger logger.ContextLogger outbound adapter.OutboundManager + tag string + url string + initialPath string options option.RuleSet updateInterval time.Duration httpClient *http.Client @@ -51,7 +56,7 @@ type RemoteRuleSet struct { refs atomic.Int32 } -func NewRemoteRuleSet(ctx context.Context, logger logger.ContextLogger, options option.RuleSet) (*RemoteRuleSet, error) { +func NewRemoteRuleSet(ctx context.Context, logger logger.ContextLogger, tag string, options option.RuleSet) (*RemoteRuleSet, error) { ctx, cancel := context.WithCancel(ctx) var updateInterval time.Duration if options.RemoteOptions.UpdateInterval > 0 { @@ -59,11 +64,19 @@ func NewRemoteRuleSet(ctx context.Context, logger logger.ContextLogger, options } else { updateInterval = 24 * time.Hour } + var initialPath string + if options.RemoteOptions.InitialPath != "" { + initialPath = filemanager.BasePath(ctx, strings.ReplaceAll(options.RemoteOptions.InitialPath, C.RuleSetTagPlaceholder, tag)) + initialPath, _ = filepath.Abs(initialPath) + } return &RemoteRuleSet{ ctx: ctx, cancel: cancel, outbound: service.FromContext[adapter.OutboundManager](ctx), logger: logger, + tag: tag, + url: strings.ReplaceAll(options.RemoteOptions.URL, C.RuleSetTagPlaceholder, tag), + initialPath: initialPath, options: options, updateInterval: updateInterval, pauseManager: service.FromContext[pause.Manager](ctx), @@ -71,7 +84,7 @@ func NewRemoteRuleSet(ctx context.Context, logger logger.ContextLogger, options } func (s *RemoteRuleSet) Name() string { - return s.options.Tag + return s.tag } func (s *RemoteRuleSet) String() string { @@ -87,7 +100,7 @@ func (s *RemoteRuleSet) StartContext(ctx context.Context, startContext *adapter. startContext.Register(transport) s.httpClient = &http.Client{Transport: transport} if s.cacheFile != nil { - if savedSet := s.cacheFile.LoadRuleSet(s.options.Tag); savedSet != nil { + if savedSet := s.cacheFile.LoadRuleSet(s.tag); savedSet != nil { err = s.loadBytes(savedSet.Content) if err != nil { return E.Cause(err, "restore cached rule-set") @@ -96,10 +109,23 @@ func (s *RemoteRuleSet) StartContext(ctx context.Context, startContext *adapter. s.lastEtag = savedSet.LastEtag } } - if s.lastUpdated.IsZero() { + var loadedFromInitialPath bool + if s.lastUpdated.IsZero() && s.initialPath != "" { + var content []byte + content, err = filemanager.ReadFile(s.ctx, s.initialPath) + if err == nil { + err = s.loadBytes(content) + } + if err != nil { + s.logger.Warn(E.Cause(err, "load initial rule-set from ", s.initialPath)) + } else { + loadedFromInitialPath = true + } + } + if s.lastUpdated.IsZero() && !loadedFromInitialPath { err = s.fetch(ctx, true) if err != nil { - s.logger.Error(E.Cause(err, "initial rule-set: ", s.options.Tag)) + s.logger.Error(E.Cause(err, "initial rule-set: ", s.tag)) //H don't fail startup on initial fetch failure, retry via update ticker instead } } s.updateTicker = time.NewTicker(s.updateInterval) @@ -182,7 +208,7 @@ func (s *RemoteRuleSet) loadBytes(content []byte) error { } } metadata := buildRuleSetMetadata(plainRuleSet.Rules) - err = validateRuleSetMetadataUpdate(s.ctx, s.options.Tag, metadata) + err = validateRuleSetMetadataUpdate(s.ctx, s.tag, metadata) if err != nil { return err } @@ -225,15 +251,15 @@ func (s *RemoteRuleSet) loopUpdate() { func (s *RemoteRuleSet) updateOnce() { err := s.fetch(s.ctx, false) if err != nil { - s.logger.Error("fetch rule-set ", s.options.Tag, ": ", err) + s.logger.Error("fetch rule-set ", s.tag, ": ", err) } else if s.refs.Load() == 0 { s.rules = nil } } func (s *RemoteRuleSet) fetch(ctx context.Context, isStart bool) error { - s.logger.Debug("updating rule-set ", s.options.Tag, " from URL: ", s.options.RemoteOptions.URL) - request, err := http.NewRequest("GET", s.options.RemoteOptions.URL, nil) + s.logger.Debug("updating rule-set ", s.tag, " from URL: ", s.url) + request, err := http.NewRequest("GET", s.url, nil) if err != nil { return err } @@ -253,17 +279,17 @@ func (s *RemoteRuleSet) fetch(ctx context.Context, isStart bool) error { case http.StatusNotModified: s.lastUpdated = time.Now() if s.cacheFile != nil { - savedRuleSet := s.cacheFile.LoadRuleSet(s.options.Tag) + savedRuleSet := s.cacheFile.LoadRuleSet(s.tag) if savedRuleSet != nil { savedRuleSet.LastUpdated = s.lastUpdated - err = s.cacheFile.SaveRuleSet(s.options.Tag, savedRuleSet) + err = s.cacheFile.SaveRuleSet(s.tag, savedRuleSet) if err != nil { s.logger.Error("save rule-set updated time: ", err) return nil } } } - s.logger.Info("update rule-set ", s.options.Tag, ": not modified") + s.logger.Info("update rule-set ", s.tag, ": not modified") return nil default: return E.New("unexpected status: ", response.Status) @@ -282,7 +308,7 @@ func (s *RemoteRuleSet) fetch(ctx context.Context, isStart bool) error { } s.lastUpdated = time.Now() if s.cacheFile != nil { - err = s.cacheFile.SaveRuleSet(s.options.Tag, &adapter.SavedBinary{ + err = s.cacheFile.SaveRuleSet(s.tag, &adapter.SavedBinary{ LastUpdated: s.lastUpdated, Content: content, LastEtag: s.lastEtag, @@ -291,7 +317,7 @@ func (s *RemoteRuleSet) fetch(ctx context.Context, isStart bool) error { s.logger.Error("save rule-set cache: ", err) } } - s.logger.Info("updated rule-set ", s.options.Tag) + s.logger.Info("updated rule-set ", s.tag) return nil } @@ -332,19 +358,9 @@ func (s *RemoteRuleSet) Close() error { } func (s *RemoteRuleSet) Match(metadata *adapter.InboundContext) bool { - return !s.matchStates(metadata).isEmpty() -} - -func (s *RemoteRuleSet) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { - return s.matchStatesWithBase(metadata, 0) + return matchAnyHeadlessRule(s.rules, metadata) } -func (s *RemoteRuleSet) matchStatesWithBase(metadata *adapter.InboundContext, base ruleMatchState) ruleMatchStateSet { - var stateSet ruleMatchStateSet - for _, rule := range s.rules { - nestedMetadata := *metadata - nestedMetadata.ResetRuleMatchCache() - stateSet = stateSet.merge(matchHeadlessRuleStatesWithBase(rule, &nestedMetadata, base)) - } - return stateSet +func (s *RemoteRuleSet) mergeableRule() *DefaultHeadlessRule { + return mergeableRuleIn(s.rules) } diff --git a/route/rule/rule_set_semantics_test.go b/route/rule/rule_set_semantics_test.go index 2fc559d204..192a640976 100644 --- a/route/rule/rule_set_semantics_test.go +++ b/route/rule/rule_set_semantics_test.go @@ -10,7 +10,6 @@ import ( "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/common/convertor/adguard" C "github.com/sagernet/sing-box/constant" - "github.com/sagernet/sing-box/option" slogger "github.com/sagernet/sing/common/logger" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" @@ -298,11 +297,11 @@ func TestRouteRuleSetOrSemantics(t *testing.T) { }) require.True(t, rule.Match(&metadata)) }) - t.Run("later rule in same set can satisfy outer group", func(t *testing.T) { + t.Run("multi rule set does not satisfy outer group", func(t *testing.T) { t.Parallel() metadata := testMetadata("www.example.com") ruleSet := newLocalRuleSetForTest( - "rule-set-or", + "rule-set-and", headlessDefaultRule(t, func(rule *abstractDefaultRule) { addOtherItem(rule, NewNetworkItem([]string{N.NetworkTCP})) }), @@ -314,7 +313,7 @@ func TestRouteRuleSetOrSemantics(t *testing.T) { addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) }) - require.True(t, rule.Match(&metadata)) + require.False(t, rule.Match(&metadata)) }) t.Run("cross ruleset union is not allowed", func(t *testing.T) { t.Parallel() @@ -336,7 +335,7 @@ func TestRouteRuleSetOrSemantics(t *testing.T) { func TestRouteRuleSetLogicalSemantics(t *testing.T) { t.Parallel() - t.Run("logical or keeps all successful branch states", func(t *testing.T) { + t.Run("logical set does not satisfy outer group", func(t *testing.T) { t.Parallel() metadata := testMetadata("www.example.com") ruleSet := newLocalRuleSetForTest("logical-or", headlessLogicalRule( @@ -353,9 +352,9 @@ func TestRouteRuleSetLogicalSemantics(t *testing.T) { addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) }) - require.True(t, rule.Match(&metadata)) + require.False(t, rule.Match(&metadata)) }) - t.Run("logical and unions child states", func(t *testing.T) { + t.Run("logical branch does not lift outer group requirements", func(t *testing.T) { t.Parallel() metadata := testMetadata("www.example.com") ruleSet := newLocalRuleSetForTest("logical-and", headlessLogicalRule( @@ -373,9 +372,28 @@ func TestRouteRuleSetLogicalSemantics(t *testing.T) { addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) addSourcePortItem(rule, []uint16{2000}) }) + require.False(t, rule.Match(&metadata)) + }) + t.Run("logical branch matches on its own conditions", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("logical-and-self", headlessLogicalRule( + C.LogicalTypeAnd, + false, + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + }), + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addSourcePortItem(rule, []uint16{1000}) + }), + )) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + addSourcePortItem(rule, []uint16{1000}) + }) require.True(t, rule.Match(&metadata)) }) - t.Run("invert success does not contribute positive state", func(t *testing.T) { + t.Run("inverted set does not satisfy outer group", func(t *testing.T) { t.Parallel() metadata := testMetadata("www.example.com") ruleSet := newLocalRuleSetForTest("invert", headlessDefaultRule(t, func(rule *abstractDefaultRule) { @@ -390,9 +408,240 @@ func TestRouteRuleSetLogicalSemantics(t *testing.T) { }) } -func TestRouteRuleSetInvertMergedBranchSemantics(t *testing.T) { +func TestRuleSetShapeBoundary(t *testing.T) { t.Parallel() - t.Run("default invert keeps inherited group outside grouped predicate", func(t *testing.T) { + buildOuter := func(ruleSet *LocalRuleSet) *DefaultRule { + return routeRuleForTest(func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, []string{"extra.example.org"}, nil) + addDestinationPortItem(rule, []uint16{443}) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + } + singleShape := buildOuter(newLocalRuleSetForTest("flat-single", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"a.example.com", "b.example.com"}) + }))) + multiShape := buildOuter(newLocalRuleSetForTest( + "flat-multi", + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"a.example.com"}) + }), + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"b.example.com"}) + }), + )) + queries := []struct { + name string + domain string + port uint16 + singleResult bool + multiResult bool + }{ + {"in set", "www.b.example.com", 443, true, false}, + {"outer own domain", "extra.example.org", 443, true, false}, + {"neither", "other.example.net", 443, false, false}, + {"in set with wrong port", "www.b.example.com", 80, false, false}, + } + for _, query := range queries { + t.Run(query.name, func(t *testing.T) { + t.Parallel() + singleMetadata := testMetadata(query.domain) + singleMetadata.Destination.Port = query.port + multiMetadata := testMetadata(query.domain) + multiMetadata.Destination.Port = query.port + require.Equal(t, query.singleResult, singleShape.Match(&singleMetadata)) + require.Equal(t, query.multiResult, multiShape.Match(&multiMetadata)) + }) + } +} + +func TestRuleSetCaseBoundary(t *testing.T) { + t.Parallel() + t.Run("single cross group rule merges into outer", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("port-single", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationPortRangeItem(t, rule, []string{"400:500"}) + })) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addDestinationPortItem(rule, []uint16{8080}) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + require.True(t, rule.Match(&metadata)) + }) + t.Run("multi rule set keeps outer group absolute", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest( + "port-multi", + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationPortRangeItem(t, rule, []string{"400:500"}) + }), + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"never.example"}) + }), + ) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addDestinationPortItem(rule, []uint16{8080}) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + require.False(t, rule.Match(&metadata)) + }) +} + +func TestRuleSetLogicalBranchSelfContained(t *testing.T) { + t.Parallel() + newRuleSet := func() *LocalRuleSet { + return newLocalRuleSetForTest("and-branch", headlessLogicalRule( + C.LogicalTypeAnd, + false, + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"b.example.com"}) + }), + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationPortRangeItem(t, rule, []string{"800:900"}) + }), + )) + } + t.Run("branch matches only on its own conditions", func(t *testing.T) { + t.Parallel() + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{newRuleSet()}}) + }) + matchedMetadata := testMetadata("www.b.example.com") + matchedMetadata.Destination.Port = 850 + require.True(t, rule.Match(&matchedMetadata)) + unmatchedMetadata := testMetadata("www.b.example.com") + require.False(t, rule.Match(&unmatchedMetadata)) + }) + t.Run("outer condition and set are both required", func(t *testing.T) { + t.Parallel() + matchedRule := routeRuleForTest(func(rule *abstractDefaultRule) { + addSourcePortItem(rule, []uint16{1000}) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{newRuleSet()}}) + }) + metadata := testMetadata("www.b.example.com") + metadata.Destination.Port = 850 + require.True(t, matchedRule.Match(&metadata)) + setMissMetadata := testMetadata("other.example.net") + setMissMetadata.Destination.Port = 850 + require.False(t, matchedRule.Match(&setMissMetadata)) + outerMissRule := routeRuleForTest(func(rule *abstractDefaultRule) { + addSourcePortItem(rule, []uint16{2000}) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{newRuleSet()}}) + }) + outerMissMetadata := testMetadata("www.b.example.com") + outerMissMetadata.Destination.Port = 850 + require.False(t, outerMissRule.Match(&outerMissMetadata)) + }) +} + +func TestRuleSetMixedReference(t *testing.T) { + t.Parallel() + t.Run("single rule set merges as or", func(t *testing.T) { + t.Parallel() + ruleSet := newLocalRuleSetForTest("mixed-single", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"set.example.com"}) + })) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, []string{"extra.example.org"}, nil) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + extraMetadata := testMetadata("extra.example.org") + require.True(t, rule.Match(&extraMetadata)) + setMetadata := testMetadata("www.set.example.com") + require.True(t, rule.Match(&setMetadata)) + otherMetadata := testMetadata("other.example.net") + require.False(t, rule.Match(&otherMetadata)) + }) + t.Run("multi rule set is an independent condition", func(t *testing.T) { + t.Parallel() + ruleSet := newLocalRuleSetForTest( + "mixed-multi", + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"set.example.com"}) + }), + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"set2.example.com"}) + }), + ) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addDestinationPortItem(rule, []uint16{443}) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + matchedMetadata := testMetadata("www.set2.example.com") + require.True(t, rule.Match(&matchedMetadata)) + portMissMetadata := testMetadata("www.set2.example.com") + portMissMetadata.Destination.Port = 80 + require.False(t, rule.Match(&portMissMetadata)) + setMissMetadata := testMetadata("other.example.net") + require.False(t, rule.Match(&setMissMetadata)) + }) +} + +func TestRuleSetStandaloneReference(t *testing.T) { + t.Parallel() + ruleSet := newLocalRuleSetForTest( + "standalone", + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"a.example.net"}) + }), + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationPortRangeItem(t, rule, []string{"400:500"}) + }), + ) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + domainMetadata := testMetadata("www.a.example.net") + domainMetadata.Destination.Port = 80 + require.True(t, rule.Match(&domainMetadata)) + portMetadata := testMetadata("other.example.org") + require.True(t, rule.Match(&portMetadata)) + missMetadata := testMetadata("other.example.org") + missMetadata.Destination.Port = 80 + require.False(t, rule.Match(&missMetadata)) +} + +func TestRuleSetInvertedSingleRuleIsBoolean(t *testing.T) { + t.Parallel() + ruleSet := newLocalRuleSetForTest("inverted-single", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + rule.invert = true + addDestinationAddressItem(t, rule, nil, []string{"blocked.example"}) + })) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + allowedMetadata := testMetadata("good.example.org") + require.True(t, rule.Match(&allowedMetadata)) + blockedMetadata := testMetadata("www.blocked.example") + require.False(t, rule.Match(&blockedMetadata)) +} + +func TestRuleSetEmptySetNeverMatches(t *testing.T) { + t.Parallel() + emptySet := newLocalRuleSetForTest("empty") + t.Run("outer own group does not bypass the set", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{emptySet}}) + }) + require.False(t, rule.Match(&metadata)) + }) + t.Run("standalone reference does not match", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{emptySet}}) + }) + require.False(t, rule.Match(&metadata)) + }) +} + +func TestRouteRuleSetInvertBranchSemantics(t *testing.T) { + t.Parallel() + t.Run("inverted default branch acts as boolean term", func(t *testing.T) { t.Parallel() metadata := testMetadata("www.example.com") ruleSet := newLocalRuleSetForTest("invert-grouped", headlessDefaultRule(t, func(rule *abstractDefaultRule) { @@ -405,7 +654,7 @@ func TestRouteRuleSetInvertMergedBranchSemantics(t *testing.T) { }) require.True(t, rule.Match(&metadata)) }) - t.Run("default invert keeps inherited group after negation succeeds", func(t *testing.T) { + t.Run("inverted default branch with non grouped condition acts as boolean term", func(t *testing.T) { t.Parallel() metadata := testMetadata("www.example.com") ruleSet := newLocalRuleSetForTest("invert-network", headlessDefaultRule(t, func(rule *abstractDefaultRule) { @@ -418,7 +667,7 @@ func TestRouteRuleSetInvertMergedBranchSemantics(t *testing.T) { }) require.True(t, rule.Match(&metadata)) }) - t.Run("logical invert keeps inherited group outside grouped predicate", func(t *testing.T) { + t.Run("inverted logical branch acts as boolean term", func(t *testing.T) { t.Parallel() metadata := testMetadata("www.example.com") ruleSet := newLocalRuleSetForTest("logical-invert-grouped", headlessLogicalRule( @@ -434,7 +683,7 @@ func TestRouteRuleSetInvertMergedBranchSemantics(t *testing.T) { }) require.True(t, rule.Match(&metadata)) }) - t.Run("logical invert keeps inherited group after negation succeeds", func(t *testing.T) { + t.Run("inverted logical branch with non grouped condition acts as boolean term", func(t *testing.T) { t.Parallel() metadata := testMetadata("www.example.com") ruleSet := newLocalRuleSetForTest("logical-invert-network", headlessLogicalRule( @@ -501,21 +750,26 @@ func TestDefaultRuleDoesNotReuseGroupedMatchCacheAcrossEvaluations(t *testing.T) func TestRouteRuleSetRemoteUsesSameSemantics(t *testing.T) { t.Parallel() - metadata := testMetadata("www.example.com") ruleSet := newRemoteRuleSetForTest( "remote", headlessDefaultRule(t, func(rule *abstractDefaultRule) { - addOtherItem(rule, NewNetworkItem([]string{N.NetworkTCP})) + addOtherItem(rule, NewNetworkItem([]string{N.NetworkUDP})) }), headlessDefaultRule(t, func(rule *abstractDefaultRule) { addDestinationAddressItem(t, rule, nil, []string{"example.com"}) }), ) - rule := routeRuleForTest(func(rule *abstractDefaultRule) { + standaloneRule := routeRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + standaloneMetadata := testMetadata("www.example.com") + require.True(t, standaloneRule.Match(&standaloneMetadata)) + combinedRule := routeRuleForTest(func(rule *abstractDefaultRule) { addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) }) - require.True(t, rule.Match(&metadata)) + combinedMetadata := testMetadata("www.example.com") + require.False(t, combinedRule.Match(&combinedMetadata)) } func TestDNSRuleSetSemantics(t *testing.T) { @@ -544,7 +798,7 @@ func TestDNSRuleSetSemantics(t *testing.T) { }) require.False(t, rule.Match(&metadata)) }) - t.Run("outer destination group stays outside inverted grouped branch", func(t *testing.T) { + t.Run("inverted branch acts as boolean term", func(t *testing.T) { t.Parallel() metadata := testMetadata("www.baidu.com") ruleSet := newLocalRuleSetForTest("dns-invert-grouped", headlessDefaultRule(t, func(rule *abstractDefaultRule) { @@ -557,7 +811,7 @@ func TestDNSRuleSetSemantics(t *testing.T) { }) require.True(t, rule.Match(&metadata)) }) - t.Run("outer destination group stays outside inverted logical branch", func(t *testing.T) { + t.Run("inverted logical branch acts as boolean term", func(t *testing.T) { t.Parallel() metadata := testMetadata("www.example.com") ruleSet := newLocalRuleSetForTest("dns-logical-invert-network", headlessLogicalRule( @@ -1110,8 +1364,8 @@ func newLocalRuleSetForTest(tag string, rules ...adapter.HeadlessRule) *LocalRul func newRemoteRuleSetForTest(tag string, rules ...adapter.HeadlessRule) *RemoteRuleSet { return &RemoteRuleSet{ - options: option.RuleSet{Tag: tag}, - rules: rules, + tag: tag, + rules: rules, } } diff --git a/route/rule/rule_set_update_validation_test.go b/route/rule/rule_set_update_validation_test.go index 0583d7bb62..bd409095f3 100644 --- a/route/rule/rule_set_update_validation_test.go +++ b/route/rule/rule_set_update_validation_test.go @@ -87,8 +87,8 @@ func TestRemoteRuleSetLoadBytesRejectsInvalidUpdateBeforeCommit(t *testing.T) { }) ruleSet := &RemoteRuleSet{ ctx: ctx, + tag: "dynamic-set", options: option.RuleSet{ - Tag: "dynamic-set", Format: C.RuleSetFormatSource, }, callbacks: list.List[adapter.RuleSetUpdateCallback]{}, diff --git a/schema/builder.go b/schema/builder.go new file mode 100644 index 0000000000..45e6de72f2 --- /dev/null +++ b/schema/builder.go @@ -0,0 +1,58 @@ +package schema + +import ( + "context" + "reflect" + + E "github.com/sagernet/sing/common/exceptions" +) + +type Builder interface { + Context() context.Context + Describe(valueType reflect.Type) (*Node, error) + FlattenStruct(node *Node, structType reflect.Type) error + Define(name string, build func() (*Node, error)) (*Node, error) +} + +type Describer interface { + DescribeSchema(builder Builder) (*Node, error) +} + +type UnionVariant struct { + Value any + StructType reflect.Type + TypeOptional bool +} + +func DiscriminatedUnion(builder Builder, discriminatorKey string, discriminatorRequired bool, variants []UnionVariant, buildBase func(variant *Node) error) (*Node, error) { + variantNodes := make([]*Node, 0, len(variants)) + for _, variant := range variants { + variantNode := StrictObject() + if variant.TypeOptional { + stringValue, isString := variant.Value.(string) + if !isString { + return nil, E.New("optional discriminator requires a string value") + } + variantNode.Properties.Put(discriminatorKey, StringEnum(stringValue, "")) + } else { + variantNode.Properties.Put(discriminatorKey, &Node{Const: variant.Value}) + } + if discriminatorRequired || !variant.TypeOptional { + variantNode.Required = append(variantNode.Required, discriminatorKey) + } + if buildBase != nil { + err := buildBase(variantNode) + if err != nil { + return nil, err + } + } + if variant.StructType != nil { + err := builder.FlattenStruct(variantNode, variant.StructType) + if err != nil { + return nil, err + } + } + variantNodes = append(variantNodes, variantNode) + } + return OneOf(variantNodes...), nil +} diff --git a/schema/generate.go b/schema/generate.go new file mode 100644 index 0000000000..4353e30dda --- /dev/null +++ b/schema/generate.go @@ -0,0 +1,25 @@ +package schema + +import ( + "context" + stdjson "encoding/json" + "reflect" +) + +func Generate(ctx context.Context, rootType reflect.Type) ([]byte, error) { + g := &generator{ + ctx: ctx, + defs: make(map[string]*Node), + defTypes: make(map[reflect.Type]string), + } + root, err := g.Describe(rootType) + if err != nil { + return nil, err + } + root.Defs = g.sortedDefs() + content, err := stdjson.MarshalIndent(root, "", " ") + if err != nil { + return nil, err + } + return append(content, '\n'), nil +} diff --git a/schema/generator.go b/schema/generator.go new file mode 100644 index 0000000000..357392e478 --- /dev/null +++ b/schema/generator.go @@ -0,0 +1,421 @@ +package schema + +import ( + "context" + "encoding" + stdjson "encoding/json" + "reflect" + "slices" + "strconv" + "strings" + + "github.com/sagernet/sing/common/byteformats" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/json/badjson" + "github.com/sagernet/sing/common/json/badoption" +) + +var ( + jsonUnmarshalerType = reflect.TypeFor[stdjson.Unmarshaler]() + contextUnmarshalerType = reflect.TypeFor[json.ContextUnmarshaler]() + textUnmarshalerType = reflect.TypeFor[encoding.TextUnmarshaler]() + + durationType = reflect.TypeFor[badoption.Duration]() + addrType = reflect.TypeFor[badoption.Addr]() + prefixType = reflect.TypeFor[badoption.Prefix]() + prefixableType = reflect.TypeFor[badoption.Prefixable]() + httpHeaderType = reflect.TypeFor[badoption.HTTPHeader]() + memoryBytesType = reflect.TypeFor[byteformats.MemoryBytes]() + networkBytesCompatType = reflect.TypeFor[byteformats.NetworkBytesCompat]() +) + +type generator struct { + ctx context.Context + defs map[string]*Node + defTypes map[reflect.Type]string + path []string +} + +func (g *generator) Context() context.Context { + return g.ctx +} + +func (g *generator) Define(name string, build func() (*Node, error)) (*Node, error) { + _, exists := g.defs[name] + if exists { + return RefNode(name), nil + } + g.defs[name] = nil + node, err := build() + if err != nil { + return nil, err + } + g.defs[name] = node + return RefNode(name), nil +} + +func implementationOf[T any](valueType reflect.Type) (T, bool) { + interfaceType := reflect.TypeFor[T]() + if !valueType.Implements(interfaceType) && !reflect.PointerTo(valueType).Implements(interfaceType) { + var zeroValue T + return zeroValue, false + } + return reflect.New(valueType).Interface().(T), true +} + +func (g *generator) Describe(valueType reflect.Type) (*Node, error) { + for valueType.Kind() == reflect.Pointer { + valueType = valueType.Elem() + } + describer, described := implementationOf[Describer](valueType) + if described { + return describer.DescribeSchema(g) + } + switch valueType { + case durationType: + return g.Define("Duration", func() (*Node, error) { + return DurationNode(), nil + }) + case addrType, prefixType, prefixableType: + return StringNode(), nil + case httpHeaderType: + return g.Define("HTTPHeader", func() (*Node, error) { + return &Node{Type: "object", AdditionalProperties: ListableOf(StringNode())}, nil + }) + case memoryBytesType, networkBytesCompatType: + return AnyOf(UnsignedNode(64), StringNode()), nil + } + if isListable(valueType) { + elementNode, err := g.Describe(valueType.Elem()) + if err != nil { + return nil, err + } + return ListableOf(elementNode), nil + } + if isTypedMap(valueType) { + return g.typedMapNode(valueType) + } + pointerType := reflect.PointerTo(valueType) + if pointerType.Implements(jsonUnmarshalerType) || pointerType.Implements(contextUnmarshalerType) { + return nil, E.New("unmapped custom JSON type ", valueType.String(), " at ", strings.Join(g.path, ".")) + } + if pointerType.Implements(textUnmarshalerType) { + return StringNode(), nil + } + switch valueType.Kind() { + case reflect.Struct: + if valueType.Name() == "" { + node := StrictObject() + err := g.FlattenStruct(node, valueType) + if err != nil { + return nil, err + } + return node, nil + } + return g.Define(g.defNameFor(valueType), func() (*Node, error) { + node := StrictObject() + err := g.FlattenStruct(node, valueType) + if err != nil { + return nil, err + } + return node, nil + }) + case reflect.Slice, reflect.Array: + if valueType.Kind() == reflect.Slice && valueType.Elem().Kind() == reflect.Uint8 { + // encoding/json accepts both base64 strings and number arrays. + return AnyOf(StringNode(), &Node{Type: "array", Items: UnsignedNode(8)}), nil + } + elementNode, err := g.Describe(valueType.Elem()) + if err != nil { + return nil, err + } + return &Node{Type: "array", Items: elementNode}, nil + case reflect.Map: + if valueType.Key().Kind() != reflect.String { + return nil, E.New("unsupported map key type ", valueType.String(), " at ", strings.Join(g.path, ".")) + } + valueNode, err := g.Describe(valueType.Elem()) + if err != nil { + return nil, err + } + return &Node{Type: "object", AdditionalProperties: valueNode}, nil + case reflect.Bool: + return BooleanNode(), nil + case reflect.String: + return StringNode(), nil + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return IntegerNode(), nil + case reflect.Uint, reflect.Uint64: + return UnsignedNode(64), nil + case reflect.Uint8: + return UnsignedNode(8), nil + case reflect.Uint16: + return UnsignedNode(16), nil + case reflect.Uint32: + return UnsignedNode(32), nil + case reflect.Float32, reflect.Float64: + return &Node{Type: "number"}, nil + default: + return nil, E.New("unsupported kind ", valueType.Kind().String(), " for ", valueType.String(), " at ", strings.Join(g.path, ".")) + } +} + +func (g *generator) defNameFor(fieldType reflect.Type) string { + existingName, loaded := g.defTypes[fieldType] + if loaded { + return existingName + } + name := fieldType.Name() + for otherType, otherName := range g.defTypes { + if otherName == name && otherType != fieldType { + name = pathBase(fieldType.PkgPath()) + "." + name + break + } + } + g.defTypes[fieldType] = name + return name +} + +func pathBase(packagePath string) string { + index := strings.LastIndexByte(packagePath, '/') + if index < 0 { + return packagePath + } + return packagePath[index+1:] +} + +// FlattenStruct merges the JSON fields of structType into node, following the +// same flattening semantics as badjson.MarshallObjects / anonymous embedding. +func (g *generator) FlattenStruct(node *Node, structType reflect.Type) error { + for structType.Kind() == reflect.Pointer { + structType = structType.Elem() + } + if structType.Kind() != reflect.Struct { + return E.New("cannot flatten non-struct type ", structType.String(), " at ", strings.Join(g.path, ".")) + } + for i := range structType.NumField() { + field := structType.Field(i) + if !field.IsExported() && !field.Anonymous { + continue + } + tagValue := field.Tag.Get("json") + tagName, _, _ := strings.Cut(tagValue, ",") + if tagName == "-" { + continue + } + fieldType := field.Type + for fieldType.Kind() == reflect.Pointer { + fieldType = fieldType.Elem() + } + if field.Tag.Get("schema") == "omit" { + continue + } + if field.Anonymous && tagName == "" { + err := g.FlattenStruct(node, fieldType) + if err != nil { + return err + } + continue + } + if tagName == "" { + tagName = field.Name + } + enumTag := field.Tag.Get("enum") + examplesTag := field.Tag.Get("examples") + referenceTag := field.Tag.Get("reference") + g.path = append(g.path, structType.Name()+"."+tagName) + var fieldNode *Node + var err error + if enumTag != "" || examplesTag != "" || referenceTag != "" { + fieldNode, err = taggedFieldNode(fieldType, enumTag, examplesTag, referenceTag) + } else { + fieldNode, err = g.Describe(fieldType) + } + g.path = g.path[:len(g.path)-1] + if err != nil { + return err + } + node.Properties.Put(tagName, fieldNode) + } + return nil +} + +func taggedFieldNode(fieldType reflect.Type, enumTag string, examplesTag string, referenceTag string) (*Node, error) { + elementType := fieldType + for elementType.Kind() == reflect.Pointer { + elementType = elementType.Elem() + } + listable := isListable(fieldType) + plainSlice := !listable && elementType.Kind() == reflect.Slice && elementType.Elem().Kind() == reflect.String + if listable { + elementType = fieldType.Elem() + } else if plainSlice { + elementType = elementType.Elem() + } + var element *Node + var err error + if enumTag != "" { + element, err = taggedValueNode(elementType, strings.Split(enumTag, ",")) + if err != nil { + return nil, err + } + } else { + element, err = taggedValueNode(elementType, nil) + if err != nil { + return nil, err + } + } + if examplesTag != "" { + examples, parseErr := taggedValues(elementType, strings.Split(examplesTag, ",")) + if parseErr != nil { + return nil, parseErr + } + element.Examples = examples + } + if referenceTag != "" { + if elementType.Kind() != reflect.String { + return nil, E.New("reference tags require a string field, got ", fieldType.String()) + } + element.TagReference = referenceTag + } + if listable { + return ListableOf(element), nil + } + if plainSlice { + return &Node{Type: "array", Items: element}, nil + } + return element, nil +} + +func taggedValueNode(fieldType reflect.Type, values []string) (*Node, error) { + switch fieldType.Kind() { + case reflect.String: + node := StringNode() + for _, value := range values { + node.Enum = append(node.Enum, value) + } + return node, nil + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + node := IntegerNode() + enumValues, err := taggedValues(fieldType, values) + if err != nil { + return nil, err + } + node.Enum = enumValues + return node, nil + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + node := UnsignedNode(fieldType.Bits()) + enumValues, err := taggedValues(fieldType, values) + if err != nil { + return nil, err + } + node.Enum = enumValues + return node, nil + default: + node := StringNode() + for _, value := range values { + err := unmarshalTaggedValue(fieldType, value) + if err != nil { + return nil, err + } + node.Enum = append(node.Enum, value) + } + return node, nil + } +} + +func unmarshalTaggedValue(fieldType reflect.Type, value string) error { + err := json.Unmarshal([]byte(strconv.Quote(value)), reflect.New(fieldType).Interface()) + if err != nil { + return E.Cause(err, "unmarshal tagged value ", value, " as ", fieldType.String()) + } + return nil +} + +func taggedValues(fieldType reflect.Type, values []string) ([]any, error) { + result := make([]any, 0, len(values)) + for _, value := range values { + switch fieldType.Kind() { + case reflect.String: + result = append(result, value) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + integerValue, err := strconv.ParseInt(value, 10, fieldType.Bits()) + if err != nil { + return nil, E.Cause(err, "parse enum value ", value, " for ", fieldType.String()) + } + result = append(result, integerValue) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + unsignedValue, err := strconv.ParseUint(value, 10, fieldType.Bits()) + if err != nil { + return nil, E.Cause(err, "parse enum value ", value, " for ", fieldType.String()) + } + result = append(result, unsignedValue) + default: + err := unmarshalTaggedValue(fieldType, value) + if err != nil { + return nil, err + } + result = append(result, value) + } + } + return result, nil +} + +func isListable(fieldType reflect.Type) bool { + return fieldType.Kind() == reflect.Slice && + fieldType.PkgPath() == "github.com/sagernet/sing/common/json/badoption" && + strings.HasPrefix(fieldType.Name(), "Listable[") +} + +func isTypedMap(fieldType reflect.Type) bool { + return fieldType.Kind() == reflect.Struct && + fieldType.PkgPath() == "github.com/sagernet/sing/common/json/badjson" && + strings.HasPrefix(fieldType.Name(), "TypedMap[") +} + +func (g *generator) typedMapNode(fieldType reflect.Type) (*Node, error) { + mapField, found := fieldType.FieldByName("Map") + if !found { + return nil, E.New("unexpected TypedMap layout: missing Map in ", fieldType.String()) + } + rawMapField, found := mapField.Type.FieldByName("rawMap") + if !found { + return nil, E.New("unexpected TypedMap layout: missing rawMap in ", fieldType.String()) + } + keyType := rawMapField.Type.Key() + elementValueField, found := rawMapField.Type.Elem().Elem().FieldByName("Value") + if !found { + return nil, E.New("unexpected TypedMap layout: missing element value in ", fieldType.String()) + } + entryValueField, found := elementValueField.Type.FieldByName("Value") + if !found { + return nil, E.New("unexpected TypedMap layout: missing entry value in ", fieldType.String()) + } + valueNode, err := g.Describe(entryValueField.Type) + if err != nil { + return nil, err + } + node := &Node{Type: "object", AdditionalProperties: valueNode} + if keyType.Kind() != reflect.String || keyType.PkgPath() != "" { + keyNode, keyErr := g.Describe(keyType) + if keyErr != nil { + return nil, keyErr + } + node.PropertyNames = keyNode + } + return node, nil +} + +func (g *generator) sortedDefs() *badjson.TypedMap[string, *Node] { + names := make([]string, 0, len(g.defs)) + for name := range g.defs { + names = append(names, name) + } + slices.Sort(names) + result := new(badjson.TypedMap[string, *Node]) + for _, name := range names { + result.Put(name, g.defs[name]) + } + return result +} diff --git a/schema/schema.go b/schema/schema.go new file mode 100644 index 0000000000..9c879d49de --- /dev/null +++ b/schema/schema.go @@ -0,0 +1,105 @@ +package schema + +import ( + "github.com/sagernet/sing/common/json/badjson" +) + +type Node struct { + SchemaURI string `json:"$schema,omitempty"` + ID string `json:"$id,omitempty"` + Ref string `json:"$ref,omitempty"` + Type any `json:"type,omitempty"` + Const any `json:"const,omitempty"` + Enum []any `json:"enum,omitempty"` + Pattern string `json:"pattern,omitempty"` + Minimum *int64 `json:"minimum,omitempty"` + Maximum *uint64 `json:"maximum,omitempty"` + Items *Node `json:"items,omitempty"` + Properties *badjson.TypedMap[string, *Node] `json:"properties,omitempty"` + Required []string `json:"required,omitempty"` + PropertyNames *Node `json:"propertyNames,omitempty"` + AdditionalProperties any `json:"additionalProperties,omitempty"` + UnevaluatedProperties any `json:"unevaluatedProperties,omitempty"` + AllOf []*Node `json:"allOf,omitempty"` + AnyOf []*Node `json:"anyOf,omitempty"` + OneOf []*Node `json:"oneOf,omitempty"` + Deprecated bool `json:"deprecated,omitempty"` + Examples []any `json:"examples,omitempty"` + TagReference string `json:"x-tag-reference,omitempty"` + Defs *badjson.TypedMap[string, *Node] `json:"$defs,omitempty"` +} + +func StrictObject() *Node { + return &Node{ + Type: "object", + Properties: new(badjson.TypedMap[string, *Node]), + AdditionalProperties: false, + } +} + +func LooseObject() *Node { + return &Node{ + Type: "object", + Properties: new(badjson.TypedMap[string, *Node]), + } +} + +func StringNode() *Node { + return &Node{Type: "string"} +} + +func TagReferenceNode(kind string) *Node { + return &Node{Type: "string", TagReference: kind} +} + +func BooleanNode() *Node { + return &Node{Type: "boolean"} +} + +func IntegerNode() *Node { + return &Node{Type: "integer"} +} + +func UnsignedNode(bits int) *Node { + minimumValue := int64(0) + node := &Node{Type: "integer", Minimum: &minimumValue} + if bits < 64 { + maximumValue := uint64(1)< ../replace/psiphon-tls replace github.com/net2share/vaydns => github.com/hiddify/vaydns v0.0.0-20260401180616-890dc987a6a9 +replace github.com/kianmhz/GooseRelayVPN => github.com/hiddify/GooseRelayVPN v0.0.0-20260429125124-0e68c2a3ae4c + +replace github.com/sagernet/sing-vmess => github.com/starifly/sing-vmess v0.2.7-mod.9 + +replace github.com/Diniboy1123/connect-ip-go => github.com/shtorm-7/connect-ip-go v1.0.0-extended-1.0.0 + require ( github.com/docker/docker v27.3.1+incompatible github.com/docker/go-connections v0.5.0 github.com/gofrs/uuid/v5 v5.4.0 + github.com/opencontainers/image-spec v1.1.0 github.com/sagernet/quic-go v0.59.0-sing-box-mod.4 - github.com/sagernet/sing v0.8.7-0.20260417135631-0d22698ed386 - github.com/sagernet/sing-quic v0.6.2-0.20260412143638-8f65b6be7cd6 + github.com/sagernet/sing v0.8.12-0.20260721063414-596db5dd6ef4 + github.com/sagernet/sing-openvpn v0.0.0-20260721005523-64b754d1c277 + github.com/sagernet/sing-quic v0.6.4-0.20260720125339-63fa6a7ebc31 github.com/sagernet/sing-shadowsocks v0.2.8 github.com/sagernet/sing-shadowsocks2 v0.2.1 + github.com/sagernet/sing-tun v0.8.12-0.20260724023008-68aa7bdc2819 github.com/spyzhov/ajson v0.9.4 github.com/stretchr/testify v1.11.1 go.uber.org/goleak v1.3.0 @@ -41,7 +50,8 @@ require ( filippo.io/keygen v0.0.0-20230306160926-5201437acf8e // indirect github.com/AdguardTeam/golibs v0.32.7 // indirect github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 // indirect - github.com/Jigsaw-Code/outline-sdk v0.0.16 // indirect + github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect + github.com/Jigsaw-Code/outline-sdk v0.0.20 // indirect github.com/Microsoft/go-winio v0.6.1 // indirect github.com/Psiphon-Labs/bolt v0.0.0-20200624191537-23cedaef7ad7 // indirect github.com/Psiphon-Labs/consistent v0.0.0-20240322131436-20aaa4e05737 // indirect @@ -50,13 +60,16 @@ require ( github.com/Psiphon-Labs/psiphon-tunnel-core v1.0.11-0.20260126173038-c86a1497a760 // indirect github.com/Psiphon-Labs/quic-go v0.0.0-20250527153145-79fe45fb83b1 // indirect github.com/Psiphon-Labs/utls v0.0.0-20260129182755-24497d415a8d // indirect + github.com/RyuaNerin/go-krypto v1.3.0 // indirect github.com/ajg/form v1.5.1 // indirect github.com/akutz/memconn v0.1.0 // indirect github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect github.com/ameshkov/dnscrypt/v2 v2.4.0 // indirect github.com/ameshkov/dnsstamps v1.0.3 // indirect - github.com/amnezia-vpn/amneziawg-go v0.2.16 // indirect + github.com/amnezia-vpn/amneziawg-go v0.2.17-0.20251219021448-449d7cffd4ad // indirect + github.com/anchore/go-lzo v0.1.0 // indirect github.com/andybalholm/brotli v1.2.0 // indirect + github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/anthropics/anthropic-sdk-go v1.26.0 // indirect github.com/anytls/sing-anytls v0.0.11 // indirect github.com/armon/go-proxyproto v0.0.0-20180202201750-5b7edb60ff5f // indirect @@ -65,7 +78,7 @@ require ( github.com/biter777/countries v1.7.5 // indirect github.com/bits-and-blooms/bitset v1.10.0 // indirect github.com/bits-and-blooms/bloom/v3 v3.6.0 // indirect - github.com/caddyserver/certmagic v0.25.2 // indirect + github.com/caddyserver/certmagic v0.25.3-0.20260421143802-60d9d8b415d6 // indirect github.com/caddyserver/zerossl v0.1.5 // indirect github.com/cespare/xxhash v1.1.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect @@ -83,16 +96,17 @@ require ( github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect github.com/dchest/siphash v1.2.3 // indirect github.com/dgraph-io/badger v1.5.4-0.20180815194500-3a87f6d9c273 // indirect + github.com/dgryski/go-camellia v0.0.0-20191119043421-69a8a13fb23d // indirect github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da // indirect github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33 // indirect github.com/distribution/reference v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect - github.com/ebitengine/purego v0.9.1 // indirect + github.com/ebitengine/purego v0.10.0 // indirect github.com/enfein/mieru/v3 v3.27.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/florianl/go-nfqueue/v2 v2.0.2 // indirect github.com/flynn/noise v1.1.0 // indirect - github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/gaissmai/bart v0.18.0 // indirect github.com/go-chi/chi/v5 v5.2.5 // indirect @@ -106,33 +120,43 @@ require ( github.com/gobwas/pool v0.2.1 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/btree v1.1.3 // indirect + github.com/google/certificate-transparency-go v1.3.2 // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/google/gopacket v1.1.19 // indirect github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 // indirect github.com/google/uuid v1.6.0 // indirect github.com/grafov/m3u8 v0.0.0-20171211212457-6ab8f28ed427 // indirect github.com/hashicorp/yamux v0.1.2 // indirect github.com/hdevalence/ed25519consensus v0.2.0 // indirect + github.com/hiddify/hmrd_multi_resolver_dns v0.0.0-20260429114007-8d809dc33d0e // indirect + github.com/huin/goupnp v1.2.0 // indirect github.com/insomniacslk/dhcp v0.0.0-20260220084031-5adc3eb26f91 // indirect + github.com/jackpal/go-nat-pmp v1.0.2 // indirect github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86 // indirect github.com/jsimonetti/rtnetlink v1.4.0 // indirect github.com/kamstrup/intmap v0.5.2 // indirect github.com/keybase/go-keychain v0.0.1 // indirect + github.com/kianmhz/GooseRelayVPN v0.0.0-20260429125124-0e68c2a3ae4c // indirect github.com/klauspost/compress v1.18.3 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/reedsolomon v1.13.0 // indirect + github.com/koron/go-ssdp v0.0.4 // indirect + github.com/kr/fs v0.1.0 // indirect github.com/libdns/acmedns v0.5.0 // indirect github.com/libdns/alidns v1.0.6 // indirect github.com/libdns/cloudflare v0.2.2 // indirect github.com/libdns/libdns v1.1.1 // indirect + github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138 // indirect + github.com/libp2p/go-netroute v0.2.1 // indirect github.com/libp2p/go-reuseport v0.4.0 // indirect github.com/logrusorgru/aurora v2.0.3+incompatible // indirect github.com/marusama/semaphore v0.0.0-20171214154724-565ffd8e868a // indirect github.com/mdlayher/netlink v1.9.0 // indirect github.com/mdlayher/socket v0.5.1 // indirect - github.com/metacubex/utls v1.8.4 // indirect + github.com/metacubex/utls v1.8.7 // indirect github.com/mholt/acmez/v3 v3.1.6 // indirect github.com/miekg/dns v1.1.72 // indirect github.com/mitchellh/go-ps v1.0.0 // indirect @@ -143,14 +167,14 @@ require ( github.com/net2share/vaydns v0.2.6 // indirect github.com/openai/openai-go/v3 v3.26.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect - github.com/opencontainers/image-spec v1.1.0 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/pierrec/lz4/v4 v4.1.21 // indirect github.com/pion/datachannel v1.5.5 // indirect github.com/pion/dtls/v2 v2.2.7 // indirect + github.com/pion/dtls/v3 v3.1.5 // indirect github.com/pion/ice/v2 v2.3.24 // indirect github.com/pion/interceptor v0.1.25 // indirect - github.com/pion/logging v0.2.2 // indirect + github.com/pion/logging v0.2.4 // indirect github.com/pion/mdns v0.0.12 // indirect github.com/pion/randutil v0.1.0 // indirect github.com/pion/rtcp v1.2.12 // indirect @@ -160,6 +184,7 @@ require ( github.com/pion/srtp/v2 v2.0.18 // indirect github.com/pion/stun v0.6.1 // indirect github.com/pion/transport/v2 v2.2.4 // indirect + github.com/pion/transport/v4 v4.0.2 // indirect github.com/pion/turn/v2 v2.1.3 // indirect github.com/pion/webrtc/v3 v3.2.40 // indirect github.com/pires/go-proxyproto v0.8.1 // indirect @@ -175,53 +200,56 @@ require ( github.com/safchain/ethtool v0.3.0 // indirect github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a // indirect github.com/sagernet/cors v1.2.1 // indirect - github.com/sagernet/cronet-go v0.0.0-20260413093659-e4926ba205fa // indirect - github.com/sagernet/cronet-go/all v0.0.0-20260413093659-e4926ba205fa // indirect - github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260413092954-cd09eb3e271b // indirect - github.com/sagernet/fswatch v0.1.1 // indirect + github.com/sagernet/cronet-go v0.0.0-20260712143338-d22f2ea3630e // indirect + github.com/sagernet/cronet-go/all v0.0.0-20260712143338-d22f2ea3630e // indirect + github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260712142643-1e5048bd5587 // indirect + github.com/sagernet/fswatch v0.1.2 // indirect github.com/sagernet/gvisor v0.0.0-20250822052253-5558536cf237 // indirect github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a // indirect - github.com/sagernet/nftables v0.3.0-beta.4 // indirect - github.com/sagernet/sing-cloudflared v0.0.0-20260416083718-efa6ab16dba9 // indirect - github.com/sagernet/sing-mux v0.3.4 // indirect - github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11 // indirect - github.com/sagernet/sing-tun v0.8.8-0.20260410061515-018f5eaae695 // indirect + github.com/sagernet/nftables v0.3.0-mod.4 // indirect + github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 // indirect + github.com/sagernet/sing-mux v0.3.5 // indirect + github.com/sagernet/sing-openconnect v0.0.0-20260722140139-e0c977659ca2 // indirect + github.com/sagernet/sing-shadowtls v0.2.1 // indirect + github.com/sagernet/sing-snell v0.0.0-20260719094200-c43fbee0e839 // indirect + github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb // indirect github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 // indirect github.com/sagernet/smux v1.5.50-sing-box-mod.1 // indirect - github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7 // indirect - github.com/sagernet/wireguard-go v0.0.2-beta.1.0.20260224074747-506b7631853c // indirect + github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7.0.20260717155615-b353b93d194a // indirect + github.com/sagernet/wireguard-go v0.0.5-0.20260717024847-6f5e8b1947ae // indirect github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 // indirect github.com/sergeyfrolov/bsbuffer v0.0.0-20180903213811-94e85abb8507 // indirect github.com/shadowsocks/go-shadowsocks2 v0.1.5 // indirect github.com/sirupsen/logrus v1.9.4 // indirect + github.com/smallstep/pkcs7 v0.1.1 // indirect github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635 // indirect github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect @@ -230,6 +258,7 @@ require ( github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 // indirect github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect + github.com/things-go/go-socks5 v0.1.1 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect @@ -240,8 +269,10 @@ require ( github.com/wader/filtertransport v0.0.0-20200316221534-bdd9e61eee78 // indirect github.com/wlynxg/anet v0.0.5 // indirect github.com/x448/float16 v0.8.4 // indirect + github.com/xchacha20-poly1305/sing-trusttunnel v0.2.1 // indirect github.com/xtaci/kcp-go/v5 v5.6.70 // indirect github.com/xtaci/smux v1.5.50 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect github.com/zeebo/blake3 v0.2.4 // indirect gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/goptlib v1.5.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect diff --git a/test/go.sum b/test/go.sum index 097fc845e7..f0d2950ce0 100644 --- a/test/go.sum +++ b/test/go.sum @@ -18,6 +18,8 @@ github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/k github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/Jigsaw-Code/outline-sdk v0.0.16 h1:WbHmv80FKDIpzEmR3GehTbq5CibYTLvcxIIpMMILiEs= github.com/Jigsaw-Code/outline-sdk v0.0.16/go.mod h1:e1oQZbSdLJBBuHgfeQsgEkvkuyIePPwstUeZRGq0KO8= +github.com/Jigsaw-Code/outline-sdk v0.0.20 h1:4ep7MK9lFmcyPIRIbn4xrP1VKdJNsqR6+iJEOHDKnNg= +github.com/Jigsaw-Code/outline-sdk v0.0.20/go.mod h1:CFDKyGZA4zatKE4vMLe8TyQpZCyINOeRFbMAmYHxodw= github.com/Jigsaw-Code/outline-ss-server v1.8.0 h1:6h7CZsyl1vQLz3nvxmL9FbhDug4QxJ1YTxm534eye1E= github.com/Jigsaw-Code/outline-ss-server v1.8.0/go.mod h1:slnHH3OZsQmZx/DRKhxvvaGE/8+n3Lkd6363h1ev71E= github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= @@ -38,6 +40,8 @@ github.com/Psiphon-Labs/psiphon-tunnel-core v1.0.11-0.20260126173038-c86a1497a76 github.com/Psiphon-Labs/psiphon-tunnel-core v1.0.11-0.20260126173038-c86a1497a760/go.mod h1:FcBjozoGjC2I7ZID0lXFlVQcy/jx6irCzlPxapGebEY= github.com/Psiphon-Labs/utls v0.0.0-20260129182755-24497d415a8d h1:PlKwrArEuQOVqEmThSs9KsXMiBduP8MSu9rlWmQ4jgE= github.com/Psiphon-Labs/utls v0.0.0-20260129182755-24497d415a8d/go.mod h1:1vv0gVAzq9e2XYkW8HAKrmtuuZrBdDixQFx5H22KAjI= +github.com/RyuaNerin/go-krypto v1.3.0 h1:smavTzSMAx8iuVlGb4pEwl9MD2qicqMzuXR2QWp2/Pg= +github.com/RyuaNerin/go-krypto v1.3.0/go.mod h1:9R9TU936laAIqAmjcHo/LsaXYOZlymudOAxjaBf62UM= github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= @@ -48,8 +52,15 @@ github.com/ameshkov/dnsstamps v1.0.3 h1:Srzik+J9mivH1alRACTbys2xOxs0lRH9qnTA7Y1O github.com/ameshkov/dnsstamps v1.0.3/go.mod h1:Ii3eUu73dx4Vw5O4wjzmT5+lkCwovjzaEZZ4gKyIH5A= github.com/amnezia-vpn/amneziawg-go v0.2.16 h1:XY6HOq/xtqH8ZXMncRWkjFs85EKdN10NLNnw23kTpE0= github.com/amnezia-vpn/amneziawg-go v0.2.16/go.mod h1:nRkPpIzjCxMW8pZKXTRkpqAQVlmFJdVOGkeQSC7wbms= +github.com/amnezia-vpn/amneziawg-go v0.2.17-0.20251219021448-449d7cffd4ad/go.mod h1:aMgOk9MuX0xI7b5TKAYp8pLM54RlXcOPzDvYw3YEO5A= +github.com/anchore/go-lzo v0.1.0 h1:NgAacnzqPeGH49Ky19QKLBZEuFRqtTG9cdaucc3Vncs= +github.com/anchore/go-lzo v0.1.0/go.mod h1:3kLx0bve2oN1iDwgM1U5zGku1Tfbdb0No5qp1eL1fIk= +github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY= github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q= github.com/anytls/sing-anytls v0.0.11 h1:w8e9Uj1oP3m4zxkyZDewPk0EcQbvVxb7Nn+rapEx4fc= @@ -68,6 +79,8 @@ github.com/bits-and-blooms/bloom/v3 v3.6.0 h1:dTU0OVLJSoOhz9m68FTXMFfA39nR8U/nTC github.com/bits-and-blooms/bloom/v3 v3.6.0/go.mod h1:VKlUSvp0lFIYqxJjzdnSsZEw4iHb1kOL2tfHTgyJBHg= github.com/caddyserver/certmagic v0.25.2 h1:D7xcS7ggX/WEY54x0czj7ioTkmDWKIgxtIi2OcQclUc= github.com/caddyserver/certmagic v0.25.2/go.mod h1:llW/CvsNmza8S6hmsuggsZeiX+uS27dkqY27wDIuBWg= +github.com/caddyserver/certmagic v0.25.3-0.20260421143802-60d9d8b415d6 h1:LYSB6VgWzKtNrcxElw3c97BP40Oc7bizKxA9K1Vi/5k= +github.com/caddyserver/certmagic v0.25.3-0.20260421143802-60d9d8b415d6/go.mod h1:llW/CvsNmza8S6hmsuggsZeiX+uS27dkqY27wDIuBWg= github.com/caddyserver/zerossl v0.1.5 h1:dkvOjBAEEtY6LIGAHei7sw2UgqSD6TrWweXpV7lvEvE= github.com/caddyserver/zerossl v0.1.5/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4= github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= @@ -114,6 +127,8 @@ github.com/deckarep/golang-set v0.0.0-20171013212420-1d4478f51bed h1:njG8LmGD6JC github.com/deckarep/golang-set v0.0.0-20171013212420-1d4478f51bed/go.mod h1:93vsz/8Wt4joVM7c2AVqh+YRMiUSc14yDtF28KmMOgQ= github.com/dgraph-io/badger v1.5.4-0.20180815194500-3a87f6d9c273 h1:45qZ7jowabqhyi3l9Ervox4dhQvLGB5BJPdC8w0a77k= github.com/dgraph-io/badger v1.5.4-0.20180815194500-3a87f6d9c273/go.mod h1:VZxzAIRPHRVNRKRo6AXrX9BJegn6il06VMTZVJYCIjQ= +github.com/dgryski/go-camellia v0.0.0-20191119043421-69a8a13fb23d h1:CPqTNIigGweVPT4CYb+OO2E6XyRKFOmvTHwWRLgCAlE= +github.com/dgryski/go-camellia v0.0.0-20191119043421-69a8a13fb23d/go.mod h1:QX5ZVULjAfZJux/W62Y91HvCh9hyW6enAwcrrv/sLj0= github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38= github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33 h1:ucRHb6/lvW/+mTEIGbvhcYU3S8+uSNkuMjx/qZFfhtM= @@ -132,6 +147,7 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4 github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/elazarl/goproxy v0.0.0-20200809112317-0581fc3aee2d h1:rtM8HsT3NG37YPjz8sYSbUSdElP9lUsQENYzJDZDUBE= github.com/elazarl/goproxy v0.0.0-20200809112317-0581fc3aee2d/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= github.com/elazarl/goproxy/ext v0.0.0-20200809112317-0581fc3aee2d h1:st1tmvy+4duoRj+RaeeJoECWCWM015fBtf/4aR+hhqk= @@ -153,6 +169,8 @@ github.com/frankban/quicktest v1.14.5 h1:dfYrrRyLtiqT9GyKXgdh+k4inNeTvmGbuSgZ3lx github.com/frankban/quicktest v1.14.5/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= github.com/gaissmai/bart v0.18.0 h1:jQLBT/RduJu0pv/tLwXE+xKPgtWJejbxuXAR+wLJafo= @@ -193,6 +211,8 @@ github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69 github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -207,10 +227,13 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/certificate-transparency-go v1.3.2 h1:9ahSNZF2o7SYMaKaXhAumVEzXB2QaayzII9C8rv7v+A= +github.com/google/certificate-transparency-go v1.3.2/go.mod h1:H5FpMUaGa5Ab2+KCYsxg6sELw3Flkl7pGZzWdBoYLXs= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= @@ -232,10 +255,18 @@ github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8 github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= +github.com/hiddify/GooseRelayVPN v0.0.0-20260429125124-0e68c2a3ae4c h1:9Zc0hJ14G+SNJvGxtaSCSsvGB/RdLltrgjgR//9zgTQ= +github.com/hiddify/GooseRelayVPN v0.0.0-20260429125124-0e68c2a3ae4c/go.mod h1:LI/1qA7FDU3MumegfyFOHGZCXcOw6rjIk7NPXga3/pQ= +github.com/hiddify/hmrd_multi_resolver_dns v0.0.0-20260429114007-8d809dc33d0e h1:GsHpSccRtSFe1l3hq0WtBjqsKjJdU+an2D+uCsXm+xk= +github.com/hiddify/hmrd_multi_resolver_dns v0.0.0-20260429114007-8d809dc33d0e/go.mod h1:7u9Ece+I3gvMFzcpqhROWaykNUyoE21g/fYQMBasAWY= github.com/hiddify/vaydns v0.0.0-20260401180616-890dc987a6a9 h1:KXnaABX8hHmkcL0jbL769hEIGI5+z/DajCrlO+Bkzcc= github.com/hiddify/vaydns v0.0.0-20260401180616-890dc987a6a9/go.mod h1:+8kEfQsZJn7/4aIppVekrSuqhrKjGBIgnacTJkdAlS8= +github.com/huin/goupnp v1.2.0 h1:uOKW26NG1hsSSbXIZ1IR7XP9Gjd1U8pnLaCMgntmkmY= +github.com/huin/goupnp v1.2.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= github.com/insomniacslk/dhcp v0.0.0-20260220084031-5adc3eb26f91 h1:u9i04mGE3iliBh0EFuWaKsmcwrLacqGmq1G3XoaM7gY= github.com/insomniacslk/dhcp v0.0.0-20260220084031-5adc3eb26f91/go.mod h1:qfvBmyDNp+/liLEYWRvqny/PEz9hGe2Dz833eXILSmo= +github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= +github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86 h1:elKwZS1OcdQ0WwEDBeqxKwb7WB62QX8bvZ/FJnVXIfk= github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86/go.mod h1:aFAMtuldEgx/4q7iSGazk22+IcgvtiC+HIimFO9XlS8= github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I= @@ -252,6 +283,10 @@ github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzh github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/reedsolomon v1.13.0 h1:E0Cmgf2kMuhZTj6eefnvpKC4/Q4jhCi9YIjcZjK4arc= github.com/klauspost/reedsolomon v1.13.0/go.mod h1:ggJT9lc71Vu+cSOPBlxGvBN6TfAS77qB4fp8vJ05NSA= +github.com/koron/go-ssdp v0.0.4 h1:1IDwrghSKYM7yLf7XCzbByg2sJ/JcNOZRXS2jczTwz0= +github.com/koron/go-ssdp v0.0.4/go.mod h1:oDXq+E5IL5q0U8uSBcoAXzTzInwy5lEgC91HoKtbmZk= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -274,6 +309,10 @@ github.com/libdns/cloudflare v0.2.2 h1:XWHv+C1dDcApqazlh08Q6pjytYLgR2a+Y3xrXFu0v github.com/libdns/cloudflare v0.2.2/go.mod h1:w9uTmRCDlAoafAsTPnn2nJ0XHK/eaUMh86DUk8BWi60= github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U= github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= +github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138 h1:YohuNPT/1k3VcThCQlBZ43PCPWPfMRS1zcxWBF2SLK8= +github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138/go.mod h1:TXQg5tfSy+bUjnhT5728j5j/MBj7keIYqqZ1+8k/ui8= +github.com/libp2p/go-netroute v0.2.1 h1:V8kVrpD8GK0Riv15/7VN6RbUQ3URNZVosw7H2v9tksU= +github.com/libp2p/go-netroute v0.2.1/go.mod h1:hraioZr0fhBjG0ZRXJJ6Zj2IVEVNx6tDTFQfSmcq7mQ= github.com/libp2p/go-reuseport v0.4.0 h1:nR5KU7hD0WxXCJbmw7r2rhRYruNRl2koHw8fQscQm2s= github.com/libp2p/go-reuseport v0.4.0/go.mod h1:ZtI03j/wO5hZVDFo2jKywN6bYKWLOy8Se6DrI2E1cLU= github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8= @@ -288,6 +327,8 @@ github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= github.com/metacubex/utls v1.8.4 h1:HmL9nUApDdWSkgUyodfwF6hSjtiwCGGdyhaSpEejKpg= github.com/metacubex/utls v1.8.4/go.mod h1:kncGGVhFaoGn5M3pFe3SXhZCzsbCJayNOH4UEqTKTko= +github.com/metacubex/utls v1.8.7 h1:Cp+yWkNTFkSihETgGWq34hlVFds5HpYWVOR1xovUVTs= +github.com/metacubex/utls v1.8.7/go.mod h1:kncGGVhFaoGn5M3pFe3SXhZCzsbCJayNOH4UEqTKTko= github.com/mholt/acmez/v3 v3.1.6 h1:eGVQNObP0pBN4sxqrXeg7MYqTOWyoiYpQqITVWlrevk= github.com/mholt/acmez/v3 v3.1.6/go.mod h1:5nTPosTGosLxF3+LU4ygbgMRFDhbAVpqMI4+a4aHLBY= github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= @@ -333,12 +374,16 @@ github.com/pion/datachannel v1.5.5 h1:10ef4kwdjije+M9d7Xm9im2Y3O6A6ccQb0zcqZcJew github.com/pion/datachannel v1.5.5/go.mod h1:iMz+lECmfdCMqFRhXhcA/219B0SQlbpoR2V118yimL0= github.com/pion/dtls/v2 v2.2.7 h1:cSUBsETxepsCSFSxC3mc/aDo14qQLMSL+O6IjG28yV8= github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s= +github.com/pion/dtls/v3 v3.1.5 h1:9xJtVsHwMYeSjPp5Hh1FTis4DchnQWtnOa5o+6ygqfc= +github.com/pion/dtls/v3 v3.1.5/go.mod h1:gz1K4jg6c+fq86oQMH4pilpCEOEPwmEr2jY+VcF/mkU= github.com/pion/ice/v2 v2.3.24 h1:RYgzhH/u5lH0XO+ABatVKCtRd+4U1GEaCXSMjNr13tI= github.com/pion/ice/v2 v2.3.24/go.mod h1:KXJJcZK7E8WzrBEYnV4UtqEZsGeWfHxsNqhVcVvgjxw= github.com/pion/interceptor v0.1.25 h1:pwY9r7P6ToQ3+IF0bajN0xmk/fNw/suTgaTdlwTDmhc= github.com/pion/interceptor v0.1.25/go.mod h1:wkbPYAak5zKsfpVDYMtEfWEy8D4zL+rpxCxPImLOg3Y= github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY= github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= +github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= +github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= github.com/pion/mdns v0.0.12 h1:CiMYlY+O0azojWDmxdNr7ADGrnZ+V6Ilfner+6mSVK8= github.com/pion/mdns v0.0.12/go.mod h1:VExJjv8to/6Wqm1FXK+Ii/Z9tsVk/F5sD/N70cnYFbk= github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= @@ -369,6 +414,8 @@ github.com/pion/transport/v2 v2.2.4/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLh github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0= github.com/pion/transport/v3 v3.0.2 h1:r+40RJR25S9w3jbA6/5uEPTzcdn7ncyU44RWCbHkLg4= github.com/pion/transport/v3 v3.0.2/go.mod h1:nIToODoOlb5If2jF9y2Igfx3PFYWfuXi37m0IlWa/D0= +github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk= +github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM= github.com/pion/turn/v2 v2.1.3 h1:pYxTVWG2gpC97opdRc5IGsQ1lJ9O/IlNhkzj7MMrGAA= github.com/pion/turn/v2 v2.1.3/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY= github.com/pion/webrtc/v3 v3.2.40 h1:Wtfi6AZMQg+624cvCXUuSmrKWepSB7zfgYDOYqsSOVU= @@ -411,96 +458,234 @@ github.com/sagernet/cors v1.2.1 h1:Cv5Z8y9YSD6Gm+qSpNrL3LO4lD3eQVvbFYJSG7JCMHQ= github.com/sagernet/cors v1.2.1/go.mod h1:O64VyOjjhrkLmQIjF4KGRrJO/5dVXFdpEmCW/eISRAI= github.com/sagernet/cronet-go v0.0.0-20260413093659-e4926ba205fa h1:7SehNSF1UHbLZa5dk+1rW1aperffJzl5r6TCJIXtAaY= github.com/sagernet/cronet-go v0.0.0-20260413093659-e4926ba205fa/go.mod h1:hwFHBEjjthyEquDULbr4c4ucMedp8Drb6Jvm2kt/0Bw= +github.com/sagernet/cronet-go v0.0.0-20260620140045-05ab0dc17597/go.mod h1:T/mwtrpC4JlWfScw73CmSBvHzIvc7BatQ1MhRr+cYNw= +github.com/sagernet/cronet-go v0.0.0-20260712143338-d22f2ea3630e h1:Y5mhsZrYuZ9jraIqg7hg1fw4zoVreae81fWvmozbCsQ= +github.com/sagernet/cronet-go v0.0.0-20260712143338-d22f2ea3630e/go.mod h1:T/mwtrpC4JlWfScw73CmSBvHzIvc7BatQ1MhRr+cYNw= github.com/sagernet/cronet-go/all v0.0.0-20260413093659-e4926ba205fa h1:ijk5v9N/akiMgqu734yMpv7Pk9F4Qmjh8Vfdcb4uJHE= github.com/sagernet/cronet-go/all v0.0.0-20260413093659-e4926ba205fa/go.mod h1:+FENo4+0AOvH9e3oY6/iO7yy7USNt61dgbnI5W0TDZ0= +github.com/sagernet/cronet-go/all v0.0.0-20260620140045-05ab0dc17597/go.mod h1:zVHZ5tgDTwbNvUGffAgLmouYs4in0grEzhSdaggoZOw= +github.com/sagernet/cronet-go/all v0.0.0-20260712143338-d22f2ea3630e h1:rdNlS1dRSi7jQe/ingFf7QmV9ZCUZMbxAfHnBrdVW7g= +github.com/sagernet/cronet-go/all v0.0.0-20260712143338-d22f2ea3630e/go.mod h1:WNl4xfTNuR+f7SObmuBtrk0p4MhlmvuuiWYoty3U52E= github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260413092954-cd09eb3e271b h1:O+PkYT88ayVWESX5tqxeMeS9OnzC3ZTic8gYiPJNXT8= github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:XXDwdjX/T8xftoeJxQmbBoYXZp8MAPFR2CwbFuTpEtw= +github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:XXDwdjX/T8xftoeJxQmbBoYXZp8MAPFR2CwbFuTpEtw= +github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260712142643-1e5048bd5587 h1:ENmDXbGH92/jsMwhjIxK2a0URkA8ILC3npjqmTGj0Yc= +github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:XXDwdjX/T8xftoeJxQmbBoYXZp8MAPFR2CwbFuTpEtw= github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260413092954-cd09eb3e271b h1:o0MsgbsJwYkbqlbfaCvmAwb8/LAXeoSP8NE/aNvR/yY= github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:iNiUGoLtnr8/JTuVNj7XJbmpOAp2C6+B81KDrPxwaZM= +github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:iNiUGoLtnr8/JTuVNj7XJbmpOAp2C6+B81KDrPxwaZM= +github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260712142643-1e5048bd5587 h1:5xn/EZOO5LriSEih91thvTuR+gxb59jNxNQiB/KQPEc= +github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:iNiUGoLtnr8/JTuVNj7XJbmpOAp2C6+B81KDrPxwaZM= github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260413092954-cd09eb3e271b h1:JEQnc7cRMUahWJFtWY6n0hs1LE0KgyRv3pD0RWS8Yo8= github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:19ILNUOGIzRdOqa2mq+iY0JoHxuieB7/lnjYeaA2vEc= +github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260620135226-def9ff0fb992/go.mod h1:19ILNUOGIzRdOqa2mq+iY0JoHxuieB7/lnjYeaA2vEc= +github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260712142643-1e5048bd5587 h1:2/UN0LvWnAM0Sc9B/zE2qWylGrX2hQXhknyQyTlvz4k= +github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260712142643-1e5048bd5587/go.mod h1:19ILNUOGIzRdOqa2mq+iY0JoHxuieB7/lnjYeaA2vEc= github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260413092954-cd09eb3e271b h1:69+AKzuUW9hzw2nU79c2DWfuzrIZ3PJm1KAwXh+7xr0= github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:JxzGyQf94Cr6sBShKqODGDyRUlESfJK/Njcz9Lz6qMQ= +github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:JxzGyQf94Cr6sBShKqODGDyRUlESfJK/Njcz9Lz6qMQ= +github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260712142643-1e5048bd5587 h1:SDULc9o0HkneJD38H1G+HRLby69zfdtyQk5qaKtn/tU= +github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:JxzGyQf94Cr6sBShKqODGDyRUlESfJK/Njcz9Lz6qMQ= github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260413092954-cd09eb3e271b h1:jp9FHUVTCJQ67Ecw3Inoct6/z1VTFXPtNYpXt47pa4E= github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:KN+9T9TBycGOLzmKU4QdcHAJEj6Nlx48ifnlTvvHMvs= +github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:KN+9T9TBycGOLzmKU4QdcHAJEj6Nlx48ifnlTvvHMvs= +github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260712142643-1e5048bd5587 h1:hfcM9YccWN4O2LENHN16Jgm4g1/1PRouV0RmvvrS1f0= +github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:KN+9T9TBycGOLzmKU4QdcHAJEj6Nlx48ifnlTvvHMvs= github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260413092954-cd09eb3e271b h1:WN3DZoECd2UbhmYQGpOA4jx4QBXiZuN1DvL/35NT61g= github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:kojvtUc29KKnk8hs2QIANynVR59921SnGWA9kXohHc0= +github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:kojvtUc29KKnk8hs2QIANynVR59921SnGWA9kXohHc0= +github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260712142643-1e5048bd5587 h1:arY9CL3C7lwJfG2Cdz2ZLvHLzT1wnzCVyAomNER6CrY= +github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:kojvtUc29KKnk8hs2QIANynVR59921SnGWA9kXohHc0= github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260413092954-cd09eb3e271b h1:H4RKicwrIa4PwTXZOmXOg85hiCrpeFja4daOlX180pE= github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:hkQzRE5GDbaH1/ioqYh0Taho4L6i0yLRCVEZ5xHz5M0= +github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260620135226-def9ff0fb992/go.mod h1:hkQzRE5GDbaH1/ioqYh0Taho4L6i0yLRCVEZ5xHz5M0= +github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260712142643-1e5048bd5587 h1:HrtCf6KPJsW9KJ4L4T8HMW1vx+3xefLRLObpjZEtzF4= +github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260712142643-1e5048bd5587/go.mod h1:hkQzRE5GDbaH1/ioqYh0Taho4L6i0yLRCVEZ5xHz5M0= github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260413092954-cd09eb3e271b h1:Rwi+Cu+Hgwj28F1lh837gGqSqn7oU8+r5i3UJyLPkKc= github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:tzVJFTOm66UxLxy6K0ZN5Ic2PC79e+sKKnt+V9puEa4= +github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:tzVJFTOm66UxLxy6K0ZN5Ic2PC79e+sKKnt+V9puEa4= +github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260712142643-1e5048bd5587 h1:Inzzp4hyvcC0lawhndaK8iwEN0vGLDJLCThbaKZpksg= +github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:tzVJFTOm66UxLxy6K0ZN5Ic2PC79e+sKKnt+V9puEa4= github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260413092954-cd09eb3e271b h1:v2wcnPX3gt0PngFYXjXYAiarFckwx3pVAP6ETSpbSWE= github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:M/pN6m3j0HFU6/y83n0HU6GLYys3tYdr/xTE8hVEGMo= +github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260620135226-def9ff0fb992/go.mod h1:M/pN6m3j0HFU6/y83n0HU6GLYys3tYdr/xTE8hVEGMo= +github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260712142643-1e5048bd5587 h1:qN6vqr9nFnZqjZXhLez+7tMcbm3FMfhaJRWNMlK3SLI= +github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260712142643-1e5048bd5587/go.mod h1:M/pN6m3j0HFU6/y83n0HU6GLYys3tYdr/xTE8hVEGMo= github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260413092954-cd09eb3e271b h1:Bl0zZ3QZq6pPJMbQlYHDhhaGngVefRlFzxWc0p48eHo= github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:cGh5hO6eljCo6KMQ/Cel8Xgq4+etL0awZLRBDVG1EZQ= +github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:cGh5hO6eljCo6KMQ/Cel8Xgq4+etL0awZLRBDVG1EZQ= +github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260712142643-1e5048bd5587 h1:Zwzpw6555p3rWw844JxNZ+5iRqli6ZBOEtMH3qq1c7s= +github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:cGh5hO6eljCo6KMQ/Cel8Xgq4+etL0awZLRBDVG1EZQ= github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260413092954-cd09eb3e271b h1:vf+MbGv6RvvmXUNvganykBOnDIVXxy8XgtKOOqOcxtE= github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:JFE0/cxaKkx0wqPMZU7MgaplQlU0zudv82dROJjClKU= +github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260620135226-def9ff0fb992/go.mod h1:JFE0/cxaKkx0wqPMZU7MgaplQlU0zudv82dROJjClKU= +github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260712142643-1e5048bd5587 h1:CwsGHfo1HwvEhA2Wnh3O8WxazN3d4Un44LXhiuIvA/w= +github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260712142643-1e5048bd5587/go.mod h1:JFE0/cxaKkx0wqPMZU7MgaplQlU0zudv82dROJjClKU= github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260413092954-cd09eb3e271b h1:2IAc1bVFYF+B6hof34ChQKVhw7LElBxEEx7S0n+7o78= github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:vU8VftFeSt7fURCa3JXD6+k6ss1YAX+idQjPvHmJ2tI= +github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:vU8VftFeSt7fURCa3JXD6+k6ss1YAX+idQjPvHmJ2tI= +github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260712142643-1e5048bd5587 h1:Pn6vsVFOJkj9q/XKBOZosfuDLB6luNMLkqq6YLUPXEk= +github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:vU8VftFeSt7fURCa3JXD6+k6ss1YAX+idQjPvHmJ2tI= github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260413092954-cd09eb3e271b h1:NrJaiOS0VLmWTbUHhXDsLTqelmCW4y3xJqptPs4Sx0s= github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:vCe4OUuL+XOUge9v3MyTD45BnuAXiH+DkjN9quDXJzQ= +github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260620135226-def9ff0fb992/go.mod h1:vCe4OUuL+XOUge9v3MyTD45BnuAXiH+DkjN9quDXJzQ= +github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260712142643-1e5048bd5587 h1:vgRWcEr2jlgICj28XwDMXPQcy8mTob1ZcO+tPdiZjes= +github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260712142643-1e5048bd5587/go.mod h1:vCe4OUuL+XOUge9v3MyTD45BnuAXiH+DkjN9quDXJzQ= github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260413092954-cd09eb3e271b h1:A+ubSkca1nl2cT8pYUqCo1O7M41suNrKpWhZKCM/aIQ= github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:w9amBWrvjtohQzBGCKJ7LCh22LhTIJs4sE7cYaKQzM0= +github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260620135226-def9ff0fb992/go.mod h1:w9amBWrvjtohQzBGCKJ7LCh22LhTIJs4sE7cYaKQzM0= +github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260712142643-1e5048bd5587 h1:uiO62HvSAdRJR3d1Jc4duxWig3kkdwHBSQ3TmUFqw48= +github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260712142643-1e5048bd5587/go.mod h1:w9amBWrvjtohQzBGCKJ7LCh22LhTIJs4sE7cYaKQzM0= github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260413092954-cd09eb3e271b h1:WrhGH5FDXlCAoXwN6N44yCMvy6EbIurmTmptkz3mmms= github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:TqlsFtcYS/etTeck46kHBeT8Le0Igw1Q/AV88UnMS3s= +github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:TqlsFtcYS/etTeck46kHBeT8Le0Igw1Q/AV88UnMS3s= +github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260712142643-1e5048bd5587 h1:mXioRdq9h2YlIr9XGM51kXgIkToywwVfnGmJiaz/uzM= +github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:TqlsFtcYS/etTeck46kHBeT8Le0Igw1Q/AV88UnMS3s= github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260413092954-cd09eb3e271b h1:kgwB5p5e0gdVX5iYRE7VbZS/On4qnb4UKonkGPwhkDI= github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:B6Qd0vys8sv9OKVRN6J9RqDzYRGE938Fb2zrYdBDyTQ= +github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260620135226-def9ff0fb992/go.mod h1:B6Qd0vys8sv9OKVRN6J9RqDzYRGE938Fb2zrYdBDyTQ= +github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260712142643-1e5048bd5587 h1:oOlfOL+sq0KNZRsk06+5KgvtKw2TEXl2EZnLakTx+WM= +github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260712142643-1e5048bd5587/go.mod h1:B6Qd0vys8sv9OKVRN6J9RqDzYRGE938Fb2zrYdBDyTQ= github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260413092954-cd09eb3e271b h1:Z3dOeFlRIOeQhSh+mCYDHui1yR3S/Uw8eupczzBvxqw= github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:3tXMMFY7AHugOVBZ5Al7cL7JKsnFOe5bMVr0hZPk3ow= +github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260620135226-def9ff0fb992/go.mod h1:3tXMMFY7AHugOVBZ5Al7cL7JKsnFOe5bMVr0hZPk3ow= +github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260712142643-1e5048bd5587 h1:UDCa0lYiSUXD4wbxA/G5pGPSXhJaesCQCh5IEiZKb3M= +github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260712142643-1e5048bd5587/go.mod h1:3tXMMFY7AHugOVBZ5Al7cL7JKsnFOe5bMVr0hZPk3ow= github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260413092954-cd09eb3e271b h1:LPi6jz1k11Q67hm3Pw6aaPJ/Z6e3VtNhzrRjr5/5AQo= github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:Wt5uFdU3tnmm8YzobYewwdF7Mt6SucRQg6xeTNWC3Tk= +github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:Wt5uFdU3tnmm8YzobYewwdF7Mt6SucRQg6xeTNWC3Tk= +github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260712142643-1e5048bd5587 h1:Yg2Ut7mPs0GK4W6p9LDh8RrDOnhTe4YpvG/WeJyUqMo= +github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:Wt5uFdU3tnmm8YzobYewwdF7Mt6SucRQg6xeTNWC3Tk= github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260413092954-cd09eb3e271b h1:55sqihyfXWN7y7p7gOEgtUz9cm1mV3SDQ90/v6ROFaA= github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:lyIF6wKBLwWa5ZXaAKbAoewewl+yCHo2iYev39Mbj4E= +github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260620135226-def9ff0fb992/go.mod h1:lyIF6wKBLwWa5ZXaAKbAoewewl+yCHo2iYev39Mbj4E= +github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260712142643-1e5048bd5587 h1:NtagC/YHvucD0Azh86aVghOk4z5f7oOAAy25Uw4knXQ= +github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260712142643-1e5048bd5587/go.mod h1:lyIF6wKBLwWa5ZXaAKbAoewewl+yCHo2iYev39Mbj4E= github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260413092954-cd09eb3e271b h1:OTA1cbv5YIDVsYA8AAXHC4NgEc7b6pDiY+edujLWfJU= github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:H46PnSTTZNcZokLLiDeMDaHiS1l14PH3tzWi0eykjD8= +github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260620135226-def9ff0fb992/go.mod h1:H46PnSTTZNcZokLLiDeMDaHiS1l14PH3tzWi0eykjD8= +github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260712142643-1e5048bd5587 h1:INzfLHBKjgJxoUDBeCaiB9hYqjhn5yfudkMz2phYypA= +github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260712142643-1e5048bd5587/go.mod h1:H46PnSTTZNcZokLLiDeMDaHiS1l14PH3tzWi0eykjD8= github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260413092954-cd09eb3e271b h1:B/rdD/1A+RgqUYUZcoGhLeMqijnBd1mUt8+5LhOH7j8= github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:RBhSUDAKWq7fswtV4nQUQhuaTLcX3ettR7teA7/yf2w= +github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260620135226-def9ff0fb992/go.mod h1:RBhSUDAKWq7fswtV4nQUQhuaTLcX3ettR7teA7/yf2w= +github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260712142643-1e5048bd5587 h1:IX5NCEV9nojHdjpOkxKVN4L5FUuA5nwawXlSTdFXjCE= +github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260712142643-1e5048bd5587/go.mod h1:RBhSUDAKWq7fswtV4nQUQhuaTLcX3ettR7teA7/yf2w= github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260413092954-cd09eb3e271b h1:QFRWi6FucrODS4xQ8e9GYIzGSeMFO/DAMtTCVeJiCvM= github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:wRzoIOGG4xbpp3Gh3triLKwMwYriScXzFtunLYhY4w0= +github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260620135226-def9ff0fb992/go.mod h1:wRzoIOGG4xbpp3Gh3triLKwMwYriScXzFtunLYhY4w0= +github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260712142643-1e5048bd5587 h1:pzgA94sR7kvrP+H86/to2oN3efYORQmh0Q3b6AyfRJQ= +github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260712142643-1e5048bd5587/go.mod h1:wRzoIOGG4xbpp3Gh3triLKwMwYriScXzFtunLYhY4w0= github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260413092954-cd09eb3e271b h1:2WJjPKZHLNIB4D17c3o9S+SP9kb3Qh0D26oWlun1+pE= github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:LNiZXmWil1OPwKCheqQjtakZlJuKGFz+iv2eGF76Hhs= +github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:LNiZXmWil1OPwKCheqQjtakZlJuKGFz+iv2eGF76Hhs= +github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260712142643-1e5048bd5587 h1:nkS6jhF90E24PW37Xemhs6+hUM0l2iRcQcZFhPJL8R8= +github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:LNiZXmWil1OPwKCheqQjtakZlJuKGFz+iv2eGF76Hhs= github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260413092954-cd09eb3e271b h1:cUNTe4gNncRpYL28jzQf6qcJej40zzGQsH0o6CLUGws= github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:YFDGKTkpkJGc5+hnX/RYosZyTWg9h+68VB55fYRRLYc= +github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260620135226-def9ff0fb992/go.mod h1:YFDGKTkpkJGc5+hnX/RYosZyTWg9h+68VB55fYRRLYc= +github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260712142643-1e5048bd5587 h1:EbydHlp6vWdqhb8e8zBPVJv/F3HPob+FgO6rsSdNSr0= +github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260712142643-1e5048bd5587/go.mod h1:YFDGKTkpkJGc5+hnX/RYosZyTWg9h+68VB55fYRRLYc= github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260413092954-cd09eb3e271b h1:+sc1LJF0FjU2hVO5xBqqT+8qzoU08J2uHwxSle2m/Hw= github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:aaX0YGl8nhGmfRWI8bc3BtDjY8Vzx6O0cS/e1uqxDq4= +github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260620135226-def9ff0fb992/go.mod h1:aaX0YGl8nhGmfRWI8bc3BtDjY8Vzx6O0cS/e1uqxDq4= +github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260712142643-1e5048bd5587 h1:sSfVTswgqQZJqh9wTP0Acvvo5/qYARAoHegRYwZ+gyU= +github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260712142643-1e5048bd5587/go.mod h1:aaX0YGl8nhGmfRWI8bc3BtDjY8Vzx6O0cS/e1uqxDq4= github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260413092954-cd09eb3e271b h1:+D/uhFxllI/KTLpeNEl8dwF3omPGmUFbrqt5tJkAyp0= github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:EdzMKA96xITc42QEI+ct4SwqX8Dn3ltKK8wzdkLWpSc= +github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:EdzMKA96xITc42QEI+ct4SwqX8Dn3ltKK8wzdkLWpSc= +github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260712142643-1e5048bd5587 h1:lHqqbALbKdJdq/1rcI4yyg3zvibHDw7wmhDYVbjy498= +github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:EdzMKA96xITc42QEI+ct4SwqX8Dn3ltKK8wzdkLWpSc= github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260413092954-cd09eb3e271b h1:nSUzzTUAZdqjGGckayk64sz+F0TGJPHvauTiAn27UKk= github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:qix4kv1TTAJ5tY4lJ9vjhe9EY4mM+B7H5giOhbxDVcc= +github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260620135226-def9ff0fb992/go.mod h1:qix4kv1TTAJ5tY4lJ9vjhe9EY4mM+B7H5giOhbxDVcc= +github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260712142643-1e5048bd5587 h1:23GyWjb58Nk9a7WXgRfFUiTrMD9kvYSRHx73AaBthYc= +github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260712142643-1e5048bd5587/go.mod h1:qix4kv1TTAJ5tY4lJ9vjhe9EY4mM+B7H5giOhbxDVcc= github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260413092954-cd09eb3e271b h1:PE/fYBiHzB52gnQMg0soBfQyJCzmWHti48kCe2TBt9w= github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:lm9w/oCCRyBiUa3G8lDQTT8x/ONUvgVR2iV9fVzUZB8= +github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:lm9w/oCCRyBiUa3G8lDQTT8x/ONUvgVR2iV9fVzUZB8= +github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260712142643-1e5048bd5587 h1:64EMjgVuZMD4TX5b7oWUfWRVp8aVl3hg4aWrriQKOWo= +github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:lm9w/oCCRyBiUa3G8lDQTT8x/ONUvgVR2iV9fVzUZB8= github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260413092954-cd09eb3e271b h1:hy/3lPV11pKAAojDFnb95l9NpwOym6kME7FxS9p8sXs= github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260413092954-cd09eb3e271b/go.mod h1:n34YyLgapgjWdKa0IoeczjAFCwD3/dxbsH5sucKw0bw= +github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260620135226-def9ff0fb992/go.mod h1:n34YyLgapgjWdKa0IoeczjAFCwD3/dxbsH5sucKw0bw= +github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260712142643-1e5048bd5587 h1:x8cvgMQUs0EgVwt3iT/isRQ9KImvXvPobLKO5bHTN4o= +github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260712142643-1e5048bd5587/go.mod h1:n34YyLgapgjWdKa0IoeczjAFCwD3/dxbsH5sucKw0bw= github.com/sagernet/fswatch v0.1.1 h1:YqID+93B7VRfqIH3PArW/XpJv5H4OLEVWDfProGoRQs= github.com/sagernet/fswatch v0.1.1/go.mod h1:nz85laH0mkQqJfaOrqPpkwtU1znMFNVTpT/5oRsVz/o= +github.com/sagernet/fswatch v0.1.2 h1:/TT7k4mkce1qFPxamLO842WjqBgbTBiXP2mlUjp9PFk= +github.com/sagernet/fswatch v0.1.2/go.mod h1:5BpGmpUQVd3Mc5r313HRpvADHRg3/rKn5QbwFteB880= github.com/sagernet/gvisor v0.0.0-20250822052253-5558536cf237 h1:SUPFNB+vSP4RBPrSEgNII+HkfqC8hKMpYLodom4o4EU= github.com/sagernet/gvisor v0.0.0-20250822052253-5558536cf237/go.mod h1:QkkPEJLw59/tfxgapHta14UL5qMUah5NXhO0Kw2Kan4= github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a h1:ObwtHN2VpqE0ZNjr6sGeT00J8uU7JF4cNUdb44/Duis= github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a/go.mod h1:xLnfdiJbSp8rNqYEdIW/6eDO4mVoogml14Bh2hSiFpM= github.com/sagernet/nftables v0.3.0-beta.4 h1:kbULlAwAC3jvdGAC1P5Fa3GSxVwQJibNenDW2zaXr8I= github.com/sagernet/nftables v0.3.0-beta.4/go.mod h1:OQXAjvjNGGFxaTgVCSTRIhYB5/llyVDeapVoENYBDS8= +github.com/sagernet/nftables v0.3.0-mod.3/go.mod h1:8kslHG4VvYNihcco+i6uxIX7qbT8A56T0y5q7U44ZaQ= +github.com/sagernet/nftables v0.3.0-mod.4 h1:vnOtcDYeSXv2e5RoRuGH0lrpttQFJ8iC4ICS2nhlDSo= +github.com/sagernet/nftables v0.3.0-mod.4/go.mod h1:8kslHG4VvYNihcco+i6uxIX7qbT8A56T0y5q7U44ZaQ= github.com/sagernet/quic-go v0.59.0-sing-box-mod.4 h1:6qvrUW79S+CrPwWz6cMePXohgjHoKxLo3c+MDhNwc3o= github.com/sagernet/quic-go v0.59.0-sing-box-mod.4/go.mod h1:OqILvS182CyOol5zNNo6bguvOGgXzV459+chpRaUC+4= github.com/sagernet/sing v0.8.7-0.20260417135631-0d22698ed386 h1:gNonzIaoKi4MiEmK/5iJI+RoGof1PtYqzSaNE/lmWLw= github.com/sagernet/sing v0.8.7-0.20260417135631-0d22698ed386/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak= +github.com/sagernet/sing v0.8.12-0.20260702081104-2ded2af32d3d h1:BhsQU0Iug1tU4xR52cjm8Sc+LBo+KwdyLTRn3ie9moo= +github.com/sagernet/sing v0.8.12-0.20260702081104-2ded2af32d3d/go.mod h1:olXxWQNqRW/l2Q6JI3b2Qmz8iQnIFlOeeH8bx6JhgUA= +github.com/sagernet/sing v0.8.12-0.20260717153536-4f1ed45a99a5 h1:WyL7xI7h+mNF6we/arqVNDEB+0iK2SwJRjim8rGydDM= +github.com/sagernet/sing v0.8.12-0.20260717153536-4f1ed45a99a5/go.mod h1:olXxWQNqRW/l2Q6JI3b2Qmz8iQnIFlOeeH8bx6JhgUA= +github.com/sagernet/sing v0.8.12-0.20260721063414-596db5dd6ef4 h1:dIHd4IiQs0mtptfe8SdwDascDwh1w2LqDI8exoDuKeQ= +github.com/sagernet/sing v0.8.12-0.20260721063414-596db5dd6ef4/go.mod h1:olXxWQNqRW/l2Q6JI3b2Qmz8iQnIFlOeeH8bx6JhgUA= github.com/sagernet/sing-cloudflared v0.0.0-20260416083718-efa6ab16dba9 h1:wWJexq4V7ope1c4XYSqT/hydqd9omdtbDRvSZIOhjfY= github.com/sagernet/sing-cloudflared v0.0.0-20260416083718-efa6ab16dba9/go.mod h1:bH2NKX+NpDTY1Zkxfboxw6MXB/ZywaNLmrDJYgKMJ2Y= +github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 h1:3y6++yIa8XlDhxPkpR4p+7RUHVY2KTP9CPIGnWmOlO8= +github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3/go.mod h1:XEqEDYRCAYLaoPjZ1ifVWJg5iWAJHL2gOAXe/PM28Cg= github.com/sagernet/sing-mux v0.3.4 h1:ZQplKl8MNXutjzbMVtWvWG31fohhgOfCuUZR4dVQ8+s= github.com/sagernet/sing-mux v0.3.4/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk= +github.com/sagernet/sing-mux v0.3.5 h1:RHnhVEc+SFqkrK4xMygYjDwwLhzp2Bj3lztSukONfhI= +github.com/sagernet/sing-mux v0.3.5/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk= +github.com/sagernet/sing-openconnect v0.0.0-20260720032640-bf28b6a6f10e h1:z0QFO/Bj3ikw4qDM7iwlEzSRdt/04Mh1s1pPeI/LRs0= +github.com/sagernet/sing-openconnect v0.0.0-20260720032640-bf28b6a6f10e/go.mod h1:EIzh5HtImfQJxPKXFwS9lyMnmMy4aCQCx7ntQ4u41Gs= +github.com/sagernet/sing-openconnect v0.0.0-20260722140139-e0c977659ca2 h1:IOzb7F/NRhAKx49+8HEsJmKrZT73EPseFouxQ6MvAOo= +github.com/sagernet/sing-openconnect v0.0.0-20260722140139-e0c977659ca2/go.mod h1:4AKZLVcvY3r54UaK2Gbnm7aN8pOwdLz+y4EP0QFZ5Eg= +github.com/sagernet/sing-openvpn v0.0.0-20260720132803-a5e407d00242 h1:0ZvKyBmBIlAuZ9G+zAWdGh6SrmXIXN7NWENHDrptxtI= +github.com/sagernet/sing-openvpn v0.0.0-20260720132803-a5e407d00242/go.mod h1:mK4GzZyUIhG751Mt1MSvSqLGYOR7DFJd8q5QfgFjE2Y= +github.com/sagernet/sing-openvpn v0.0.0-20260721005523-64b754d1c277 h1:4H38L3OxOx1fGEuH4n9lh/5O7XtZTgQ/1V/gdQ+b+Es= +github.com/sagernet/sing-openvpn v0.0.0-20260721005523-64b754d1c277/go.mod h1:PWX7WygD8jpwfqfaGNySXpJYTn0SOwjBI1BKHHC2+Bw= github.com/sagernet/sing-quic v0.6.2-0.20260412143638-8f65b6be7cd6 h1:j3ISQRDyY5rs27NzUS/le+DHR0iOO0K0x+mWDLzu4Ok= github.com/sagernet/sing-quic v0.6.2-0.20260412143638-8f65b6be7cd6/go.mod h1:r5Adw0EMUyhGBCjPI2JEupDtC040DrrvreXtua7Ifdc= +github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc h1:zdc0fj4JdAdgAmQIoh7ZF+B/wPTEF2X75lYDqTmvlaw= +github.com/sagernet/sing-quic v0.6.4-0.20260709034545-e23afe1172dc/go.mod h1:9k+dzGsWMttUGldBzq3dU792YHXzW6NgfbOGltnXq+0= +github.com/sagernet/sing-quic v0.6.4-0.20260720125339-63fa6a7ebc31 h1:FuiTs45PaA1/f2Kj6E2QlctFBrJUUFB8QAHnV5im9c8= +github.com/sagernet/sing-quic v0.6.4-0.20260720125339-63fa6a7ebc31/go.mod h1:9k+dzGsWMttUGldBzq3dU792YHXzW6NgfbOGltnXq+0= github.com/sagernet/sing-shadowsocks v0.2.8 h1:PURj5PRoAkqeHh2ZW205RWzN9E9RtKCVCzByXruQWfE= github.com/sagernet/sing-shadowsocks v0.2.8/go.mod h1:lo7TWEMDcN5/h5B8S0ew+r78ZODn6SwVaFhvB6H+PTI= github.com/sagernet/sing-shadowsocks2 v0.2.1 h1:dWV9OXCeFPuYGHb6IRqlSptVnSzOelnqqs2gQ2/Qioo= github.com/sagernet/sing-shadowsocks2 v0.2.1/go.mod h1:RnXS0lExcDAovvDeniJ4IKa2IuChrdipolPYWBv9hWQ= github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11 h1:tK+75l64tm9WvEFrYRE1t0YxoFdWQqw/h7Uhzj0vJ+w= github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA= +github.com/sagernet/sing-shadowtls v0.2.1 h1:ZiHZdnEnP+YS73NMsxiZmIFCwNd0M4k7PkGCKNXhbaM= +github.com/sagernet/sing-shadowtls v0.2.1/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA= +github.com/sagernet/sing-snell v0.0.0-20260710094516-a4e97ee24beb h1:VvU2/PZqP5tbKTDq0BxkhRO8ZnKI4UJzziakgBiP2Qg= +github.com/sagernet/sing-snell v0.0.0-20260710094516-a4e97ee24beb/go.mod h1:PcwzX/Xvqky0EP3kGt8OCjYb3R1pydenPHNQZcPZmXY= +github.com/sagernet/sing-snell v0.0.0-20260719094200-c43fbee0e839 h1:YL0oCb55moImUGvjhhVEYODGMo5i9dAf+RpmMFPCq9w= +github.com/sagernet/sing-snell v0.0.0-20260719094200-c43fbee0e839/go.mod h1:PcwzX/Xvqky0EP3kGt8OCjYb3R1pydenPHNQZcPZmXY= github.com/sagernet/sing-tun v0.8.8-0.20260410061515-018f5eaae695 h1:2maqN3XuorEo5faXHIyYZQZ1/ybim4hImfCEWZwdPbk= github.com/sagernet/sing-tun v0.8.8-0.20260410061515-018f5eaae695/go.mod h1:pLCo4o+LacXEzz0bhwhJkKBjLlKOGPBNOAZ97ZVZWzs= +github.com/sagernet/sing-tun v0.8.12-0.20260711072757-9e647fcd6b38 h1:O+Ci3Re4JyPa19JqYqs116WOT29Nmo+QwTSlL3rV2Ao= +github.com/sagernet/sing-tun v0.8.12-0.20260711072757-9e647fcd6b38/go.mod h1:QvarqUtHfj1ULaRR+6kZOS/OoCE+pYGq67A5tyIy+dQ= +github.com/sagernet/sing-tun v0.8.12-0.20260719094150-557ca930fccd h1:tH79/IieRjLx5DiVu3NpXc1hir0xL6Avmmist0aFHks= +github.com/sagernet/sing-tun v0.8.12-0.20260719094150-557ca930fccd/go.mod h1:F/gRq5VX1WN/OZtsvbN2JjXXuNl2ATJglHMSk1/iN9U= +github.com/sagernet/sing-tun v0.8.12-0.20260724023008-68aa7bdc2819 h1:JxzMsy4EMUsAJqKtDbWKW79NIvWfuD4ARPl5RLoMPjE= +github.com/sagernet/sing-tun v0.8.12-0.20260724023008-68aa7bdc2819/go.mod h1:BPbhYPXfcoJhQERTFf9DLEXkp2lMA8J47f892W+a/gM= +github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb h1:KEMbfexD4DvrQGYWwx6r+AwH9Veh8z6cnBZmtCS2G+0= +github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb/go.mod h1:D4CnJX3MNAAANhbQUxfIRgBdnvlTEaV7h6ojedcs+pw= github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 h1:aSwUNYUkVyVvdmBSufR8/nRFonwJeKSIROxHcm5br9o= github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1/go.mod h1:P11scgTxMxVVQ8dlM27yNm3Cro40mD0+gHbnqrNGDuY= github.com/sagernet/smux v1.5.50-sing-box-mod.1 h1:XkJcivBC9V4wBjiGXIXZ229aZCU1hzcbp6kSkkyQ478= github.com/sagernet/smux v1.5.50-sing-box-mod.1/go.mod h1:NjhsCEWedJm7eFLyhuBgIEzwfhRmytrUoiLluxs5Sk8= +github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7.0.20260717155615-b353b93d194a h1:mORXldIuzgU8Bk6n9KpOmLv1rV7Ta8us+lZ9JSv2eBw= +github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7.0.20260717155615-b353b93d194a/go.mod h1:p8Ms8FbGlwQJyHb862XmdShTS50fFJ8C71VdO6xvWyk= +github.com/sagernet/wireguard-go v0.0.5-0.20260717024847-6f5e8b1947ae h1:GmxlXWnRmeNfPE1tWXRZIFgKJd5BH5okoDHKZkkI5bw= +github.com/sagernet/wireguard-go v0.0.5-0.20260717024847-6f5e8b1947ae/go.mod h1:hEqi4y5czEg6LYtX2Bpjg+lV0b/J1n+5rA885Z66Mx0= github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 h1:6uUiZcDRnZSAegryaUGwPC/Fj13JSHwiTftrXhMmYOc= github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854/go.mod h1:LtfoSK3+NG57tvnVEHgcuBW9ujgE8enPSgzgwStwCAA= github.com/sergeyfrolov/bsbuffer v0.0.0-20180903213811-94e85abb8507 h1:ML7ZNtcln5UBo5Wv7RIv9Xg3Pr5VuRCWLFXEwda54Y4= @@ -513,12 +698,18 @@ github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFt github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= github.com/shtorm-7/dnscrypt/v2 v2.4.0-extended-1.0.0 h1:e5s7RKBd2rIPR0StbvZ2vTVtJ5jDTsTk5wtIIapZTRg= github.com/shtorm-7/dnscrypt/v2 v2.4.0-extended-1.0.0/go.mod h1:WpEFV2uhebXb8Jhes/5/fSdpmhGV8TL22RDaeWwV6hI= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/smallstep/pkcs7 v0.1.1 h1:x+rPdt2W088V9Vkjho4KtoggyktZJlMduZAtRHm68LU= +github.com/smallstep/pkcs7 v0.1.1/go.mod h1:dL6j5AIz9GHjVEBTXtW+QliALcgM19RtXaTeyxI+AfA= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72 h1:qLC7fQah7D6K1B0ujays3HV9gkFtllcxhzImRR7ArPQ= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spyzhov/ajson v0.9.4 h1:MVibcTCgO7DY4IlskdqIlCmDOsUOZ9P7oKj8ifdcf84= github.com/spyzhov/ajson v0.9.4/go.mod h1:a6oSw0MMb7Z5aD2tPoPO+jq11ETKgXUr2XktHdT8Wt8= +github.com/starifly/sing-vmess v0.2.7-mod.9 h1:xobAmejSbBQ0A3f/EtJ9cJd3m6gK7dDPccPdeGz7tXY= +github.com/starifly/sing-vmess v0.2.7-mod.9/go.mod h1:5aYoOtYksAyS0NXDm0qKeTYW1yoE1bJVcv+XLcVoyJs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -550,6 +741,8 @@ github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:U github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ= github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA= github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk= +github.com/things-go/go-socks5 v0.1.1 h1:48hy9cHEXPKeG91G/g4n8zW4uynzPUQy/FkcrJ7r5AY= +github.com/things-go/go-socks5 v0.1.1/go.mod h1:1YBHVYG7Oli5ae+Pwkp630cPAwY1pjUPmohO1n0Emg0= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -581,6 +774,8 @@ github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xchacha20-poly1305/sing-trusttunnel v0.2.1 h1:3Hqp6hP3l8X4153tGrKLA5Uonh/66zhbp7HgaeC0lyE= +github.com/xchacha20-poly1305/sing-trusttunnel v0.2.1/go.mod h1:DwhGZ+Xy+r2LCoqVgGgk4opoiCRbrwNzcn41OePwIrU= github.com/xtaci/kcp-go/v5 v5.6.70 h1:AYX0QZl6PqmNj2IdYGZGuBfZuDUkUfl+eHYNijCqaO0= github.com/xtaci/kcp-go/v5 v5.6.70/go.mod h1:9O3D8WR+cyyUjGiTILYfg17vn72otWuXK2AFfqIe6CM= github.com/xtaci/lossyconn v0.0.0-20190602105132-8df528c0c9ae h1:J0GxkO96kL4WF+AIT3M4mfUVinOCPgf2uUWYFUzN0sM= @@ -589,6 +784,8 @@ github.com/xtaci/smux v1.5.50 h1:y/1DlWQC9bnMeZzsyk4oL2hbLK6uVk4BKTz5BeQqUEA= github.com/xtaci/smux v1.5.50/go.mod h1:IGQ9QYrBphmb/4aTnLEcJby0TNr3NV+OslIOMrX825Q= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= @@ -604,12 +801,18 @@ gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/goptlib v1.5.0 h1 gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/goptlib v1.5.0/go.mod h1:70bhd4JKW/+1HLfm+TMrgHJsUHG4coelMWwiVEJ2gAg= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 h1:1fTNlAIJZGWLP5FVu0fikVry1IsiUnXjf7QFvoNN3Xw= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0/go.mod h1:zjPK58DtkqQFn+YUMbx0M2XV3QgKU0gS9LeGohREyK4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0 h1:lUsI2TYsQw2r1IASwoROaCnjdj2cvC2+Jbxvk6nHnWU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0/go.mod h1:2HpZxxQurfGxJlJDblybejHB6RX6pmExPNe517hREw4= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 h1:xJ2qHD0C1BeYVTLLR9sX12+Qb95kfeD/byKj6Ky1pXg= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0/go.mod h1:u5BF1xyjstDowA1R5QAO9JHzqK+ublenEW/dyqTjBVk= go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= @@ -649,7 +852,11 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.30.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -660,10 +867,15 @@ golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -684,7 +896,10 @@ golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -698,6 +913,10 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -711,6 +930,7 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -723,9 +943,14 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -735,7 +960,11 @@ golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -747,7 +976,10 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= @@ -758,10 +990,13 @@ golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3 golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -782,6 +1017,7 @@ google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9Ywl google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20250122153221-138b5a5a4fd4 h1:Pw6WnI9W/LIdRxqK7T6XGugGbHIRl5Q7q3BssH6xk4s= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= diff --git a/test/openconnect_test.go b/test/openconnect_test.go new file mode 100644 index 0000000000..5ec905b3e2 --- /dev/null +++ b/test/openconnect_test.go @@ -0,0 +1,618 @@ +package main + +import ( + "bytes" + "context" + "crypto/rand" + "io" + "net" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/sagernet/sing-box" + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json/badoption" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + + "github.com/stretchr/testify/require" +) + +const ( + openConnectInteropEnvironment = "OPENCONNECT_IT" + openConnectOcservVersion = "1.3.0-2" + openConnectOcservImage = "sing-box-openconnect-ocserv:" + openConnectOcservVersion + openConnectUsername = "test" + openConnectPassword = "test" + openConnectTunnelAddress = "192.168.77.1" + openConnectEchoPort = 18080 +) + +const openConnectOcservPasswordFile = "test:tost,group1,group2:$5$i6SNmLDCgBNjyJ7q$SZ4bVJb7I/DLgXo3txHBVohRFBjOtdbxGQZp.DOnrA.\n" + +const openConnectOcservConfiguration = `auth = "plain[passwd=/fixture/ocpasswd]" + +tcp-port = 443 +udp-port = 443 + +run-as-user = nobody +run-as-group = nogroup +socket-file = /run/ocserv-socket +use-occtl = true +occtl-socket-file = /run/occtl.socket + +server-cert = /fixture/server-cert.pem +server-key = /fixture/server-key.pem +tls-priorities = "NORMAL:%SERVER_PRECEDENCE:%COMPAT" + +isolate-workers = false +max-clients = 4 +max-same-clients = 2 +rate-limit-ms = 0 +max-ban-score = 0 +auth-timeout = 30 +cookie-timeout = 300 +keepalive = 1 +dpd = 2 +try-mtu-discovery = false + +device = vpns +ipv4-network = 192.168.77.0 +ipv4-netmask = 255.255.255.0 +route = 192.168.77.0/255.255.255.0 +ping-leases = false +mtu = 1400 + +cisco-client-compat = false +dtls-psk = true +dtls-legacy = false +match-tls-dtls-ciphers = false +rekey-time = 0 +rekey-method = new-tunnel +` + +type openConnectOcservContainer struct { + name string + tcpAddress string + serverAddress string + certificateAuthorityPath string + passwordPath string +} + +type openConnectTCPProxy struct { + listener net.Listener + target string + access sync.Mutex + connections map[*openConnectTCPProxyConnection]struct{} + closed bool + accepted atomic.Uint64 +} + +type openConnectTCPProxyConnection struct { + proxy *openConnectTCPProxy + downstream net.Conn + upstream net.Conn + closeOnce sync.Once +} + +func TestOpenConnectDockerInterop(t *testing.T) { + if testing.Short() || strings.TrimSpace(os.Getenv(openConnectInteropEnvironment)) == "" { + t.Skip(openConnectInteropEnvironment + " is not set or short testing is enabled") + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + t.Cleanup(cancel) + requireOpenConnectDockerImage(t, ctx) + + t.Run("prefilled_credentials_and_tcp_echo", func(subtest *testing.T) { + container := startOpenConnectOcservContainer(subtest, ctx) + instance := startInstance(subtest, openConnectInstanceOptions( + container.serverAddress, + container.certificateAuthorityPath, + openConnectUsername, + openConnectPassword, + )) + endpoint := requireOpenConnectEndpoint(subtest, instance) + status := waitForOpenConnectState(subtest, endpoint, adapter.OpenConnectStateConnected, 45*time.Second) + require.Nil(subtest, status.AuthChallenge) + err := exchangeOpenConnectTCPEcho(endpoint, 256*1024, 30*time.Second) + require.NoError(subtest, err) + err = exchangeOpenConnectUDPEcho(endpoint, 1400, 30*time.Second) + require.NoError(subtest, err) + }) + + t.Run("interactive_password_auth", func(subtest *testing.T) { + container := startOpenConnectOcservContainer(subtest, ctx) + instance := startInstance(subtest, openConnectInstanceOptions( + container.serverAddress, + container.certificateAuthorityPath, + "", + "", + )) + endpoint := requireOpenConnectEndpoint(subtest, instance) + driveOpenConnectInteractiveAuthentication(subtest, endpoint, 45*time.Second) + err := exchangeOpenConnectTCPEcho(endpoint, 64*1024, 30*time.Second) + require.NoError(subtest, err) + }) + + t.Run("cstp_reconnect_reuses_cookie", func(subtest *testing.T) { + container := startOpenConnectOcservContainer(subtest, ctx) + proxy := startOpenConnectTCPProxy(subtest, container.tcpAddress) + instance := startInstance(subtest, openConnectInstanceOptions( + openConnectLocalhostAddress(subtest, proxy.listener.Addr().String()), + container.certificateAuthorityPath, + openConnectUsername, + openConnectPassword, + )) + endpoint := requireOpenConnectEndpoint(subtest, instance) + waitForOpenConnectState(subtest, endpoint, adapter.OpenConnectStateConnected, 45*time.Second) + waitForOpenConnectTCPEcho(subtest, endpoint, 30*time.Second) + + acceptedBeforeDrop := proxy.accepted.Load() + err := os.WriteFile(container.passwordPath, []byte("test:tost,group1,group2:!\n"), 0o644) + require.NoError(subtest, err) + droppedConnections := proxy.dropConnections() + require.Positive(subtest, droppedConnections) + waitForOpenConnectProxyAccept(subtest, proxy, acceptedBeforeDrop, 30*time.Second) + waitForOpenConnectTCPEcho(subtest, endpoint, 60*time.Second) + + status := endpoint.OpenConnectStatus() + require.Equal(subtest, adapter.OpenConnectStateConnected, status.State, status.Error) + require.Nil(subtest, status.AuthChallenge) + logs, err := openConnectDockerOutput(ctx, "logs", container.name) + require.NoError(subtest, err) + require.GreaterOrEqual(subtest, strings.Count(logs, "HTTP CONNECT /CSCOSSLC/tunnel"), 2, logs) + require.Equal(subtest, 1, strings.Count(logs, "user '"+openConnectUsername+"' obtained cookie"), logs) + }) +} + +func openConnectInstanceOptions(server string, certificateAuthorityPath string, username string, password string) option.Options { + endpointOptions := option.OpenConnectEndpointOptions{ + Server: server, + Flavor: "anyconnect", + Username: username, + Password: password, + NoUDP: true, + UDPTimeout: badoption.Duration(time.Minute), + UDPMapping: option.UDPNATBehaviorAddressDependent, + UDPFiltering: option.UDPNATBehaviorAddressAndPortDependent, + UDPNATMax: 128, + TLS: option.OpenConnectTLSOptions{ + CertificateAuthorityPath: certificateAuthorityPath, + }, + } + return option.Options{ + Endpoints: []option.Endpoint{ + { + Type: C.TypeOpenConnect, + Tag: "openconnect-client", + Options: &endpointOptions, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + }, + } +} + +func requireOpenConnectEndpoint(t *testing.T, instance *box.Box) adapter.OpenConnectEndpoint { + t.Helper() + endpoint, loaded := instance.Endpoint().Get("openconnect-client") + require.True(t, loaded) + openConnectEndpoint, supported := endpoint.(adapter.OpenConnectEndpoint) + require.True(t, supported) + return openConnectEndpoint +} + +func waitForOpenConnectState(t *testing.T, endpoint adapter.OpenConnectEndpoint, expectedState string, timeout time.Duration) adapter.OpenConnectStatus { + t.Helper() + timeoutTimer := time.NewTimer(timeout) + defer timeoutTimer.Stop() + for { + statusUpdated := endpoint.StatusUpdated() + status := endpoint.OpenConnectStatus() + if status.State == expectedState { + return status + } + if status.State == adapter.OpenConnectStateError { + t.Fatalf("OpenConnect endpoint failed while waiting for %q: %s", expectedState, status.Error) + } + select { + case <-statusUpdated: + case <-timeoutTimer.C: + t.Fatalf("timed out waiting for OpenConnect state %q; last state %q, error %q", expectedState, status.State, status.Error) + } + } +} + +func driveOpenConnectInteractiveAuthentication(t *testing.T, endpoint adapter.OpenConnectEndpoint, timeout time.Duration) { + t.Helper() + timeoutTimer := time.NewTimer(timeout) + defer timeoutTimer.Stop() + completedForms := make(map[string]struct{}) + sawUsername := false + sawPassword := false + for { + statusUpdated := endpoint.StatusUpdated() + status := endpoint.OpenConnectStatus() + switch status.State { + case adapter.OpenConnectStateConnected: + require.True(t, sawUsername) + require.True(t, sawPassword) + return + case adapter.OpenConnectStateError: + t.Fatal(status.Error) + case adapter.OpenConnectStateAuthPending: + challenge := status.AuthChallenge + require.NotNil(t, challenge) + require.NotNil(t, challenge.Form) + require.Nil(t, challenge.Browser) + require.NotEmpty(t, challenge.ID) + _, completed := completedForms[challenge.ID] + if !completed { + values := make(map[string]string, len(challenge.Form.Fields)) + for _, field := range challenge.Form.Fields { + require.NotEmpty(t, field.SubmissionKey) + switch field.Name { + case "username": + sawUsername = true + values[field.SubmissionKey] = openConnectUsername + case "password": + sawPassword = true + values[field.SubmissionKey] = openConnectPassword + default: + t.Fatalf("unexpected ocserv authentication field: %#v", field) + } + } + require.NotEmpty(t, values) + err := endpoint.CompleteAuthChallenge(challenge.ID, adapter.OpenConnectAuthResponse{ + Form: &adapter.OpenConnectAuthFormResponse{Values: values}, + }) + require.NoError(t, err) + completedForms[challenge.ID] = struct{}{} + continue + } + } + select { + case <-statusUpdated: + case <-timeoutTimer.C: + t.Fatalf("timed out driving OpenConnect authentication; last state %q, error %q", status.State, status.Error) + } + } +} + +func exchangeOpenConnectTCPEcho(endpoint adapter.OpenConnectEndpoint, payloadSize int, timeout time.Duration) error { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + conn, err := endpoint.DialContext(ctx, N.NetworkTCP, M.ParseSocksaddrHostPort(openConnectTunnelAddress, openConnectEchoPort)) + if err != nil { + return E.Cause(err, "dial ocserv tunnel echo") + } + defer conn.Close() + err = conn.SetDeadline(time.Now().Add(timeout)) + if err != nil { + return E.Cause(err, "set ocserv tunnel echo deadline") + } + payload := make([]byte, payloadSize) + _, err = rand.Read(payload) + if err != nil { + return E.Cause(err, "generate ocserv tunnel echo payload") + } + written := 0 + for written < len(payload) { + var n int + n, err = conn.Write(payload[written:]) + if err != nil { + return E.Cause(err, "write ocserv tunnel echo payload") + } + written += n + } + response := make([]byte, len(payload)) + _, err = io.ReadFull(conn, response) + if err != nil { + return E.Cause(err, "read ocserv tunnel echo payload") + } + if !bytes.Equal(response, payload) { + return E.New("ocserv tunnel echo payload mismatch") + } + return nil +} + +func exchangeOpenConnectUDPEcho(endpoint adapter.OpenConnectEndpoint, payloadSize int, timeout time.Duration) error { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + conn, err := endpoint.DialContext(ctx, N.NetworkUDP, M.ParseSocksaddrHostPort(openConnectTunnelAddress, openConnectEchoPort)) + if err != nil { + return E.Cause(err, "dial ocserv tunnel UDP echo") + } + defer conn.Close() + err = conn.SetDeadline(time.Now().Add(timeout)) + if err != nil { + return E.Cause(err, "set ocserv tunnel UDP echo deadline") + } + payload := make([]byte, payloadSize) + _, err = rand.Read(payload) + if err != nil { + return E.Cause(err, "generate ocserv tunnel UDP echo payload") + } + _, err = conn.Write(payload) + if err != nil { + return E.Cause(err, "write ocserv tunnel UDP echo payload") + } + response := make([]byte, payloadSize+1) + responseLength, err := conn.Read(response) + if err != nil { + return E.Cause(err, "read ocserv tunnel UDP echo payload") + } + if !bytes.Equal(response[:responseLength], payload) { + return E.New("ocserv tunnel UDP echo payload mismatch") + } + return nil +} + +func waitForOpenConnectTCPEcho(t *testing.T, endpoint adapter.OpenConnectEndpoint, timeout time.Duration) { + t.Helper() + deadline := time.Now().Add(timeout) + var lastErr error + for time.Now().Before(deadline) { + lastErr = exchangeOpenConnectTCPEcho(endpoint, 4096, 3*time.Second) + if lastErr == nil { + return + } + time.Sleep(100 * time.Millisecond) + } + if lastErr == nil { + t.Fatal("timed out before attempting OpenConnect tunnel echo") + } + t.Fatal(E.Cause(lastErr, "timed out waiting for OpenConnect tunnel echo")) +} + +func requireOpenConnectDockerImage(t *testing.T, ctx context.Context) { + t.Helper() + _, err := openConnectDockerOutput(ctx, "version", "--format", "{{.Server.Version}}") + require.NoError(t, err) + buildContext, err := filepath.Abs(filepath.Join("testdata", "openconnect", "ocserv")) + require.NoError(t, err) + _, err = openConnectDockerOutput(ctx, "build", "--pull=false", "--tag", openConnectOcservImage, buildContext) + require.NoError(t, err) +} + +func startOpenConnectOcservContainer(t *testing.T, ctx context.Context) openConnectOcservContainer { + t.Helper() + certificateAuthorityPath, certificatePath, keyPath := createSelfSignedCertificate(t, "localhost") + workspace := t.TempDir() + err := os.Chmod(workspace, 0o755) + require.NoError(t, err) + certificate, err := os.ReadFile(certificatePath) + require.NoError(t, err) + key, err := os.ReadFile(keyPath) + require.NoError(t, err) + serverCertificatePath := filepath.Join(workspace, "server-cert.pem") + serverKeyPath := filepath.Join(workspace, "server-key.pem") + passwordPath := filepath.Join(workspace, "ocpasswd") + err = os.WriteFile(serverCertificatePath, certificate, 0o644) + require.NoError(t, err) + err = os.WriteFile(serverKeyPath, key, 0o600) + require.NoError(t, err) + err = os.WriteFile(passwordPath, []byte(openConnectOcservPasswordFile), 0o644) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(workspace, "ocserv.conf"), []byte(openConnectOcservConfiguration), 0o644) + require.NoError(t, err) + + containerName := "sing-box-openconnect-" + strconv.FormatInt(time.Now().UnixNano(), 10) + _, err = openConnectDockerOutput( + ctx, + "run", "--detach", "--rm", "--name", containerName, + "--cap-add", "NET_ADMIN", "--device", "/dev/net/tun", + "--publish", "127.0.0.1::443/tcp", + "--mount", "type=bind,source="+workspace+",target=/fixture", + "--entrypoint", "sh", + openConnectOcservImage, + "-c", "python3 /usr/local/bin/openconnect-echo-server & exec ocserv -f -d 4 -c /fixture/ocserv.conf", + ) + require.NoError(t, err) + t.Cleanup(func() { + if t.Failed() { + logsContext, cancelLogs := context.WithTimeout(context.Background(), 5*time.Second) + logs, logsErr := openConnectDockerOutput(logsContext, "logs", containerName) + cancelLogs() + if logsErr == nil { + t.Log("ocserv logs:\n" + logs) + } + } + removeContext, cancelRemove := context.WithTimeout(context.Background(), 5*time.Second) + _, _ = openConnectDockerOutput(removeContext, "rm", "--force", containerName) + cancelRemove() + }) + waitForOpenConnectContainerLog(t, ctx, containerName, "openconnect echo ready") + tcpAddress := openConnectDockerPublishedAddress(t, ctx, containerName, "443/tcp") + waitForOpenConnectTCP(t, ctx, containerName, tcpAddress) + versionOutput, err := openConnectDockerOutput(ctx, "exec", containerName, "dpkg-query", "-W", "-f=${Version}", "ocserv") + require.NoError(t, err) + require.Equal(t, openConnectOcservVersion, strings.TrimSpace(versionOutput)) + return openConnectOcservContainer{ + name: containerName, + tcpAddress: tcpAddress, + serverAddress: openConnectLocalhostAddress(t, tcpAddress), + certificateAuthorityPath: certificateAuthorityPath, + passwordPath: passwordPath, + } +} + +func openConnectDockerPublishedAddress(t *testing.T, ctx context.Context, containerName string, port string) string { + t.Helper() + for { + output, err := openConnectDockerOutput(ctx, "port", containerName, port) + if err == nil { + address := strings.TrimSpace(output) + _, _, splitErr := net.SplitHostPort(address) + if splitErr == nil { + return address + } + } + select { + case <-ctx.Done(): + t.Fatal(E.Cause(ctx.Err(), "wait for Docker published address")) + case <-time.After(100 * time.Millisecond): + } + } +} + +func openConnectLocalhostAddress(t *testing.T, address string) string { + t.Helper() + _, port, err := net.SplitHostPort(address) + require.NoError(t, err) + return net.JoinHostPort("localhost", port) +} + +func waitForOpenConnectContainerLog(t *testing.T, ctx context.Context, containerName string, expected string) { + t.Helper() + for { + logs, logsErr := openConnectDockerOutput(ctx, "logs", containerName) + if logsErr == nil && strings.Contains(logs, expected) { + return + } + running, inspectErr := openConnectDockerOutput(ctx, "inspect", "--format", "{{.State.Running}}", containerName) + if inspectErr == nil && strings.TrimSpace(running) != "true" { + t.Fatalf("ocserv container exited while waiting for %q:\n%s", expected, logs) + } + select { + case <-ctx.Done(): + t.Fatal(E.Cause(ctx.Err(), "wait for ocserv container log ", expected)) + case <-time.After(100 * time.Millisecond): + } + } +} + +func waitForOpenConnectTCP(t *testing.T, ctx context.Context, containerName string, address string) { + t.Helper() + for { + conn, err := net.DialTimeout("tcp", address, 250*time.Millisecond) + if err == nil { + _ = conn.Close() + return + } + running, inspectErr := openConnectDockerOutput(ctx, "inspect", "--format", "{{.State.Running}}", containerName) + if inspectErr == nil && strings.TrimSpace(running) != "true" { + logs, _ := openConnectDockerOutput(ctx, "logs", containerName) + t.Fatalf("ocserv container exited before TCP readiness:\n%s", logs) + } + select { + case <-ctx.Done(): + t.Fatal(E.Cause(ctx.Err(), "wait for ocserv TCP listener")) + case <-time.After(100 * time.Millisecond): + } + } +} + +func openConnectDockerOutput(ctx context.Context, arguments ...string) (string, error) { + command := exec.CommandContext(ctx, "docker", arguments...) + output, err := command.CombinedOutput() + if err != nil { + return "", E.Cause(err, "docker ", strings.Join(arguments, " "), ": ", strings.TrimSpace(string(output))) + } + return string(output), nil +} + +func startOpenConnectTCPProxy(t *testing.T, target string) *openConnectTCPProxy { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + proxy := &openConnectTCPProxy{ + listener: listener, + target: target, + connections: make(map[*openConnectTCPProxyConnection]struct{}), + } + go proxy.acceptLoop() + t.Cleanup(proxy.close) + return proxy +} + +func (p *openConnectTCPProxy) acceptLoop() { + for { + downstream, err := p.listener.Accept() + if err != nil { + return + } + upstream, err := net.DialTimeout("tcp", p.target, 5*time.Second) + if err != nil { + _ = downstream.Close() + continue + } + connection := &openConnectTCPProxyConnection{ + proxy: p, + downstream: downstream, + upstream: upstream, + } + p.access.Lock() + if p.closed { + p.access.Unlock() + connection.close() + return + } + p.connections[connection] = struct{}{} + p.accepted.Add(1) + p.access.Unlock() + go connection.copy(upstream, downstream) + go connection.copy(downstream, upstream) + } +} + +func (c *openConnectTCPProxyConnection) copy(destination net.Conn, source net.Conn) { + _, _ = io.Copy(destination, source) + c.close() +} + +func (c *openConnectTCPProxyConnection) close() { + c.closeOnce.Do(func() { + _ = c.downstream.Close() + _ = c.upstream.Close() + c.proxy.access.Lock() + delete(c.proxy.connections, c) + c.proxy.access.Unlock() + }) +} + +func (p *openConnectTCPProxy) dropConnections() int { + p.access.Lock() + connections := make([]*openConnectTCPProxyConnection, 0, len(p.connections)) + for connection := range p.connections { + connections = append(connections, connection) + } + p.access.Unlock() + for _, connection := range connections { + connection.close() + } + return len(connections) +} + +func (p *openConnectTCPProxy) close() { + p.access.Lock() + p.closed = true + p.access.Unlock() + _ = p.listener.Close() + p.dropConnections() +} + +func waitForOpenConnectProxyAccept(t *testing.T, proxy *openConnectTCPProxy, previous uint64, timeout time.Duration) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if proxy.accepted.Load() > previous { + return + } + time.Sleep(50 * time.Millisecond) + } + t.Fatalf("OpenConnect proxy accepted %d connections, expected more than %d after CSTP drop", proxy.accepted.Load(), previous) +} diff --git a/test/openvpn_test.go b/test/openvpn_test.go new file mode 100644 index 0000000000..80f27e58ec --- /dev/null +++ b/test/openvpn_test.go @@ -0,0 +1,2042 @@ +package main + +import ( + "archive/tar" + "bytes" + "context" + "crypto/rand" + "crypto/rsa" + "crypto/sha1" + "crypto/sha256" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/hex" + "encoding/pem" + "io" + "net" + "net/netip" + "os" + "path/filepath" + "runtime" + "slices" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/sagernet/sing-box" + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + ovpn "github.com/sagernet/sing-openvpn" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing-tun/gtcpip/header" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/auth" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json/badoption" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/protocol/socks" + + typesapi "github.com/docker/docker/api/types" + containerapi "github.com/docker/docker/api/types/container" + "github.com/docker/docker/client" + "github.com/docker/docker/errdefs" + "github.com/docker/docker/pkg/jsonmessage" + "github.com/docker/docker/pkg/stdcopy" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/stretchr/testify/require" +) + +const ( + openVPNTLSUsername = "test-user" + openVPNTLSPassword = "test-password" + openVPNStaticChallengeText = "Enter OTP" + openVPNStaticChallengeResponse = "31337" + openVPNLargeDataTimeout = 3 * time.Minute + openVPNLargeTCPPackets = 1 + openVPNLargeTCPSize = 2048 + + openVPNDockerImage = "sing-box-openvpn-2.6.14-options:test" + openVPNDockerPackageVersion = "2.6.14-0+deb12u2" + openVPNDockerRoot = "/config" +) + +var ( + openVPNDockerImageOnce sync.Once + openVPNDockerImageErr error +) + +type openVPNCertificateBundle struct { + caPath string + serverCertPath string + serverKeyPath string + clientCertPath string + clientKeyPath string +} + +type openVPNDockerServerEnvironment struct { + certificates openVPNCertificateBundle + workspace string + openVPNPort uint16 + echoPort uint16 + container *openVPNDockerContainer +} + +type openVPNSelfCase struct { + name string + protocol string + tlsCrypt bool + omitServerKeyUsage bool + remoteCertificateTLS string +} + +type openVPNPacketReturn struct { + packets chan []byte +} + +func (r *openVPNPacketReturn) ReturnHeadroom() int { + return 0 +} + +func (r *openVPNPacketReturn) ReturnPackets(packets [][]byte) [][]byte { + for _, packet := range packets { + r.packets <- slices.Clone(packet) + } + return nil +} + +func TestOpenVPNSelfToSelf(t *testing.T) { + testCases := []openVPNSelfCase{ + { + name: "real_tls_udp", + protocol: N.NetworkUDP, + }, + { + name: "real_tls_tcp", + protocol: N.NetworkTCP, + }, + { + name: "real_tls_udp_tls_crypt", + protocol: N.NetworkUDP, + tlsCrypt: true, + }, + { + name: "missing_server_key_usage", + protocol: N.NetworkUDP, + omitServerKeyUsage: true, + remoteCertificateTLS: "none", + }, + } + for i := range testCases { + currentTestCase := testCases[i] + t.Run(currentTestCase.name, func(t *testing.T) { + runOpenVPNSelfToSelf(t, currentTestCase) + }) + } +} + +func TestOpenVPNStaticKeyClientDataPath(t *testing.T) { + const ( + clientTunnelAddress = "10.91.0.2" + peerTunnelAddress = "10.91.0.1" + ) + listener, err := net.Listen(N.NetworkTCP, "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { _ = listener.Close() }) + openVPNPort := uint16(listener.Addr().(*net.TCPAddr).Port) + staticKey := createOpenVPNStaticKey(t) + staticKeyPath := writeOpenVPNStaticKeyFile(t, staticKey) + peerContext, cancelPeer := context.WithCancel(context.Background()) + peerClient, err := ovpn.NewClient(ovpn.ClientOptions{ + Context: peerContext, + Mode: ovpn.ModeStaticKey, + Transport: ovpn.ClientTransportOptions{ + Remotes: []ovpn.Remote{{ + Host: "127.0.0.1", + Port: openVPNPort, + Protocol: N.NetworkTCP, + }}, + Protocol: N.NetworkTCP, + DialContext: func(ctx context.Context, network string, address string) (net.Conn, error) { + return listener.Accept() + }, + }, + DataChannel: ovpn.ClientDataChannelOptions{ + MTU: 1500, + Cipher: "AES-256-CBC", + Auth: "SHA256", + }, + Tunnel: ovpn.ClientTunnelOptions{ + DevType: "tun", + Topology: "p2p", + LocalAddress: []netip.Prefix{netip.MustParsePrefix(peerTunnelAddress + "/30")}, + VPNGateway: netip.MustParseAddr(clientTunnelAddress), + }, + StaticKey: ovpn.Material{Content: []byte(staticKey)}, + KeyDirection: 0, + }) + require.NoError(t, err) + t.Cleanup(func() { + cancelPeer() + _ = listener.Close() + _ = peerClient.Close() + }) + err = peerClient.Start() + require.NoError(t, err) + + clientOptions := option.OpenVPNClientEndpointOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: openVPNPort, + }, + Mode: ovpn.ModeStaticKey, + Network: N.NetworkTCP, + Address: []netip.Prefix{netip.MustParsePrefix(clientTunnelAddress + "/30")}, + PeerAddress: badoption.Addr(netip.MustParseAddr(peerTunnelAddress)), + Topology: "p2p", + StaticKeyPath: staticKeyPath, + KeyDirection: "client", + Cipher: "AES-256-CBC", + Auth: "SHA256", + MSSFixDisabled: true, + PingRestartDisabled: true, + } + proxyPort := reserveOpenVPNTCPPort(t) + clientInstance := startInstance(t, openVPNClientInstanceOptions(clientOptions, proxyPort)) + clientEndpoint := requireOpenVPNEndpoint(t, clientInstance, "openvpn-client") + connectedStatus := waitForOpenVPNStatus(t, clientEndpoint, 30*time.Second, func(status adapter.OpenVPNStatus) bool { + require.NotEqual(t, adapter.OpenVPNStateError, status.State, status.Error) + return status.State == adapter.OpenVPNStateConnected + }) + require.Equal(t, []netip.Prefix{netip.MustParsePrefix(clientTunnelAddress + "/30")}, connectedStatus.TunnelInfo.IPv4) + + port, supported := clientEndpoint.(tun.Port) + require.True(t, supported) + returnPath := &openVPNPacketReturn{packets: make(chan []byte, 1)} + err = port.AttachReturn(returnPath) + require.NoError(t, err) + t.Cleanup(func() { _ = port.DetachReturn(returnPath) }) + + outboundPacket := newOpenVPNDataPathUDPPacket( + netip.MustParseAddrPort(clientTunnelAddress+":12000"), + netip.MustParseAddrPort(peerTunnelAddress+":13000"), + []byte("sing-box static-key outbound"), + ) + err = port.WritePackets([][]byte{outboundPacket}) + require.NoError(t, err) + readContext, cancelRead := context.WithTimeout(context.Background(), 10*time.Second) + peerPacket, err := peerClient.ReadDataPacket(readContext) + cancelRead() + require.NoError(t, err) + require.Equal(t, outboundPacket, peerPacket) + + inboundPacket := newOpenVPNDataPathUDPPacket( + netip.MustParseAddrPort(peerTunnelAddress+":13000"), + netip.MustParseAddrPort(clientTunnelAddress+":12000"), + []byte("sing-box static-key inbound"), + ) + err = peerClient.WriteDataPacket(inboundPacket) + require.NoError(t, err) + select { + case returnedPacket := <-returnPath.packets: + require.Equal(t, inboundPacket, returnedPacket) + case <-time.After(10 * time.Second): + t.Fatal("timed out waiting for static-key inbound packet") + } +} + +func TestOpenVPNStaticKeySelfToSelf(t *testing.T) { + for _, protocol := range []string{N.NetworkTCP, N.NetworkUDP} { + t.Run(protocol, func(t *testing.T) { + runOpenVPNStaticKeySelfToSelf(t, protocol) + }) + } +} + +func runOpenVPNStaticKeySelfToSelf(t *testing.T, protocol string) { + t.Helper() + const ( + serverTunnelAddress = "10.92.0.1" + clientTunnelAddress = "10.92.0.2" + ) + openVPNPort := reserveOpenVPNProtocolPort(t, protocol) + var clientOpenVPNPort uint16 + var serverRemote string + if protocol == N.NetworkUDP { + clientOpenVPNPort = reserveOpenVPNUDPPort(t) + serverRemote = "127.0.0.1" + } + proxyPort := reserveOpenVPNTCPPort(t) + echoPort := reserveOpenVPNEchoPort(t) + readinessPort := reserveOpenVPNEchoPort(t) + staticKeyPath := writeOpenVPNStaticKeyFile(t, createOpenVPNStaticKey(t)) + serverOptions := option.OpenVPNServerEndpointOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.MustParseAddr("127.0.0.1"))), + ListenPort: openVPNPort, + }, + Mode: ovpn.ModeStaticKey, + Network: protocol, + Remote: serverRemote, + RemotePort: clientOpenVPNPort, + MaxClients: 1, + Address: []netip.Prefix{netip.MustParsePrefix(serverTunnelAddress + "/30")}, + PeerAddress: badoption.Addr(netip.MustParseAddr(clientTunnelAddress)), + Topology: "p2p", + StaticKeyPath: staticKeyPath, + KeyDirection: "server", + Cipher: "AES-256-CBC", + Auth: "SHA256", + MSSFixDisabled: true, + } + clientOptions := option.OpenVPNClientEndpointOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: openVPNPort, + }, + Mode: ovpn.ModeStaticKey, + Network: protocol, + Address: []netip.Prefix{netip.MustParsePrefix(clientTunnelAddress + "/30")}, + PeerAddress: badoption.Addr(netip.MustParseAddr(serverTunnelAddress)), + Topology: "p2p", + StaticKeyPath: staticKeyPath, + KeyDirection: "client", + Cipher: "AES-256-CBC", + Auth: "SHA256", + MSSFixDisabled: true, + PingRestartDisabled: true, + } + clientOptions.UDPBindPort = clientOpenVPNPort + + startInstance(t, openVPNServerInstanceOptions(serverOptions)) + startInstance(t, openVPNClientInstanceOptions(clientOptions, proxyPort)) + waitForOpenVPNClientReady(t, proxyPort, readinessPort, serverTunnelAddress) + testSuitOpenVPN(t, proxyPort, echoPort, serverTunnelAddress) +} + +func TestOpenVPNDockerInterop(t *testing.T) { + t.Run("official_server_to_sing_box_client", func(t *testing.T) { + testOpenVPNDockerOfficialServerToSingBoxClient(t) + }) + t.Run("official_client_to_sing_box_server", func(t *testing.T) { + testOpenVPNDockerOfficialClientToSingBoxServer(t) + }) +} + +func runOpenVPNSelfToSelf(t *testing.T, testCase openVPNSelfCase) { + t.Helper() + const serverAddress = "10.8.0.1" + serverPrefix := netip.MustParsePrefix(serverAddress + "/24") + proxyPort := reserveOpenVPNTCPPort(t) + openVPNPort := reserveOpenVPNProtocolPort(t, testCase.protocol) + echoPort := reserveOpenVPNEchoPort(t) + readinessPort := reserveOpenVPNEchoPort(t) + certificates := createOpenVPNCertificateBundle(t) + if testCase.omitServerKeyUsage { + certificates = createOpenVPNCertificateBundleWithServerKeyUsage(t, 0) + } + serverOptions := option.OpenVPNServerEndpointOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.MustParseAddr("127.0.0.1"))), + ListenPort: openVPNPort, + }, + Network: testCase.protocol, + Address: []netip.Prefix{serverPrefix}, + Users: []auth.User{ + { + Username: openVPNTLSUsername, + Password: openVPNTLSPassword, + }, + }, + TLS: &option.OpenVPNInboundTLSOptions{ + CertificatePath: certificates.serverCertPath, + KeyPath: certificates.serverKeyPath, + ClientCertificatePath: certificates.caPath, + }, + } + serverOptions.UDPMapping = option.UDPNATBehaviorAddressDependent + serverOptions.UDPFiltering = option.UDPNATBehaviorAddressAndPortDependent + serverOptions.UDPNATMax = 128 + clientOptions := newOpenVPNTLSClientOptions(testCase.protocol, openVPNPort, certificates.caPath, certificates.clientCertPath, certificates.clientKeyPath) + clientOptions.TLS.RemoteCertificateTLS = testCase.remoteCertificateTLS + clientOptions.UDPMapping = option.UDPNATBehaviorAddressDependent + clientOptions.UDPFiltering = option.UDPNATBehaviorAddressAndPortDependent + clientOptions.UDPNATMax = 128 + if testCase.tlsCrypt { + tlsCryptKeyPath := writeOpenVPNStaticKeyFile(t, createOpenVPNStaticKey(t)) + serverOptions.TLS.ControlWrap = &option.OpenVPNInboundControlWrapOptions{ + Type: "tls_crypt", + KeyPath: tlsCryptKeyPath, + } + clientOptions.TLS.ControlWrap = &option.OpenVPNControlWrapOptions{ + Type: "tls_crypt", + KeyPath: tlsCryptKeyPath, + } + } + clientOptions.Username = openVPNTLSUsername + clientOptions.Password = openVPNTLSPassword + + startInstance(t, openVPNServerInstanceOptions(serverOptions)) + startInstance(t, openVPNClientInstanceOptions(clientOptions, proxyPort)) + waitForOpenVPNClientReady(t, proxyPort, readinessPort, serverAddress) + testSuitOpenVPN(t, proxyPort, echoPort, serverAddress) +} + +func testOpenVPNDockerOfficialServerToSingBoxClient(t *testing.T) { + t.Helper() + environment := startOpenVPNDockerOfficialServer(t, "official-server") + proxyPort := reserveOpenVPNTCPPort(t) + + clientOptions := newOpenVPNTLSClientOptions( + N.NetworkUDP, + environment.openVPNPort, + filepath.Join(environment.workspace, "ca.crt"), + filepath.Join(environment.workspace, "client.crt"), + filepath.Join(environment.workspace, "client.key"), + ) + clientOptions.Username = openVPNTLSUsername + clientOptions.Password = openVPNTLSPassword + startInstance(t, openVPNClientInstanceOptions(clientOptions, proxyPort)) + waitForOpenVPNRemoteReady(t, proxyPort, "10.8.0.1", environment.echoPort, 30*time.Second) + testRemoteEchoThroughSocks(t, proxyPort, "10.8.0.1", environment.echoPort) +} + +func TestOpenVPNDockerMultiRemoteFailover(t *testing.T) { + environment := startOpenVPNDockerOfficialServer(t, "multi-remote-server") + deadPort := reserveOpenVPNTCPPort(t) + proxyPort := reserveOpenVPNTCPPort(t) + clientOptions := newOpenVPNTLSClientOptions( + N.NetworkUDP, + environment.openVPNPort, + filepath.Join(environment.workspace, "ca.crt"), + filepath.Join(environment.workspace, "client.crt"), + filepath.Join(environment.workspace, "client.key"), + ) + clientOptions.Server = "" + clientOptions.ServerPort = 0 + clientOptions.Servers = []option.OpenVPNRemoteOptions{ + { + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: deadPort, + }, + Network: N.NetworkTCP, + }, + { + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: environment.openVPNPort, + }, + Network: N.NetworkUDP, + }, + } + clientOptions.Username = openVPNTLSUsername + clientOptions.Password = openVPNTLSPassword + startInstance(t, openVPNClientInstanceOptions(clientOptions, proxyPort)) + waitForOpenVPNRemoteReady(t, proxyPort, "10.8.0.1", environment.echoPort, 30*time.Second) + testRemoteEchoThroughSocks(t, proxyPort, "10.8.0.1", environment.echoPort) +} + +func TestOpenVPNDockerMSSFix(t *testing.T) { + dockerClient := requireOpenVPNDockerEnvironment(t) + certificates := createOpenVPNCertificateBundle(t) + workspace := newOpenVPNDockerWorkspace(t, certificates) + openVPNPort := reserveOpenVPNUDPPort(t) + proxyPort := reserveOpenVPNTCPPort(t) + echoPort := reserveOpenVPNEchoPort(t) + writeOpenVPNDockerServerConfig(t, workspace, openVPNPort, "/config/check_userpass.sh", "mssfix 1200") + writeOpenVPNDockerEchoServer(t, workspace, "10.8.0.1", echoPort) + serverCommand := strings.Join([]string{ + "python3 /config/echo_server.py &", + "openvpn --config /config/server.conf &", + "openvpn_pid=$!", + "until grep -q 'Initialization Sequence Completed' /config/openvpn.log; do", + " if ! kill -0 \"$openvpn_pid\" 2>/dev/null; then cat /config/openvpn.log; exit 1; fi", + " sleep 0.1", + "done", + "tcpdump -i tun0 -l -nn -v 'dst host 10.8.0.1 and tcp[tcpflags] & tcp-syn != 0' > /config/mss.log 2>&1 &", + "wait \"$openvpn_pid\"", + }, "\n") + serverContainer := startOpenVPNDockerContainer(t, dockerClient, "mss-fix-server", workspace, serverCommand) + dumpOpenVPNDockerLogsOnFailure(t, serverContainer, workspace) + waitForOpenVPNDockerFile(t, serverContainer, filepath.Join(workspace, "openvpn.log"), "Initialization Sequence Completed", 30*time.Second) + waitForOpenVPNDockerFile(t, serverContainer, filepath.Join(workspace, "echo.ready"), "ready", 30*time.Second) + waitForOpenVPNDockerFile(t, serverContainer, filepath.Join(workspace, "mss.log"), "listening on tun0", 30*time.Second) + + clientOptions := newOpenVPNTLSClientOptions( + N.NetworkUDP, + openVPNPort, + filepath.Join(workspace, "ca.crt"), + filepath.Join(workspace, "client.crt"), + filepath.Join(workspace, "client.key"), + ) + clientOptions.Username = openVPNTLSUsername + clientOptions.Password = openVPNTLSPassword + clientOptions.MSSFix = 1200 + startInstance(t, openVPNClientInstanceOptions(clientOptions, proxyPort)) + waitForOpenVPNRemoteReady(t, proxyPort, "10.8.0.1", echoPort, 30*time.Second) + testRemoteEchoThroughSocks(t, proxyPort, "10.8.0.1", echoPort) + waitForOpenVPNDockerFile(t, serverContainer, filepath.Join(workspace, "mss.log"), "mss 1136", 30*time.Second) +} + +func TestOpenVPNDockerCompressionNegotiation(t *testing.T) { + environment := startOpenVPNDockerOfficialServer(t, "compression-server", + "allow-compression no", + "compress stub", + "push \"compress stub\"", + ) + proxyPort := reserveOpenVPNTCPPort(t) + clientOptions := newOpenVPNTLSClientOptions( + N.NetworkUDP, + environment.openVPNPort, + filepath.Join(environment.workspace, "ca.crt"), + filepath.Join(environment.workspace, "client.crt"), + filepath.Join(environment.workspace, "client.key"), + ) + clientOptions.Username = openVPNTLSUsername + clientOptions.Password = openVPNTLSPassword + clientOptions.Compression = "stub" + clientOptions.AllowCompression = "asym" + startInstance(t, openVPNClientInstanceOptions(clientOptions, proxyPort)) + waitForOpenVPNRemoteReady(t, proxyPort, "10.8.0.1", environment.echoPort, 30*time.Second) + testRemoteEchoThroughSocks(t, proxyPort, "10.8.0.1", environment.echoPort) + waitForOpenVPNDockerFile(t, environment.container, filepath.Join(environment.workspace, "openvpn.log"), "IV_LZO=1", 30*time.Second) +} + +func TestOpenVPNDockerPeerFingerprint(t *testing.T) { + environment := startOpenVPNDockerOfficialServer(t, "peer-fingerprint-server") + fingerprint := openVPNCertificateSHA256Fingerprint(t, environment.certificates.serverCertPath) + wrongFingerprint := "0" + fingerprint[1:] + if fingerprint[0] == '0' { + wrongFingerprint = "1" + fingerprint[1:] + } + + t.Run("matching", func(matchingTest *testing.T) { + matchingProxyPort := reserveOpenVPNTCPPort(matchingTest) + matchingClientOptions := newOpenVPNTLSClientOptions( + N.NetworkUDP, + environment.openVPNPort, + "", + filepath.Join(environment.workspace, "client.crt"), + filepath.Join(environment.workspace, "client.key"), + ) + matchingClientOptions.Username = openVPNTLSUsername + matchingClientOptions.Password = openVPNTLSPassword + matchingClientOptions.TLS.PeerFingerprint = []string{fingerprint} + startInstance(matchingTest, openVPNClientInstanceOptions(matchingClientOptions, matchingProxyPort)) + waitForOpenVPNRemoteReady(matchingTest, matchingProxyPort, "10.8.0.1", environment.echoPort, 30*time.Second) + testRemoteEchoThroughSocks(matchingTest, matchingProxyPort, "10.8.0.1", environment.echoPort) + }) + + t.Run("mismatch", func(mismatchTest *testing.T) { + mismatchProxyPort := reserveOpenVPNTCPPort(mismatchTest) + mismatchClientOptions := newOpenVPNTLSClientOptions( + N.NetworkUDP, + environment.openVPNPort, + "", + filepath.Join(environment.workspace, "client.crt"), + filepath.Join(environment.workspace, "client.key"), + ) + mismatchClientOptions.Username = openVPNTLSUsername + mismatchClientOptions.Password = openVPNTLSPassword + mismatchClientOptions.TLS.PeerFingerprint = []string{wrongFingerprint} + mismatchInstance := startInstance(mismatchTest, openVPNClientInstanceOptions(mismatchClientOptions, mismatchProxyPort)) + mismatchEndpoint := requireOpenVPNEndpoint(mismatchTest, mismatchInstance, "openvpn-client") + mismatchStatus := waitForOpenVPNStatus(mismatchTest, mismatchEndpoint, 30*time.Second, func(status adapter.OpenVPNStatus) bool { + return status.State == adapter.OpenVPNStateError + }) + require.Contains(mismatchTest, mismatchStatus.Error, "peer fingerprint mismatch") + }) +} + +func TestOpenVPNDockerClientRoutes(t *testing.T) { + environment := startOpenVPNDockerOfficialServer(t, "client-routes-server") + localRoute := netip.MustParsePrefix("192.0.2.0/24") + + t.Run("routes", func(routesTest *testing.T) { + routesProxyPort := reserveOpenVPNTCPPort(routesTest) + routesClientOptions := newOpenVPNTLSClientOptions( + N.NetworkUDP, + environment.openVPNPort, + filepath.Join(environment.workspace, "ca.crt"), + filepath.Join(environment.workspace, "client.crt"), + filepath.Join(environment.workspace, "client.key"), + ) + routesClientOptions.Username = openVPNTLSUsername + routesClientOptions.Password = openVPNTLSPassword + routesClientOptions.Routes = []netip.Prefix{localRoute} + routesInstance := startInstance(routesTest, openVPNClientInstanceOptions(routesClientOptions, routesProxyPort)) + waitForOpenVPNRemoteReady(routesTest, routesProxyPort, "10.8.0.1", environment.echoPort, 30*time.Second) + routesEndpoint, loaded := routesInstance.Endpoint().Get("openvpn-client") + require.True(routesTest, loaded) + preferredRoutes, supported := routesEndpoint.(adapter.OutboundWithPreferredRoutes) + require.True(routesTest, supported) + require.True(routesTest, preferredRoutes.PreferredAddress(nil, netip.MustParseAddr("192.0.2.1"))) + require.False(routesTest, preferredRoutes.PreferredAddress(nil, netip.MustParseAddr("198.51.100.1"))) + }) + + t.Run("redirect_gateway", func(redirectTest *testing.T) { + redirectProxyPort := reserveOpenVPNTCPPort(redirectTest) + redirectClientOptions := newOpenVPNTLSClientOptions( + N.NetworkUDP, + environment.openVPNPort, + filepath.Join(environment.workspace, "ca.crt"), + filepath.Join(environment.workspace, "client.crt"), + filepath.Join(environment.workspace, "client.key"), + ) + redirectClientOptions.Username = openVPNTLSUsername + redirectClientOptions.Password = openVPNTLSPassword + redirectClientOptions.Routes = []netip.Prefix{localRoute} + redirectClientOptions.RedirectGateway = true + redirectClientOptions.RedirectGatewayFlags = []string{"def1"} + redirectInstance := startInstance(redirectTest, openVPNClientInstanceOptions(redirectClientOptions, redirectProxyPort)) + waitForOpenVPNRemoteReady(redirectTest, redirectProxyPort, "10.8.0.1", environment.echoPort, 30*time.Second) + redirectEndpoint, loaded := redirectInstance.Endpoint().Get("openvpn-client") + require.True(redirectTest, loaded) + preferredRoutes, supported := redirectEndpoint.(adapter.OutboundWithPreferredRoutes) + require.True(redirectTest, supported) + require.True(redirectTest, preferredRoutes.PreferredAddress(nil, netip.MustParseAddr("192.0.2.1"))) + require.True(redirectTest, preferredRoutes.PreferredAddress(nil, netip.MustParseAddr("198.51.100.1"))) + require.False(redirectTest, preferredRoutes.PreferredAddress(nil, netip.MustParseAddr("2001:db8::1"))) + }) +} + +func startOpenVPNDockerOfficialServer(t *testing.T, nameSuffix string, serverDirectives ...string) openVPNDockerServerEnvironment { + t.Helper() + dockerClient := requireOpenVPNDockerEnvironment(t) + certificates := createOpenVPNCertificateBundle(t) + workspace := newOpenVPNDockerWorkspace(t, certificates) + openVPNPort := reserveOpenVPNUDPPort(t) + echoPort := reserveOpenVPNEchoPort(t) + writeOpenVPNDockerServerConfig(t, workspace, openVPNPort, "/config/check_userpass.sh", serverDirectives...) + writeOpenVPNDockerEchoServer(t, workspace, "10.8.0.1", echoPort) + serverContainer := startOpenVPNDockerContainer(t, dockerClient, nameSuffix, workspace, "python3 /config/echo_server.py & exec openvpn --config /config/server.conf") + dumpOpenVPNDockerLogsOnFailure(t, serverContainer, workspace) + waitForOpenVPNDockerFile(t, serverContainer, filepath.Join(workspace, "openvpn.log"), "Initialization Sequence Completed", 30*time.Second) + waitForOpenVPNDockerFile(t, serverContainer, filepath.Join(workspace, "echo.ready"), "ready", 30*time.Second) + return openVPNDockerServerEnvironment{ + certificates: certificates, + workspace: workspace, + openVPNPort: openVPNPort, + echoPort: echoPort, + container: serverContainer, + } +} + +func TestOpenVPNInteractiveAuth(t *testing.T) { + dockerClient := requireOpenVPNDockerEnvironment(t) + certificates := createOpenVPNCertificateBundle(t) + workspace := newOpenVPNDockerWorkspace(t, certificates) + openVPNPort := reserveOpenVPNUDPPort(t) + proxyPort := reserveOpenVPNTCPPort(t) + echoPort := reserveOpenVPNEchoPort(t) + + writeOpenVPNDockerStaticChallengeScript(t, workspace) + writeOpenVPNDockerServerConfig(t, workspace, openVPNPort, "/config/check_scrv1.sh") + writeOpenVPNDockerEchoServer(t, workspace, "10.8.0.1", echoPort) + serverContainer := startOpenVPNDockerContainer(t, dockerClient, "interactive-auth-server", workspace, "python3 /config/echo_server.py & exec openvpn --config /config/server.conf") + dumpOpenVPNDockerLogsOnFailure(t, serverContainer, workspace) + waitForOpenVPNDockerFile(t, serverContainer, filepath.Join(workspace, "openvpn.log"), "Initialization Sequence Completed", 30*time.Second) + waitForOpenVPNDockerFile(t, serverContainer, filepath.Join(workspace, "echo.ready"), "ready", 30*time.Second) + + clientOptions := newOpenVPNTLSClientOptions( + N.NetworkUDP, + openVPNPort, + filepath.Join(workspace, "ca.crt"), + filepath.Join(workspace, "client.crt"), + filepath.Join(workspace, "client.key"), + ) + clientOptions.StaticChallenge = openVPNStaticChallengeText + clientOptions.StaticChallengeEcho = true + clientInstance := startInstance(t, openVPNClientInstanceOptions(clientOptions, proxyPort)) + clientEndpoint := requireOpenVPNEndpoint(t, clientInstance, "openvpn-client") + + challengeStatus := waitForOpenVPNStatus(t, clientEndpoint, 30*time.Second, func(status adapter.OpenVPNStatus) bool { + require.NotEqual(t, adapter.OpenVPNStateConnected, status.State) + require.NotEqual(t, adapter.OpenVPNStateError, status.State, status.Error) + return status.State == adapter.OpenVPNStateAuthPending + }) + challenge := challengeStatus.Challenge + require.NotNil(t, challenge) + require.NotEmpty(t, challenge.ID) + require.Equal(t, "credentials", challenge.Kind) + require.Equal(t, openVPNStaticChallengeText, challenge.SecretMessage) + require.True(t, challenge.Echo) + require.Empty(t, challenge.PreviousError) + + err := clientEndpoint.CompleteChallenge(challenge.ID, adapter.OpenVPNChallengeResponse{ + Username: openVPNTLSUsername, + Password: openVPNTLSPassword, + Secret: openVPNStaticChallengeResponse, + }) + require.NoError(t, err) + + waitForOpenVPNStatus(t, clientEndpoint, time.Minute, func(status adapter.OpenVPNStatus) bool { + require.NotEqual(t, adapter.OpenVPNStateError, status.State, status.Error) + return status.State == adapter.OpenVPNStateConnected + }) + waitForOpenVPNRemoteReady(t, proxyPort, "10.8.0.1", echoPort, 30*time.Second) + testRemoteEchoThroughSocks(t, proxyPort, "10.8.0.1", echoPort) +} + +func writeOpenVPNDockerStaticChallengeScript(t *testing.T, workspace string) { + t.Helper() + script := strings.Join([]string{ + "#!/bin/bash", + "set -eu", + "credentials_file=\"$1\"", + "username=\"$(sed -n '1p' \"$credentials_file\")\"", + "password=\"$(sed -n '2p' \"$credentials_file\")\"", + "[ \"$username\" = \"" + openVPNTLSUsername + "\" ] || exit 1", + "case \"$password\" in", + "SCRV1:*) ;;", + "*) exit 1 ;;", + "esac", + "encoded_password=\"$(printf '%s' \"$password\" | cut -d: -f2)\"", + "encoded_response=\"$(printf '%s' \"$password\" | cut -d: -f3)\"", + "[ \"$(printf '%s' \"$encoded_password\" | base64 -d)\" = \"" + openVPNTLSPassword + "\" ] || exit 1", + "[ \"$(printf '%s' \"$encoded_response\" | base64 -d)\" = \"" + openVPNStaticChallengeResponse + "\" ] || exit 1", + "exit 0", + "", + }, "\n") + err := os.WriteFile(filepath.Join(workspace, "check_scrv1.sh"), []byte(script), 0o700) + require.NoError(t, err) +} + +func requireOpenVPNEndpoint(t *testing.T, instance *box.Box, tag string) adapter.OpenVPNEndpoint { + t.Helper() + endpoint, loaded := instance.Endpoint().Get(tag) + require.True(t, loaded) + openVPNEndpoint, supported := endpoint.(adapter.OpenVPNEndpoint) + require.True(t, supported) + return openVPNEndpoint +} + +func waitForOpenVPNStatus(t *testing.T, endpoint adapter.OpenVPNEndpoint, timeout time.Duration, predicate func(status adapter.OpenVPNStatus) bool) adapter.OpenVPNStatus { + t.Helper() + timeoutChannel := time.After(timeout) + for { + statusUpdated := endpoint.StatusUpdated() + status := endpoint.OpenVPNStatus() + if predicate(status) { + return status + } + select { + case <-statusUpdated: + case <-timeoutChannel: + t.Fatalf("timed out waiting for OpenVPN endpoint status, last state %q, error %q", status.State, status.Error) + } + } +} + +func TestOpenVPNClientReconnectSelfToSelf(t *testing.T) { + const serverAddress = "10.8.0.1" + serverPrefix := netip.MustParsePrefix(serverAddress + "/24") + proxyPort := reserveOpenVPNTCPPort(t) + openVPNPort := reserveOpenVPNUDPPort(t) + echoPort := reserveOpenVPNEchoPort(t) + readinessPort := reserveOpenVPNEchoPort(t) + certificates := createOpenVPNCertificateBundle(t) + serverOptions := option.OpenVPNServerEndpointOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.MustParseAddr("127.0.0.1"))), + ListenPort: openVPNPort, + }, + Network: N.NetworkUDP, + Address: []netip.Prefix{serverPrefix}, + TLS: &option.OpenVPNInboundTLSOptions{ + CertificatePath: certificates.serverCertPath, + KeyPath: certificates.serverKeyPath, + ClientCertificatePath: certificates.caPath, + }, + PingInterval: badoption.Duration(time.Second), + PingRestart: badoption.Duration(4 * time.Second), + Push: &option.OpenVPNPushOptions{ + PingInterval: badoption.Duration(time.Second), + PingRestart: badoption.Duration(2 * time.Second), + }, + Users: []auth.User{ + { + Username: openVPNTLSUsername, + Password: openVPNTLSPassword, + }, + }, + } + clientOptions := newOpenVPNTLSClientOptions(N.NetworkUDP, openVPNPort, certificates.caPath, certificates.clientCertPath, certificates.clientKeyPath) + clientOptions.Username = openVPNTLSUsername + clientOptions.Password = openVPNTLSPassword + serverInstance := startInstance(t, openVPNServerInstanceOptions(serverOptions)) + startInstance(t, openVPNClientInstanceOptions(clientOptions, proxyPort)) + waitForOpenVPNClientReady(t, proxyPort, readinessPort, serverAddress) + err := serverInstance.Close() + require.NoError(t, err) + startInstance(t, openVPNServerInstanceOptions(serverOptions)) + waitForOpenVPNClientReady(t, proxyPort, readinessPort, serverAddress) + testSuitOpenVPN(t, proxyPort, echoPort, serverAddress) +} + +func testOpenVPNDockerOfficialClientToSingBoxServer(t *testing.T) { + t.Helper() + dockerClient := requireOpenVPNDockerEnvironment(t) + certificates := createOpenVPNCertificateBundle(t) + workspace := newOpenVPNDockerWorkspace(t, certificates) + openVPNPort := reserveOpenVPNUDPPort(t) + echoPort := reserveOpenVPNEchoPort(t) + + startOpenVPNHostEchoServers(t, echoPort) + serverOptions := option.OpenVPNServerEndpointOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.MustParseAddr("127.0.0.1"))), + ListenPort: openVPNPort, + }, + Network: N.NetworkUDP, + Address: []netip.Prefix{netip.MustParsePrefix("10.8.0.1/24")}, + TLS: &option.OpenVPNInboundTLSOptions{ + CertificatePath: filepath.Join(workspace, "server.crt"), + KeyPath: filepath.Join(workspace, "server.key"), + ClientCertificatePath: filepath.Join(workspace, "ca.crt"), + }, + Users: []auth.User{ + { + Username: openVPNTLSUsername, + Password: openVPNTLSPassword, + }, + }, + } + startInstance(t, openVPNServerInstanceOptions(serverOptions)) + + writeOpenVPNDockerClientConfig(t, workspace, openVPNPort) + writeOpenVPNDockerEchoClient(t, workspace, "10.8.0.1", echoPort) + clientCommand := strings.Join([]string{ + "openvpn --config /config/client.conf &", + "openvpn_pid=$!", + "until grep -q 'Initialization Sequence Completed' /config/openvpn.log; do", + " if ! kill -0 \"$openvpn_pid\" 2>/dev/null; then cat /config/openvpn.log; exit 1; fi", + " sleep 0.1", + "done", + "python3 /config/echo_client.py", + "kill \"$openvpn_pid\"", + "wait \"$openvpn_pid\" || true", + }, "\n") + clientContainer := startOpenVPNDockerContainer(t, dockerClient, "official-client", workspace, clientCommand) + dumpOpenVPNDockerLogsOnFailure(t, clientContainer, workspace) + waitResult := clientContainer.Wait(t, 60*time.Second) + require.Equal(t, int64(0), waitResult.exitCode, waitResult.logs) +} + +func waitForOpenVPNClientReady(t *testing.T, proxyPort uint16, echoPort uint16, tunnelAddress string) { + t.Helper() + closeEcho := startOpenVPNReadinessEcho(t, echoPort) + defer closeEcho() + waitForOpenVPNRemoteReady(t, proxyPort, tunnelAddress, echoPort, 3*time.Minute) +} + +func startOpenVPNReadinessEcho(t *testing.T, port uint16) func() { + t.Helper() + listener, err := listen("tcp", ":"+strconv.Itoa(int(port))) + require.NoError(t, err) + done := make(chan struct{}) + go func() { + defer close(done) + for { + conn, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + go echoOpenVPNTCPConnection(conn) + } + }() + return func() { + listener.Close() + <-done + } +} + +func waitForOpenVPNRemoteReady(t *testing.T, proxyPort uint16, tunnelAddress string, tunnelPort uint16, timeout time.Duration) { + t.Helper() + deadline := time.Now().Add(timeout) + var lastErr error + for time.Now().Before(deadline) { + lastErr = probeOpenVPNTCPWithTimeout(proxyPort, tunnelAddress, tunnelPort, 3*time.Second) + if lastErr == nil { + return + } + time.Sleep(100 * time.Millisecond) + } + require.NoError(t, lastErr) +} + +func probeOpenVPNTCPWithTimeout(proxyPort uint16, tunnelAddress string, tunnelPort uint16, timeout time.Duration) error { + resultCh := make(chan error, 1) + go func() { + probeErr := probeOpenVPNTCP(proxyPort, tunnelAddress, tunnelPort) + resultCh <- probeErr + }() + select { + case resultErr := <-resultCh: + return resultErr + case <-time.After(timeout): + return E.New("timeout") + } +} + +func probeOpenVPNTCP(proxyPort uint16, tunnelAddress string, tunnelPort uint16) error { + dialer := socks.NewClient(N.SystemDialer, M.ParseSocksaddrHostPort("127.0.0.1", proxyPort), socks.Version5, "", "") + destination := M.ParseSocksaddrHostPort(tunnelAddress, tunnelPort) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + conn, err := dialer.DialContext(ctx, N.NetworkTCP, destination) + if err != nil { + return err + } + defer conn.Close() + err = conn.SetDeadline(time.Now().Add(time.Second)) + if err != nil { + return err + } + return writeAndReadEcho(conn, []byte("ready")) +} + +func openVPNServerInstanceOptions(serverOptions option.OpenVPNServerEndpointOptions) option.Options { + return option.Options{ + Endpoints: []option.Endpoint{ + { + Type: C.TypeOpenVPNServer, + Tag: "openvpn-server", + Options: &serverOptions, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + }, + } +} + +func openVPNClientInstanceOptions(clientOptions option.OpenVPNClientEndpointOptions, proxyPort uint16) option.Options { + return option.Options{ + Endpoints: []option.Endpoint{ + { + Type: C.TypeOpenVPNClient, + Tag: "openvpn-client", + Options: &clientOptions, + }, + }, + Inbounds: []option.Inbound{ + { + Type: C.TypeSOCKS, + Tag: "socks-in", + Options: &option.SocksInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.MustParseAddr("127.0.0.1"))), + ListenPort: proxyPort, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + Inbound: []string{"socks-in"}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.RouteActionOptions{ + Outbound: "openvpn-client", + }, + }, + }, + }, + }, + }, + } +} + +func newOpenVPNTLSClientOptions(protocol string, port uint16, certificatePath string, clientCertificatePath string, clientKeyPath string) option.OpenVPNClientEndpointOptions { + return option.OpenVPNClientEndpointOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: port, + }, + Network: protocol, + TLS: &option.OpenVPNOutboundTLSOptions{ + CertificatePath: certificatePath, + ClientCertificatePath: clientCertificatePath, + ClientKeyPath: clientKeyPath, + }, + } +} + +func writeOpenVPNStaticKeyFile(t *testing.T, staticKey string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "static.key") + err := os.WriteFile(path, []byte(staticKey), 0o600) + require.NoError(t, err) + return path +} + +func newOpenVPNDockerWorkspace(t *testing.T, certificates openVPNCertificateBundle) string { + t.Helper() + workspace := t.TempDir() + copyOpenVPNFile(t, certificates.caPath, filepath.Join(workspace, "ca.crt"), 0o600) + copyOpenVPNFile(t, certificates.serverCertPath, filepath.Join(workspace, "server.crt"), 0o600) + copyOpenVPNFile(t, certificates.serverKeyPath, filepath.Join(workspace, "server.key"), 0o600) + copyOpenVPNFile(t, certificates.clientCertPath, filepath.Join(workspace, "client.crt"), 0o600) + copyOpenVPNFile(t, certificates.clientKeyPath, filepath.Join(workspace, "client.key"), 0o600) + authFileContent := openVPNTLSUsername + "\n" + openVPNTLSPassword + "\n" + err := os.WriteFile(filepath.Join(workspace, "auth-user-pass.txt"), []byte(authFileContent), 0o600) + require.NoError(t, err) + checkUserPassScript := strings.Join([]string{ + "#!/bin/sh", + "set -eu", + "credentials_file=\"$1\"", + "username=\"$(sed -n '1p' \"$credentials_file\")\"", + "password=\"$(sed -n '2p' \"$credentials_file\")\"", + "[ \"$username\" = \"" + openVPNTLSUsername + "\" ] && [ \"$password\" = \"" + openVPNTLSPassword + "\" ]", + "", + }, "\n") + err = os.WriteFile(filepath.Join(workspace, "check_userpass.sh"), []byte(checkUserPassScript), 0o700) + require.NoError(t, err) + return workspace +} + +func copyOpenVPNFile(t *testing.T, sourcePath string, targetPath string, mode os.FileMode) { + t.Helper() + content, err := os.ReadFile(sourcePath) + require.NoError(t, err) + err = os.WriteFile(targetPath, content, mode) + require.NoError(t, err) +} + +func openVPNCertificateSHA256Fingerprint(t *testing.T, certificatePath string) string { + t.Helper() + certificatePEM, err := os.ReadFile(certificatePath) + require.NoError(t, err) + certificateBlock, _ := pem.Decode(certificatePEM) + require.NotNil(t, certificateBlock) + require.Equal(t, "CERTIFICATE", certificateBlock.Type) + fingerprint := sha256.Sum256(certificateBlock.Bytes) + return hex.EncodeToString(fingerprint[:]) +} + +func writeOpenVPNDockerServerConfig(t *testing.T, workspace string, openVPNPort uint16, verifyScriptPath string, serverDirectives ...string) { + t.Helper() + configLines := []string{ + "port " + strconv.Itoa(int(openVPNPort)), + "proto udp4", + "dev tun", + "topology subnet", + "server 10.8.0.0 255.255.255.0", + "ca /config/ca.crt", + "cert /config/server.crt", + "key /config/server.key", + "dh none", + "persist-key", + "persist-tun", + "verb 4", + "script-security 2", + "auth-user-pass-verify " + verifyScriptPath + " via-file", + "explicit-exit-notify 1", + } + configLines = append(configLines, serverDirectives...) + configLines = append(configLines, + "log /config/openvpn.log", + "", + ) + config := strings.Join(configLines, "\n") + err := os.WriteFile(filepath.Join(workspace, "server.conf"), []byte(config), 0o600) + require.NoError(t, err) +} + +func writeOpenVPNDockerClientConfig(t *testing.T, workspace string, openVPNPort uint16) { + t.Helper() + config := strings.Join([]string{ + "client", + "dev tun", + "proto udp4", + "remote 127.0.0.1 " + strconv.Itoa(int(openVPNPort)), + "resolv-retry infinite", + "nobind", + "float", + "persist-key", + "persist-tun", + "remote-cert-tls server", + "ca /config/ca.crt", + "cert /config/client.crt", + "key /config/client.key", + "auth-user-pass /config/auth-user-pass.txt", + "verb 4", + "explicit-exit-notify 1", + "log /config/openvpn.log", + "", + }, "\n") + err := os.WriteFile(filepath.Join(workspace, "client.conf"), []byte(config), 0o600) + require.NoError(t, err) +} + +func writeOpenVPNDockerEchoServer(t *testing.T, workspace string, host string, port uint16) { + t.Helper() + script := strings.ReplaceAll(openVPNDockerEchoServerScript, "{{HOST}}", host) + script = strings.ReplaceAll(script, "{{PORT}}", strconv.Itoa(int(port))) + err := os.WriteFile(filepath.Join(workspace, "echo_server.py"), []byte(script), 0o700) + require.NoError(t, err) +} + +func writeOpenVPNDockerEchoClient(t *testing.T, workspace string, host string, port uint16) { + t.Helper() + script := strings.ReplaceAll(openVPNDockerEchoClientScript, "{{HOST}}", host) + script = strings.ReplaceAll(script, "{{PORT}}", strconv.Itoa(int(port))) + err := os.WriteFile(filepath.Join(workspace, "echo_client.py"), []byte(script), 0o700) + require.NoError(t, err) +} + +func testRemoteEchoThroughSocks(t *testing.T, proxyPort uint16, destinationAddress string, destinationPort uint16) { + t.Helper() + dialer := socks.NewClient(N.SystemDialer, M.ParseSocksaddrHostPort("127.0.0.1", proxyPort), socks.Version5, "", "") + destination := M.ParseSocksaddrHostPort(destinationAddress, destinationPort) + err := testRemoteTCPEcho(t, dialer, destination) + require.NoError(t, err) + err = testRemoteUDPEcho(t, dialer, destination) + require.NoError(t, err) +} + +func testRemoteTCPEcho(t *testing.T, dialer *socks.Client, destination M.Socksaddr) error { + t.Helper() + conn, err := dialer.DialContext(context.Background(), N.NetworkTCP, destination) + if err != nil { + return err + } + defer conn.Close() + err = conn.SetDeadline(time.Now().Add(30 * time.Second)) + if err != nil { + return err + } + err = writeAndReadEcho(conn, []byte("ping")) + if err != nil { + return err + } + payload := make([]byte, 64*1024) + for i := 0; i < 100; i++ { + _, err = rand.Read(payload[1:]) + if err != nil { + return err + } + payload[0] = byte(i) + err = writeAndReadEcho(conn, payload) + if err != nil { + return err + } + } + return nil +} + +func testRemoteUDPEcho(t *testing.T, dialer *socks.Client, destination M.Socksaddr) error { + t.Helper() + conn, err := dialer.DialContext(context.Background(), N.NetworkUDP, destination) + if err != nil { + return err + } + defer conn.Close() + err = conn.SetDeadline(time.Now().Add(30 * time.Second)) + if err != nil { + return err + } + err = writeAndReadPacketEcho(conn, []byte("ping")) + if err != nil { + return err + } + payload := make([]byte, 1500) + for i := 0; i < 50; i++ { + _, err = rand.Read(payload[1:]) + if err != nil { + return err + } + payload[0] = byte(i) + err = writeAndReadPacketEcho(conn, payload) + if err != nil { + return err + } + } + return nil +} + +func writeAndReadEcho(conn net.Conn, payload []byte) error { + _, err := conn.Write(payload) + if err != nil { + return err + } + response := make([]byte, len(payload)) + _, err = io.ReadFull(conn, response) + if err != nil { + return err + } + if !bytes.Equal(payload, response) { + return E.New("unexpected TCP echo response") + } + return nil +} + +func writeAndReadPacketEcho(conn net.Conn, payload []byte) error { + _, err := conn.Write(payload) + if err != nil { + return err + } + response := make([]byte, len(payload)+512) + n, err := conn.Read(response) + if err != nil { + return err + } + if !bytes.Equal(payload, response[:n]) { + return E.New("unexpected UDP echo response") + } + return nil +} + +func startOpenVPNHostEchoServers(t *testing.T, port uint16) { + t.Helper() + tcpListener, err := listen("tcp", ":"+strconv.Itoa(int(port))) + require.NoError(t, err) + udpConnection, err := listenPacket("udp", ":"+strconv.Itoa(int(port))) + require.NoError(t, err) + t.Cleanup(func() { + tcpListener.Close() + udpConnection.Close() + }) + go func() { + for { + conn, acceptErr := tcpListener.Accept() + if acceptErr != nil { + return + } + go echoOpenVPNTCPConnection(conn) + } + }() + go func() { + buffer := make([]byte, 64*1024) + for { + n, address, readErr := udpConnection.ReadFrom(buffer) + if readErr != nil { + return + } + _, _ = udpConnection.WriteTo(buffer[:n], address) + } + }() +} + +func echoOpenVPNTCPConnection(conn net.Conn) { + defer conn.Close() + buffer := make([]byte, 64*1024) + for { + n, err := conn.Read(buffer) + if err != nil { + return + } + _, err = conn.Write(buffer[:n]) + if err != nil { + return + } + } +} + +type openVPNDockerContainer struct { + dockerClient *client.Client + containerID string + name string +} + +type openVPNDockerWaitResult struct { + exitCode int64 + logs string +} + +func requireOpenVPNDockerEnvironment(t *testing.T) *client.Client { + t.Helper() + dockerClient := openVPNDockerClientForTest(t) + _, err := dockerClient.Ping(context.Background()) + if err != nil { + dockerClient.Close() + t.Skipf("Docker is unavailable: %v", err) + } + openVPNDockerImageOnce.Do(func() { + openVPNDockerImageErr = ensureOpenVPNDockerImage(dockerClient) + }) + require.NoError(t, openVPNDockerImageErr) + verifyOpenVPNDockerImage(t, dockerClient) + return dockerClient +} + +func openVPNDockerClientForTest(t *testing.T) *client.Client { + t.Helper() + clientOptions := []client.Opt{client.WithAPIVersionNegotiation()} + dockerHost := os.Getenv("DOCKER_HOST") + switch { + case dockerHost != "": + clientOptions = append(clientOptions, client.WithHost(dockerHost)) + case openVPNFileExists("/Users/sekai/.orbstack/run/docker.sock"): + clientOptions = append(clientOptions, client.WithHost("unix:///Users/sekai/.orbstack/run/docker.sock")) + case openVPNFileExists("/var/run/docker.sock"): + clientOptions = append(clientOptions, client.WithHost("unix:///var/run/docker.sock")) + default: + t.Skip("Docker is unavailable: docker socket not found") + } + dockerClient, err := client.NewClientWithOpts(clientOptions...) + require.NoError(t, err) + t.Cleanup(func() { + dockerClient.Close() + }) + return dockerClient +} + +func openVPNFileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func ensureOpenVPNDockerImage(dockerClient *client.Client) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _, _, err := dockerClient.ImageInspectWithRaw(ctx, openVPNDockerImage) + if err == nil { + return nil + } + if !errdefs.IsNotFound(err) { + return err + } + return buildOpenVPNDockerImage(dockerClient) +} + +func buildOpenVPNDockerImage(dockerClient *client.Client) error { + dockerfile := strings.Join([]string{ + "FROM debian:bookworm-slim", + "ARG OPENVPN_VERSION=" + openVPNDockerPackageVersion, + "RUN apt-get update \\", + " && apt-get install -y --no-install-recommends \\", + " bash \\", + " ca-certificates \\", + " grep \\", + " iproute2 \\", + " iputils-ping \\", + " openvpn=${OPENVPN_VERSION} \\", + " procps \\", + " python3 \\", + " tcpdump \\", + " && rm -rf /var/lib/apt/lists/*", + "RUN openvpn --version | head -n 1 | grep 'OpenVPN 2.6.14'", + "", + }, "\n") + var buffer bytes.Buffer + tarWriter := tar.NewWriter(&buffer) + header := &tar.Header{ + Name: "Dockerfile", + Mode: 0o644, + Size: int64(len(dockerfile)), + } + err := tarWriter.WriteHeader(header) + if err == nil { + _, err = tarWriter.Write([]byte(dockerfile)) + } + closeErr := tarWriter.Close() + if err != nil { + return err + } + if closeErr != nil { + return closeErr + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + buildOptions := typesapi.ImageBuildOptions{ + Tags: []string{openVPNDockerImage}, + Remove: true, + PullParent: true, + Platform: openVPNDockerPlatform(), + } + response, err := dockerClient.ImageBuild(ctx, bytes.NewReader(buffer.Bytes()), buildOptions) + if err != nil { + return err + } + defer response.Body.Close() + var output bytes.Buffer + err = jsonmessage.DisplayJSONMessagesStream(response.Body, &output, 0, false, nil) + if err != nil { + return E.Cause(err, "build OpenVPN docker image\n", output.String()) + } + return nil +} + +func verifyOpenVPNDockerImage(t *testing.T, dockerClient *client.Client) { + t.Helper() + containerConfig := &containerapi.Config{ + Image: openVPNDockerImage, + Cmd: []string{"openvpn", "--version"}, + } + createdContainer, err := dockerClient.ContainerCreate(context.Background(), containerConfig, &containerapi.HostConfig{}, nil, openVPNDockerOCIPlatform(), "") + require.NoError(t, err) + t.Cleanup(func() { + _ = removeOpenVPNDockerContainer(context.Background(), dockerClient, createdContainer.ID) + }) + err = dockerClient.ContainerStart(context.Background(), createdContainer.ID, containerapi.StartOptions{}) + require.NoError(t, err) + containerHandle := &openVPNDockerContainer{ + dockerClient: dockerClient, + containerID: createdContainer.ID, + name: "openvpn-version", + } + waitResult := containerHandle.Wait(t, 30*time.Second) + require.Equal(t, int64(0), waitResult.exitCode, waitResult.logs) + firstLine, _, _ := strings.Cut(waitResult.logs, "\n") + require.Contains(t, firstLine, "OpenVPN 2.6.14") +} + +func startOpenVPNDockerContainer(t *testing.T, dockerClient *client.Client, nameSuffix string, workspace string, command string) *openVPNDockerContainer { + t.Helper() + name := "sing-box-openvpn-" + nameSuffix + "-" + sanitizeOpenVPNDockerName(t.Name()) + containerConfig := &containerapi.Config{ + Image: openVPNDockerImage, + Cmd: []string{"bash", "-lc", command}, + } + hostConfig := &containerapi.HostConfig{ + NetworkMode: containerapi.NetworkMode("host"), + Binds: []string{workspace + ":" + openVPNDockerRoot}, + CapAdd: []string{"NET_ADMIN"}, + Resources: containerapi.Resources{ + Devices: []containerapi.DeviceMapping{ + { + PathOnHost: "/dev/net/tun", + PathInContainer: "/dev/net/tun", + CgroupPermissions: "rwm", + }, + }, + }, + } + createdContainer, err := dockerClient.ContainerCreate(context.Background(), containerConfig, hostConfig, nil, openVPNDockerOCIPlatform(), name) + require.NoError(t, err) + containerHandle := &openVPNDockerContainer{ + dockerClient: dockerClient, + containerID: createdContainer.ID, + name: name, + } + t.Cleanup(func() { + _ = removeOpenVPNDockerContainer(context.Background(), dockerClient, createdContainer.ID) + }) + err = dockerClient.ContainerStart(context.Background(), createdContainer.ID, containerapi.StartOptions{}) + require.NoError(t, err) + return containerHandle +} + +func waitForOpenVPNDockerFile(t *testing.T, containerHandle *openVPNDockerContainer, path string, content string, timeout time.Duration) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + fileContent, err := os.ReadFile(path) + if err == nil && strings.Contains(string(fileContent), content) { + return + } + containerHandle.failIfExited(t) + time.Sleep(100 * time.Millisecond) + } + fileContent, _ := os.ReadFile(path) + t.Fatalf("timed out waiting for %q in %s\nfile:\n%s\ncontainer logs:\n%s", content, path, string(fileContent), containerHandle.Logs(context.Background())) +} + +func dumpOpenVPNDockerLogsOnFailure(t *testing.T, containerHandle *openVPNDockerContainer, workspace string) { + t.Helper() + t.Cleanup(func() { + if !t.Failed() { + return + } + t.Logf("container logs for %s:\n%s", containerHandle.name, containerHandle.Logs(context.Background())) + openVPNLog, err := os.ReadFile(filepath.Join(workspace, "openvpn.log")) + if err == nil { + t.Logf("openvpn log for %s:\n%s", containerHandle.name, string(openVPNLog)) + } + }) +} + +func (c *openVPNDockerContainer) failIfExited(t *testing.T) { + t.Helper() + containerInfo, err := c.dockerClient.ContainerInspect(context.Background(), c.containerID) + require.NoError(t, err) + if containerInfo.State != nil && !containerInfo.State.Running { + t.Fatalf("docker container %s exited with code %d\nlogs:\n%s", c.name, containerInfo.State.ExitCode, c.Logs(context.Background())) + } +} + +func (c *openVPNDockerContainer) Wait(t *testing.T, timeout time.Duration) openVPNDockerWaitResult { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + statusChannel, errChannel := c.dockerClient.ContainerWait(ctx, c.containerID, containerapi.WaitConditionNotRunning) + select { + case waitErr := <-errChannel: + if waitErr != nil { + t.Fatalf("wait docker container %s: %v\nlogs:\n%s", c.name, waitErr, c.Logs(context.Background())) + } + case status := <-statusChannel: + return openVPNDockerWaitResult{ + exitCode: status.StatusCode, + logs: c.Logs(context.Background()), + } + case <-ctx.Done(): + t.Fatalf("docker container %s timed out\nlogs:\n%s", c.name, c.Logs(context.Background())) + } + return openVPNDockerWaitResult{} +} + +func (c *openVPNDockerContainer) Logs(ctx context.Context) string { + logReader, err := c.dockerClient.ContainerLogs(ctx, c.containerID, containerapi.LogsOptions{ + ShowStdout: true, + ShowStderr: true, + }) + if err != nil { + return "read logs: " + err.Error() + } + defer logReader.Close() + var stdout bytes.Buffer + var stderr bytes.Buffer + _, err = stdcopy.StdCopy(&stdout, &stderr, logReader) + if err != nil { + return "decode logs: " + err.Error() + } + if stderr.Len() == 0 { + return stdout.String() + } + if stdout.Len() == 0 { + return stderr.String() + } + return stdout.String() + "\nSTDERR:\n" + stderr.String() +} + +func removeOpenVPNDockerContainer(ctx context.Context, dockerClient *client.Client, containerID string) error { + err := dockerClient.ContainerRemove(ctx, containerID, containerapi.RemoveOptions{ + Force: true, + RemoveVolumes: true, + }) + if err != nil && !errdefs.IsNotFound(err) { + return err + } + return nil +} + +func openVPNDockerPlatform() string { + switch runtime.GOARCH { + case "arm64": + return "linux/arm64" + case "amd64": + return "linux/amd64" + default: + return "" + } +} + +func openVPNDockerOCIPlatform() *ocispec.Platform { + switch runtime.GOARCH { + case "arm64", "amd64": + return &ocispec.Platform{ + OS: "linux", + Architecture: runtime.GOARCH, + } + default: + return nil + } +} + +func sanitizeOpenVPNDockerName(name string) string { + replacer := strings.NewReplacer("/", "-", "_", "-", " ", "-") + return replacer.Replace(name) +} + +const openVPNDockerEchoServerScript = `#!/usr/bin/env python3 +import os +import socket +import threading +import time + +HOST = "{{HOST}}" +PORT = {{PORT}} +READY = "/config/echo.ready" + + +def bind_with_retry(sock, address): + last_error = None + for _ in range(300): + try: + sock.bind(address) + return + except OSError as error: + last_error = error + time.sleep(0.1) + raise last_error + + +def handle_tcp(conn): + with conn: + while True: + data = conn.recv(65536) + if not data: + return + conn.sendall(data) + + +def tcp_server(): + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + bind_with_retry(sock, (HOST, PORT)) + sock.listen(64) + print("tcp echo ready", flush=True) + while True: + conn, _ = sock.accept() + threading.Thread(target=handle_tcp, args=(conn,), daemon=True).start() + + +def udp_server(): + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + bind_with_retry(sock, (HOST, PORT)) + print("udp echo ready", flush=True) + while True: + data, address = sock.recvfrom(65536) + sock.sendto(data, address) + + +threading.Thread(target=tcp_server, daemon=True).start() +threading.Thread(target=udp_server, daemon=True).start() +time.sleep(0.2) +with open(READY, "w", encoding="utf-8") as ready_file: + ready_file.write("ready\n") +while True: + time.sleep(3600) +` + +const openVPNDockerEchoClientScript = `#!/usr/bin/env python3 +import os +import socket + +HOST = "{{HOST}}" +PORT = {{PORT}} + + +def check_tcp(): + sock = socket.create_connection((HOST, PORT), timeout=10) + sock.settimeout(10) + with sock: + payload = b"ping" + sock.sendall(payload) + response = sock.recv(len(payload)) + if response != payload: + raise RuntimeError("unexpected tcp ping response") + for index in range(100): + payload = bytes([index]) + os.urandom(64 * 1024 - 1) + sock.sendall(payload) + response = bytearray() + while len(response) < len(payload): + chunk = sock.recv(len(payload) - len(response)) + if not chunk: + raise RuntimeError("tcp echo closed") + response.extend(chunk) + if bytes(response) != payload: + raise RuntimeError("unexpected tcp large response") + + +def check_udp(): + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.settimeout(10) + sock.connect((HOST, PORT)) + with sock: + payload = b"ping" + sock.send(payload) + response = sock.recv(65536) + if response != payload: + raise RuntimeError("unexpected udp ping response") + for index in range(50): + payload = bytes([index]) + os.urandom(1499) + sock.send(payload) + response = sock.recv(65536) + if response != payload: + raise RuntimeError("unexpected udp large response") + + +check_tcp() +check_udp() +print("echo client ok", flush=True) +` + +func testSuitOpenVPN(t *testing.T, proxyPort uint16, echoPort uint16, tunnelAddress string) { + t.Helper() + dialer := socks.NewClient(N.SystemDialer, M.ParseSocksaddrHostPort("127.0.0.1", proxyPort), socks.Version5, "", "") + destination := M.ParseSocksaddrHostPort(tunnelAddress, echoPort) + dialTCP := func() (net.Conn, error) { + return dialer.DialContext(context.Background(), N.NetworkTCP, destination) + } + dialUDP := func() (net.PacketConn, error) { + conn, err := dialer.DialContext(context.Background(), N.NetworkUDP, destination) + if err != nil { + return nil, err + } + return bufio.NewUnbindPacketConn(conn), nil + } + require.NoError(t, testOpenVPNEchoWithConn(echoPort, dialTCP)) + require.NoError(t, testOpenVPNEchoWithPacketConn(echoPort, dialUDP)) + require.NoError(t, testOpenVPNLargeDataWithConn(echoPort, dialTCP)) +} + +func testOpenVPNEchoWithConn(port uint16, dialTCP func() (net.Conn, error)) error { + listener, err := listen("tcp", ":"+strconv.Itoa(int(port))) + if err != nil { + return err + } + defer listener.Close() + serverErrCh := make(chan error, 1) + go func() { + serverConn, acceptErr := listener.Accept() + if acceptErr != nil { + serverErrCh <- acceptErr + return + } + defer serverConn.Close() + deadlineErr := serverConn.SetDeadline(time.Now().Add(openVPNLargeDataTimeout)) + if deadlineErr != nil { + serverErrCh <- deadlineErr + return + } + buffer := make([]byte, 4) + _, readErr := io.ReadFull(serverConn, buffer) + if readErr != nil { + serverErrCh <- readErr + return + } + _, writeErr := serverConn.Write(buffer) + if writeErr != nil { + serverErrCh <- writeErr + return + } + serverErrCh <- nil + }() + conn, err := dialTCP() + if err != nil { + return err + } + defer conn.Close() + err = conn.SetDeadline(time.Now().Add(openVPNLargeDataTimeout)) + if err != nil { + return err + } + err = writeAndReadEcho(conn, []byte("ping")) + if err != nil { + return err + } + return <-serverErrCh +} + +func testOpenVPNEchoWithPacketConn(port uint16, listenUDP func() (net.PacketConn, error)) error { + listener, err := listenPacket("udp", ":"+strconv.Itoa(int(port))) + if err != nil { + return err + } + defer listener.Close() + serverErrCh := make(chan error, 1) + go func() { + buffer := make([]byte, 1024) + readCount, address, readErr := listener.ReadFrom(buffer) + if readErr != nil { + serverErrCh <- readErr + return + } + _, writeErr := listener.WriteTo(buffer[:readCount], address) + if writeErr != nil { + serverErrCh <- writeErr + return + } + serverErrCh <- nil + }() + packetConn, err := listenUDP() + if err != nil { + return err + } + defer packetConn.Close() + remoteAddress := &net.UDPAddr{IP: localIP.AsSlice(), Port: int(port)} + payload := []byte("ping") + _, err = packetConn.WriteTo(payload, remoteAddress) + if err != nil { + return err + } + response := make([]byte, 1024) + readCount, err := readOpenVPNPacketWithTimeout(packetConn, response) + if err != nil { + return err + } + if !bytes.Equal(response[:readCount], payload) { + return E.New("unexpected UDP echo response") + } + return <-serverErrCh +} + +func testOpenVPNLargeDataWithConn(port uint16, dialTCP func() (net.Conn, error)) error { + listener, err := listen("tcp", ":"+strconv.Itoa(int(port))) + if err != nil { + return err + } + defer listener.Close() + serverErrCh := make(chan error, 1) + go func() { + serverConn, acceptErr := listener.Accept() + if acceptErr != nil { + serverErrCh <- acceptErr + return + } + defer serverConn.Close() + deadlineErr := serverConn.SetDeadline(time.Now().Add(openVPNLargeDataTimeout)) + if deadlineErr != nil { + serverErrCh <- deadlineErr + return + } + buffer := make([]byte, openVPNLargeTCPSize) + for range openVPNLargeTCPPackets { + _, readErr := io.ReadFull(serverConn, buffer) + if readErr != nil { + serverErrCh <- readErr + return + } + _, writeErr := serverConn.Write(buffer) + if writeErr != nil { + serverErrCh <- writeErr + return + } + } + serverErrCh <- nil + }() + conn, err := dialTCP() + if err != nil { + return err + } + defer conn.Close() + err = conn.SetDeadline(time.Now().Add(openVPNLargeDataTimeout)) + if err != nil { + return err + } + response := make([]byte, openVPNLargeTCPSize) + for index := range openVPNLargeTCPPackets { + payload := make([]byte, openVPNLargeTCPSize) + payload[0] = byte(index) + _, err = rand.Read(payload[1:]) + if err != nil { + return err + } + _, err = conn.Write(payload) + if err != nil { + return err + } + _, err = io.ReadFull(conn, response) + if err != nil { + return err + } + if !bytes.Equal(response, payload) { + return E.New("unexpected tcp large response") + } + } + return <-serverErrCh +} + +type openVPNPacketReadResult struct { + readCount int + err error +} + +func readOpenVPNPacketWithTimeout(packetConn net.PacketConn, buffer []byte) (int, error) { + resultCh := make(chan openVPNPacketReadResult, 1) + go func() { + readCount, _, readErr := packetConn.ReadFrom(buffer) + resultCh <- openVPNPacketReadResult{ + readCount: readCount, + err: readErr, + } + }() + select { + case result := <-resultCh: + return result.readCount, result.err + case <-time.After(openVPNLargeDataTimeout): + return 0, E.New("timeout") + } +} + +func createOpenVPNCertificateBundle(t *testing.T) openVPNCertificateBundle { + return createOpenVPNCertificateBundleWithServerKeyUsage(t, x509.KeyUsageKeyEncipherment|x509.KeyUsageDigitalSignature) +} + +func createOpenVPNCertificateBundleWithServerKeyUsage(t *testing.T, serverKeyUsage x509.KeyUsage) openVPNCertificateBundle { + t.Helper() + tempDir := t.TempDir() + caKey, err := rsa.GenerateKey(rand.Reader, 3072) + require.NoError(t, err) + spkiASN1, err := x509.MarshalPKIXPublicKey(caKey.Public()) + require.NoError(t, err) + var spki struct { + Algorithm pkix.AlgorithmIdentifier + SubjectPublicKey asn1.BitString + } + _, err = asn1.Unmarshal(spkiASN1, &spki) + require.NoError(t, err) + subjectKeyID := sha1.Sum(spki.SubjectPublicKey.Bytes) + caTemplate := &x509.Certificate{ + SerialNumber: randomSerialNumber(t), + Subject: pkix.Name{ + Organization: []string{"sing-box OpenVPN test CA"}, + CommonName: "sing-box OpenVPN test CA", + }, + SubjectKeyId: subjectKeyID[:], + NotAfter: time.Now().AddDate(1, 0, 0), + NotBefore: time.Now().Add(-time.Minute), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + MaxPathLenZero: true, + } + caCertificate, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, caKey.Public(), caKey) + require.NoError(t, err) + caPath := filepath.Join(tempDir, "ca.crt") + writePEMFile(t, caPath, "CERTIFICATE", caCertificate) + serverCertPath, serverKeyPath := createOpenVPNLeafCertificate(t, tempDir, "server", serverKeyUsage, x509.ExtKeyUsageServerAuth, caTemplate, caKey) + clientCertPath, clientKeyPath := createOpenVPNLeafCertificate(t, tempDir, "client", x509.KeyUsageKeyEncipherment|x509.KeyUsageDigitalSignature, x509.ExtKeyUsageClientAuth, caTemplate, caKey) + return openVPNCertificateBundle{ + caPath: caPath, + serverCertPath: serverCertPath, + serverKeyPath: serverKeyPath, + clientCertPath: clientCertPath, + clientKeyPath: clientKeyPath, + } +} + +func createOpenVPNLeafCertificate(t *testing.T, tempDir string, commonName string, keyUsage x509.KeyUsage, extendedKeyUsage x509.ExtKeyUsage, caTemplate *x509.Certificate, caKey *rsa.PrivateKey) (string, string) { + t.Helper() + leafKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + leafTemplate := &x509.Certificate{ + SerialNumber: randomSerialNumber(t), + Subject: pkix.Name{ + Organization: []string{"sing-box OpenVPN test"}, + CommonName: commonName, + }, + NotBefore: time.Now().Add(-time.Minute), + NotAfter: time.Now().AddDate(0, 1, 0), + KeyUsage: keyUsage, + ExtKeyUsage: []x509.ExtKeyUsage{ + extendedKeyUsage, + }, + } + if extendedKeyUsage == x509.ExtKeyUsageServerAuth { + leafTemplate.IPAddresses = append(leafTemplate.IPAddresses, net.ParseIP("127.0.0.1")) + leafTemplate.DNSNames = append(leafTemplate.DNSNames, "localhost") + } + leafCertificate, err := x509.CreateCertificate(rand.Reader, leafTemplate, caTemplate, leafKey.Public(), caKey) + require.NoError(t, err) + certPath := filepath.Join(tempDir, commonName+".crt") + keyPath := filepath.Join(tempDir, commonName+".key") + writePEMFile(t, certPath, "CERTIFICATE", leafCertificate) + privateKey, err := x509.MarshalPKCS8PrivateKey(leafKey) + require.NoError(t, err) + writePEMFile(t, keyPath, "PRIVATE KEY", privateKey) + return certPath, keyPath +} + +func writePEMFile(t *testing.T, path string, blockType string, bytes []byte) { + t.Helper() + content := pem.EncodeToMemory(&pem.Block{ + Type: blockType, + Bytes: bytes, + }) + err := os.WriteFile(path, content, 0o600) + require.NoError(t, err) +} + +func createOpenVPNStaticKey(t *testing.T) string { + t.Helper() + keyMaterial := make([]byte, 256) + _, err := rand.Read(keyMaterial) + require.NoError(t, err) + hexKey := hex.EncodeToString(keyMaterial) + lines := []string{"-----BEGIN OpenVPN Static key V1-----"} + for index := 0; index < len(hexKey); index += 32 { + lines = append(lines, hexKey[index:index+32]) + } + lines = append(lines, "-----END OpenVPN Static key V1-----", "") + return strings.Join(lines, "\n") +} + +func newOpenVPNDataPathUDPPacket(source netip.AddrPort, destination netip.AddrPort, payload []byte) []byte { + packet := make([]byte, header.IPv4MinimumSize+header.UDPMinimumSize+len(payload)) + ipHeader := header.IPv4(packet) + ipHeader.Encode(&header.IPv4Fields{ + TotalLength: uint16(len(packet)), + TTL: 64, + Protocol: uint8(header.UDPProtocolNumber), + SrcAddr: source.Addr(), + DstAddr: destination.Addr(), + }) + ipHeader.SetChecksum(^ipHeader.CalculateChecksum()) + udpHeader := header.UDP(packet[header.IPv4MinimumSize:]) + udpHeader.Encode(&header.UDPFields{ + SrcPort: source.Port(), + DstPort: destination.Port(), + Length: uint16(header.UDPMinimumSize + len(payload)), + }) + copy(udpHeader.Payload(), payload) + return packet +} + +func reserveOpenVPNProtocolPort(t *testing.T, protocol string) uint16 { + t.Helper() + if protocol == N.NetworkTCP { + return reserveOpenVPNTCPPort(t) + } + return reserveOpenVPNUDPPort(t) +} + +func reserveOpenVPNTCPPort(t *testing.T) uint16 { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer listener.Close() + tcpAddress := listener.Addr().(*net.TCPAddr) + return uint16(tcpAddress.Port) +} + +func reserveOpenVPNUDPPort(t *testing.T) uint16 { + t.Helper() + listener, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + defer listener.Close() + udpAddress := listener.LocalAddr().(*net.UDPAddr) + return uint16(udpAddress.Port) +} + +func reserveOpenVPNEchoPort(t *testing.T) uint16 { + t.Helper() + for i := 0; i < 20; i++ { + tcpListener, err := net.Listen("tcp", ":0") + require.NoError(t, err) + tcpAddress := tcpListener.Addr().(*net.TCPAddr) + port := uint16(tcpAddress.Port) + udpListener, err := net.ListenPacket("udp", ":"+strconv.Itoa(int(port))) + if err == nil { + udpListener.Close() + tcpListener.Close() + return port + } + tcpListener.Close() + } + t.Fatal("reserve TCP and UDP echo port") + return 0 +} diff --git a/test/testdata/openconnect/ocserv/Dockerfile b/test/testdata/openconnect/ocserv/Dockerfile new file mode 100644 index 0000000000..c10d965cec --- /dev/null +++ b/test/testdata/openconnect/ocserv/Dockerfile @@ -0,0 +1,11 @@ +FROM debian:trixie-slim@sha256:cedb1ef40439206b673ee8b33a46a03a0c9fa90bf3732f54704f99cb061d2c5a + +ARG OCSERV_VERSION=1.3.0-2 + +RUN apt-get update \ + && apt-get install --yes --no-install-recommends ocserv="${OCSERV_VERSION}" iproute2 python3 \ + && rm -rf /var/lib/apt/lists/* + +COPY echo_server.py /usr/local/bin/openconnect-echo-server + +EXPOSE 443/tcp 443/udp diff --git a/test/testdata/openconnect/ocserv/echo_server.py b/test/testdata/openconnect/ocserv/echo_server.py new file mode 100644 index 0000000000..beb934e293 --- /dev/null +++ b/test/testdata/openconnect/ocserv/echo_server.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +import socket +import threading + + +def echo(connection): + with connection: + while True: + data = connection.recv(65536) + if not data: + return + connection.sendall(data) + + +def echo_udp(connection): + while True: + data, address = connection.recvfrom(65536) + connection.sendto(data, address) + + +udp_listener = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) +udp_listener.bind(("0.0.0.0", 18080)) +threading.Thread(target=echo_udp, args=(udp_listener,), daemon=True).start() + +tcp_listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +tcp_listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +tcp_listener.bind(("0.0.0.0", 18080)) +tcp_listener.listen() +print("openconnect echo ready", flush=True) +while True: + accepted, _ = tcp_listener.accept() + threading.Thread(target=echo, args=(accepted,), daemon=True).start() diff --git a/test/tfo_test.go b/test/tfo_test.go index d74dbfb66c..c920983c7a 100644 --- a/test/tfo_test.go +++ b/test/tfo_test.go @@ -52,7 +52,9 @@ func TestTCPSlowOpen(t *testing.T) { ServerPort: serverPort, }, DialerOptions: option.DialerOptions{ - TCPFastOpen: true, + AbstractDialerOptions: option.AbstractDialerOptions{ + TCPFastOpen: true, + }, }, Method: method, Password: password, diff --git a/test/trusttunnel_quic_test.go b/test/trusttunnel_quic_test.go new file mode 100644 index 0000000000..ca06dd4cdc --- /dev/null +++ b/test/trusttunnel_quic_test.go @@ -0,0 +1,104 @@ +//go:build with_quic + +package main + +import ( + "net/netip" + "testing" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/auth" + "github.com/sagernet/sing/common/json/badoption" + "github.com/sagernet/sing/common/network" +) + +func TestTrustTunnelQUICSelf(t *testing.T) { + caPem, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Tag: "mixed-in", + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + { + Type: C.TypeTrustTunnel, + Tag: "trusttunnel-in", + Options: &option.TrustTunnelInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Users: []auth.User{ + { + Username: "sekai", + Password: "password", + }, + }, + Network: network.NetworkUDP, + InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ + TLS: &option.InboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + ALPN: []string{"h3"}, + CertificatePath: certPem, + KeyPath: keyPem, + }, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + { + Type: C.TypeTrustTunnel, + Tag: "trusttunnel-out", + Options: &option.TrustTunnelOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + Username: "sekai", + Password: "password", + QUIC: true, + OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{ + TLS: &option.OutboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + ALPN: []string{"h3"}, + CertificatePath: caPem, + }, + }, + }, + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + Inbound: []string{"mixed-in"}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.RouteActionOptions{ + Outbound: "trusttunnel-out", + }, + }, + }, + }, + }, + }, + }) + testSuit(t, clientPort, testPort) +} diff --git a/test/trusttunnel_test.go b/test/trusttunnel_test.go new file mode 100644 index 0000000000..8951221006 --- /dev/null +++ b/test/trusttunnel_test.go @@ -0,0 +1,101 @@ +package main + +import ( + "net/netip" + "testing" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/auth" + "github.com/sagernet/sing/common/json/badoption" + "github.com/sagernet/sing/common/network" +) + +func TestTrustTunnelSelf(t *testing.T) { + _, certPem, keyPem := createSelfSignedCertificate(t, "example.org") + startInstance(t, option.Options{ + Inbounds: []option.Inbound{ + { + Type: C.TypeMixed, + Tag: "mixed-in", + Options: &option.HTTPMixedInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: clientPort, + }, + }, + }, + { + Type: C.TypeTrustTunnel, + Tag: "trusttunnel-in", + Options: &option.TrustTunnelInboundOptions{ + ListenOptions: option.ListenOptions{ + Listen: common.Ptr(badoption.Addr(netip.IPv4Unspecified())), + ListenPort: serverPort, + }, + Users: []auth.User{ + { + Username: "sekai", + Password: "password", + }, + }, + Network: network.NetworkTCP, + InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{ + TLS: &option.InboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + ALPN: []string{"h2"}, + CertificatePath: certPem, + KeyPath: keyPem, + }, + }, + }, + }, + }, + Outbounds: []option.Outbound{ + { + Type: C.TypeDirect, + }, + { + Type: C.TypeTrustTunnel, + Tag: "trusttunnel-out", + Options: &option.TrustTunnelOutboundOptions{ + ServerOptions: option.ServerOptions{ + Server: "127.0.0.1", + ServerPort: serverPort, + }, + Username: "sekai", + Password: "password", + OutboundTLSOptionsContainer: option.OutboundTLSOptionsContainer{ + TLS: &option.OutboundTLSOptions{ + Enabled: true, + ServerName: "example.org", + ALPN: []string{"h2"}, + CertificatePath: certPem, + }, + }, + }, + }, + }, + Route: &option.RouteOptions{ + Rules: []option.Rule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RawDefaultRule: option.RawDefaultRule{ + Inbound: []string{"mixed-in"}, + }, + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.RouteActionOptions{ + Outbound: "trusttunnel-out", + }, + }, + }, + }, + }, + }, + }) + testSuit(t, clientPort, testPort) +} diff --git a/transport/awg/tun_system.go b/transport/awg/tun_system.go index c924d91696..671e721103 100644 --- a/transport/awg/tun_system.go +++ b/transport/awg/tun_system.go @@ -34,7 +34,9 @@ func newSystemTun(ctx context.Context, address []netip.Prefix, allowedIps []neti events := make(chan awgTun.Event) dial, err := dialer.NewDefault(ctx, option.DialerOptions{ - BindInterface: name, + AbstractDialerOptions: option.AbstractDialerOptions{ + BindInterface: name, + }, }) if err != nil { return nil, exceptions.Cause(err, "get in-tunnel dialer") diff --git a/transport/openconnect/device.go b/transport/openconnect/device.go new file mode 100644 index 0000000000..7a2b18fe69 --- /dev/null +++ b/transport/openconnect/device.go @@ -0,0 +1,206 @@ +package openconnect + +import ( + "context" + "net/netip" + "sync/atomic" + "time" + + "github.com/sagernet/sing-openconnect" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common/buf" + "github.com/sagernet/sing/common/control" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + N "github.com/sagernet/sing/common/network" +) + +const ( + DefaultMTU = 1500 + PacketHeadroom = openconnect.PacketHeadroom +) + +type PacketWriter func(packetBuffers []*buf.Buffer) error + +type Device interface { + N.Dialer + Start() error + UpdateConfiguration(configuration Configuration) error + WriteInboundBuffers(packetBuffers []*buf.Buffer) error + SetPacketWriter(writer PacketWriter) + PortAddresses() (netip.Addr, netip.Addr) + PortMTU() uint32 + AttachReturn(returnPath tun.Return) error + DetachReturn(returnPath tun.Return) error + ReturnPath() (tun.Return, int) + Close() error +} + +type DeviceOptions struct { + Context context.Context + Logger logger.ContextLogger + System bool + Handler tun.Handler + UDPTimeout time.Duration + ICMPTimeout time.Duration + UDPMapping tun.NATMapping + UDPFiltering tun.NATFiltering + UDPNATMax uint32 + InterfaceFinder control.InterfaceFinder + ExcludeInterface []string + Name string + MTU uint32 + Configuration Configuration +} + +type Configuration struct { + MTU uint32 + Addresses []netip.Prefix + Routes []Route + ExcludedRoutes []Route + DNS []netip.Addr + NBNS []netip.Addr + SearchDomains []string + SplitDNS []string + SplitDNSRules []SplitDNSRule + ProxyAutoConfigURL string + Banner string + TunnelAllDNS bool + ClientBypassProtocol bool + IdleTimeout time.Duration + AuthenticationExpiration time.Time +} + +type Route struct { + Prefix netip.Prefix + Gateway netip.Addr + Metric int +} + +type SplitDNSRule struct { + Domains []string + Servers []netip.Addr +} + +func NewDevice(options DeviceOptions) (Device, error) { + if !options.System { + return newStackDevice(options) + } + if !tun.WithGVisor { + return newSystemDevice(options) + } + return newSystemStackDevice(options) +} + +type baseDevice struct { + packetWriter PacketWriter + returnState atomic.Pointer[returnPathState] +} + +func (d *baseDevice) SetPacketWriter(writer PacketWriter) { + d.packetWriter = writer +} + +func (d *baseDevice) writeOutbound(packetBuffers []*buf.Buffer) error { + if d.packetWriter == nil { + buf.ReleaseMulti(packetBuffers) + return E.New("missing packet writer") + } + return d.packetWriter(packetBuffers) +} + +func (d *baseDevice) processInboundBuffers(packetBuffers []*buf.Buffer, writeBuffers func(packetBuffers []*buf.Buffer) error) error { + if len(packetBuffers) == 0 { + return nil + } + state := d.returnState.Load() + if state == nil { + return writeBuffers(packetBuffers) + } + packets := make([][]byte, len(packetBuffers)) + for i, packetBuffer := range packetBuffers { + packetBuffer.ExtendHeader(state.headroom) + packets[i] = packetBuffer.Bytes() + } + unconsumed := state.returnPath.ReturnPackets(packets) + if len(unconsumed) == 0 { + return nil + } + unconsumedBuffers := make([]*buf.Buffer, len(unconsumed)) + for i, packet := range unconsumed { + packetBuffer := buf.As(packet) + packetBuffer.Advance(state.headroom) + unconsumedBuffers[i] = packetBuffer + } + return writeBuffers(unconsumedBuffers) +} + +func (d *baseDevice) AttachReturn(returnPath tun.Return) error { + headroom := returnPath.ReturnHeadroom() + if headroom > PacketHeadroom { + return E.New("return path headroom ", headroom, " exceeds available ", PacketHeadroom) + } + newState := &returnPathState{ + returnPath: returnPath, + headroom: headroom, + } + for { + currentState := d.returnState.Load() + if currentState != nil { + if currentState.returnPath == returnPath { + return nil + } + return E.New("return path already attached") + } + if d.returnState.CompareAndSwap(nil, newState) { + return nil + } + } +} + +func (d *baseDevice) DetachReturn(returnPath tun.Return) error { + currentState := d.returnState.Load() + if currentState != nil && currentState.returnPath == returnPath { + d.returnState.CompareAndSwap(currentState, nil) + } + return nil +} + +func (d *baseDevice) ReturnPath() (tun.Return, int) { + state := d.returnState.Load() + if state == nil { + return nil, 0 + } + return state.returnPath, state.headroom +} + +type returnPathState struct { + returnPath tun.Return + headroom int +} + +func firstAddresses(addresses []netip.Prefix) (netip.Addr, netip.Addr) { + var inet4Address netip.Addr + var inet6Address netip.Addr + for _, prefix := range addresses { + if prefix.Addr().Is4() && !inet4Address.IsValid() { + inet4Address = prefix.Addr() + } else if prefix.Addr().Is6() && !inet6Address.IsValid() { + inet6Address = prefix.Addr() + } + } + return inet4Address, inet6Address +} + +func splitPrefixes(prefixes []netip.Prefix) ([]netip.Prefix, []netip.Prefix) { + var inet4Prefixes []netip.Prefix + var inet6Prefixes []netip.Prefix + for _, prefix := range prefixes { + if prefix.Addr().Is4() { + inet4Prefixes = append(inet4Prefixes, prefix) + } else { + inet6Prefixes = append(inet6Prefixes, prefix) + } + } + return inet4Prefixes, inet6Prefixes +} diff --git a/transport/openconnect/device_stack.go b/transport/openconnect/device_stack.go new file mode 100644 index 0000000000..3df15ebc6f --- /dev/null +++ b/transport/openconnect/device_stack.go @@ -0,0 +1,359 @@ +//go:build with_gvisor + +package openconnect + +import ( + "context" + "net" + "net/netip" + "slices" + "sync" + "sync/atomic" + + "github.com/sagernet/gvisor/pkg/buffer" + "github.com/sagernet/gvisor/pkg/tcpip" + "github.com/sagernet/gvisor/pkg/tcpip/adapters/gonet" + "github.com/sagernet/gvisor/pkg/tcpip/header" + "github.com/sagernet/gvisor/pkg/tcpip/network/ipv4" + "github.com/sagernet/gvisor/pkg/tcpip/network/ipv6" + "github.com/sagernet/gvisor/pkg/tcpip/stack" + "github.com/sagernet/gvisor/pkg/tcpip/transport/icmp" + "github.com/sagernet/gvisor/pkg/tcpip/transport/tcp" + "github.com/sagernet/gvisor/pkg/tcpip/transport/udp" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +var _ Device = (*stackDevice)(nil) + +type stackDevice struct { + baseDevice + stateAccess sync.RWMutex + options DeviceOptions + stack *stack.Stack + endpoint *stackEndpoint + inet4Address netip.Addr + inet6Address netip.Addr + udpForwarder *tun.UDPForwarder + icmpForwarder *tun.ICMPForwarder + closeOnce sync.Once +} + +func newStackDevice(options DeviceOptions) (*stackDevice, error) { + if options.MTU == 0 { + options.MTU = DefaultMTU + } + device := &stackDevice{ + options: options, + } + endpoint := &stackEndpoint{ + device: device, + done: make(chan struct{}), + } + endpoint.mtu.Store(options.MTU) + ipStack, err := tun.NewGVisorStackWithOptions(endpoint, stack.NICOptions{}, true) + if err != nil { + return nil, err + } + device.stack = ipStack + device.endpoint = endpoint + err = device.updateAddresses(nil, options.Configuration.Addresses) + if err != nil { + ipStack.Close() + return nil, err + } + if options.Handler != nil { + ipStack.SetTransportProtocolHandler(tcp.ProtocolNumber, tun.NewTCPForwarder(options.Context, ipStack, options.Handler).HandlePacket) + udpForwarder := tun.NewUDPForwarder(options.Context, ipStack, options.Handler, tun.UDPNatOptions{ + Timeout: options.UDPTimeout, + Shared: true, + Mapping: options.UDPMapping, + Filtering: options.UDPFiltering, + MaxSize: options.UDPNATMax, + InterfaceFinder: options.InterfaceFinder, + ExcludeInterface: options.ExcludeInterface, + }) + ipStack.SetTransportProtocolHandler(udp.ProtocolNumber, udpForwarder.HandlePacket) + device.udpForwarder = udpForwarder + icmpForwarder := tun.NewICMPForwarder(ipStack, options.Handler, options.Logger) + ipStack.SetTransportProtocolHandler(icmp.ProtocolNumber4, icmpForwarder.HandlePacket) + ipStack.SetTransportProtocolHandler(icmp.ProtocolNumber6, icmpForwarder.HandlePacket) + device.icmpForwarder = icmpForwarder + } + return device, nil +} + +func (d *stackDevice) Start() error { + if d.udpForwarder != nil { + err := d.udpForwarder.Start() + if err != nil { + return err + } + } + return nil +} + +func (d *stackDevice) UpdateConfiguration(configuration Configuration) error { + d.stateAccess.Lock() + defer d.stateAccess.Unlock() + if configuration.MTU != 0 { + d.options.MTU = configuration.MTU + d.endpoint.mtu.Store(configuration.MTU) + } + previousAddresses := d.options.Configuration.Addresses + d.options.Configuration = configuration + return d.updateAddresses(previousAddresses, configuration.Addresses) +} + +func (d *stackDevice) updateAddresses(previousAddresses []netip.Prefix, addresses []netip.Prefix) error { + for _, prefix := range previousAddresses { + if slices.Contains(addresses, prefix) { + continue + } + gErr := d.stack.RemoveAddress(tun.DefaultNIC, tun.AddressFromAddr(prefix.Addr())) + if gErr != nil { + return E.New("remove local address ", prefix, ": ", gErr.String()) + } + } + for _, prefix := range addresses { + if slices.Contains(previousAddresses, prefix) { + continue + } + protocolAddress := tcpip.ProtocolAddress{ + AddressWithPrefix: tcpip.AddressWithPrefix{ + Address: tun.AddressFromAddr(prefix.Addr()), + PrefixLen: prefix.Bits(), + }, + } + if prefix.Addr().Is4() { + protocolAddress.Protocol = ipv4.ProtocolNumber + } else { + protocolAddress.Protocol = ipv6.ProtocolNumber + } + gErr := d.stack.AddProtocolAddress(tun.DefaultNIC, protocolAddress, stack.AddressProperties{}) + if gErr != nil { + return E.New("add local address ", prefix, ": ", gErr.String()) + } + } + d.inet4Address, d.inet6Address = firstAddresses(addresses) + return nil +} + +func (d *stackDevice) WriteInboundBuffers(packetBuffers []*buf.Buffer) error { + return d.processInboundBuffers(packetBuffers, d.writeBuffers) +} + +func (d *stackDevice) writeBuffers(packetBuffers []*buf.Buffer) error { + networkProtocols := make([]tcpip.NetworkProtocolNumber, 0, len(packetBuffers)) + stackPacketBuffers := make([]*stack.PacketBuffer, 0, len(packetBuffers)) + var packetErr error + for _, packetBuffer := range packetBuffers { + packet := packetBuffer.Bytes() + var networkProtocol tcpip.NetworkProtocolNumber + switch header.IPVersion(packet) { + case header.IPv4Version: + networkProtocol = header.IPv4ProtocolNumber + case header.IPv6Version: + networkProtocol = header.IPv6ProtocolNumber + default: + if packetErr == nil { + packetErr = E.New("invalid IP packet") + } + continue + } + networkProtocols = append(networkProtocols, networkProtocol) + stackPacketBuffers = append(stackPacketBuffers, stack.NewPacketBuffer(stack.PacketBufferOptions{ + Payload: buffer.MakeWithData(packet), + })) + } + d.endpoint.deliverNetworkPackets(networkProtocols, stackPacketBuffers) + for _, packetBuffer := range stackPacketBuffers { + packetBuffer.DecRef() + } + return packetErr +} + +func (d *stackDevice) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + inet4Address, inet6Address := d.PortAddresses() + address := tcpip.FullAddress{ + NIC: tun.DefaultNIC, + Port: destination.Port, + Addr: tun.AddressFromAddr(destination.Addr), + } + bind := tcpip.FullAddress{ + NIC: tun.DefaultNIC, + } + var networkProtocol tcpip.NetworkProtocolNumber + if destination.IsIPv4() { + if !inet4Address.IsValid() { + return nil, E.New("missing IPv4 local address") + } + networkProtocol = header.IPv4ProtocolNumber + bind.Addr = tun.AddressFromAddr(inet4Address) + } else { + if !inet6Address.IsValid() { + return nil, E.New("missing IPv6 local address") + } + networkProtocol = header.IPv6ProtocolNumber + bind.Addr = tun.AddressFromAddr(inet6Address) + } + switch N.NetworkName(network) { + case N.NetworkTCP: + return gonet.DialTCPWithBind(ctx, d.stack, bind, address, networkProtocol) + case N.NetworkUDP: + return gonet.DialUDP(d.stack, &bind, &address, networkProtocol) + default: + return nil, E.Extend(N.ErrUnknownNetwork, network) + } +} + +func (d *stackDevice) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + inet4Address, inet6Address := d.PortAddresses() + bind := tcpip.FullAddress{ + NIC: tun.DefaultNIC, + } + var networkProtocol tcpip.NetworkProtocolNumber + if destination.IsIPv4() { + if !inet4Address.IsValid() { + return nil, E.New("missing IPv4 local address") + } + networkProtocol = header.IPv4ProtocolNumber + bind.Addr = tun.AddressFromAddr(inet4Address) + } else { + if !inet6Address.IsValid() { + return nil, E.New("missing IPv6 local address") + } + networkProtocol = header.IPv6ProtocolNumber + bind.Addr = tun.AddressFromAddr(inet6Address) + } + return gonet.DialUDP(d.stack, &bind, nil, networkProtocol) +} + +func (d *stackDevice) PortAddresses() (netip.Addr, netip.Addr) { + d.stateAccess.RLock() + defer d.stateAccess.RUnlock() + return d.inet4Address, d.inet6Address +} + +func (d *stackDevice) PortMTU() uint32 { + d.stateAccess.RLock() + defer d.stateAccess.RUnlock() + return d.options.MTU +} + +func (d *stackDevice) Close() error { + d.closeOnce.Do(func() { + close(d.endpoint.done) + if d.udpForwarder != nil { + d.udpForwarder.Close() + } + if d.icmpForwarder != nil { + d.icmpForwarder.Close() + } + d.stack.Close() + for _, endpoint := range d.stack.CleanupEndpoints() { + endpoint.Abort() + } + d.stack.Wait() + }) + return nil +} + +type stackEndpoint struct { + device *stackDevice + mtu atomic.Uint32 + done chan struct{} + dispatcherAccess sync.RWMutex + dispatcher stack.NetworkDispatcher +} + +func (e *stackEndpoint) MTU() uint32 { + return e.mtu.Load() +} + +func (e *stackEndpoint) SetMTU(mtu uint32) { + e.mtu.Store(mtu) +} + +func (e *stackEndpoint) MaxHeaderLength() uint16 { + return 0 +} + +func (e *stackEndpoint) LinkAddress() tcpip.LinkAddress { + return "" +} + +func (e *stackEndpoint) SetLinkAddress(addr tcpip.LinkAddress) { +} + +func (e *stackEndpoint) Capabilities() stack.LinkEndpointCapabilities { + return stack.CapabilityRXChecksumOffload +} + +func (e *stackEndpoint) Attach(dispatcher stack.NetworkDispatcher) { + e.dispatcherAccess.Lock() + defer e.dispatcherAccess.Unlock() + e.dispatcher = dispatcher +} + +func (e *stackEndpoint) IsAttached() bool { + e.dispatcherAccess.RLock() + defer e.dispatcherAccess.RUnlock() + return e.dispatcher != nil +} + +func (e *stackEndpoint) deliverNetworkPackets(networkProtocols []tcpip.NetworkProtocolNumber, packetBuffers []*stack.PacketBuffer) { + e.dispatcherAccess.RLock() + defer e.dispatcherAccess.RUnlock() + if e.dispatcher == nil { + return + } + for i, packetBuffer := range packetBuffers { + e.dispatcher.DeliverNetworkPacket(networkProtocols[i], packetBuffer) + } +} + +func (e *stackEndpoint) Wait() { +} + +func (e *stackEndpoint) ARPHardwareType() header.ARPHardwareType { + return header.ARPHardwareNone +} + +func (e *stackEndpoint) AddHeader(packetBuffer *stack.PacketBuffer) { +} + +func (e *stackEndpoint) ParseHeader(packetBuffer *stack.PacketBuffer) bool { + return true +} + +func (e *stackEndpoint) WritePackets(list stack.PacketBufferList) (int, tcpip.Error) { + packetBuffers := make([]*buf.Buffer, 0, list.Len()) + for _, packetBuffer := range list.AsSlice() { + packetSlices := packetBuffer.AsSlices() + packetLength := 0 + for _, packetSlice := range packetSlices { + packetLength += len(packetSlice) + } + outboundBuffer := buf.NewSize(PacketHeadroom + packetLength + systemDevicePacketRearSpace) + outboundBuffer.Resize(PacketHeadroom, 0) + for _, packetSlice := range packetSlices { + _, _ = outboundBuffer.Write(packetSlice) + } + packetBuffers = append(packetBuffers, outboundBuffer) + } + err := e.device.writeOutbound(packetBuffers) + if err != nil { + return 0, &tcpip.ErrClosedForSend{} + } + return list.Len(), nil +} + +func (e *stackEndpoint) Close() { +} + +func (e *stackEndpoint) SetOnCloseAction(action func()) { +} diff --git a/transport/openconnect/device_stack_stub.go b/transport/openconnect/device_stack_stub.go new file mode 100644 index 0000000000..1a96524c34 --- /dev/null +++ b/transport/openconnect/device_stack_stub.go @@ -0,0 +1,13 @@ +//go:build !with_gvisor + +package openconnect + +import E "github.com/sagernet/sing/common/exceptions" + +func newStackDevice(options DeviceOptions) (Device, error) { + return nil, E.New("system:false requires the with_gvisor build tag") +} + +func newSystemStackDevice(options DeviceOptions) (Device, error) { + return nil, E.New("system stack requires the with_gvisor build tag") +} diff --git a/transport/openconnect/device_system.go b/transport/openconnect/device_system.go new file mode 100644 index 0000000000..0750dc7370 --- /dev/null +++ b/transport/openconnect/device_system.go @@ -0,0 +1,361 @@ +package openconnect + +import ( + "context" + "net" + "net/netip" + "slices" + "sync" + "syscall" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing-tun/gtcpip/header" + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/service" +) + +var _ Device = (*systemDevice)(nil) + +const ( + systemDeviceReadBufferSize = 65535 + tun.PacketOffset + systemDevicePacketRearSpace = 64 +) + +type systemDevice struct { + baseDevice + stateAccess sync.RWMutex + options DeviceOptions + dialer N.Dialer + device tun.Tun + inet4Address netip.Addr + inet6Address netip.Addr + closed bool +} + +func newSystemDevice(options DeviceOptions) (*systemDevice, error) { + if options.Name == "" { + options.Name = tun.CalculateInterfaceName("oc") + } + if options.MTU == 0 { + options.MTU = DefaultMTU + } + interfaceDialer, err := dialer.NewDefault(options.Context, option.DialerOptions{ + AbstractDialerOptions: option.AbstractDialerOptions{ + BindInterface: options.Name, + }, + }) + if err != nil { + return nil, err + } + inet4Address, inet6Address := firstAddresses(options.Configuration.Addresses) + return &systemDevice{ + options: options, + dialer: interfaceDialer, + inet4Address: inet4Address, + inet6Address: inet6Address, + }, nil +} + +func (d *systemDevice) Start() error { + d.stateAccess.Lock() + defer d.stateAccess.Unlock() + return d.startLocked() +} + +func (d *systemDevice) startLocked() error { + if d.closed { + return net.ErrClosed + } + if d.device != nil { + return nil + } + tunOptions := d.buildTunOptions() + tunInterface, err := tun.New(tunOptions) + if err != nil { + return err + } + err = tunInterface.Start() + if err != nil { + tunInterface.Close() + return err + } + d.device = tunInterface + d.options.Logger.Info("started at ", d.options.Name) + go d.readLoop(tunInterface, int(d.options.MTU)) + return nil +} + +func (d *systemDevice) buildTunOptions() tun.Options { + inet4Address, inet6Address := firstAddresses(d.options.Configuration.Addresses) + d.inet4Address = inet4Address + d.inet6Address = inet6Address + inet4Addresses, inet6Addresses := splitPrefixes(d.options.Configuration.Addresses) + networkManager := service.FromContext[adapter.NetworkManager](d.options.Context) + tunOptions := tun.Options{ + Name: d.options.Name, + Inet4Address: inet4Addresses, + Inet6Address: inet6Addresses, + MTU: d.options.MTU, + GSO: true, + InterfaceScope: true, + DNSMode: tun.DNSModeDisabled, + InterfaceMonitor: nil, + InterfaceFinder: nil, + Logger: d.options.Logger, + IPRoute2TableIndex: tun.DefaultIPRoute2TableIndex, + IPRoute2RuleIndex: tun.DefaultIPRoute2RuleIndex, + EXP_DisableDNSHijack: true, + } + if networkManager != nil { + tunOptions.InterfaceMonitor = networkManager.InterfaceMonitor() + tunOptions.InterfaceFinder = networkManager.InterfaceFinder() + } + return tunOptions +} + +func (d *systemDevice) readLoop(tunInterface tun.Tun, mtu int) { + linuxTUN, isLinuxTUN := tunInterface.(tun.LinuxTUN) + if isLinuxTUN && linuxTUN.BatchSize() > 1 { + d.readLoopLinux(linuxTUN, linuxTUN.BatchSize(), mtu) + return + } + darwinTUN, isDarwinTUN := tunInterface.(tun.DarwinTUN) + if isDarwinTUN { + d.readLoopDarwin(darwinTUN) + return + } + packetBuffer := buf.NewSize(PacketHeadroom + systemDeviceReadBufferSize + systemDevicePacketRearSpace) + defer packetBuffer.Release() + for { + packetBuffer.Reset() + packetBuffer.Resize(PacketHeadroom, 0) + readN, err := tunInterface.Read(packetBuffer.FreeBytes()[:systemDeviceReadBufferSize]) + if err != nil { + if E.IsClosed(err) { + return + } + d.options.Logger.Error(E.Cause(err, "read packet")) + return + } + if readN <= tun.PacketOffset { + continue + } + packetBuffer.Truncate(readN) + packetBuffer.Advance(tun.PacketOffset) + packetBuffer.IncRef() + err = d.writeOutbound([]*buf.Buffer{packetBuffer}) + packetBuffer.DecRef() + if err != nil { + d.options.Logger.Error(E.Cause(err, "write packet")) + return + } + } +} + +func (d *systemDevice) readLoopLinux(tunInterface tun.LinuxTUN, batchSize int, mtu int) { + packetBuffers := make([]*buf.Buffer, batchSize) + readBuffers := make([][]byte, batchSize) + packetSizes := make([]int, batchSize) + for i := range packetBuffers { + packetBuffers[i] = buf.NewSize(PacketHeadroom + mtu + systemDevicePacketRearSpace) + } + defer buf.ReleaseMulti(packetBuffers) + for { + for i, packetBuffer := range packetBuffers { + packetBuffer.Reset() + packetBuffer.Resize(PacketHeadroom, 0) + readBuffers[i] = packetBuffer.FreeBytes()[:mtu] + } + packetCount, readErr := tunInterface.BatchRead(readBuffers, 0, packetSizes) + for i := range packetCount { + packetBuffers[i].Truncate(packetSizes[i]) + packetBuffers[i].IncRef() + } + if packetCount > 0 { + writeErr := d.writeOutbound(packetBuffers[:packetCount]) + for i := range packetCount { + packetBuffers[i].DecRef() + } + if writeErr != nil { + d.options.Logger.Error(E.Cause(writeErr, "write packet batch")) + return + } + } + if readErr != nil { + if E.IsClosed(readErr) { + return + } + d.options.Logger.Error(E.Cause(readErr, "batch read packet")) + return + } + } +} + +func (d *systemDevice) readLoopDarwin(tunInterface tun.DarwinTUN) { + for { + packetBuffers, readErr := tunInterface.BatchRead() + outboundBuffers := packetBuffers[:0] + for _, packetBuffer := range packetBuffers { + if packetBuffer.IsEmpty() { + packetBuffer.Release() + continue + } + outboundBuffers = append(outboundBuffers, packetBuffer) + } + if len(outboundBuffers) > 0 { + writeErr := d.writeOutbound(outboundBuffers) + if writeErr != nil { + d.options.Logger.Error(E.Cause(writeErr, "write packet batch")) + return + } + } + if readErr != nil { + if E.IsClosed(readErr) || E.IsMulti(readErr, syscall.EBADF) { + return + } + d.options.Logger.Error(E.Cause(readErr, "batch read packet")) + return + } + } +} + +func (d *systemDevice) UpdateConfiguration(configuration Configuration) error { + d.stateAccess.Lock() + defer d.stateAccess.Unlock() + previousConfiguration := d.options.Configuration + previousMTU := d.options.MTU + updatedMTU := d.options.MTU + if configuration.MTU != 0 { + updatedMTU = configuration.MTU + } + d.options.MTU = updatedMTU + d.options.Configuration = configuration + if d.device == nil { + inet4Address, inet6Address := firstAddresses(configuration.Addresses) + d.inet4Address = inet4Address + d.inet6Address = inet6Address + return nil + } + if !slices.Equal(previousConfiguration.Addresses, configuration.Addresses) || + previousMTU != updatedMTU { + d.device.Close() + d.device = nil + return d.startLocked() + } + return nil +} + +func (d *systemDevice) WriteInboundBuffers(packetBuffers []*buf.Buffer) error { + return d.processInboundBuffers(packetBuffers, d.writeBuffers) +} + +func (d *systemDevice) writeBuffers(packetBuffers []*buf.Buffer) error { + d.stateAccess.RLock() + tunInterface := d.device + d.stateAccess.RUnlock() + if tunInterface == nil { + return E.New("system device is not ready") + } + linuxTUN, isLinuxTUN := tunInterface.(tun.LinuxTUN) + if isLinuxTUN { + headroom := linuxTUN.FrontHeadroom() + packets := make([][]byte, len(packetBuffers)) + var temporaryBuffers []*buf.Buffer + for i, packetBuffer := range packetBuffers { + if packetBuffer.Start() >= headroom { + packetBuffer.ExtendHeader(headroom) + packets[i] = packetBuffer.Bytes() + packetBuffer.Advance(headroom) + continue + } + temporaryBuffer := buf.NewSize(headroom + packetBuffer.Len()) + temporaryBuffer.Resize(headroom, 0) + _, _ = temporaryBuffer.Write(packetBuffer.Bytes()) + temporaryBuffer.ExtendHeader(headroom) + packets[i] = temporaryBuffer.Bytes() + temporaryBuffers = append(temporaryBuffers, temporaryBuffer) + } + _, err := linuxTUN.BatchWrite(packets, headroom) + buf.ReleaseMulti(temporaryBuffers) + return err + } + darwinTUN, isDarwinTUN := tunInterface.(tun.DarwinTUN) + if isDarwinTUN { + return darwinTUN.BatchWrite(packetBuffers) + } + for _, packetBuffer := range packetBuffers { + err := d.writePacket(packetBuffer.Bytes()) + if err != nil { + return err + } + } + return nil +} + +func (d *systemDevice) writePacket(packet []byte) error { + d.stateAccess.RLock() + tunInterface := d.device + d.stateAccess.RUnlock() + if tunInterface == nil { + return E.New("system device is not ready") + } + if tun.PacketOffset == 0 { + _, err := tunInterface.Write(packet) + return err + } + writeBuffer := make([]byte, tun.PacketOffset+len(packet)) + tun.PacketFillHeader(writeBuffer[:tun.PacketOffset], header.IPVersion(packet)) + copy(writeBuffer[tun.PacketOffset:], packet) + _, err := tunInterface.Write(writeBuffer) + return err +} + +func (d *systemDevice) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + if !destination.Addr.IsValid() { + return nil, E.New("invalid non-IP destination") + } + return d.dialer.DialContext(ctx, network, destination) +} + +func (d *systemDevice) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + if !destination.Addr.IsValid() { + return nil, E.New("invalid non-IP destination") + } + return d.dialer.ListenPacket(ctx, destination) +} + +func (d *systemDevice) PortAddresses() (netip.Addr, netip.Addr) { + d.stateAccess.RLock() + defer d.stateAccess.RUnlock() + return d.inet4Address, d.inet6Address +} + +func (d *systemDevice) PortMTU() uint32 { + d.stateAccess.RLock() + defer d.stateAccess.RUnlock() + return d.options.MTU +} + +func (d *systemDevice) Close() error { + d.stateAccess.Lock() + defer d.stateAccess.Unlock() + d.closed = true + if d.device == nil { + return nil + } + err := d.device.Close() + d.device = nil + return err +} + +func (d *systemDevice) configurationAddresses() []netip.Prefix { + d.stateAccess.RLock() + defer d.stateAccess.RUnlock() + return slices.Clone(d.options.Configuration.Addresses) +} diff --git a/transport/openconnect/device_system_stack.go b/transport/openconnect/device_system_stack.go new file mode 100644 index 0000000000..2042881e93 --- /dev/null +++ b/transport/openconnect/device_system_stack.go @@ -0,0 +1,116 @@ +//go:build with_gvisor + +package openconnect + +import ( + "net/netip" + + "github.com/sagernet/sing-tun/gtcpip/header" + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" +) + +var _ Device = (*systemStackDevice)(nil) + +type systemStackDevice struct { + *systemDevice + stackDevice *stackDevice +} + +func newSystemStackDevice(options DeviceOptions) (*systemStackDevice, error) { + system, err := newSystemDevice(options) + if err != nil { + return nil, err + } + stackOptions := options + stackOptions.System = false + stackOptions.Name = system.options.Name + stackOptions.ExcludeInterface = []string{system.options.Name} + stackDevice, err := newStackDevice(stackOptions) + if err != nil { + system.Close() + return nil, err + } + return &systemStackDevice{ + systemDevice: system, + stackDevice: stackDevice, + }, nil +} + +func (d *systemStackDevice) SetPacketWriter(writer PacketWriter) { + d.systemDevice.SetPacketWriter(writer) + d.stackDevice.SetPacketWriter(writer) +} + +func (d *systemStackDevice) Start() error { + err := d.stackDevice.Start() + if err != nil { + return err + } + err = d.systemDevice.Start() + return err +} + +func (d *systemStackDevice) UpdateConfiguration(configuration Configuration) error { + err := d.systemDevice.UpdateConfiguration(configuration) + if err != nil { + return err + } + return d.stackDevice.UpdateConfiguration(configuration) +} + +func (d *systemStackDevice) WriteInboundBuffers(packetBuffers []*buf.Buffer) error { + return d.systemDevice.processInboundBuffers(packetBuffers, d.writeBuffers) +} + +func (d *systemStackDevice) writeBuffers(packetBuffers []*buf.Buffer) error { + addresses := d.systemDevice.configurationAddresses() + runStart := 0 + runUsesSystemDevice := false + var writeErr error + for i, packetBuffer := range packetBuffers { + destination := packetDestination(packetBuffer.Bytes()) + useSystemDevice := false + for _, prefix := range addresses { + if prefix.Contains(destination) { + useSystemDevice = true + break + } + } + if i > runStart && useSystemDevice != runUsesSystemDevice { + var err error + if runUsesSystemDevice { + err = d.systemDevice.writeBuffers(packetBuffers[runStart:i]) + } else { + err = d.stackDevice.writeBuffers(packetBuffers[runStart:i]) + } + writeErr = E.Errors(writeErr, err) + runStart = i + } + if i == runStart { + runUsesSystemDevice = useSystemDevice + } + } + if runStart == len(packetBuffers) { + return writeErr + } + if runUsesSystemDevice { + return E.Errors(writeErr, d.systemDevice.writeBuffers(packetBuffers[runStart:])) + } + return E.Errors(writeErr, d.stackDevice.writeBuffers(packetBuffers[runStart:])) +} + +func packetDestination(packet []byte) netip.Addr { + switch header.IPVersion(packet) { + case header.IPv4Version: + return header.IPv4(packet).DestinationAddr() + case header.IPv6Version: + return header.IPv6(packet).DestinationAddr() + default: + return netip.Addr{} + } +} + +func (d *systemStackDevice) Close() error { + return E.Errors(d.stackDevice.Close(), d.systemDevice.Close()) +} diff --git a/transport/openvpn/device.go b/transport/openvpn/device.go new file mode 100644 index 0000000000..570d8fac63 --- /dev/null +++ b/transport/openvpn/device.go @@ -0,0 +1,214 @@ +package openvpn + +import ( + "context" + "net/netip" + "sync/atomic" + "time" + + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common/buf" + "github.com/sagernet/sing/common/control" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + N "github.com/sagernet/sing/common/network" +) + +const ( + DefaultMTU = 1500 + PacketHeadroom = 4096 +) + +type PacketWriter func(packetBuffers []*buf.Buffer) error + +type Device interface { + N.Dialer + Start() error + UpdateConfiguration(configuration Configuration) error + WriteInboundBuffers(packetBuffers []*buf.Buffer) error + SetPacketWriter(writer PacketWriter) + PortAddresses() (netip.Addr, netip.Addr) + PortMTU() uint32 + AttachReturn(returnPath tun.Return) error + DetachReturn(returnPath tun.Return) error + ReturnPath() (tun.Return, int) + Close() error +} + +type DeviceOptions struct { + Context context.Context + Logger logger.ContextLogger + System bool + Handler tun.Handler + UDPTimeout time.Duration + ICMPTimeout time.Duration + UDPMapping tun.NATMapping + UDPFiltering tun.NATFiltering + UDPNATMax uint32 + InterfaceFinder control.InterfaceFinder + ExcludeInterface []string + Name string + MTU uint32 + Configuration Configuration +} + +type Configuration struct { + MTU uint32 + Address []netip.Prefix + Routes []Route + ExcludedRoutes []Route + DNS []netip.Addr + DNSServers []DNSServer + SearchDomains []string + DNSRoutes []string + Topology string + Interface string + BlockIPv6 bool +} + +type Route struct { + Prefix netip.Prefix + Gateway netip.Addr + Metric int +} + +type DNSServer struct { + Priority int + Addresses []netip.AddrPort + ResolveDomains []string + DNSSEC string + Transport string + SNI string +} + +func NewDevice(options DeviceOptions) (Device, error) { + if !options.System { + return newStackDevice(options) + } + if !tun.WithGVisor { + return newSystemDevice(options) + } + return newSystemStackDevice(options) +} + +type baseDevice struct { + packetWriter PacketWriter + returnState atomic.Pointer[returnPathState] +} + +func (d *baseDevice) SetPacketWriter(writer PacketWriter) { + d.packetWriter = writer +} + +func (d *baseDevice) writeOutbound(packetBuffers []*buf.Buffer) error { + if d.packetWriter == nil { + buf.ReleaseMulti(packetBuffers) + return E.New("missing packet writer") + } + return d.packetWriter(packetBuffers) +} + +func (d *baseDevice) processInboundBuffers(packetBuffers []*buf.Buffer, writeBuffers func(packetBuffers []*buf.Buffer) error) error { + if len(packetBuffers) == 0 { + return nil + } + state := d.returnState.Load() + if state == nil { + return writeBuffers(packetBuffers) + } + packets := make([][]byte, len(packetBuffers)) + for i, packetBuffer := range packetBuffers { + packetBuffer.ExtendHeader(state.headroom) + packets[i] = packetBuffer.Bytes() + } + unconsumed := state.returnPath.ReturnPackets(packets) + if len(unconsumed) == 0 { + return nil + } + unconsumedBuffers := make([]*buf.Buffer, len(unconsumed)) + for i, packet := range unconsumed { + packetBuffer := buf.As(packet) + packetBuffer.Advance(state.headroom) + unconsumedBuffers[i] = packetBuffer + } + return writeBuffers(unconsumedBuffers) +} + +func (d *baseDevice) AttachReturn(returnPath tun.Return) error { + headroom := returnPath.ReturnHeadroom() + if headroom > PacketHeadroom { + return E.New("return path headroom ", headroom, " exceeds available ", PacketHeadroom) + } + newState := &returnPathState{ + returnPath: returnPath, + headroom: headroom, + } + for { + currentState := d.returnState.Load() + if currentState != nil { + if currentState.returnPath == returnPath { + return nil + } + return E.New("return path already attached") + } + if d.returnState.CompareAndSwap(nil, newState) { + return nil + } + } +} + +func (d *baseDevice) DetachReturn(returnPath tun.Return) error { + currentState := d.returnState.Load() + if currentState != nil && currentState.returnPath == returnPath { + d.returnState.CompareAndSwap(currentState, nil) + } + return nil +} + +func (d *baseDevice) ReturnPath() (tun.Return, int) { + state := d.returnState.Load() + if state == nil { + return nil, 0 + } + return state.returnPath, state.headroom +} + +type returnPathState struct { + returnPath tun.Return + headroom int +} + +func firstAddresses(addresses []netip.Prefix) (netip.Addr, netip.Addr) { + var inet4Address netip.Addr + var inet6Address netip.Addr + for _, prefix := range addresses { + if prefix.Addr().Is4() && !inet4Address.IsValid() { + inet4Address = prefix.Addr() + } else if prefix.Addr().Is6() && !inet6Address.IsValid() { + inet6Address = prefix.Addr() + } + } + return inet4Address, inet6Address +} + +func splitPrefixes(prefixes []netip.Prefix) ([]netip.Prefix, []netip.Prefix) { + var inet4Prefixes []netip.Prefix + var inet6Prefixes []netip.Prefix + for _, prefix := range prefixes { + if prefix.Addr().Is4() { + inet4Prefixes = append(inet4Prefixes, prefix) + } else { + inet6Prefixes = append(inet6Prefixes, prefix) + } + } + return inet4Prefixes, inet6Prefixes +} + +func hasRouteOptions(routes []Route) bool { + for _, route := range routes { + if route.Gateway.IsValid() || route.Metric != 0 { + return true + } + } + return false +} diff --git a/transport/openvpn/device_stack.go b/transport/openvpn/device_stack.go new file mode 100644 index 0000000000..9a0bd5edd5 --- /dev/null +++ b/transport/openvpn/device_stack.go @@ -0,0 +1,377 @@ +//go:build with_gvisor + +package openvpn + +import ( + "context" + "net" + "net/netip" + "slices" + "sync" + "sync/atomic" + + "github.com/sagernet/gvisor/pkg/buffer" + "github.com/sagernet/gvisor/pkg/tcpip" + "github.com/sagernet/gvisor/pkg/tcpip/adapters/gonet" + "github.com/sagernet/gvisor/pkg/tcpip/header" + "github.com/sagernet/gvisor/pkg/tcpip/network/ipv4" + "github.com/sagernet/gvisor/pkg/tcpip/network/ipv6" + "github.com/sagernet/gvisor/pkg/tcpip/stack" + "github.com/sagernet/gvisor/pkg/tcpip/transport/icmp" + "github.com/sagernet/gvisor/pkg/tcpip/transport/tcp" + "github.com/sagernet/gvisor/pkg/tcpip/transport/udp" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +var _ Device = (*stackDevice)(nil) + +type stackDevice struct { + baseDevice + stateAccess sync.RWMutex + options DeviceOptions + stack *stack.Stack + endpoint *stackEndpoint + inet4Address netip.Addr + inet6Address netip.Addr + udpForwarder *tun.UDPForwarder + icmpForwarder *tun.ICMPForwarder + logRouteOptions bool + closeOnce sync.Once +} + +func newStackDevice(options DeviceOptions) (*stackDevice, error) { + if options.MTU == 0 { + options.MTU = DefaultMTU + } + device := &stackDevice{ + options: options, + logRouteOptions: true, + } + endpoint := &stackEndpoint{ + device: device, + done: make(chan struct{}), + } + endpoint.mtu.Store(options.MTU) + ipStack, err := tun.NewGVisorStackWithOptions(endpoint, stack.NICOptions{}, true) + if err != nil { + return nil, err + } + device.stack = ipStack + device.endpoint = endpoint + err = device.updateAddresses(nil, options.Configuration.Address) + if err != nil { + ipStack.Close() + return nil, err + } + if options.Handler != nil { + ipStack.SetTransportProtocolHandler(tcp.ProtocolNumber, tun.NewTCPForwarder(options.Context, ipStack, options.Handler).HandlePacket) + udpForwarder := tun.NewUDPForwarder(options.Context, ipStack, options.Handler, tun.UDPNatOptions{ + Timeout: options.UDPTimeout, + Shared: true, + Mapping: options.UDPMapping, + Filtering: options.UDPFiltering, + MaxSize: options.UDPNATMax, + InterfaceFinder: options.InterfaceFinder, + ExcludeInterface: options.ExcludeInterface, + }) + ipStack.SetTransportProtocolHandler(udp.ProtocolNumber, udpForwarder.HandlePacket) + device.udpForwarder = udpForwarder + icmpForwarder := tun.NewICMPForwarder(ipStack, options.Handler, options.Logger) + ipStack.SetTransportProtocolHandler(icmp.ProtocolNumber4, icmpForwarder.HandlePacket) + ipStack.SetTransportProtocolHandler(icmp.ProtocolNumber6, icmpForwarder.HandlePacket) + device.icmpForwarder = icmpForwarder + } + return device, nil +} + +func (d *stackDevice) Start() error { + if d.udpForwarder != nil { + err := d.udpForwarder.Start() + if err != nil { + return err + } + } + return nil +} + +func (d *stackDevice) UpdateConfiguration(configuration Configuration) error { + d.stateAccess.Lock() + defer d.stateAccess.Unlock() + if d.logRouteOptions && hasRouteOptions(configuration.Routes) { + d.options.Logger.Debug("route gateway and metric options are not representable by the gVisor stack device; routes are installed by prefix") + d.logRouteOptions = false + } + if configuration.MTU != 0 { + d.options.MTU = configuration.MTU + d.endpoint.mtu.Store(configuration.MTU) + } + previousAddresses := d.options.Configuration.Address + d.options.Configuration = configuration + return d.updateAddresses(previousAddresses, configuration.Address) +} + +func (d *stackDevice) updateAddresses(previousAddresses []netip.Prefix, addresses []netip.Prefix) error { + for _, prefix := range previousAddresses { + if slices.Contains(addresses, prefix) { + continue + } + gErr := d.stack.RemoveAddress(tun.DefaultNIC, tun.AddressFromAddr(prefix.Addr())) + if gErr != nil { + return E.New("remove local address ", prefix, ": ", gErr.String()) + } + } + for _, prefix := range addresses { + if slices.Contains(previousAddresses, prefix) { + continue + } + protocolAddress := tcpip.ProtocolAddress{ + AddressWithPrefix: tcpip.AddressWithPrefix{ + Address: tun.AddressFromAddr(prefix.Addr()), + PrefixLen: prefix.Bits(), + }, + } + if prefix.Addr().Is4() { + protocolAddress.Protocol = ipv4.ProtocolNumber + } else { + protocolAddress.Protocol = ipv6.ProtocolNumber + } + gErr := d.stack.AddProtocolAddress(tun.DefaultNIC, protocolAddress, stack.AddressProperties{}) + if gErr != nil { + return E.New("add local address ", prefix, ": ", gErr.String()) + } + } + d.inet4Address, d.inet6Address = firstAddresses(addresses) + return nil +} + +func (d *stackDevice) WriteInboundBuffers(packetBuffers []*buf.Buffer) error { + return d.processInboundBuffers(packetBuffers, d.writeBuffers) +} + +func (d *stackDevice) writeBuffers(packetBuffers []*buf.Buffer) error { + networkProtocols := make([]tcpip.NetworkProtocolNumber, 0, len(packetBuffers)) + stackPacketBuffers := make([]*stack.PacketBuffer, 0, len(packetBuffers)) + var packetErr error + for _, packetBuffer := range packetBuffers { + packet := packetBuffer.Bytes() + var networkProtocol tcpip.NetworkProtocolNumber + switch header.IPVersion(packet) { + case header.IPv4Version: + networkProtocol = header.IPv4ProtocolNumber + case header.IPv6Version: + networkProtocol = header.IPv6ProtocolNumber + default: + if packetErr == nil { + packetErr = E.New("invalid IP packet") + } + continue + } + networkProtocols = append(networkProtocols, networkProtocol) + stackPacketBuffers = append(stackPacketBuffers, stack.NewPacketBuffer(stack.PacketBufferOptions{ + Payload: buffer.MakeWithData(packet), + })) + } + d.endpoint.deliverNetworkPackets(networkProtocols, stackPacketBuffers) + for _, packetBuffer := range stackPacketBuffers { + packetBuffer.DecRef() + } + return packetErr +} + +func (d *stackDevice) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + if destination.IsIPv6() && d.blockIPv6Enabled() { + return nil, E.New("IPv6 blocked by pushed block-ipv6") + } + inet4Address, inet6Address := d.PortAddresses() + address := tcpip.FullAddress{ + NIC: tun.DefaultNIC, + Port: destination.Port, + Addr: tun.AddressFromAddr(destination.Addr), + } + bind := tcpip.FullAddress{ + NIC: tun.DefaultNIC, + } + var networkProtocol tcpip.NetworkProtocolNumber + if destination.IsIPv4() { + if !inet4Address.IsValid() { + return nil, E.New("missing IPv4 local address") + } + networkProtocol = header.IPv4ProtocolNumber + bind.Addr = tun.AddressFromAddr(inet4Address) + } else { + if !inet6Address.IsValid() { + return nil, E.New("missing IPv6 local address") + } + networkProtocol = header.IPv6ProtocolNumber + bind.Addr = tun.AddressFromAddr(inet6Address) + } + switch N.NetworkName(network) { + case N.NetworkTCP: + return gonet.DialTCPWithBind(ctx, d.stack, bind, address, networkProtocol) + case N.NetworkUDP: + return gonet.DialUDP(d.stack, &bind, &address, networkProtocol) + default: + return nil, E.Extend(N.ErrUnknownNetwork, network) + } +} + +func (d *stackDevice) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + if destination.IsIPv6() && d.blockIPv6Enabled() { + return nil, E.New("IPv6 blocked by pushed block-ipv6") + } + inet4Address, inet6Address := d.PortAddresses() + bind := tcpip.FullAddress{ + NIC: tun.DefaultNIC, + } + var networkProtocol tcpip.NetworkProtocolNumber + if destination.IsIPv4() { + if !inet4Address.IsValid() { + return nil, E.New("missing IPv4 local address") + } + networkProtocol = header.IPv4ProtocolNumber + bind.Addr = tun.AddressFromAddr(inet4Address) + } else { + if !inet6Address.IsValid() { + return nil, E.New("missing IPv6 local address") + } + networkProtocol = header.IPv6ProtocolNumber + bind.Addr = tun.AddressFromAddr(inet6Address) + } + return gonet.DialUDP(d.stack, &bind, nil, networkProtocol) +} + +func (d *stackDevice) blockIPv6Enabled() bool { + d.stateAccess.RLock() + defer d.stateAccess.RUnlock() + return d.options.Configuration.BlockIPv6 +} + +func (d *stackDevice) PortAddresses() (netip.Addr, netip.Addr) { + d.stateAccess.RLock() + defer d.stateAccess.RUnlock() + return d.inet4Address, d.inet6Address +} + +func (d *stackDevice) PortMTU() uint32 { + d.stateAccess.RLock() + defer d.stateAccess.RUnlock() + return d.options.MTU +} + +func (d *stackDevice) Close() error { + d.closeOnce.Do(func() { + close(d.endpoint.done) + if d.udpForwarder != nil { + d.udpForwarder.Close() + } + if d.icmpForwarder != nil { + d.icmpForwarder.Close() + } + d.stack.Close() + for _, endpoint := range d.stack.CleanupEndpoints() { + endpoint.Abort() + } + d.stack.Wait() + }) + return nil +} + +type stackEndpoint struct { + device *stackDevice + mtu atomic.Uint32 + done chan struct{} + dispatcherAccess sync.RWMutex + dispatcher stack.NetworkDispatcher +} + +func (e *stackEndpoint) MTU() uint32 { + return e.mtu.Load() +} + +func (e *stackEndpoint) SetMTU(mtu uint32) { + e.mtu.Store(mtu) +} + +func (e *stackEndpoint) MaxHeaderLength() uint16 { + return 0 +} + +func (e *stackEndpoint) LinkAddress() tcpip.LinkAddress { + return "" +} + +func (e *stackEndpoint) SetLinkAddress(addr tcpip.LinkAddress) { +} + +func (e *stackEndpoint) Capabilities() stack.LinkEndpointCapabilities { + return stack.CapabilityRXChecksumOffload +} + +func (e *stackEndpoint) Attach(dispatcher stack.NetworkDispatcher) { + e.dispatcherAccess.Lock() + defer e.dispatcherAccess.Unlock() + e.dispatcher = dispatcher +} + +func (e *stackEndpoint) IsAttached() bool { + e.dispatcherAccess.RLock() + defer e.dispatcherAccess.RUnlock() + return e.dispatcher != nil +} + +func (e *stackEndpoint) deliverNetworkPackets(networkProtocols []tcpip.NetworkProtocolNumber, packetBuffers []*stack.PacketBuffer) { + e.dispatcherAccess.RLock() + defer e.dispatcherAccess.RUnlock() + if e.dispatcher == nil { + return + } + for i, packetBuffer := range packetBuffers { + e.dispatcher.DeliverNetworkPacket(networkProtocols[i], packetBuffer) + } +} + +func (e *stackEndpoint) Wait() { +} + +func (e *stackEndpoint) ARPHardwareType() header.ARPHardwareType { + return header.ARPHardwareNone +} + +func (e *stackEndpoint) AddHeader(packetBuffer *stack.PacketBuffer) { +} + +func (e *stackEndpoint) ParseHeader(packetBuffer *stack.PacketBuffer) bool { + return true +} + +func (e *stackEndpoint) WritePackets(list stack.PacketBufferList) (int, tcpip.Error) { + packetBuffers := make([]*buf.Buffer, 0, list.Len()) + for _, packetBuffer := range list.AsSlice() { + packetSlices := packetBuffer.AsSlices() + packetLength := 0 + for _, packetSlice := range packetSlices { + packetLength += len(packetSlice) + } + outboundBuffer := buf.NewSize(PacketHeadroom + packetLength + systemDevicePacketRearSpace) + outboundBuffer.Resize(PacketHeadroom, 0) + for _, packetSlice := range packetSlices { + _, _ = outboundBuffer.Write(packetSlice) + } + packetBuffers = append(packetBuffers, outboundBuffer) + } + err := e.device.writeOutbound(packetBuffers) + if err != nil { + return 0, &tcpip.ErrClosedForSend{} + } + return list.Len(), nil +} + +func (e *stackEndpoint) Close() { +} + +func (e *stackEndpoint) SetOnCloseAction(action func()) { +} diff --git a/transport/openvpn/device_stack_stub.go b/transport/openvpn/device_stack_stub.go new file mode 100644 index 0000000000..a9aa080618 --- /dev/null +++ b/transport/openvpn/device_stack_stub.go @@ -0,0 +1,13 @@ +//go:build !with_gvisor + +package openvpn + +import E "github.com/sagernet/sing/common/exceptions" + +func newStackDevice(options DeviceOptions) (Device, error) { + return nil, E.New("system:false requires the with_gvisor build tag") +} + +func newSystemStackDevice(options DeviceOptions) (Device, error) { + return nil, E.New("system stack requires the with_gvisor build tag") +} diff --git a/transport/openvpn/device_system.go b/transport/openvpn/device_system.go new file mode 100644 index 0000000000..728626a9a9 --- /dev/null +++ b/transport/openvpn/device_system.go @@ -0,0 +1,382 @@ +package openvpn + +import ( + "context" + "net" + "net/netip" + "slices" + "sync" + "syscall" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-tun" + "github.com/sagernet/sing-tun/gtcpip/header" + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/service" +) + +var _ Device = (*systemDevice)(nil) + +const ( + systemDeviceReadBufferSize = 65535 + tun.PacketOffset + systemDevicePacketRearSpace = 64 +) + +type systemDevice struct { + baseDevice + stateAccess sync.RWMutex + options DeviceOptions + dialer N.Dialer + device tun.Tun + inet4Address netip.Addr + inet6Address netip.Addr + closed bool +} + +func newSystemDevice(options DeviceOptions) (*systemDevice, error) { + if options.Name == "" { + options.Name = tun.CalculateInterfaceName("ovpn") + } + if options.MTU == 0 { + options.MTU = DefaultMTU + } + interfaceDialer, err := dialer.NewDefault(options.Context, option.DialerOptions{ + AbstractDialerOptions: option.AbstractDialerOptions{ + BindInterface: options.Name, + }, + }) + if err != nil { + return nil, err + } + inet4Address, inet6Address := firstAddresses(options.Configuration.Address) + return &systemDevice{ + options: options, + dialer: interfaceDialer, + inet4Address: inet4Address, + inet6Address: inet6Address, + }, nil +} + +func (d *systemDevice) Start() error { + d.stateAccess.Lock() + defer d.stateAccess.Unlock() + return d.startLocked() +} + +func (d *systemDevice) startLocked() error { + if d.closed { + return net.ErrClosed + } + if d.device != nil { + return nil + } + tunOptions := d.buildTunOptions() + tunInterface, err := tun.New(tunOptions) + if err != nil { + return err + } + err = tunInterface.Start() + if err != nil { + tunInterface.Close() + return err + } + d.device = tunInterface + d.options.Logger.Info("started at ", d.options.Name) + go d.readLoop(tunInterface, int(d.options.MTU)) + return nil +} + +func (d *systemDevice) buildTunOptions() tun.Options { + inet4Address, inet6Address := firstAddresses(d.options.Configuration.Address) + d.inet4Address = inet4Address + d.inet6Address = inet6Address + inet4Addresses, inet6Addresses := splitPrefixes(d.options.Configuration.Address) + networkManager := service.FromContext[adapter.NetworkManager](d.options.Context) + tunOptions := tun.Options{ + Name: d.options.Name, + Inet4Address: inet4Addresses, + Inet6Address: inet6Addresses, + MTU: d.options.MTU, + GSO: true, + InterfaceScope: true, + DNSMode: tun.DNSModeDisabled, + InterfaceMonitor: nil, + InterfaceFinder: nil, + Logger: d.options.Logger, + IPRoute2TableIndex: tun.DefaultIPRoute2TableIndex, + IPRoute2RuleIndex: tun.DefaultIPRoute2RuleIndex, + EXP_DisableDNSHijack: true, + } + if networkManager != nil { + tunOptions.InterfaceMonitor = networkManager.InterfaceMonitor() + tunOptions.InterfaceFinder = networkManager.InterfaceFinder() + } + return tunOptions +} + +func (d *systemDevice) readLoop(tunInterface tun.Tun, mtu int) { + linuxTUN, isLinuxTUN := tunInterface.(tun.LinuxTUN) + if isLinuxTUN && linuxTUN.BatchSize() > 1 { + d.readLoopLinux(linuxTUN, linuxTUN.BatchSize(), mtu) + return + } + darwinTUN, isDarwinTUN := tunInterface.(tun.DarwinTUN) + if isDarwinTUN { + d.readLoopDarwin(darwinTUN) + return + } + packetBuffer := buf.NewSize(PacketHeadroom + systemDeviceReadBufferSize + systemDevicePacketRearSpace) + defer packetBuffer.Release() + for { + packetBuffer.Reset() + packetBuffer.Resize(PacketHeadroom, 0) + readN, err := tunInterface.Read(packetBuffer.FreeBytes()[:systemDeviceReadBufferSize]) + if err != nil { + if E.IsClosed(err) { + return + } + d.options.Logger.Error(E.Cause(err, "read packet")) + return + } + if readN <= tun.PacketOffset { + continue + } + packetBuffer.Truncate(readN) + packetBuffer.Advance(tun.PacketOffset) + if d.blockIPv6Enabled() && header.IPVersion(packetBuffer.Bytes()) == header.IPv6Version { + continue + } + packetBuffer.IncRef() + err = d.writeOutbound([]*buf.Buffer{packetBuffer}) + packetBuffer.DecRef() + if err != nil { + d.options.Logger.Error(E.Cause(err, "write packet")) + return + } + } +} + +func (d *systemDevice) readLoopLinux(tunInterface tun.LinuxTUN, batchSize int, mtu int) { + packetBuffers := make([]*buf.Buffer, batchSize) + readBuffers := make([][]byte, batchSize) + packetSizes := make([]int, batchSize) + outboundBuffers := make([]*buf.Buffer, 0, batchSize) + for i := range packetBuffers { + packetBuffers[i] = buf.NewSize(PacketHeadroom + mtu + systemDevicePacketRearSpace) + } + defer buf.ReleaseMulti(packetBuffers) + for { + for i, packetBuffer := range packetBuffers { + packetBuffer.Reset() + packetBuffer.Resize(PacketHeadroom, 0) + readBuffers[i] = packetBuffer.FreeBytes()[:mtu] + } + packetCount, readErr := tunInterface.BatchRead(readBuffers, 0, packetSizes) + outboundBuffers = outboundBuffers[:0] + blockIPv6 := d.blockIPv6Enabled() + for i := range packetCount { + packetBuffers[i].Truncate(packetSizes[i]) + if blockIPv6 && header.IPVersion(packetBuffers[i].Bytes()) == header.IPv6Version { + continue + } + packetBuffers[i].IncRef() + outboundBuffers = append(outboundBuffers, packetBuffers[i]) + } + if len(outboundBuffers) > 0 { + writeErr := d.writeOutbound(outboundBuffers) + for _, packetBuffer := range outboundBuffers { + packetBuffer.DecRef() + } + if writeErr != nil { + d.options.Logger.Error(E.Cause(writeErr, "write packet batch")) + return + } + } + if readErr != nil { + if E.IsClosed(readErr) { + return + } + d.options.Logger.Error(E.Cause(readErr, "batch read packet")) + return + } + } +} + +func (d *systemDevice) readLoopDarwin(tunInterface tun.DarwinTUN) { + for { + packetBuffers, readErr := tunInterface.BatchRead() + outboundBuffers := packetBuffers[:0] + blockIPv6 := d.blockIPv6Enabled() + for _, packetBuffer := range packetBuffers { + if packetBuffer.IsEmpty() { + packetBuffer.Release() + continue + } + if blockIPv6 && header.IPVersion(packetBuffer.Bytes()) == header.IPv6Version { + packetBuffer.Release() + continue + } + outboundBuffers = append(outboundBuffers, packetBuffer) + } + if len(outboundBuffers) > 0 { + writeErr := d.writeOutbound(outboundBuffers) + if writeErr != nil { + d.options.Logger.Error(E.Cause(writeErr, "write packet batch")) + return + } + } + if readErr != nil { + if E.IsClosed(readErr) || E.IsMulti(readErr, syscall.EBADF) { + return + } + d.options.Logger.Error(E.Cause(readErr, "batch read packet")) + return + } + } +} + +func (d *systemDevice) UpdateConfiguration(configuration Configuration) error { + d.stateAccess.Lock() + defer d.stateAccess.Unlock() + previousConfiguration := d.options.Configuration + previousMTU := d.options.MTU + updatedMTU := d.options.MTU + if configuration.MTU != 0 { + updatedMTU = configuration.MTU + } + d.options.MTU = updatedMTU + d.options.Configuration = configuration + if d.device == nil { + inet4Address, inet6Address := firstAddresses(configuration.Address) + d.inet4Address = inet4Address + d.inet6Address = inet6Address + return nil + } + if !slices.Equal(previousConfiguration.Address, configuration.Address) || + previousMTU != updatedMTU { + d.device.Close() + d.device = nil + return d.startLocked() + } + return nil +} + +func (d *systemDevice) blockIPv6Enabled() bool { + d.stateAccess.RLock() + defer d.stateAccess.RUnlock() + return d.options.Configuration.BlockIPv6 +} + +func (d *systemDevice) WriteInboundBuffers(packetBuffers []*buf.Buffer) error { + return d.processInboundBuffers(packetBuffers, d.writeBuffers) +} + +func (d *systemDevice) writeBuffers(packetBuffers []*buf.Buffer) error { + d.stateAccess.RLock() + tunInterface := d.device + d.stateAccess.RUnlock() + if tunInterface == nil { + return E.New("system device is not ready") + } + linuxTUN, isLinuxTUN := tunInterface.(tun.LinuxTUN) + if isLinuxTUN { + headroom := linuxTUN.FrontHeadroom() + packets := make([][]byte, len(packetBuffers)) + var temporaryBuffers []*buf.Buffer + for i, packetBuffer := range packetBuffers { + if packetBuffer.Start() >= headroom { + packetBuffer.ExtendHeader(headroom) + packets[i] = packetBuffer.Bytes() + packetBuffer.Advance(headroom) + continue + } + temporaryBuffer := buf.NewSize(headroom + packetBuffer.Len()) + temporaryBuffer.Resize(headroom, 0) + _, _ = temporaryBuffer.Write(packetBuffer.Bytes()) + temporaryBuffer.ExtendHeader(headroom) + packets[i] = temporaryBuffer.Bytes() + temporaryBuffers = append(temporaryBuffers, temporaryBuffer) + } + _, err := linuxTUN.BatchWrite(packets, headroom) + buf.ReleaseMulti(temporaryBuffers) + return err + } + darwinTUN, isDarwinTUN := tunInterface.(tun.DarwinTUN) + if isDarwinTUN { + return darwinTUN.BatchWrite(packetBuffers) + } + for _, packetBuffer := range packetBuffers { + err := d.writePacket(packetBuffer.Bytes()) + if err != nil { + return err + } + } + return nil +} + +func (d *systemDevice) writePacket(packet []byte) error { + d.stateAccess.RLock() + tunInterface := d.device + d.stateAccess.RUnlock() + if tunInterface == nil { + return E.New("system device is not ready") + } + if tun.PacketOffset == 0 { + _, err := tunInterface.Write(packet) + return err + } + writeBuffer := make([]byte, tun.PacketOffset+len(packet)) + tun.PacketFillHeader(writeBuffer[:tun.PacketOffset], header.IPVersion(packet)) + copy(writeBuffer[tun.PacketOffset:], packet) + _, err := tunInterface.Write(writeBuffer) + return err +} + +func (d *systemDevice) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + if !destination.Addr.IsValid() { + return nil, E.New("invalid non-IP destination") + } + return d.dialer.DialContext(ctx, network, destination) +} + +func (d *systemDevice) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + if !destination.Addr.IsValid() { + return nil, E.New("invalid non-IP destination") + } + return d.dialer.ListenPacket(ctx, destination) +} + +func (d *systemDevice) PortAddresses() (netip.Addr, netip.Addr) { + d.stateAccess.RLock() + defer d.stateAccess.RUnlock() + return d.inet4Address, d.inet6Address +} + +func (d *systemDevice) PortMTU() uint32 { + d.stateAccess.RLock() + defer d.stateAccess.RUnlock() + return d.options.MTU +} + +func (d *systemDevice) Close() error { + d.stateAccess.Lock() + defer d.stateAccess.Unlock() + d.closed = true + if d.device == nil { + return nil + } + err := d.device.Close() + d.device = nil + return err +} + +func (d *systemDevice) configurationAddresses() []netip.Prefix { + d.stateAccess.RLock() + defer d.stateAccess.RUnlock() + return slices.Clone(d.options.Configuration.Address) +} diff --git a/transport/openvpn/device_system_stack.go b/transport/openvpn/device_system_stack.go new file mode 100644 index 0000000000..d0d9a97788 --- /dev/null +++ b/transport/openvpn/device_system_stack.go @@ -0,0 +1,117 @@ +//go:build with_gvisor + +package openvpn + +import ( + "net/netip" + + "github.com/sagernet/sing-tun/gtcpip/header" + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" +) + +var _ Device = (*systemStackDevice)(nil) + +type systemStackDevice struct { + *systemDevice + stackDevice *stackDevice +} + +func newSystemStackDevice(options DeviceOptions) (*systemStackDevice, error) { + system, err := newSystemDevice(options) + if err != nil { + return nil, err + } + stackOptions := options + stackOptions.System = false + stackOptions.Name = system.options.Name + stackOptions.ExcludeInterface = []string{system.options.Name} + stackDevice, err := newStackDevice(stackOptions) + if err != nil { + system.Close() + return nil, err + } + stackDevice.logRouteOptions = false + return &systemStackDevice{ + systemDevice: system, + stackDevice: stackDevice, + }, nil +} + +func (d *systemStackDevice) SetPacketWriter(writer PacketWriter) { + d.systemDevice.SetPacketWriter(writer) + d.stackDevice.SetPacketWriter(writer) +} + +func (d *systemStackDevice) Start() error { + err := d.stackDevice.Start() + if err != nil { + return err + } + err = d.systemDevice.Start() + return err +} + +func (d *systemStackDevice) UpdateConfiguration(configuration Configuration) error { + err := d.systemDevice.UpdateConfiguration(configuration) + if err != nil { + return err + } + return d.stackDevice.UpdateConfiguration(configuration) +} + +func (d *systemStackDevice) WriteInboundBuffers(packetBuffers []*buf.Buffer) error { + return d.systemDevice.processInboundBuffers(packetBuffers, d.writeBuffers) +} + +func (d *systemStackDevice) writeBuffers(packetBuffers []*buf.Buffer) error { + addresses := d.systemDevice.configurationAddresses() + runStart := 0 + runUsesSystemDevice := false + var writeErr error + for i, packetBuffer := range packetBuffers { + destination := packetDestination(packetBuffer.Bytes()) + useSystemDevice := false + for _, prefix := range addresses { + if prefix.Contains(destination) { + useSystemDevice = true + break + } + } + if i > runStart && useSystemDevice != runUsesSystemDevice { + var err error + if runUsesSystemDevice { + err = d.systemDevice.writeBuffers(packetBuffers[runStart:i]) + } else { + err = d.stackDevice.writeBuffers(packetBuffers[runStart:i]) + } + writeErr = E.Errors(writeErr, err) + runStart = i + } + if i == runStart { + runUsesSystemDevice = useSystemDevice + } + } + if runStart == len(packetBuffers) { + return writeErr + } + if runUsesSystemDevice { + return E.Errors(writeErr, d.systemDevice.writeBuffers(packetBuffers[runStart:])) + } + return E.Errors(writeErr, d.stackDevice.writeBuffers(packetBuffers[runStart:])) +} + +func packetDestination(packet []byte) netip.Addr { + switch header.IPVersion(packet) { + case header.IPv4Version: + return header.IPv4(packet).DestinationAddr() + case header.IPv6Version: + return header.IPv6(packet).DestinationAddr() + default: + return netip.Addr{} + } +} + +func (d *systemStackDevice) Close() error { + return E.Errors(d.stackDevice.Close(), d.systemDevice.Close()) +} diff --git a/transport/wireguard/device.go b/transport/wireguard/device.go index 0cfbce6fff..37575e4d77 100644 --- a/transport/wireguard/device.go +++ b/transport/wireguard/device.go @@ -6,6 +6,7 @@ import ( "time" "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common/control" "github.com/sagernet/sing/common/logger" N "github.com/sagernet/sing/common/network" "github.com/sagernet/wireguard-go/device" @@ -22,17 +23,22 @@ type Device interface { } type DeviceOptions struct { - Context context.Context - Logger logger.ContextLogger - System bool - Handler tun.Handler - UDPTimeout time.Duration - ICMPTimeout time.Duration - CreateDialer func(interfaceName string) N.Dialer - Name string - MTU uint32 - Address []netip.Prefix - AllowedAddress []netip.Prefix + Context context.Context + Logger logger.ContextLogger + System bool + Handler tun.Handler + UDPTimeout time.Duration + ICMPTimeout time.Duration + UDPMapping tun.NATMapping + UDPFiltering tun.NATFiltering + UDPNATMax uint32 + NetworkMonitor tun.NetworkUpdateMonitor + InterfaceFinder control.InterfaceFinder + CreateDialer func(interfaceName string) N.Dialer + Name string + MTU uint32 + Address []netip.Prefix + AllowedAddress []netip.Prefix } func NewDevice(options DeviceOptions) (Device, error) { diff --git a/transport/wireguard/device_stack.go b/transport/wireguard/device_stack.go index 949575e514..3321ef8f0d 100644 --- a/transport/wireguard/device_stack.go +++ b/transport/wireguard/device_stack.go @@ -42,6 +42,7 @@ type stackDevice struct { inet4Address netip.Addr inet6Address netip.Addr icmpForwarder *tun.ICMPForwarder + udpForwarder *tun.UDPForwarder } func newStackDevice(options DeviceOptions) (*stackDevice, error) { @@ -79,7 +80,16 @@ func newStackDevice(options DeviceOptions) (*stackDevice, error) { tunDevice.stack = ipStack if options.Handler != nil { ipStack.SetTransportProtocolHandler(tcp.ProtocolNumber, tun.NewTCPForwarder(options.Context, ipStack, options.Handler).HandlePacket) - ipStack.SetTransportProtocolHandler(udp.ProtocolNumber, tun.NewUDPForwarder(options.Context, ipStack, options.Handler, options.UDPTimeout).HandlePacket) + udpForwarder := tun.NewUDPForwarder(options.Context, ipStack, options.Handler, tun.UDPNatOptions{ + Timeout: options.UDPTimeout, + Shared: true, + Mapping: options.UDPMapping, + Filtering: options.UDPFiltering, + MaxSize: options.UDPNATMax, + InterfaceFinder: options.InterfaceFinder, + }) + ipStack.SetTransportProtocolHandler(udp.ProtocolNumber, udpForwarder.HandlePacket) + tunDevice.udpForwarder = udpForwarder icmpForwarder := tun.NewICMPForwarder(ipStack, options.Handler, options.Logger) ipStack.SetTransportProtocolHandler(icmp.ProtocolNumber4, icmpForwarder.HandlePacket) ipStack.SetTransportProtocolHandler(icmp.ProtocolNumber6, icmpForwarder.HandlePacket) @@ -166,6 +176,12 @@ func (w *stackDevice) SetDevice(device *device.Device) { } func (w *stackDevice) Start() error { + if w.udpForwarder != nil { + err := w.udpForwarder.Start() + if err != nil { + return err + } + } w.events <- wgTun.EventUp return nil } @@ -245,6 +261,9 @@ func (w *stackDevice) Close() error { if w.icmpForwarder != nil { w.icmpForwarder.Close() } + if w.udpForwarder != nil { + _ = w.udpForwarder.Close() + } w.stack.Close() for _, endpoint := range w.stack.CleanupEndpoints() { endpoint.Abort() diff --git a/transport/wireguard/device_system_stack.go b/transport/wireguard/device_system_stack.go index 0db1297c5f..ae92235b41 100644 --- a/transport/wireguard/device_system_stack.go +++ b/transport/wireguard/device_system_stack.go @@ -27,6 +27,7 @@ type systemStackDevice struct { stack *stack.Stack endpoint *deviceEndpoint icmpForwarder *tun.ICMPForwarder + udpForwarder *tun.UDPForwarder writeBufs [][]byte closeOnce sync.Once } @@ -69,7 +70,17 @@ func newSystemStackDevice(options DeviceOptions) (*systemStackDevice, error) { } if options.Handler != nil { ipStack.SetTransportProtocolHandler(tcp.ProtocolNumber, tun.NewTCPForwarder(options.Context, ipStack, options.Handler).HandlePacket) - ipStack.SetTransportProtocolHandler(udp.ProtocolNumber, tun.NewUDPForwarder(options.Context, ipStack, options.Handler, options.UDPTimeout).HandlePacket) + udpForwarder := tun.NewUDPForwarder(options.Context, ipStack, options.Handler, tun.UDPNatOptions{ + Timeout: options.UDPTimeout, + Shared: true, + Mapping: options.UDPMapping, + Filtering: options.UDPFiltering, + MaxSize: options.UDPNATMax, + InterfaceFinder: options.InterfaceFinder, + ExcludeInterface: []string{options.Name}, + }) + ipStack.SetTransportProtocolHandler(udp.ProtocolNumber, udpForwarder.HandlePacket) + stackDevice.udpForwarder = udpForwarder icmpForwarder := tun.NewICMPForwarder(ipStack, options.Handler, options.Logger) ipStack.SetTransportProtocolHandler(icmp.ProtocolNumber4, icmpForwarder.HandlePacket) ipStack.SetTransportProtocolHandler(icmp.ProtocolNumber6, icmpForwarder.HandlePacket) @@ -82,6 +93,20 @@ func (w *systemStackDevice) SetDevice(device *device.Device) { w.endpoint.device = device } +func (w *systemStackDevice) Start() error { + if w.udpForwarder != nil { + err := w.udpForwarder.Start() + if err != nil { + return err + } + } + err := w.systemDevice.Start() + if err != nil && w.udpForwarder != nil { + _ = w.udpForwarder.Close() + } + return err +} + func (w *systemStackDevice) Write(bufs [][]byte, offset int) (count int, err error) { if w.batchDevice != nil { w.writeBufs = w.writeBufs[:0] @@ -118,6 +143,9 @@ func (w *systemStackDevice) Close() error { if w.icmpForwarder != nil { w.icmpForwarder.Close() } + if w.udpForwarder != nil { + _ = w.udpForwarder.Close() + } w.stack.Close() for _, endpoint := range w.stack.CleanupEndpoints() { endpoint.Abort() diff --git a/transport/wireguard/endpoint.go b/transport/wireguard/endpoint.go index 1289e99a6a..c019319e8f 100644 --- a/transport/wireguard/endpoint.go +++ b/transport/wireguard/endpoint.go @@ -13,6 +13,7 @@ import ( "unsafe" "github.com/sagernet/sing-box/common/dialer" + "github.com/sagernet/sing-tun" "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" F "github.com/sagernet/sing/common/format" @@ -35,6 +36,7 @@ type Endpoint struct { returnDevice *returnDeviceWrapper device *device.Device allowedIPs *device.AllowedIPs + egressPool *tun.UDPEgressPool pause pause.Manager pauseCallback *list.Element[pause.Callback] } @@ -101,17 +103,21 @@ func NewEndpoint(options EndpointOptions) (*Endpoint, error) { options.MTU = 1408 } deviceOptions := DeviceOptions{ - Context: options.Context, - Logger: options.Logger, - System: options.System, - Handler: options.Handler, - UDPTimeout: options.UDPTimeout, - ICMPTimeout: options.ICMPTimeout, - CreateDialer: options.CreateDialer, - Name: options.Name, - MTU: options.MTU, - Address: options.Address, - AllowedAddress: allowedAddresses, + Context: options.Context, + Logger: options.Logger, + System: options.System, + Handler: options.Handler, + UDPTimeout: options.UDPTimeout, + ICMPTimeout: options.ICMPTimeout, + UDPMapping: options.UDPMapping, + UDPFiltering: options.UDPFiltering, + UDPNATMax: options.UDPNATMax, + InterfaceFinder: options.InterfaceFinder, + CreateDialer: options.CreateDialer, + Name: options.Name, + MTU: options.MTU, + Address: options.Address, + AllowedAddress: allowedAddresses, } tunDevice, err := NewDevice(deviceOptions) if err != nil { @@ -148,13 +154,16 @@ func (e *Endpoint) Start(resolve bool) error { return nil } var bind conn.Bind - wgListener, isWgListener := common.Cast[dialer.WireGuardListener](e.options.Dialer) - if isWgListener { - stdBind := conn.NewStdNetBind(wgListener.WireGuardControl()) - if e.options.ListenPort == 0 && len(e.peers) == 1 && e.peers[0].endpoint.IsValid() { - stdBind.(*conn.StdNetBind).SetSinglePeerMode() - } - bind = stdBind + udpListener, isUDPListener := common.Cast[dialer.UDPListener](e.options.Dialer) + if isUDPListener { + listenerControl, _ := udpListener.UDPListenerControl() + standardBind := conn.NewStdNetBind(listenerControl).(*conn.StdNetBind) + // TODO: hiddify's wireguard-go fork (replace/wireguard-go) has not yet been + // rebased onto sagernet/wireguard-go@6f5e8b1 ("Add EgressProvider"), which + // this upstream sync's transport/wireguard code expects. StdNetBind. + // SetSinglePeerMode/SetEgressProvider and the UDP egress-pool feature are + // unavailable until that fork is updated. + bind = standardBind } else { var ( isConnect bool @@ -168,7 +177,7 @@ func (e *Endpoint) Start(resolve bool) error { } bind = NewClientBind(e.options.Context, e.options.Logger, e.options.Dialer, isConnect, connectAddr, reserved) } - if isWgListener || len(e.peers) > 1 { + if isUDPListener || len(e.peers) > 1 { for _, peer := range e.peers { if peer.reserved != [3]uint8{} { bind.SetReservedForEndpoint(peer.endpoint, peer.reserved) @@ -228,12 +237,17 @@ func (e *Endpoint) Close() error { e.pause.UnregisterCallback(e.pauseCallback) e.pauseCallback = nil } + if e.egressPool != nil { + e.egressPool.Close() + e.egressPool = nil + } if e.device != nil { e.device.Down() e.device.Close() e.device = nil + return nil } - return nil + return e.tunDevice.Close() } func (e *Endpoint) Lookup(address netip.Addr) *device.Peer { @@ -243,6 +257,13 @@ func (e *Endpoint) Lookup(address netip.Addr) *device.Peer { return e.allowedIPs.Lookup(address.AsSlice()) } +func (e *Endpoint) BindUpdate() error { + if e.device == nil { + return nil + } + return e.device.BindUpdate() +} + func (e *Endpoint) onPauseUpdated(event int) { switch event { case pause.EventDevicePaused, pause.EventNetworkPause: diff --git a/transport/wireguard/endpoint_options.go b/transport/wireguard/endpoint_options.go index 623e40e368..10d4463ae2 100644 --- a/transport/wireguard/endpoint_options.go +++ b/transport/wireguard/endpoint_options.go @@ -6,6 +6,7 @@ import ( "time" "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common/control" "github.com/sagernet/sing/common/logger" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" @@ -19,17 +20,23 @@ type EndpointOptions struct { Handler tun.Handler UDPTimeout time.Duration ICMPTimeout time.Duration - Dialer N.Dialer - CreateDialer func(interfaceName string) N.Dialer - Name string - MTU uint32 - Address []netip.Prefix - PrivateKey string - ListenPort uint16 - ResolvePeer func(domain string) (netip.Addr, error) - Peers []PeerOptions - Workers int - Noise hiddify.NoiseOptions //H + UDPMapping tun.NATMapping + UDPFiltering tun.NATFiltering + UDPNATMax uint32 + + InterfaceFinder control.InterfaceFinder + EgressPoolOptions tun.UDPEgressPoolOptions + Dialer N.Dialer + CreateDialer func(interfaceName string) N.Dialer + Name string + MTU uint32 + Address []netip.Prefix + PrivateKey string + ListenPort uint16 + ResolvePeer func(domain string) (netip.Addr, error) + Peers []PeerOptions + Workers int + Noise hiddify.NoiseOptions //H } type PeerOptions struct {