diff --git a/.fpm_systemd b/.fpm_systemd index 402ed429..9b455da9 100644 --- a/.fpm_systemd +++ b/.fpm_systemd @@ -4,6 +4,7 @@ --license GPL-3.0-or-later --description "The universal proxy platform." --url "https://sing-box.sagernet.org/" +--vendor SagerNet --maintainer "nekohasekai " --deb-field "Bug: https://github.com/SagerNet/sing-box/issues" --no-deb-generate-changes diff --git a/.github/CRONET_GO_VERSION b/.github/CRONET_GO_VERSION index 2838ee07..f8f1198f 100644 --- a/.github/CRONET_GO_VERSION +++ b/.github/CRONET_GO_VERSION @@ -1 +1 @@ -cba7b9ac0399055aa49fbdc57c03c374f58e1597 +e4926ba205fae5351e3d3eeafff7e7029654424a diff --git a/.github/build_alpine_apk.sh b/.github/build_alpine_apk.sh new file mode 100755 index 00000000..610103b5 --- /dev/null +++ b/.github/build_alpine_apk.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash + +set -e -o pipefail + +prepare_apk_root() { + # apk mkpkg resolves owner/group names through --root/etc/{passwd,group}. + APK_ROOT_DIR=$(mktemp -d) + mkdir -p "$APK_ROOT_DIR/etc" + cat > "$APK_ROOT_DIR/etc/passwd" < "$APK_ROOT_DIR/etc/group" < " + exit 1 +fi + +PROJECT=$(cd "$(dirname "$0")/.."; pwd) + +# Convert version to APK format: +# 1.13.0-beta.8 -> 1.13.0_beta8-r0 +# 1.13.0-rc.3 -> 1.13.0_rc3-r0 +# 1.13.0 -> 1.13.0-r0 +APK_VERSION=$(echo "$VERSION" | sed -E 's/-([a-z]+)\.([0-9]+)/_\1\2/') +APK_VERSION="${APK_VERSION}-r0" + +ROOT_DIR=$(mktemp -d) +prepare_apk_root +trap 'rm -rf "$ROOT_DIR" "$APK_ROOT_DIR"' EXIT + +# Binary +install -Dm755 "$BINARY_PATH" "$ROOT_DIR/usr/bin/sing-box" + +# Config files +install -Dm644 "$PROJECT/release/config/config.json" "$ROOT_DIR/etc/sing-box/config.json" +install -Dm755 "$PROJECT/release/config/sing-box.initd" "$ROOT_DIR/etc/init.d/sing-box" +install -Dm644 "$PROJECT/release/config/sing-box.confd" "$ROOT_DIR/etc/conf.d/sing-box" + +# Service files +install -Dm644 "$PROJECT/release/config/sing-box.service" "$ROOT_DIR/usr/lib/systemd/system/sing-box.service" +install -Dm644 "$PROJECT/release/config/sing-box@.service" "$ROOT_DIR/usr/lib/systemd/system/sing-box@.service" + +# Completions +install -Dm644 "$PROJECT/release/completions/sing-box.bash" "$ROOT_DIR/usr/share/bash-completion/completions/sing-box.bash" +install -Dm644 "$PROJECT/release/completions/sing-box.fish" "$ROOT_DIR/usr/share/fish/vendor_completions.d/sing-box.fish" +install -Dm644 "$PROJECT/release/completions/sing-box.zsh" "$ROOT_DIR/usr/share/zsh/site-functions/_sing-box" + +# License +install -Dm644 "$PROJECT/LICENSE" "$ROOT_DIR/usr/share/licenses/sing-box/LICENSE" + +# APK metadata +PACKAGES_DIR="$ROOT_DIR/lib/apk/packages" +mkdir -p "$PACKAGES_DIR" + +# .conffiles +cat > "$PACKAGES_DIR/.conffiles" <<'EOF' +/etc/conf.d/sing-box +/etc/init.d/sing-box +/etc/sing-box/config.json +EOF + +# .conffiles_static (sha256 checksums) +while IFS= read -r conffile; do + sha256=$(sha256sum "$ROOT_DIR$conffile" | cut -d' ' -f1) + echo "$conffile $sha256" +done < "$PACKAGES_DIR/.conffiles" > "$PACKAGES_DIR/.conffiles_static" + +# .list (all files, excluding lib/apk/packages/ metadata) +(cd "$ROOT_DIR" && find . -type f -o -type l) \ + | sed 's|^\./|/|' \ + | grep -v '^/lib/apk/packages/' \ + | sort > "$PACKAGES_DIR/.list" + +# Build APK +apk --root "$APK_ROOT_DIR" mkpkg \ + --info "name:sing-box" \ + --info "version:${APK_VERSION}" \ + --info "description:The universal proxy platform." \ + --info "arch:${ARCHITECTURE}" \ + --info "license:GPL-3.0-or-later with name use or association addition" \ + --info "origin:sing-box" \ + --info "url:https://sing-box.sagernet.org/" \ + --info "maintainer:nekohasekai " \ + --files "$ROOT_DIR" \ + --output "$OUTPUT_PATH" diff --git a/.github/build_openwrt_apk.sh b/.github/build_openwrt_apk.sh new file mode 100755 index 00000000..59f07fd8 --- /dev/null +++ b/.github/build_openwrt_apk.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash + +set -e -o pipefail + +prepare_apk_root() { + # apk mkpkg resolves owner/group names through --root/etc/{passwd,group}. + APK_ROOT_DIR=$(mktemp -d) + mkdir -p "$APK_ROOT_DIR/etc" + cat > "$APK_ROOT_DIR/etc/passwd" < "$APK_ROOT_DIR/etc/group" < " + exit 1 +fi + +PROJECT=$(cd "$(dirname "$0")/.."; pwd) + +# Convert version to APK format: +# 1.13.0-beta.8 -> 1.13.0_beta8-r0 +# 1.13.0-rc.3 -> 1.13.0_rc3-r0 +# 1.13.0 -> 1.13.0-r0 +APK_VERSION=$(echo "$VERSION" | sed -E 's/-([a-z]+)\.([0-9]+)/_\1\2/') +APK_VERSION="${APK_VERSION}-r0" + +ROOT_DIR=$(mktemp -d) +prepare_apk_root +trap 'rm -rf "$ROOT_DIR" "$APK_ROOT_DIR"' EXIT + +# Binary +install -Dm755 "$BINARY_PATH" "$ROOT_DIR/usr/bin/sing-box" + +# Config files +install -Dm644 "$PROJECT/release/config/config.json" "$ROOT_DIR/etc/sing-box/config.json" +install -Dm644 "$PROJECT/release/config/openwrt.conf" "$ROOT_DIR/etc/config/sing-box" +install -Dm755 "$PROJECT/release/config/openwrt.init" "$ROOT_DIR/etc/init.d/sing-box" +install -Dm644 "$PROJECT/release/config/openwrt.keep" "$ROOT_DIR/lib/upgrade/keep.d/sing-box" + +# Completions +install -Dm644 "$PROJECT/release/completions/sing-box.bash" "$ROOT_DIR/usr/share/bash-completion/completions/sing-box.bash" +install -Dm644 "$PROJECT/release/completions/sing-box.fish" "$ROOT_DIR/usr/share/fish/vendor_completions.d/sing-box.fish" +install -Dm644 "$PROJECT/release/completions/sing-box.zsh" "$ROOT_DIR/usr/share/zsh/site-functions/_sing-box" + +# License +install -Dm644 "$PROJECT/LICENSE" "$ROOT_DIR/usr/share/licenses/sing-box/LICENSE" + +# APK metadata +PACKAGES_DIR="$ROOT_DIR/lib/apk/packages" +mkdir -p "$PACKAGES_DIR" + +# .conffiles +cat > "$PACKAGES_DIR/.conffiles" <<'EOF' +/etc/config/sing-box +/etc/sing-box/config.json +EOF + +# .conffiles_static (sha256 checksums) +while IFS= read -r conffile; do + sha256=$(sha256sum "$ROOT_DIR$conffile" | cut -d' ' -f1) + echo "$conffile $sha256" +done < "$PACKAGES_DIR/.conffiles" > "$PACKAGES_DIR/.conffiles_static" + +# .list (all files, excluding lib/apk/packages/ metadata) +(cd "$ROOT_DIR" && find . -type f -o -type l) \ + | sed 's|^\./|/|' \ + | grep -v '^/lib/apk/packages/' \ + | sort > "$PACKAGES_DIR/.list" + +# Build APK +apk --root "$APK_ROOT_DIR" mkpkg \ + --info "name:sing-box" \ + --info "version:${APK_VERSION}" \ + --info "description:The universal proxy platform." \ + --info "arch:${ARCHITECTURE}" \ + --info "license:GPL-3.0-or-later" \ + --info "origin:sing-box" \ + --info "url:https://sing-box.sagernet.org/" \ + --info "maintainer:nekohasekai " \ + --info "depends:ca-bundle kmod-inet-diag kmod-tun firewall4 kmod-nft-queue" \ + --info "provider-priority:100" \ + --script "pre-deinstall:${PROJECT}/release/config/openwrt.prerm" \ + --files "$ROOT_DIR" \ + --output "$OUTPUT_PATH" diff --git a/.github/detect_track.sh b/.github/detect_track.sh new file mode 100755 index 00000000..124ca6e2 --- /dev/null +++ b/.github/detect_track.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +branches=$(git branch -r --contains HEAD) +if echo "$branches" | grep -q 'origin/stable'; then + track=stable +elif echo "$branches" | grep -q 'origin/testing'; then + track=testing +elif echo "$branches" | grep -q 'origin/oldstable'; then + track=oldstable +else + echo "ERROR: HEAD is not on any known release branch (stable/testing/oldstable)" >&2 + exit 1 +fi + +if [[ "$track" == "stable" ]]; then + tag=$(git describe --tags --exact-match HEAD 2>/dev/null || true) + if [[ -n "$tag" && "$tag" == *"-"* ]]; then + track=beta + fi +fi + +case "$track" in + stable) name=sing-box; docker_tag=latest ;; + beta) name=sing-box-beta; docker_tag=latest-beta ;; + testing) name=sing-box-testing; docker_tag=latest-testing ;; + oldstable) name=sing-box-oldstable; docker_tag=latest-oldstable ;; +esac + +echo "track=${track} name=${name} docker_tag=${docker_tag}" >&2 +echo "TRACK=${track}" >> "$GITHUB_ENV" +echo "NAME=${name}" >> "$GITHUB_ENV" +echo "DOCKER_TAG=${docker_tag}" >> "$GITHUB_ENV" diff --git a/.github/setup_go_for_macos1013.sh b/.github/setup_go_for_macos1013.sh new file mode 100755 index 00000000..9889d236 --- /dev/null +++ b/.github/setup_go_for_macos1013.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash + +set -euo pipefail + +VERSION="1.25.9" +PATCH_COMMITS=( + "afe69d3cec1c6dcf0f1797b20546795730850070" + "1ed289b0cf87dc5aae9c6fe1aa5f200a83412938" +) +CURL_ARGS=( + -fL + --silent + --show-error +) + +if [[ -n "${GITHUB_TOKEN:-}" ]]; then + CURL_ARGS+=(-H "Authorization: Bearer ${GITHUB_TOKEN}") +fi + +mkdir -p "$HOME/go" +cd "$HOME/go" +wget "https://dl.google.com/go/go${VERSION}.darwin-arm64.tar.gz" +tar -xzf "go${VERSION}.darwin-arm64.tar.gz" +#cp -a go go_bootstrap +mv go go_osx +cd go_osx + +# these patch URLs only work on golang1.25.x +# that means after golang1.26 release it must be changed +# see: https://github.com/SagerNet/go/commits/release-branch.go1.25/ +# revert: +# 33d3f603c1: "cmd/link/internal/ld: use 12.0.0 OS/SDK versions for macOS linking" +# 937368f84e: "crypto/x509: change how we retrieve chains on darwin" + +for patch_commit in "${PATCH_COMMITS[@]}"; do + curl "${CURL_ARGS[@]}" "https://github.com/SagerNet/go/commit/${patch_commit}.diff" | patch --verbose -p 1 +done + +# Rebuild is not needed: we build with CGO_ENABLED=1, so Apple's external +# linker handles LC_BUILD_VERSION via MACOSX_DEPLOYMENT_TARGET, and the +# stdlib (crypto/x509) is compiled from patched src automatically. +#cd src +#GOROOT_BOOTSTRAP="$HOME/go/go_bootstrap" ./make.bash +#cd ../.. +#rm -rf go_bootstrap "go${VERSION}.darwin-arm64.tar.gz" diff --git a/.github/setup_go_for_windows7.sh b/.github/setup_go_for_windows7.sh index fe31b944..e8c36596 100755 --- a/.github/setup_go_for_windows7.sh +++ b/.github/setup_go_for_windows7.sh @@ -1,16 +1,35 @@ #!/usr/bin/env bash -VERSION="1.25.7" +set -euo pipefail -mkdir -p $HOME/go -cd $HOME/go +VERSION="1.25.9" +PATCH_COMMITS=( + "466f6c7a29bc098b0d4c987b803c779222894a11" + "1bdabae205052afe1dadb2ad6f1ba612cdbc532a" + "a90777dcf692dd2168577853ba743b4338721b06" + "f6bddda4e8ff58a957462a1a09562924d5f3d05c" + "bed309eff415bcb3c77dd4bc3277b682b89a388d" + "34b899c2fb39b092db4fa67c4417e41dc046be4b" +) +CURL_ARGS=( + -fL + --silent + --show-error +) + +if [[ -n "${GITHUB_TOKEN:-}" ]]; then + CURL_ARGS+=(-H "Authorization: Bearer ${GITHUB_TOKEN}") +fi + +mkdir -p "$HOME/go" +cd "$HOME/go" wget "https://dl.google.com/go/go${VERSION}.linux-amd64.tar.gz" tar -xzf "go${VERSION}.linux-amd64.tar.gz" mv go go_win7 cd go_win7 # modify from https://github.com/restic/restic/issues/4636#issuecomment-1896455557 -# this patch file only works on golang1.25.x +# these patch URLs only work on golang1.25.x # that means after golang1.26 release it must be changed # see: https://github.com/MetaCubeX/go/commits/release-branch.go1.25/ # revert: @@ -18,10 +37,10 @@ cd go_win7 # 7c1157f9544922e96945196b47b95664b1e39108: "net: remove sysSocket fallback for Windows 7" # 48042aa09c2f878c4faa576948b07fe625c4707a: "syscall: remove Windows 7 console handle workaround" # a17d959debdb04cd550016a3501dd09d50cd62e7: "runtime: always use LoadLibraryEx to load system libraries" +# fixes: +# bed309eff415bcb3c77dd4bc3277b682b89a388d: "Fix os.RemoveAll not working on Windows7" +# 34b899c2fb39b092db4fa67c4417e41dc046be4b: "Revert \"os: remove 5ms sleep on Windows in (*Process).Wait\"" -alias curl='curl -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}"' - -curl https://github.com/MetaCubeX/go/commit/8cb5472d94c34b88733a81091bd328e70ee565a4.diff | patch --verbose -p 1 -curl https://github.com/MetaCubeX/go/commit/6788c4c6f9fafb56729bad6b660f7ee2272d699f.diff | patch --verbose -p 1 -curl https://github.com/MetaCubeX/go/commit/a5b2168bb836ed9d6601c626f95e56c07923f906.diff | patch --verbose -p 1 -curl https://github.com/MetaCubeX/go/commit/f56f1e23507e646c85243a71bde7b9629b2f970c.diff | patch --verbose -p 1 +for patch_commit in "${PATCH_COMMITS[@]}"; do + curl "${CURL_ARGS[@]}" "https://github.com/MetaCubeX/go/commit/${patch_commit}.diff" | patch --verbose -p 1 +done diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3ebb2ca9..9d144536 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -47,7 +47,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: ~1.25.7 + go-version: ~1.25.9 - name: Check input version if: github.event_name == 'workflow_dispatch' run: |- @@ -72,27 +72,27 @@ jobs: include: - { os: linux, arch: amd64, variant: purego, naive: true } - { os: linux, arch: amd64, variant: glibc, naive: true } - - { os: linux, arch: amd64, variant: musl, naive: true, debian: amd64, rpm: x86_64, pacman: x86_64, openwrt: "x86_64" } + - { os: linux, arch: amd64, variant: musl, naive: true, debian: amd64, rpm: x86_64, pacman: x86_64, alpine: x86_64, openwrt: "x86_64" } - { os: linux, arch: arm64, variant: purego, naive: true } - { os: linux, arch: arm64, variant: glibc, naive: true } - - { os: linux, arch: arm64, variant: musl, naive: true, debian: arm64, rpm: aarch64, pacman: aarch64, openwrt: "aarch64_cortex-a53 aarch64_cortex-a72 aarch64_cortex-a76 aarch64_generic" } + - { os: linux, arch: arm64, variant: musl, naive: true, debian: arm64, rpm: aarch64, pacman: aarch64, alpine: aarch64, openwrt: "aarch64_cortex-a53 aarch64_cortex-a72 aarch64_cortex-a76 aarch64_generic" } - { os: linux, arch: "386", go386: sse2 } - { os: linux, arch: "386", variant: glibc, naive: true, go386: sse2 } - - { os: linux, arch: "386", variant: musl, naive: true, go386: sse2, debian: i386, rpm: i386, openwrt: "i386_pentium4" } + - { os: linux, arch: "386", variant: musl, naive: true, go386: sse2, debian: i386, rpm: i386, alpine: x86, openwrt: "i386_pentium4" } - { os: linux, arch: arm, goarm: "7" } - { os: linux, arch: arm, variant: glibc, naive: true, goarm: "7" } - - { os: linux, arch: arm, variant: musl, naive: true, goarm: "7", debian: armhf, rpm: armv7hl, pacman: armv7hl, openwrt: "arm_cortex-a5_vfpv4 arm_cortex-a7_neon-vfpv4 arm_cortex-a7_vfpv4 arm_cortex-a8_vfpv3 arm_cortex-a9_neon arm_cortex-a9_vfpv3-d16 arm_cortex-a15_neon-vfpv4" } + - { os: linux, arch: arm, variant: musl, naive: true, goarm: "7", debian: armhf, rpm: armv7hl, pacman: armv7hl, alpine: armv7, openwrt: "arm_cortex-a5_vfpv4 arm_cortex-a7_neon-vfpv4 arm_cortex-a7_vfpv4 arm_cortex-a8_vfpv3 arm_cortex-a9_neon arm_cortex-a9_vfpv3-d16 arm_cortex-a15_neon-vfpv4" } - { os: linux, arch: mipsle, gomips: hardfloat, naive: true, variant: glibc } - { os: linux, arch: mipsle, gomips: softfloat, naive: true, variant: musl, debian: mipsel, rpm: mipsel, openwrt: "mipsel_24kc mipsel_74kc mipsel_mips32" } - { os: linux, arch: mips64le, gomips: hardfloat, naive: true, variant: glibc, debian: mips64el, rpm: mips64el } - { os: linux, arch: riscv64, naive: true, variant: glibc } - - { os: linux, arch: riscv64, naive: true, variant: musl, debian: riscv64, rpm: riscv64, openwrt: "riscv64_generic" } + - { os: linux, arch: riscv64, naive: true, variant: musl, debian: riscv64, rpm: riscv64, alpine: riscv64, openwrt: "riscv64_generic" } - { os: linux, arch: loong64, naive: true, variant: glibc } - - { os: linux, arch: loong64, naive: true, variant: musl, debian: loongarch64, rpm: loongarch64, openwrt: "loongarch64_generic" } + - { os: linux, arch: loong64, naive: true, variant: musl, debian: loongarch64, rpm: loongarch64, alpine: loongarch64, openwrt: "loongarch64_generic" } - { os: linux, arch: "386", go386: softfloat, openwrt: "i386_pentium-mmx" } - { os: linux, arch: arm, goarm: "5", openwrt: "arm_arm926ej-s arm_cortex-a7 arm_cortex-a9 arm_fa526 arm_xscale" } @@ -121,15 +121,10 @@ jobs: with: fetch-depth: 0 - name: Setup Go - if: ${{ ! (matrix.legacy_win7 || matrix.legacy_go124) }} + if: ${{ ! matrix.legacy_win7 }} uses: actions/setup-go@v5 with: - go-version: ~1.25.7 - - name: Setup Go 1.24 - if: matrix.legacy_go124 - uses: actions/setup-go@v5 - with: - go-version: ~1.24.10 + go-version: ~1.25.9 - name: Cache Go for Windows 7 if: matrix.legacy_win7 id: cache-go-for-windows7 @@ -137,9 +132,11 @@ jobs: with: path: | ~/go/go_win7 - key: go_win7_1255 + key: go_win7_1258 - name: Setup Go for Windows 7 if: matrix.legacy_win7 && steps.cache-go-for-windows7.outputs.cache-hit != 'true' + env: + GITHUB_TOKEN: ${{ github.token }} run: |- .github/setup_go_for_windows7.sh - name: Setup Go for Windows 7 @@ -399,6 +396,30 @@ jobs: .github/deb2ipk.sh "$architecture" "dist/openwrt.deb" "dist/sing-box_${{ needs.calculate_version.outputs.version }}_openwrt_${architecture}.ipk" done rm "dist/openwrt.deb" + - name: Install apk-tools + if: matrix.openwrt != '' || matrix.alpine != '' + run: |- + docker run --rm -v /usr/local/bin:/mnt alpine:edge sh -c "apk add --no-cache apk-tools-static && cp /sbin/apk.static /mnt/apk && chmod +x /mnt/apk" + - name: Package OpenWrt APK + if: matrix.openwrt != '' + run: |- + set -xeuo pipefail + for architecture in ${{ matrix.openwrt }}; do + .github/build_openwrt_apk.sh \ + "$architecture" \ + "${{ needs.calculate_version.outputs.version }}" \ + "dist/sing-box" \ + "dist/sing-box_${{ needs.calculate_version.outputs.version }}_openwrt_${architecture}.apk" + done + - name: Package Alpine APK + if: matrix.alpine != '' + run: |- + set -xeuo pipefail + .github/build_alpine_apk.sh \ + "${{ matrix.alpine }}" \ + "${{ needs.calculate_version.outputs.version }}" \ + "dist/sing-box" \ + "dist/sing-box_${{ needs.calculate_version.outputs.version }}_linux_${{ matrix.alpine }}.apk" - name: Archive run: | set -xeuo pipefail @@ -434,22 +455,36 @@ jobs: include: - { arch: amd64 } - { arch: arm64 } - - { arch: amd64, legacy_go124: true, legacy_name: "macos-11" } + - { arch: amd64, legacy_osx: true, legacy_name: "macos-10.13" } steps: - name: Checkout uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 with: fetch-depth: 0 - name: Setup Go - if: ${{ ! matrix.legacy_go124 }} + if: ${{ ! matrix.legacy_osx }} uses: actions/setup-go@v5 with: go-version: ^1.25.3 - - name: Setup Go 1.24 - if: matrix.legacy_go124 - uses: actions/setup-go@v5 + - name: Cache Go for macOS 10.13 + if: matrix.legacy_osx + id: cache-go-for-macos1013 + uses: actions/cache@v4 with: - go-version: ~1.24.6 + 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" @@ -457,7 +492,7 @@ jobs: - name: Set build tags run: | set -xeuo pipefail - if [[ "${{ matrix.legacy_go124 }}" != "true" ]]; then + if [[ "${{ matrix.legacy_osx }}" != "true" ]]; then TAGS=$(cat release/DEFAULT_BUILD_TAGS) else TAGS=$(cat release/DEFAULT_BUILD_TAGS_OTHERS) @@ -477,6 +512,7 @@ jobs: 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: |- @@ -605,7 +641,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: ~1.25.7 + go-version: ~1.25.9 - name: Setup Android NDK id: setup-ndk uses: nttld/setup-ndk@v1 @@ -695,7 +731,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: ~1.25.7 + go-version: ~1.25.9 - name: Setup Android NDK id: setup-ndk uses: nttld/setup-ndk@v1 @@ -794,7 +830,7 @@ jobs: if: matrix.if uses: actions/setup-go@v5 with: - go-version: ~1.25.7 + go-version: ~1.25.9 - name: Set tag if: matrix.if run: |- diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 75e32583..2ec65bda 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -19,7 +19,6 @@ env: jobs: build_binary: name: Build binary - if: github.event_name != 'release' || github.event.release.target_commitish != 'oldstable' runs-on: ubuntu-latest strategy: fail-fast: true @@ -56,7 +55,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: ~1.25.7 + go-version: ~1.25.9 - name: Clone cronet-go if: matrix.naive run: | @@ -260,13 +259,13 @@ jobs: fi echo "ref=$ref" echo "ref=$ref" >> $GITHUB_OUTPUT - if [[ $ref == *"-"* ]]; then - latest=latest-beta - else - latest=latest - fi - echo "latest=$latest" - echo "latest=$latest" >> $GITHUB_OUTPUT + - name: Checkout + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + with: + ref: ${{ steps.ref.outputs.ref }} + fetch-depth: 0 + - name: Detect track + run: bash .github/detect_track.sh - name: Download digests uses: actions/download-artifact@v5 with: @@ -286,11 +285,11 @@ jobs: working-directory: /tmp/digests run: | docker buildx imagetools create \ - -t "${{ env.REGISTRY_IMAGE }}:${{ steps.ref.outputs.latest }}" \ + -t "${{ env.REGISTRY_IMAGE }}:${{ env.DOCKER_TAG }}" \ -t "${{ env.REGISTRY_IMAGE }}:${{ steps.ref.outputs.ref }}" \ $(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *) - name: Inspect image if: github.event_name != 'push' run: | - docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.ref.outputs.latest }} + docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ env.DOCKER_TAG }} docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.ref.outputs.ref }} diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index a029329c..88c1a5fd 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -11,11 +11,6 @@ on: description: "Version name" required: true type: string - forceBeta: - description: "Force beta" - required: false - type: boolean - default: false release: types: - published @@ -23,7 +18,6 @@ on: jobs: calculate_version: name: Calculate version - if: github.event_name != 'release' || github.event.release.target_commitish != 'oldstable' runs-on: ubuntu-latest outputs: version: ${{ steps.outputs.outputs.version }} @@ -35,7 +29,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: ~1.25.7 + go-version: ~1.25.9 - name: Check input version if: github.event_name == 'workflow_dispatch' run: |- @@ -78,7 +72,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: ~1.25.7 + go-version: ~1.25.9 - name: Clone cronet-go if: matrix.naive run: | @@ -168,14 +162,8 @@ jobs: - name: Set mtime run: |- TZ=UTC touch -t '197001010000' dist/sing-box - - name: Set name - if: (! contains(needs.calculate_version.outputs.version, '-')) && !inputs.forceBeta - run: |- - echo "NAME=sing-box" >> "$GITHUB_ENV" - - name: Set beta name - if: contains(needs.calculate_version.outputs.version, '-') || inputs.forceBeta - run: |- - echo "NAME=sing-box-beta" >> "$GITHUB_ENV" + - name: Detect track + run: bash .github/detect_track.sh - name: Set version run: |- PKG_VERSION="${{ needs.calculate_version.outputs.version }}" diff --git a/README.md b/README.md index 9dd4c107..65f2d0f7 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ +<<<<<<< HEAD # sing-box-extended Sing-box with extended features. @@ -12,6 +13,9 @@ Sing-box with extended features. * SDNS (DNSCrypt) * Extended Wireguard options * Unified delay +======= +# sing-box +>>>>>>> v1.13.11 ## Examples diff --git a/adapter/dns.go b/adapter/dns.go index 8f065e2e..23fbc9de 100644 --- a/adapter/dns.go +++ b/adapter/dns.go @@ -68,6 +68,8 @@ type DNSTransport interface { Type() string Tag() string Dependencies() []string + // Reset closes the transport's existing connections so later requests use fresh connections. + // Exchanges that are currently using those connections may fail. Reset() Exchange(ctx context.Context, message *dns.Msg) (*dns.Msg, error) } diff --git a/adapter/endpoint/manager.go b/adapter/endpoint/manager.go index 19d4b651..3167b120 100644 --- a/adapter/endpoint/manager.go +++ b/adapter/endpoint/manager.go @@ -4,7 +4,6 @@ import ( "context" "os" "sync" - "time" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/common/taskmonitor" @@ -12,7 +11,6 @@ import ( "github.com/sagernet/sing-box/log" "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" - F "github.com/sagernet/sing/common/format" ) var _ adapter.EndpointManager = (*Manager)(nil) @@ -51,13 +49,12 @@ func (m *Manager) Start(stage adapter.StartStage) error { m.access.Unlock() for _, endpoint := range endpoints { name := "endpoint/" + endpoint.Type() + "[" + endpoint.Tag() + "]" - m.logger.Trace(stage, " ", name) - startTime := time.Now() + done := adapter.LogElapsed(m.logger, stage, " ", name) err := adapter.LegacyStart(endpoint, stage) + done() if err != nil { return E.Cause(err, stage, " ", name) } - m.logger.Trace(stage, " ", name, " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") } return nil } @@ -75,14 +72,13 @@ func (m *Manager) Close() error { var err error for _, endpoint := range endpoints { name := "endpoint/" + endpoint.Type() + "[" + endpoint.Tag() + "]" - m.logger.Trace("close ", name) - startTime := time.Now() + done := adapter.LogElapsed(m.logger, "close ", name) monitor.Start("close ", name) err = E.Append(err, endpoint.Close(), func(err error) error { return E.Cause(err, "close ", name) }) monitor.Finish() - m.logger.Trace("close ", name, " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") + done() } return nil } @@ -133,13 +129,12 @@ func (m *Manager) Create(ctx context.Context, router adapter.Router, logger log. if m.started { name := "endpoint/" + endpoint.Type() + "[" + endpoint.Tag() + "]" for _, stage := range adapter.ListStartStages { - m.logger.Trace(stage, " ", name) - startTime := time.Now() + done := adapter.LogElapsed(m.logger, stage, " ", name) err = adapter.LegacyStart(endpoint, stage) + done() if err != nil { return E.Cause(err, stage, " ", name) } - m.logger.Trace(stage, " ", name, " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") } } if existsEndpoint, loaded := m.endpointByTag[tag]; loaded { diff --git a/adapter/inbound.go b/adapter/inbound.go index 830791ca..a3dfb933 100644 --- a/adapter/inbound.go +++ b/adapter/inbound.go @@ -103,6 +103,10 @@ type InboundContext struct { func (c *InboundContext) ResetRuleCache() { c.IPCIDRMatchSource = false c.IPCIDRAcceptEmpty = false + c.ResetRuleMatchCache() +} + +func (c *InboundContext) ResetRuleMatchCache() { c.SourceAddressMatch = false c.SourcePortMatch = false c.DestinationAddressMatch = false diff --git a/adapter/inbound/manager.go b/adapter/inbound/manager.go index 438c20f4..d6567cde 100644 --- a/adapter/inbound/manager.go +++ b/adapter/inbound/manager.go @@ -4,7 +4,6 @@ import ( "context" "os" "sync" - "time" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/common/taskmonitor" @@ -12,7 +11,6 @@ import ( "github.com/sagernet/sing-box/log" "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" - F "github.com/sagernet/sing/common/format" ) var _ adapter.InboundManager = (*Manager)(nil) @@ -48,13 +46,12 @@ func (m *Manager) Start(stage adapter.StartStage) error { m.access.Unlock() for _, inbound := range inbounds { name := "inbound/" + inbound.Type() + "[" + inbound.Tag() + "]" - m.logger.Trace(stage, " ", name) - startTime := time.Now() + done := adapter.LogElapsed(m.logger, stage, " ", name) err := adapter.LegacyStart(inbound, stage) + done() if err != nil { return E.Cause(err, stage, " ", name) } - m.logger.Trace(stage, " ", name, " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") } return nil } @@ -72,14 +69,13 @@ func (m *Manager) Close() error { var err error for _, inbound := range inbounds { name := "inbound/" + inbound.Type() + "[" + inbound.Tag() + "]" - m.logger.Trace("close ", name) - startTime := time.Now() + done := adapter.LogElapsed(m.logger, "close ", name) monitor.Start("close ", name) err = E.Append(err, inbound.Close(), func(err error) error { return E.Cause(err, "close ", name) }) monitor.Finish() - m.logger.Trace("close ", name, " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") + done() } return nil } @@ -133,13 +129,12 @@ func (m *Manager) Create(ctx context.Context, router adapter.Router, logger log. if m.started { name := "inbound/" + inbound.Type() + "[" + inbound.Tag() + "]" for _, stage := range adapter.ListStartStages { - m.logger.Trace(stage, " ", name) - startTime := time.Now() + done := adapter.LogElapsed(m.logger, stage, " ", name) err = adapter.LegacyStart(inbound, stage) + done() if err != nil { return E.Cause(err, stage, " ", name) } - m.logger.Trace(stage, " ", name, " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") } } if existsInbound, loaded := m.inboundByTag[tag]; loaded { diff --git a/adapter/lifecycle.go b/adapter/lifecycle.go index b969c98a..2a6a8ed7 100644 --- a/adapter/lifecycle.go +++ b/adapter/lifecycle.go @@ -77,26 +77,38 @@ func getServiceName(service any) string { func Start(logger log.ContextLogger, stage StartStage, services ...Lifecycle) error { for _, service := range services { name := getServiceName(service) - logger.Trace(stage, " ", name) - startTime := time.Now() + done := LogElapsed(logger, stage, " ", name) err := service.Start(stage) + done() if err != nil { return err } - logger.Trace(stage, " ", name, " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") } return nil } func StartNamed(logger log.ContextLogger, stage StartStage, services []LifecycleService) error { for _, service := range services { - logger.Trace(stage, " ", service.Name()) - startTime := time.Now() + done := LogElapsed(logger, stage, " ", service.Name()) err := service.Start(stage) + done() if err != nil { return E.Cause(err, stage.String(), " ", service.Name()) } - logger.Trace(stage, " ", service.Name(), " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") } return nil } + +func LogElapsed(logger log.ContextLogger, description ...any) func() { + prefix := F.ToString(description...) + startTime := time.Now() + timer := time.AfterFunc(time.Second, func() { + logger.Trace(prefix, "...") + }) + return func() { + if timer.Stop() { + return + } + logger.Trace(prefix, " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") + } +} diff --git a/adapter/network.go b/adapter/network.go index dd53b2b4..14fe46c8 100644 --- a/adapter/network.go +++ b/adapter/network.go @@ -1,6 +1,9 @@ package adapter import ( + "encoding/hex" + "net" + "strings" "time" C "github.com/sagernet/sing-box/constant" @@ -51,6 +54,24 @@ type WIFIState struct { BSSID string } +func NormalizeWIFIBSSID(bssid string) string { + bssid = strings.TrimSpace(bssid) + if bssid == "" { + return "" + } + parsed, err := net.ParseMAC(bssid) + if err == nil && len(parsed) == 6 { + return parsed.String() + } + if len(bssid) == 12 { + decoded, err := hex.DecodeString(bssid) + if err == nil { + return net.HardwareAddr(decoded).String() + } + } + return bssid +} + type NetworkInterface struct { control.Interface Type C.InterfaceType diff --git a/adapter/outbound/manager.go b/adapter/outbound/manager.go index 5c1b5d99..1bbad69e 100644 --- a/adapter/outbound/manager.go +++ b/adapter/outbound/manager.go @@ -6,7 +6,6 @@ import ( "os" "strings" "sync" - "time" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/common/taskmonitor" @@ -14,7 +13,6 @@ import ( "github.com/sagernet/sing-box/log" "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" - F "github.com/sagernet/sing/common/format" "github.com/sagernet/sing/common/logger" ) @@ -84,13 +82,12 @@ func (m *Manager) Start(stage adapter.StartStage) error { m.access.Unlock() for _, outbound := range outbounds { name := "outbound/" + outbound.Type() + "[" + outbound.Tag() + "]" - m.logger.Trace(stage, " ", name) - startTime := time.Now() + done := adapter.LogElapsed(m.logger, stage, " ", name) err := adapter.LegacyStart(outbound, stage) + done() if err != nil { return E.Cause(err, stage, " ", name) } - m.logger.Trace(stage, " ", name, " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") } } return nil @@ -117,27 +114,25 @@ func (m *Manager) startOutbounds(outbounds []adapter.Outbound) error { canContinue = true name := "outbound/" + outboundToStart.Type() + "[" + outboundTag + "]" if starter, isStarter := outboundToStart.(adapter.Lifecycle); isStarter { - m.logger.Trace("start ", name) - startTime := time.Now() + done := adapter.LogElapsed(m.logger, "start ", name) monitor.Start("start ", name) err := starter.Start(adapter.StartStateStart) monitor.Finish() + done() if err != nil { return E.Cause(err, "start ", name) } - m.logger.Trace("start ", name, " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") } else if starter, isStarter := outboundToStart.(interface { Start() error }); isStarter { - m.logger.Trace("start ", name) - startTime := time.Now() + done := adapter.LogElapsed(m.logger, "start ", name) monitor.Start("start ", name) err := starter.Start() monitor.Finish() + done() if err != nil { return E.Cause(err, "start ", name) } - m.logger.Trace("start ", name, " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") } } if len(started) == len(outbounds) { @@ -185,14 +180,13 @@ func (m *Manager) Close() error { for _, outbound := range outbounds { if closer, isCloser := outbound.(io.Closer); isCloser { name := "outbound/" + outbound.Type() + "[" + outbound.Tag() + "]" - m.logger.Trace("close ", name) - startTime := time.Now() + done := adapter.LogElapsed(m.logger, "close ", name) monitor.Start("close ", name) err = E.Append(err, closer.Close(), func(err error) error { return E.Cause(err, "close ", name) }) monitor.Finish() - m.logger.Trace("close ", name, " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") + done() } } return nil @@ -275,13 +269,12 @@ func (m *Manager) Create(ctx context.Context, router adapter.Router, logger log. if m.started { name := "outbound/" + outbound.Type() + "[" + outbound.Tag() + "]" for _, stage := range adapter.ListStartStages { - m.logger.Trace(stage, " ", name) - startTime := time.Now() + done := adapter.LogElapsed(m.logger, stage, " ", name) err = adapter.LegacyStart(outbound, stage) + done() if err != nil { return E.Cause(err, stage, " ", name) } - m.logger.Trace(stage, " ", name, " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") } } m.access.Lock() diff --git a/adapter/platform.go b/adapter/platform.go index 95db93c6..df1f4471 100644 --- a/adapter/platform.go +++ b/adapter/platform.go @@ -1,6 +1,8 @@ package adapter import ( + "net/netip" + "github.com/sagernet/sing-box/option" "github.com/sagernet/sing-tun" "github.com/sagernet/sing/common/logger" @@ -36,6 +38,8 @@ type PlatformInterface interface { UsePlatformNotification() bool SendNotification(notification *Notification) error + + MyInterfaceAddress() []netip.Addr } type FindConnectionOwnerRequest struct { @@ -47,11 +51,11 @@ type FindConnectionOwnerRequest struct { } type ConnectionOwner struct { - ProcessID uint32 - UserId int32 - UserName string - ProcessPath string - AndroidPackageName string + ProcessID uint32 + UserId int32 + UserName string + ProcessPath string + AndroidPackageNames []string } type Notification struct { diff --git a/adapter/service/manager.go b/adapter/service/manager.go index f17aa07e..1a83c503 100644 --- a/adapter/service/manager.go +++ b/adapter/service/manager.go @@ -4,7 +4,6 @@ import ( "context" "os" "sync" - "time" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/common/taskmonitor" @@ -12,7 +11,6 @@ import ( "github.com/sagernet/sing-box/log" "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" - F "github.com/sagernet/sing/common/format" ) var _ adapter.ServiceManager = (*Manager)(nil) @@ -46,13 +44,12 @@ func (m *Manager) Start(stage adapter.StartStage) error { m.access.Unlock() for _, service := range services { name := "service/" + service.Type() + "[" + service.Tag() + "]" - m.logger.Trace(stage, " ", name) - startTime := time.Now() + done := adapter.LogElapsed(m.logger, stage, " ", name) err := adapter.LegacyStart(service, stage) + done() if err != nil { return E.Cause(err, stage, " ", name) } - m.logger.Trace(stage, " ", name, " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") } return nil } @@ -70,14 +67,13 @@ func (m *Manager) Close() error { var err error for _, service := range services { name := "service/" + service.Type() + "[" + service.Tag() + "]" - m.logger.Trace("close ", name) - startTime := time.Now() + done := adapter.LogElapsed(m.logger, "close ", name) monitor.Start("close ", name) err = E.Append(err, service.Close(), func(err error) error { return E.Cause(err, "close ", name) }) monitor.Finish() - m.logger.Trace("close ", name, " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") + done() } return nil } @@ -128,13 +124,12 @@ func (m *Manager) Create(ctx context.Context, logger log.ContextLogger, tag stri if m.started { name := "service/" + service.Type() + "[" + service.Tag() + "]" for _, stage := range adapter.ListStartStages { - m.logger.Trace(stage, " ", name) - startTime := time.Now() + done := adapter.LogElapsed(m.logger, stage, " ", name) err = adapter.LegacyStart(service, stage) + done() if err != nil { return E.Cause(err, stage, " ", name) } - m.logger.Trace(stage, " ", name, " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") } } if existsService, loaded := m.serviceByTag[tag]; loaded { diff --git a/box.go b/box.go index 486987b1..3dfe7d6d 100644 --- a/box.go +++ b/box.go @@ -20,7 +20,6 @@ import ( "github.com/sagernet/sing-box/common/urltest" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/dns" - "github.com/sagernet/sing-box/dns/transport/local" "github.com/sagernet/sing-box/experimental" "github.com/sagernet/sing-box/experimental/cachefile" "github.com/sagernet/sing-box/log" @@ -330,11 +329,12 @@ func New(options Options) (*Box, error) { ) }) dnsTransportManager.Initialize(func() (adapter.DNSTransport, error) { - return local.NewTransport( + return dnsTransportRegistry.CreateDNSTransport( ctx, logFactory.NewLogger("dns/local"), "local", - option.LocalDNSServerOptions{}, + C.DNSTypeLocal, + &option.LocalDNSServerOptions{}, ) }) if platformInterface != nil { @@ -519,27 +519,24 @@ func (s *Box) Close() error { {"dns-transport", s.dnsTransport}, {"network", s.network}, } { - s.logger.Trace("close ", closeItem.name) - startTime := time.Now() + done := adapter.LogElapsed(s.logger, "close ", closeItem.name) err = E.Append(err, closeItem.service.Close(), func(err error) error { return E.Cause(err, "close ", closeItem.name) }) - s.logger.Trace("close ", closeItem.name, " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") + done() } for _, lifecycleService := range s.internalService { - s.logger.Trace("close ", lifecycleService.Name()) - startTime := time.Now() + done := adapter.LogElapsed(s.logger, "close ", lifecycleService.Name()) err = E.Append(err, lifecycleService.Close(), func(err error) error { return E.Cause(err, "close ", lifecycleService.Name()) }) - s.logger.Trace("close ", lifecycleService.Name(), " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") + done() } - s.logger.Trace("close logger") - startTime := time.Now() + done := adapter.LogElapsed(s.logger, "close logger") err = E.Append(err, s.logFactory.Close(), func(err error) error { return E.Cause(err, "close logger") }) - s.logger.Trace("close logger completed (", F.Seconds(time.Since(startTime).Seconds()), "s)") + done() return err } @@ -559,6 +556,10 @@ func (s *Box) Outbound() adapter.OutboundManager { return s.outbound } +func (s *Box) Endpoint() adapter.EndpointManager { + return s.endpoint +} + func (s *Box) LogFactory() log.Factory { return s.logFactory } diff --git a/common/dialer/default.go b/common/dialer/default.go index 6b2379f4..4ffe00c1 100644 --- a/common/dialer/default.go +++ b/common/dialer/default.go @@ -149,7 +149,10 @@ func NewDefault(ctx context.Context, options option.DialerOptions) (*DefaultDial } else { dialer.Timeout = C.TCPConnectTimeout } - if !options.DisableTCPKeepAlive { + if options.DisableTCPKeepAlive { + dialer.KeepAlive = -1 + dialer.KeepAliveConfig.Enable = false + } else { keepIdle := time.Duration(options.TCPKeepAlive) if keepIdle == 0 { keepIdle = C.TCPKeepAliveInitial @@ -239,7 +242,7 @@ func setMarkWrapper(networkManager adapter.NetworkManager, mark uint32, isDefaul func (d *DefaultDialer) DialContext(ctx context.Context, network string, address M.Socksaddr) (net.Conn, error) { if !address.IsValid() { return nil, E.New("invalid address") - } else if address.IsFqdn() { + } else if address.IsDomain() { return nil, E.New("domain not resolved") } if d.networkStrategy == nil { @@ -329,9 +332,9 @@ func (d *DefaultDialer) ListenPacket(ctx context.Context, destination M.Socksadd func (d *DefaultDialer) DialerForICMPDestination(destination netip.Addr) net.Dialer { if !destination.Is6() { - return d.dialer6.Dialer - } else { return d.dialer4.Dialer + } else { + return d.dialer6.Dialer } } diff --git a/common/dialer/default_parallel_interface.go b/common/dialer/default_parallel_interface.go index ca374b2e..eafab75a 100644 --- a/common/dialer/default_parallel_interface.go +++ b/common/dialer/default_parallel_interface.go @@ -184,6 +184,12 @@ func (d *DefaultDialer) listenSerialInterfacePacket(ctx context.Context, listene func selectInterfaces(networkManager adapter.NetworkManager, strategy C.NetworkStrategy, interfaceType []C.InterfaceType, fallbackInterfaceType []C.InterfaceType) (primaryInterfaces []adapter.NetworkInterface, fallbackInterfaces []adapter.NetworkInterface) { interfaces := networkManager.NetworkInterfaces() + myInterface := networkManager.InterfaceMonitor().MyInterface() + if myInterface != "" { + interfaces = common.Filter(interfaces, func(it adapter.NetworkInterface) bool { + return it.Name != myInterface + }) + } switch strategy { case C.NetworkStrategyDefault: if len(interfaceType) == 0 { diff --git a/common/dialer/resolve.go b/common/dialer/resolve.go index 49ed0703..21fe38d5 100644 --- a/common/dialer/resolve.go +++ b/common/dialer/resolve.go @@ -96,7 +96,7 @@ func (d *resolveDialer) DialContext(ctx context.Context, network string, destina if err != nil { return nil, err } - if !destination.IsFqdn() { + if !destination.IsDomain() { return d.dialer.DialContext(ctx, network, destination) } ctx = log.ContextWithOverrideLevel(ctx, log.LevelDebug) @@ -116,7 +116,7 @@ func (d *resolveDialer) ListenPacket(ctx context.Context, destination M.Socksadd if err != nil { return nil, err } - if !destination.IsFqdn() { + if !destination.IsDomain() { return d.dialer.ListenPacket(ctx, destination) } ctx = log.ContextWithOverrideLevel(ctx, log.LevelDebug) @@ -144,7 +144,7 @@ func (d *resolveParallelNetworkDialer) DialParallelInterface(ctx context.Context if err != nil { return nil, err } - if !destination.IsFqdn() { + if !destination.IsDomain() { return d.dialer.DialContext(ctx, network, destination) } ctx = log.ContextWithOverrideLevel(ctx, log.LevelDebug) @@ -167,7 +167,7 @@ func (d *resolveParallelNetworkDialer) ListenSerialInterfacePacket(ctx context.C if err != nil { return nil, err } - if !destination.IsFqdn() { + if !destination.IsDomain() { return d.dialer.ListenPacket(ctx, destination) } ctx = log.ContextWithOverrideLevel(ctx, log.LevelDebug) diff --git a/common/ktls/ktls_read.go b/common/ktls/ktls_read.go index 7ffa1e18..5609bfb5 100644 --- a/common/ktls/ktls_read.go +++ b/common/ktls/ktls_read.go @@ -12,6 +12,7 @@ import ( "fmt" "io" "net" + "unsafe" ) func (c *Conn) Read(b []byte) (int, error) { @@ -229,7 +230,7 @@ func (c *Conn) readRawRecord() (typ uint8, data []byte, err error) { record := c.rawConn.RawInput.Next(recordHeaderLen + n) data, typ, err = c.rawConn.In.Decrypt(record) if err != nil { - err = c.rawConn.In.SetErrorLocked(c.sendAlert(uint8(err.(tls.AlertError)))) + err = c.rawConn.In.SetErrorLocked(c.sendAlert(*(*uint8)((*[2]unsafe.Pointer)(unsafe.Pointer(&err))[1]))) return } return diff --git a/common/listener/listener.go b/common/listener/listener.go index 7d49d664..cc27a62e 100644 --- a/common/listener/listener.go +++ b/common/listener/listener.go @@ -151,6 +151,7 @@ func ListenNetworkNamespace[T any](nameOrPath string, block func() (T, error)) ( if err != nil { return common.DefaultValue[T](), E.Cause(err, "get current netns") } + defer currentNs.Close() defer netns.Set(currentNs) var targetNs netns.NsHandle if strings.HasPrefix(nameOrPath, "/") { diff --git a/common/listener/listener_tcp.go b/common/listener/listener_tcp.go index 899d444f..54d84a6b 100644 --- a/common/listener/listener_tcp.go +++ b/common/listener/listener_tcp.go @@ -37,7 +37,10 @@ func (l *Listener) ListenTCP() (net.Listener, error) { if l.listenOptions.ReuseAddr { listenConfig.Control = control.Append(listenConfig.Control, control.ReuseAddr()) } - if !l.listenOptions.DisableTCPKeepAlive { + if l.listenOptions.DisableTCPKeepAlive { + listenConfig.KeepAlive = -1 + listenConfig.KeepAliveConfig.Enable = false + } else { keepIdle := time.Duration(l.listenOptions.TCPKeepAlive) if keepIdle == 0 { keepIdle = C.TCPKeepAliveInitial diff --git a/common/process/searcher.go b/common/process/searcher.go index 1af2c2bd..64305237 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) + Close() error } var ErrNotFound = E.New("process not found") @@ -28,7 +29,7 @@ func FindProcessInfo(searcher Searcher, ctx context.Context, network string, sou if err != nil { return nil, err } - if info.UserId != -1 { + if info.UserId != -1 && info.UserName == "" { osUser, _ := user.LookupId(F.ToString(info.UserId)) if osUser != nil { info.UserName = osUser.Username diff --git a/common/process/searcher_android.go b/common/process/searcher_android.go index ac9550ce..287c7219 100644 --- a/common/process/searcher_android.go +++ b/common/process/searcher_android.go @@ -6,6 +6,7 @@ import ( "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common" ) var _ Searcher = (*androidSearcher)(nil) @@ -18,22 +19,30 @@ func NewSearcher(config Config) (Searcher, error) { return &androidSearcher{config.PackageManager}, nil } +func (s *androidSearcher) Close() error { + return nil +} + func (s *androidSearcher) FindProcessInfo(ctx context.Context, network string, source netip.AddrPort, destination netip.AddrPort) (*adapter.ConnectionOwner, error) { - _, uid, err := resolveSocketByNetlink(network, source, destination) + family, protocol, err := socketDiagSettings(network, source) if err != nil { return nil, err } - if sharedPackage, loaded := s.packageManager.SharedPackageByID(uid % 100000); loaded { - return &adapter.ConnectionOwner{ - UserId: int32(uid), - AndroidPackageName: sharedPackage, - }, nil + _, uid, err := querySocketDiagOnce(family, protocol, source) + if err != nil { + return nil, err } - if packageName, loaded := s.packageManager.PackageByID(uid % 100000); loaded { - return &adapter.ConnectionOwner{ - UserId: int32(uid), - AndroidPackageName: packageName, - }, nil + appID := uid % 100000 + var packageNames []string + if sharedPackage, loaded := s.packageManager.SharedPackageByID(appID); loaded { + packageNames = append(packageNames, sharedPackage) } - return &adapter.ConnectionOwner{UserId: int32(uid)}, nil + if packages, loaded := s.packageManager.PackagesByID(appID); loaded { + packageNames = append(packageNames, packages...) + } + packageNames = common.Uniq(packageNames) + return &adapter.ConnectionOwner{ + UserId: int32(uid), + AndroidPackageNames: packageNames, + }, nil } diff --git a/common/process/searcher_darwin.go b/common/process/searcher_darwin.go index 03428cc8..1b5c0dd6 100644 --- a/common/process/searcher_darwin.go +++ b/common/process/searcher_darwin.go @@ -1,19 +1,15 @@ +//go:build darwin + package process import ( "context" - "encoding/binary" "net/netip" - "os" "strconv" "strings" "syscall" - "unsafe" "github.com/sagernet/sing-box/adapter" - N "github.com/sagernet/sing/common/network" - - "golang.org/x/sys/unix" ) var _ Searcher = (*darwinSearcher)(nil) @@ -24,12 +20,12 @@ func NewSearcher(_ Config) (Searcher, error) { return &darwinSearcher{}, nil } +func (d *darwinSearcher) Close() error { + return nil +} + func (d *darwinSearcher) FindProcessInfo(ctx context.Context, network string, source netip.AddrPort, destination netip.AddrPort) (*adapter.ConnectionOwner, error) { - processName, err := findProcessName(network, source.Addr(), int(source.Port())) - if err != nil { - return nil, err - } - return &adapter.ConnectionOwner{ProcessPath: processName, UserId: -1}, nil + return FindDarwinConnectionOwner(network, source, destination) } var structSize = func() int { @@ -47,107 +43,3 @@ var structSize = func() int { return 384 } }() - -func findProcessName(network string, ip netip.Addr, port int) (string, error) { - var spath string - switch network { - case N.NetworkTCP: - spath = "net.inet.tcp.pcblist_n" - case N.NetworkUDP: - spath = "net.inet.udp.pcblist_n" - default: - return "", os.ErrInvalid - } - - isIPv4 := ip.Is4() - - value, err := unix.SysctlRaw(spath) - if err != nil { - return "", err - } - - buf := value - - // from darwin-xnu/bsd/netinet/in_pcblist.c:get_pcblist_n - // size/offset are round up (aligned) to 8 bytes in darwin - // rup8(sizeof(xinpcb_n)) + rup8(sizeof(xsocket_n)) + - // 2 * rup8(sizeof(xsockbuf_n)) + rup8(sizeof(xsockstat_n)) - itemSize := structSize - if network == N.NetworkTCP { - // rup8(sizeof(xtcpcb_n)) - itemSize += 208 - } - - var fallbackUDPProcess string - // skip the first xinpgen(24 bytes) block - for i := 24; i+itemSize <= len(buf); i += itemSize { - // offset of xinpcb_n and xsocket_n - inp, so := i, i+104 - - srcPort := binary.BigEndian.Uint16(buf[inp+18 : inp+20]) - if uint16(port) != srcPort { - continue - } - - // xinpcb_n.inp_vflag - flag := buf[inp+44] - - var srcIP netip.Addr - srcIsIPv4 := false - switch { - case flag&0x1 > 0 && isIPv4: - // ipv4 - srcIP = netip.AddrFrom4([4]byte(buf[inp+76 : inp+80])) - srcIsIPv4 = true - case flag&0x2 > 0 && !isIPv4: - // ipv6 - srcIP = netip.AddrFrom16([16]byte(buf[inp+64 : inp+80])) - default: - continue - } - - if ip == srcIP { - // xsocket_n.so_last_pid - pid := readNativeUint32(buf[so+68 : so+72]) - return getExecPathFromPID(pid) - } - - // udp packet connection may be not equal with srcIP - if network == N.NetworkUDP && srcIP.IsUnspecified() && isIPv4 == srcIsIPv4 { - pid := readNativeUint32(buf[so+68 : so+72]) - fallbackUDPProcess, _ = getExecPathFromPID(pid) - } - } - - if network == N.NetworkUDP && len(fallbackUDPProcess) > 0 { - return fallbackUDPProcess, nil - } - - return "", ErrNotFound -} - -func getExecPathFromPID(pid uint32) (string, error) { - const ( - procpidpathinfo = 0xb - procpidpathinfosize = 1024 - proccallnumpidinfo = 0x2 - ) - buf := make([]byte, procpidpathinfosize) - _, _, errno := syscall.Syscall6( - syscall.SYS_PROC_INFO, - proccallnumpidinfo, - uintptr(pid), - procpidpathinfo, - 0, - uintptr(unsafe.Pointer(&buf[0])), - procpidpathinfosize) - if errno != 0 { - return "", errno - } - - return unix.ByteSliceToString(buf), nil -} - -func readNativeUint32(b []byte) uint32 { - return *(*uint32)(unsafe.Pointer(&b[0])) -} diff --git a/common/process/searcher_darwin_shared.go b/common/process/searcher_darwin_shared.go new file mode 100644 index 00000000..05925530 --- /dev/null +++ b/common/process/searcher_darwin_shared.go @@ -0,0 +1,269 @@ +//go:build darwin + +package process + +import ( + "encoding/binary" + "net/netip" + "os" + "sync" + "syscall" + "time" + "unsafe" + + "github.com/sagernet/sing-box/adapter" + N "github.com/sagernet/sing/common/network" + + "golang.org/x/sys/unix" +) + +const ( + darwinSnapshotTTL = 200 * time.Millisecond + + darwinXinpgenSize = 24 + darwinXsocketOffset = 104 + darwinXinpcbForeignPort = 16 + darwinXinpcbLocalPort = 18 + darwinXinpcbVFlag = 44 + darwinXinpcbForeignAddr = 48 + darwinXinpcbLocalAddr = 64 + darwinXinpcbIPv4Addr = 12 + darwinXsocketUID = 64 + darwinXsocketLastPID = 68 + darwinTCPExtraStructSize = 208 +) + +type darwinConnectionEntry struct { + localAddr netip.Addr + remoteAddr netip.Addr + localPort uint16 + remotePort uint16 + pid uint32 + uid int32 +} + +type darwinConnectionMatchKind uint8 + +const ( + darwinConnectionMatchExact darwinConnectionMatchKind = iota + darwinConnectionMatchLocalFallback + darwinConnectionMatchWildcardFallback +) + +type darwinSnapshot struct { + createdAt time.Time + entries []darwinConnectionEntry +} + +type darwinConnectionFinder struct { + access sync.Mutex + ttl time.Duration + snapshots map[string]darwinSnapshot + builder func(string) (darwinSnapshot, error) +} + +var sharedDarwinConnectionFinder = newDarwinConnectionFinder(darwinSnapshotTTL) + +func newDarwinConnectionFinder(ttl time.Duration) *darwinConnectionFinder { + return &darwinConnectionFinder{ + ttl: ttl, + snapshots: make(map[string]darwinSnapshot), + builder: buildDarwinSnapshot, + } +} + +func FindDarwinConnectionOwner(network string, source netip.AddrPort, destination netip.AddrPort) (*adapter.ConnectionOwner, error) { + return sharedDarwinConnectionFinder.find(network, source, destination) +} + +func (f *darwinConnectionFinder) find(network string, source netip.AddrPort, destination netip.AddrPort) (*adapter.ConnectionOwner, error) { + networkName := N.NetworkName(network) + source = normalizeDarwinAddrPort(source) + destination = normalizeDarwinAddrPort(destination) + var lastOwner *adapter.ConnectionOwner + for attempt := 0; attempt < 2; attempt++ { + snapshot, fromCache, err := f.loadSnapshot(networkName, attempt > 0) + if err != nil { + return nil, err + } + entry, matchKind, err := matchDarwinConnectionEntry(snapshot.entries, networkName, source, destination) + if err != nil { + if err == ErrNotFound && fromCache { + continue + } + return nil, err + } + if fromCache && matchKind != darwinConnectionMatchExact { + continue + } + owner := &adapter.ConnectionOwner{ + UserId: entry.uid, + } + lastOwner = owner + if entry.pid == 0 { + return owner, nil + } + processPath, err := getExecPathFromPID(entry.pid) + if err == nil { + owner.ProcessPath = processPath + return owner, nil + } + if fromCache { + continue + } + return owner, nil + } + if lastOwner != nil { + return lastOwner, nil + } + return nil, ErrNotFound +} + +func (f *darwinConnectionFinder) loadSnapshot(network string, forceRefresh bool) (darwinSnapshot, bool, error) { + f.access.Lock() + defer f.access.Unlock() + if !forceRefresh { + if snapshot, loaded := f.snapshots[network]; loaded && time.Since(snapshot.createdAt) < f.ttl { + return snapshot, true, nil + } + } + snapshot, err := f.builder(network) + if err != nil { + return darwinSnapshot{}, false, err + } + f.snapshots[network] = snapshot + return snapshot, false, nil +} + +func buildDarwinSnapshot(network string) (darwinSnapshot, error) { + spath, itemSize, err := darwinSnapshotSettings(network) + if err != nil { + return darwinSnapshot{}, err + } + value, err := unix.SysctlRaw(spath) + if err != nil { + return darwinSnapshot{}, err + } + return darwinSnapshot{ + createdAt: time.Now(), + entries: parseDarwinSnapshot(value, itemSize), + }, nil +} + +func darwinSnapshotSettings(network string) (string, int, error) { + itemSize := structSize + switch network { + case N.NetworkTCP: + return "net.inet.tcp.pcblist_n", itemSize + darwinTCPExtraStructSize, nil + case N.NetworkUDP: + return "net.inet.udp.pcblist_n", itemSize, nil + default: + return "", 0, os.ErrInvalid + } +} + +func parseDarwinSnapshot(buf []byte, itemSize int) []darwinConnectionEntry { + entries := make([]darwinConnectionEntry, 0, (len(buf)-darwinXinpgenSize)/itemSize) + for i := darwinXinpgenSize; i+itemSize <= len(buf); i += itemSize { + inp := i + so := i + darwinXsocketOffset + entry, ok := parseDarwinConnectionEntry(buf[inp:so], buf[so:so+structSize-darwinXsocketOffset]) + if ok { + entries = append(entries, entry) + } + } + return entries +} + +func parseDarwinConnectionEntry(inp []byte, so []byte) (darwinConnectionEntry, bool) { + if len(inp) < darwinXsocketOffset || len(so) < structSize-darwinXsocketOffset { + return darwinConnectionEntry{}, false + } + entry := darwinConnectionEntry{ + remotePort: binary.BigEndian.Uint16(inp[darwinXinpcbForeignPort : darwinXinpcbForeignPort+2]), + localPort: binary.BigEndian.Uint16(inp[darwinXinpcbLocalPort : darwinXinpcbLocalPort+2]), + pid: binary.NativeEndian.Uint32(so[darwinXsocketLastPID : darwinXsocketLastPID+4]), + uid: int32(binary.NativeEndian.Uint32(so[darwinXsocketUID : darwinXsocketUID+4])), + } + flag := inp[darwinXinpcbVFlag] + switch { + case flag&0x1 != 0: + entry.remoteAddr = netip.AddrFrom4([4]byte(inp[darwinXinpcbForeignAddr+darwinXinpcbIPv4Addr : darwinXinpcbForeignAddr+darwinXinpcbIPv4Addr+4])) + entry.localAddr = netip.AddrFrom4([4]byte(inp[darwinXinpcbLocalAddr+darwinXinpcbIPv4Addr : darwinXinpcbLocalAddr+darwinXinpcbIPv4Addr+4])) + return entry, true + case flag&0x2 != 0: + entry.remoteAddr = netip.AddrFrom16([16]byte(inp[darwinXinpcbForeignAddr : darwinXinpcbForeignAddr+16])) + entry.localAddr = netip.AddrFrom16([16]byte(inp[darwinXinpcbLocalAddr : darwinXinpcbLocalAddr+16])) + return entry, true + default: + return darwinConnectionEntry{}, false + } +} + +func matchDarwinConnectionEntry(entries []darwinConnectionEntry, network string, source netip.AddrPort, destination netip.AddrPort) (darwinConnectionEntry, darwinConnectionMatchKind, error) { + sourceAddr := source.Addr() + if !sourceAddr.IsValid() { + return darwinConnectionEntry{}, darwinConnectionMatchExact, os.ErrInvalid + } + var localFallback darwinConnectionEntry + var hasLocalFallback bool + var wildcardFallback darwinConnectionEntry + var hasWildcardFallback bool + for _, entry := range entries { + if entry.localPort != source.Port() || sourceAddr.BitLen() != entry.localAddr.BitLen() { + continue + } + if entry.localAddr == sourceAddr && destination.IsValid() && entry.remotePort == destination.Port() && entry.remoteAddr == destination.Addr() { + return entry, darwinConnectionMatchExact, nil + } + if !destination.IsValid() && entry.localAddr == sourceAddr { + return entry, darwinConnectionMatchExact, nil + } + if network != N.NetworkUDP { + continue + } + if !hasLocalFallback && entry.localAddr == sourceAddr { + hasLocalFallback = true + localFallback = entry + } + if !hasWildcardFallback && entry.localAddr.IsUnspecified() { + hasWildcardFallback = true + wildcardFallback = entry + } + } + if hasLocalFallback { + return localFallback, darwinConnectionMatchLocalFallback, nil + } + if hasWildcardFallback { + return wildcardFallback, darwinConnectionMatchWildcardFallback, nil + } + return darwinConnectionEntry{}, darwinConnectionMatchExact, ErrNotFound +} + +func normalizeDarwinAddrPort(addrPort netip.AddrPort) netip.AddrPort { + if !addrPort.IsValid() { + return addrPort + } + return netip.AddrPortFrom(addrPort.Addr().Unmap(), addrPort.Port()) +} + +func getExecPathFromPID(pid uint32) (string, error) { + const ( + procpidpathinfo = 0xb + procpidpathinfosize = 1024 + proccallnumpidinfo = 0x2 + ) + buf := make([]byte, procpidpathinfosize) + _, _, errno := syscall.Syscall6( + syscall.SYS_PROC_INFO, + proccallnumpidinfo, + uintptr(pid), + procpidpathinfo, + 0, + uintptr(unsafe.Pointer(&buf[0])), + procpidpathinfosize) + if errno != 0 { + return "", errno + } + return unix.ByteSliceToString(buf), nil +} diff --git a/common/process/searcher_linux.go b/common/process/searcher_linux.go index 86d37d7c..9b1a9160 100644 --- a/common/process/searcher_linux.go +++ b/common/process/searcher_linux.go @@ -4,33 +4,82 @@ package process import ( "context" + "errors" "net/netip" + "syscall" + "time" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/log" + E "github.com/sagernet/sing/common/exceptions" ) var _ Searcher = (*linuxSearcher)(nil) type linuxSearcher struct { - logger log.ContextLogger + logger log.ContextLogger + diagConns [4]*socketDiagConn + processPathCache *uidProcessPathCache } func NewSearcher(config Config) (Searcher, error) { - return &linuxSearcher{config.Logger}, nil + searcher := &linuxSearcher{ + logger: config.Logger, + processPathCache: newUIDProcessPathCache(time.Second), + } + for _, family := range []uint8{syscall.AF_INET, syscall.AF_INET6} { + for _, protocol := range []uint8{syscall.IPPROTO_TCP, syscall.IPPROTO_UDP} { + searcher.diagConns[socketDiagConnIndex(family, protocol)] = newSocketDiagConn(family, protocol) + } + } + return searcher, nil +} + +func (s *linuxSearcher) Close() error { + var errs []error + for _, conn := range s.diagConns { + if conn == nil { + continue + } + errs = append(errs, conn.Close()) + } + return E.Errors(errs...) } func (s *linuxSearcher) FindProcessInfo(ctx context.Context, network string, source netip.AddrPort, destination netip.AddrPort) (*adapter.ConnectionOwner, error) { - inode, uid, err := resolveSocketByNetlink(network, source, destination) + inode, uid, err := s.resolveSocketByNetlink(network, source, destination) if err != nil { return nil, err } - processPath, err := resolveProcessNameByProcSearch(inode, uid) + processInfo := &adapter.ConnectionOwner{ + UserId: int32(uid), + } + processPath, err := s.processPathCache.findProcessPath(inode, uid) if err != nil { s.logger.DebugContext(ctx, "find process path: ", err) + } else { + processInfo.ProcessPath = processPath } - return &adapter.ConnectionOwner{ - UserId: int32(uid), - ProcessPath: processPath, - }, nil + return processInfo, nil +} + +func (s *linuxSearcher) resolveSocketByNetlink(network string, source netip.AddrPort, destination netip.AddrPort) (inode, uid uint32, err error) { + family, protocol, err := socketDiagSettings(network, source) + if err != nil { + return 0, 0, err + } + conn := s.diagConns[socketDiagConnIndex(family, protocol)] + if conn == nil { + return 0, 0, E.New("missing socket diag connection for family=", family, " protocol=", protocol) + } + if destination.IsValid() && source.Addr().BitLen() == destination.Addr().BitLen() { + inode, uid, err = conn.query(source, destination) + if err == nil { + return inode, uid, nil + } + if !errors.Is(err, ErrNotFound) { + return 0, 0, err + } + } + return querySocketDiagOnce(family, protocol, source) } diff --git a/common/process/searcher_linux_shared.go b/common/process/searcher_linux_shared.go index e75b0b4f..cd0601bc 100644 --- a/common/process/searcher_linux_shared.go +++ b/common/process/searcher_linux_shared.go @@ -3,43 +3,67 @@ package process import ( - "bytes" "encoding/binary" - "fmt" - "net" + "errors" "net/netip" "os" - "path" + "path/filepath" "strings" + "sync" "syscall" + "time" "unicode" - "unsafe" - "github.com/sagernet/sing/common/buf" + "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/contrab/freelru" + "github.com/sagernet/sing/contrab/maphash" ) -// from https://github.com/vishvananda/netlink/blob/bca67dfc8220b44ef582c9da4e9172bf1c9ec973/nl/nl_linux.go#L52-L62 -var nativeEndian = func() binary.ByteOrder { - var x uint32 = 0x01020304 - if *(*byte)(unsafe.Pointer(&x)) == 0x01 { - return binary.BigEndian - } - - return binary.LittleEndian -}() - const ( - sizeOfSocketDiagRequest = syscall.SizeofNlMsghdr + 8 + 48 - socketDiagByFamily = 20 - pathProc = "/proc" + sizeOfSocketDiagRequestData = 56 + sizeOfSocketDiagRequest = syscall.SizeofNlMsghdr + sizeOfSocketDiagRequestData + socketDiagResponseMinSize = 72 + socketDiagByFamily = 20 + pathProc = "/proc" ) -func resolveSocketByNetlink(network string, source netip.AddrPort, destination netip.AddrPort) (inode, uid uint32, err error) { - var family uint8 - var protocol uint8 +type socketDiagConn struct { + access sync.Mutex + family uint8 + protocol uint8 + fd int +} +type uidProcessPathCache struct { + cache freelru.Cache[uint32, *uidProcessPaths] +} + +type uidProcessPaths struct { + entries map[uint32]string +} + +func newSocketDiagConn(family, protocol uint8) *socketDiagConn { + return &socketDiagConn{ + family: family, + protocol: protocol, + fd: -1, + } +} + +func socketDiagConnIndex(family, protocol uint8) int { + index := 0 + if protocol == syscall.IPPROTO_UDP { + index += 2 + } + if family == syscall.AF_INET6 { + index++ + } + return index +} + +func socketDiagSettings(network string, source netip.AddrPort) (family, protocol uint8, err error) { switch network { case N.NetworkTCP: protocol = syscall.IPPROTO_TCP @@ -48,151 +72,308 @@ func resolveSocketByNetlink(network string, source netip.AddrPort, destination n default: return 0, 0, os.ErrInvalid } - - if source.Addr().Is4() { + switch { + case source.Addr().Is4(): family = syscall.AF_INET - } else { + case source.Addr().Is6(): family = syscall.AF_INET6 + default: + return 0, 0, os.ErrInvalid } - - req := packSocketDiagRequest(family, protocol, source) - - socket, err := syscall.Socket(syscall.AF_NETLINK, syscall.SOCK_DGRAM, syscall.NETLINK_INET_DIAG) - if err != nil { - return 0, 0, E.Cause(err, "dial netlink") - } - defer syscall.Close(socket) - - syscall.SetsockoptTimeval(socket, syscall.SOL_SOCKET, syscall.SO_SNDTIMEO, &syscall.Timeval{Usec: 100}) - syscall.SetsockoptTimeval(socket, syscall.SOL_SOCKET, syscall.SO_RCVTIMEO, &syscall.Timeval{Usec: 100}) - - err = syscall.Connect(socket, &syscall.SockaddrNetlink{ - Family: syscall.AF_NETLINK, - Pad: 0, - Pid: 0, - Groups: 0, - }) - if err != nil { - return - } - - _, err = syscall.Write(socket, req) - if err != nil { - return 0, 0, E.Cause(err, "write netlink request") - } - - buffer := buf.New() - defer buffer.Release() - - n, err := syscall.Read(socket, buffer.FreeBytes()) - if err != nil { - return 0, 0, E.Cause(err, "read netlink response") - } - - buffer.Truncate(n) - - messages, err := syscall.ParseNetlinkMessage(buffer.Bytes()) - if err != nil { - return 0, 0, E.Cause(err, "parse netlink message") - } else if len(messages) == 0 { - return 0, 0, E.New("unexcepted netlink response") - } - - message := messages[0] - if message.Header.Type&syscall.NLMSG_ERROR != 0 { - return 0, 0, E.New("netlink message: NLMSG_ERROR") - } - - inode, uid = unpackSocketDiagResponse(&messages[0]) - return + return family, protocol, nil } -func packSocketDiagRequest(family, protocol byte, source netip.AddrPort) []byte { - s := make([]byte, 16) - copy(s, source.Addr().AsSlice()) - - buf := make([]byte, sizeOfSocketDiagRequest) - - nativeEndian.PutUint32(buf[0:4], sizeOfSocketDiagRequest) - nativeEndian.PutUint16(buf[4:6], socketDiagByFamily) - nativeEndian.PutUint16(buf[6:8], syscall.NLM_F_REQUEST|syscall.NLM_F_DUMP) - nativeEndian.PutUint32(buf[8:12], 0) - nativeEndian.PutUint32(buf[12:16], 0) - - buf[16] = family - buf[17] = protocol - buf[18] = 0 - buf[19] = 0 - nativeEndian.PutUint32(buf[20:24], 0xFFFFFFFF) - - binary.BigEndian.PutUint16(buf[24:26], source.Port()) - binary.BigEndian.PutUint16(buf[26:28], 0) - - copy(buf[28:44], s) - copy(buf[44:60], net.IPv6zero) - - nativeEndian.PutUint32(buf[60:64], 0) - nativeEndian.PutUint64(buf[64:72], 0xFFFFFFFFFFFFFFFF) - - return buf +func newUIDProcessPathCache(ttl time.Duration) *uidProcessPathCache { + cache := common.Must1(freelru.NewSharded[uint32, *uidProcessPaths](64, maphash.NewHasher[uint32]().Hash32)) + cache.SetLifetime(ttl) + return &uidProcessPathCache{cache: cache} } -func unpackSocketDiagResponse(msg *syscall.NetlinkMessage) (inode, uid uint32) { - if len(msg.Data) < 72 { - return 0, 0 +func (c *uidProcessPathCache) findProcessPath(targetInode, uid uint32) (string, error) { + if cached, ok := c.cache.Get(uid); ok { + if processPath, found := cached.entries[targetInode]; found { + return processPath, nil + } } - - data := msg.Data - - uid = nativeEndian.Uint32(data[64:68]) - inode = nativeEndian.Uint32(data[68:72]) - - return -} - -func resolveProcessNameByProcSearch(inode, uid uint32) (string, error) { - files, err := os.ReadDir(pathProc) + processPaths, err := buildProcessPathByUIDCache(uid) if err != nil { return "", err } + c.cache.Add(uid, &uidProcessPaths{entries: processPaths}) + processPath, found := processPaths[targetInode] + if !found { + return "", E.New("process of uid(", uid, "), inode(", targetInode, ") not found") + } + return processPath, nil +} +func (c *socketDiagConn) Close() error { + c.access.Lock() + defer c.access.Unlock() + return c.closeLocked() +} + +func (c *socketDiagConn) query(source netip.AddrPort, destination netip.AddrPort) (inode, uid uint32, err error) { + c.access.Lock() + defer c.access.Unlock() + request := packSocketDiagRequest(c.family, c.protocol, source, destination, false) + for attempt := 0; attempt < 2; attempt++ { + err = c.ensureOpenLocked() + if err != nil { + return 0, 0, E.Cause(err, "dial netlink") + } + inode, uid, err = querySocketDiag(c.fd, request) + if err == nil || errors.Is(err, ErrNotFound) { + return inode, uid, err + } + if !shouldRetrySocketDiag(err) { + return 0, 0, err + } + _ = c.closeLocked() + } + return 0, 0, err +} + +func querySocketDiagOnce(family, protocol uint8, source netip.AddrPort) (inode, uid uint32, err error) { + fd, err := openSocketDiag() + if err != nil { + return 0, 0, E.Cause(err, "dial netlink") + } + defer syscall.Close(fd) + return querySocketDiag(fd, packSocketDiagRequest(family, protocol, source, netip.AddrPort{}, true)) +} + +func (c *socketDiagConn) ensureOpenLocked() error { + if c.fd != -1 { + return nil + } + fd, err := openSocketDiag() + if err != nil { + return err + } + c.fd = fd + return nil +} + +func openSocketDiag() (int, error) { + fd, err := syscall.Socket(syscall.AF_NETLINK, syscall.SOCK_DGRAM|syscall.SOCK_CLOEXEC, syscall.NETLINK_INET_DIAG) + if err != nil { + return -1, err + } + timeout := &syscall.Timeval{Usec: 100} + if err = syscall.SetsockoptTimeval(fd, syscall.SOL_SOCKET, syscall.SO_SNDTIMEO, timeout); err != nil { + syscall.Close(fd) + return -1, err + } + if err = syscall.SetsockoptTimeval(fd, syscall.SOL_SOCKET, syscall.SO_RCVTIMEO, timeout); err != nil { + syscall.Close(fd) + return -1, err + } + if err = syscall.Connect(fd, &syscall.SockaddrNetlink{ + Family: syscall.AF_NETLINK, + Pid: 0, + Groups: 0, + }); err != nil { + syscall.Close(fd) + return -1, err + } + return fd, nil +} + +func (c *socketDiagConn) closeLocked() error { + if c.fd == -1 { + return nil + } + err := syscall.Close(c.fd) + c.fd = -1 + return err +} + +func packSocketDiagRequest(family, protocol byte, source netip.AddrPort, destination netip.AddrPort, dump bool) []byte { + request := make([]byte, sizeOfSocketDiagRequest) + + binary.NativeEndian.PutUint32(request[0:4], sizeOfSocketDiagRequest) + binary.NativeEndian.PutUint16(request[4:6], socketDiagByFamily) + flags := uint16(syscall.NLM_F_REQUEST) + if dump { + flags |= syscall.NLM_F_DUMP + } + binary.NativeEndian.PutUint16(request[6:8], flags) + binary.NativeEndian.PutUint32(request[8:12], 0) + binary.NativeEndian.PutUint32(request[12:16], 0) + + request[16] = family + request[17] = protocol + request[18] = 0 + request[19] = 0 + if dump { + binary.NativeEndian.PutUint32(request[20:24], 0xFFFFFFFF) + } + requestSource := source + requestDestination := destination + if protocol == syscall.IPPROTO_UDP && !dump && destination.IsValid() { + // udp_dump_one expects the exact-match endpoints reversed for historical reasons. + requestSource, requestDestination = destination, source + } + binary.BigEndian.PutUint16(request[24:26], requestSource.Port()) + binary.BigEndian.PutUint16(request[26:28], requestDestination.Port()) + if family == syscall.AF_INET6 { + copy(request[28:44], requestSource.Addr().AsSlice()) + if requestDestination.IsValid() { + copy(request[44:60], requestDestination.Addr().AsSlice()) + } + } else { + copy(request[28:32], requestSource.Addr().AsSlice()) + if requestDestination.IsValid() { + copy(request[44:48], requestDestination.Addr().AsSlice()) + } + } + binary.NativeEndian.PutUint32(request[60:64], 0) + binary.NativeEndian.PutUint64(request[64:72], 0xFFFFFFFFFFFFFFFF) + return request +} + +func querySocketDiag(fd int, request []byte) (inode, uid uint32, err error) { + _, err = syscall.Write(fd, request) + if err != nil { + return 0, 0, E.Cause(err, "write netlink request") + } + buffer := make([]byte, 64<<10) + n, err := syscall.Read(fd, buffer) + if err != nil { + return 0, 0, E.Cause(err, "read netlink response") + } + messages, err := syscall.ParseNetlinkMessage(buffer[:n]) + if err != nil { + return 0, 0, E.Cause(err, "parse netlink message") + } + return unpackSocketDiagMessages(messages) +} + +func unpackSocketDiagMessages(messages []syscall.NetlinkMessage) (inode, uid uint32, err error) { + for _, message := range messages { + switch message.Header.Type { + case syscall.NLMSG_DONE: + continue + case syscall.NLMSG_ERROR: + err = unpackSocketDiagError(&message) + if err != nil { + return 0, 0, err + } + case socketDiagByFamily: + inode, uid = unpackSocketDiagResponse(&message) + if inode != 0 || uid != 0 { + return inode, uid, nil + } + } + } + return 0, 0, ErrNotFound +} + +func unpackSocketDiagResponse(msg *syscall.NetlinkMessage) (inode, uid uint32) { + if len(msg.Data) < socketDiagResponseMinSize { + return 0, 0 + } + uid = binary.NativeEndian.Uint32(msg.Data[64:68]) + inode = binary.NativeEndian.Uint32(msg.Data[68:72]) + return inode, uid +} + +func unpackSocketDiagError(msg *syscall.NetlinkMessage) error { + if len(msg.Data) < 4 { + return E.New("netlink message: NLMSG_ERROR") + } + errno := int32(binary.NativeEndian.Uint32(msg.Data[:4])) + if errno == 0 { + return nil + } + if errno < 0 { + errno = -errno + } + sysErr := syscall.Errno(errno) + switch sysErr { + case syscall.ENOENT, syscall.ESRCH: + return ErrNotFound + default: + return E.New("netlink message: ", sysErr) + } +} + +func shouldRetrySocketDiag(err error) bool { + return err != nil && !errors.Is(err, ErrNotFound) +} + +func buildProcessPathByUIDCache(uid uint32) (map[uint32]string, error) { + files, err := os.ReadDir(pathProc) + if err != nil { + return nil, err + } buffer := make([]byte, syscall.PathMax) - socket := []byte(fmt.Sprintf("socket:[%d]", inode)) - - for _, f := range files { - if !f.IsDir() || !isPid(f.Name()) { + processPaths := make(map[uint32]string) + for _, file := range files { + if !file.IsDir() || !isPid(file.Name()) { continue } - - info, err := f.Info() + info, err := file.Info() if err != nil { - return "", err + if isIgnorableProcError(err) { + continue + } + return nil, err } if info.Sys().(*syscall.Stat_t).Uid != uid { continue } - - processPath := path.Join(pathProc, f.Name()) - fdPath := path.Join(processPath, "fd") - + processPath := filepath.Join(pathProc, file.Name()) + fdPath := filepath.Join(processPath, "fd") + exePath, err := os.Readlink(filepath.Join(processPath, "exe")) + if err != nil { + if isIgnorableProcError(err) { + continue + } + return nil, err + } fds, err := os.ReadDir(fdPath) if err != nil { continue } - for _, fd := range fds { - n, err := syscall.Readlink(path.Join(fdPath, fd.Name()), buffer) + n, err := syscall.Readlink(filepath.Join(fdPath, fd.Name()), buffer) if err != nil { continue } - - if bytes.Equal(buffer[:n], socket) { - return os.Readlink(path.Join(processPath, "exe")) + inode, ok := parseSocketInode(buffer[:n]) + if !ok { + continue + } + if _, loaded := processPaths[inode]; !loaded { + processPaths[inode] = exePath } } } + return processPaths, nil +} - return "", fmt.Errorf("process of uid(%d),inode(%d) not found", uid, inode) +func isIgnorableProcError(err error) bool { + return os.IsNotExist(err) || os.IsPermission(err) +} + +func parseSocketInode(link []byte) (uint32, bool) { + const socketPrefix = "socket:[" + if len(link) <= len(socketPrefix) || string(link[:len(socketPrefix)]) != socketPrefix || link[len(link)-1] != ']' { + return 0, false + } + var inode uint64 + for _, char := range link[len(socketPrefix) : len(link)-1] { + if char < '0' || char > '9' { + return 0, false + } + inode = inode*10 + uint64(char-'0') + if inode > uint64(^uint32(0)) { + return 0, false + } + } + return uint32(inode), true } func isPid(s string) bool { diff --git a/common/process/searcher_linux_shared_test.go b/common/process/searcher_linux_shared_test.go new file mode 100644 index 00000000..1befff4e --- /dev/null +++ b/common/process/searcher_linux_shared_test.go @@ -0,0 +1,60 @@ +//go:build linux + +package process + +import ( + "net" + "net/netip" + "os" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestQuerySocketDiagUDPExact(t *testing.T) { + t.Parallel() + server, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0}) + require.NoError(t, err) + defer server.Close() + + client, err := net.DialUDP("udp4", nil, server.LocalAddr().(*net.UDPAddr)) + require.NoError(t, err) + defer client.Close() + + err = client.SetDeadline(time.Now().Add(time.Second)) + require.NoError(t, err) + _, err = client.Write([]byte{0}) + require.NoError(t, err) + + err = server.SetReadDeadline(time.Now().Add(time.Second)) + require.NoError(t, err) + buffer := make([]byte, 1) + _, _, err = server.ReadFromUDP(buffer) + require.NoError(t, err) + + source := addrPortFromUDPAddr(t, client.LocalAddr()) + destination := addrPortFromUDPAddr(t, client.RemoteAddr()) + + fd, err := openSocketDiag() + require.NoError(t, err) + defer syscall.Close(fd) + + inode, uid, err := querySocketDiag(fd, packSocketDiagRequest(syscall.AF_INET, syscall.IPPROTO_UDP, source, destination, false)) + require.NoError(t, err) + require.NotZero(t, inode) + require.EqualValues(t, os.Getuid(), uid) +} + +func addrPortFromUDPAddr(t *testing.T, addr net.Addr) netip.AddrPort { + t.Helper() + + udpAddr, ok := addr.(*net.UDPAddr) + require.True(t, ok) + + ip, ok := netip.AddrFromSlice(udpAddr.IP) + require.True(t, ok) + + return netip.AddrPortFrom(ip.Unmap(), uint16(udpAddr.Port)) +} diff --git a/common/process/searcher_windows.go b/common/process/searcher_windows.go index ac95e0ce..39695355 100644 --- a/common/process/searcher_windows.go +++ b/common/process/searcher_windows.go @@ -28,6 +28,10 @@ func initWin32API() error { return winiphlpapi.LoadExtendedTable() } +func (s *windowsSearcher) Close() error { + return nil +} + func (s *windowsSearcher) FindProcessInfo(ctx context.Context, network string, source netip.AddrPort, destination netip.AddrPort) (*adapter.ConnectionOwner, error) { pid, err := winiphlpapi.FindPid(network, source) if err != nil { diff --git a/daemon/instance.go b/daemon/instance.go index 79271f84..0da46499 100644 --- a/daemon/instance.go +++ b/daemon/instance.go @@ -69,7 +69,7 @@ type OverrideOptions struct { } func (s *StartedService) newInstance(profileContent string, overrideOptions *OverrideOptions) (*Instance, error) { - ctx := s.ctx + ctx := service.ExtendContext(s.ctx) service.MustRegister[deprecated.Manager](ctx, new(deprecatedManager)) ctx, cancel := context.WithCancel(include.Context(ctx)) options, err := parseConfig(ctx, profileContent) diff --git a/daemon/started_service.go b/daemon/started_service.go index 7ebdac1e..c260e8cb 100644 --- a/daemon/started_service.go +++ b/daemon/started_service.go @@ -168,7 +168,7 @@ func (s *StartedService) waitForStarted(ctx context.Context) error { func (s *StartedService) StartOrReloadService(profileContent string, options *OverrideOptions) error { s.serviceAccess.Lock() switch s.serviceStatus.Status { - case ServiceStatus_IDLE, ServiceStatus_STARTED, ServiceStatus_STARTING: + case ServiceStatus_IDLE, ServiceStatus_STARTED, ServiceStatus_STARTING, ServiceStatus_FATAL: default: s.serviceAccess.Unlock() return os.ErrInvalid @@ -226,13 +226,14 @@ func (s *StartedService) CloseService() error { return os.ErrInvalid } s.updateStatus(ServiceStatus_STOPPING) - if s.instance != nil { - err := s.instance.Close() + instance := s.instance + s.instance = nil + if instance != nil { + err := instance.Close() if err != nil { return s.updateStatusError(err) } } - s.instance = nil s.startedAt = time.Time{} s.updateStatus(ServiceStatus_IDLE) s.serviceAccess.Unlock() @@ -949,11 +950,11 @@ func buildConnectionProto(metadata *trafficontrol.TrackerMetadata) *Connection { var processInfo *ProcessInfo if metadata.Metadata.ProcessInfo != nil { processInfo = &ProcessInfo{ - ProcessId: metadata.Metadata.ProcessInfo.ProcessID, - UserId: metadata.Metadata.ProcessInfo.UserId, - UserName: metadata.Metadata.ProcessInfo.UserName, - ProcessPath: metadata.Metadata.ProcessInfo.ProcessPath, - PackageName: metadata.Metadata.ProcessInfo.AndroidPackageName, + ProcessId: metadata.Metadata.ProcessInfo.ProcessID, + UserId: metadata.Metadata.ProcessInfo.UserId, + UserName: metadata.Metadata.ProcessInfo.UserName, + ProcessPath: metadata.Metadata.ProcessInfo.ProcessPath, + PackageNames: metadata.Metadata.ProcessInfo.AndroidPackageNames, } } return &Connection{ diff --git a/daemon/started_service.pb.go b/daemon/started_service.pb.go index ef9ea825..927fb514 100644 --- a/daemon/started_service.pb.go +++ b/daemon/started_service.pb.go @@ -1460,7 +1460,7 @@ type ProcessInfo struct { UserId int32 `protobuf:"varint,2,opt,name=userId,proto3" json:"userId,omitempty"` UserName string `protobuf:"bytes,3,opt,name=userName,proto3" json:"userName,omitempty"` ProcessPath string `protobuf:"bytes,4,opt,name=processPath,proto3" json:"processPath,omitempty"` - PackageName string `protobuf:"bytes,5,opt,name=packageName,proto3" json:"packageName,omitempty"` + PackageNames []string `protobuf:"bytes,5,rep,name=packageNames,proto3" json:"packageNames,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1523,11 +1523,11 @@ func (x *ProcessInfo) GetProcessPath() string { return "" } -func (x *ProcessInfo) GetPackageName() string { +func (x *ProcessInfo) GetPackageNames() []string { if x != nil { - return x.PackageName + return x.PackageNames } - return "" + return nil } type CloseConnectionRequest struct { @@ -1884,13 +1884,13 @@ const file_daemon_started_service_proto_rawDesc = "" + "\boutbound\x18\x13 \x01(\tR\boutbound\x12\"\n" + "\foutboundType\x18\x14 \x01(\tR\foutboundType\x12\x1c\n" + "\tchainList\x18\x15 \x03(\tR\tchainList\x125\n" + - "\vprocessInfo\x18\x16 \x01(\v2\x13.daemon.ProcessInfoR\vprocessInfo\"\xa3\x01\n" + + "\vprocessInfo\x18\x16 \x01(\v2\x13.daemon.ProcessInfoR\vprocessInfo\"\xa5\x01\n" + "\vProcessInfo\x12\x1c\n" + "\tprocessId\x18\x01 \x01(\rR\tprocessId\x12\x16\n" + "\x06userId\x18\x02 \x01(\x05R\x06userId\x12\x1a\n" + "\buserName\x18\x03 \x01(\tR\buserName\x12 \n" + - "\vprocessPath\x18\x04 \x01(\tR\vprocessPath\x12 \n" + - "\vpackageName\x18\x05 \x01(\tR\vpackageName\"(\n" + + "\vprocessPath\x18\x04 \x01(\tR\vprocessPath\x12\"\n" + + "\fpackageNames\x18\x05 \x03(\tR\fpackageNames\"(\n" + "\x16CloseConnectionRequest\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\"K\n" + "\x12DeprecatedWarnings\x125\n" + diff --git a/daemon/started_service.proto b/daemon/started_service.proto index cc778f91..8a76081a 100644 --- a/daemon/started_service.proto +++ b/daemon/started_service.proto @@ -195,7 +195,7 @@ message ProcessInfo { int32 userId = 2; string userName = 3; string processPath = 4; - string packageName = 5; + repeated string packageNames = 5; } message CloseConnectionRequest { diff --git a/dns/client.go b/dns/client.go index ed4e8207..1a2ee8f8 100644 --- a/dns/client.go +++ b/dns/client.go @@ -283,6 +283,9 @@ func (c *Client) Exchange(ctx context.Context, transport adapter.DNSTransport, m if timeToLive == 0 { for _, recordList := range [][]dns.RR{response.Answer, response.Ns, response.Extra} { for _, record := range recordList { + if record.Header().Rrtype == dns.TypeOPT { + continue + } if timeToLive == 0 || record.Header().Ttl > 0 && record.Header().Ttl < timeToLive { timeToLive = record.Header().Ttl } @@ -294,6 +297,9 @@ func (c *Client) Exchange(ctx context.Context, transport adapter.DNSTransport, m } for _, recordList := range [][]dns.RR{response.Answer, response.Ns, response.Extra} { for _, record := range recordList { + if record.Header().Rrtype == dns.TypeOPT { + continue + } record.Header().Ttl = timeToLive } } @@ -324,16 +330,20 @@ func (c *Client) Lookup(ctx context.Context, transport adapter.DNSTransport, dom } else { strategy = options.Strategy } + lookupOptions := options + if options.LookupStrategy != C.DomainStrategyAsIS { + lookupOptions.Strategy = strategy + } if strategy == C.DomainStrategyIPv4Only { - return c.lookupToExchange(ctx, transport, dnsName, dns.TypeA, options, responseChecker) + return c.lookupToExchange(ctx, transport, dnsName, dns.TypeA, lookupOptions, responseChecker) } else if strategy == C.DomainStrategyIPv6Only { - return c.lookupToExchange(ctx, transport, dnsName, dns.TypeAAAA, options, responseChecker) + return c.lookupToExchange(ctx, transport, dnsName, dns.TypeAAAA, lookupOptions, responseChecker) } var response4 []netip.Addr var response6 []netip.Addr var group task.Group group.Append("exchange4", func(ctx context.Context) error { - response, err := c.lookupToExchange(ctx, transport, dnsName, dns.TypeA, options, responseChecker) + response, err := c.lookupToExchange(ctx, transport, dnsName, dns.TypeA, lookupOptions, responseChecker) if err != nil { return err } @@ -341,7 +351,7 @@ func (c *Client) Lookup(ctx context.Context, transport adapter.DNSTransport, dom return nil }) group.Append("exchange6", func(ctx context.Context) error { - response, err := c.lookupToExchange(ctx, transport, dnsName, dns.TypeAAAA, options, responseChecker) + response, err := c.lookupToExchange(ctx, transport, dnsName, dns.TypeAAAA, lookupOptions, responseChecker) if err != nil { return err } @@ -377,21 +387,21 @@ func (c *Client) storeCache(transport adapter.DNSTransport, question dns.Questio } if c.disableExpire { if !c.independentCache { - c.cache.Add(question, message) + c.cache.Add(question, message.Copy()) } else { c.transportCache.Add(transportCacheKey{ Question: question, transportTag: transport.Tag(), - }, message) + }, message.Copy()) } } else { if !c.independentCache { - c.cache.AddWithLifetime(question, message, time.Second*time.Duration(timeToLive)) + c.cache.AddWithLifetime(question, message.Copy(), time.Second*time.Duration(timeToLive)) } else { c.transportCache.AddWithLifetime(transportCacheKey{ Question: question, transportTag: transport.Tag(), - }, message, time.Second*time.Duration(timeToLive)) + }, message.Copy(), time.Second*time.Duration(timeToLive)) } } } @@ -482,6 +492,9 @@ func (c *Client) loadResponse(question dns.Question, transport adapter.DNSTransp var originTTL int for _, recordList := range [][]dns.RR{response.Answer, response.Ns, response.Extra} { for _, record := range recordList { + if record.Header().Rrtype == dns.TypeOPT { + continue + } if originTTL == 0 || record.Header().Ttl > 0 && int(record.Header().Ttl) < originTTL { originTTL = int(record.Header().Ttl) } @@ -496,12 +509,18 @@ func (c *Client) loadResponse(question dns.Question, transport adapter.DNSTransp duration := uint32(originTTL - nowTTL) for _, recordList := range [][]dns.RR{response.Answer, response.Ns, response.Extra} { for _, record := range recordList { + if record.Header().Rrtype == dns.TypeOPT { + continue + } record.Header().Ttl = record.Header().Ttl - duration } } } else { for _, recordList := range [][]dns.RR{response.Answer, response.Ns, response.Extra} { for _, record := range recordList { + if record.Header().Rrtype == dns.TypeOPT { + continue + } record.Header().Ttl = uint32(nowTTL) } } diff --git a/dns/router.go b/dns/router.go index 567f3225..4f18959b 100644 --- a/dns/router.go +++ b/dns/router.go @@ -195,7 +195,16 @@ func (r *Router) matchDNS(ctx context.Context, allowFakeIP bool, ruleIndex int, } } } - return r.transport.Default(), nil, -1 + transport := r.transport.Default() + if legacyTransport, isLegacy := transport.(adapter.LegacyDNSTransport); isLegacy { + if options.Strategy == C.DomainStrategyAsIS { + options.Strategy = legacyTransport.LegacyStrategy() + } + if !options.ClientSubnet.IsValid() { + options.ClientSubnet = legacyTransport.LegacyClientSubnet() + } + } + return transport, nil, -1 } func (r *Router) Exchange(ctx context.Context, message *mDNS.Msg, options adapter.DNSQueryOptions) (*mDNS.Msg, error) { @@ -345,7 +354,7 @@ func (r *Router) Lookup(ctx context.Context, domain string, options adapter.DNSQ transport := options.Transport if legacyTransport, isLegacy := transport.(adapter.LegacyDNSTransport); isLegacy { if options.Strategy == C.DomainStrategyAsIS { - options.Strategy = r.defaultDomainStrategy + options.Strategy = legacyTransport.LegacyStrategy() } if !options.ClientSubnet.IsValid() { options.ClientSubnet = legacyTransport.LegacyClientSubnet() diff --git a/dns/transport/base.go b/dns/transport/base.go deleted file mode 100644 index 06e41fd0..00000000 --- a/dns/transport/base.go +++ /dev/null @@ -1,145 +0,0 @@ -package transport - -import ( - "context" - "os" - "sync" - - C "github.com/sagernet/sing-box/constant" - "github.com/sagernet/sing-box/dns" - E "github.com/sagernet/sing/common/exceptions" - "github.com/sagernet/sing/common/logger" -) - -type TransportState int - -const ( - StateNew TransportState = iota - StateStarted - StateClosing - StateClosed -) - -var ( - ErrTransportClosed = os.ErrClosed - ErrConnectionReset = E.New("connection reset") -) - -type BaseTransport struct { - dns.TransportAdapter - Logger logger.ContextLogger - - mutex sync.Mutex - state TransportState - inFlight int32 - queriesComplete chan struct{} - closeCtx context.Context - closeCancel context.CancelFunc -} - -func NewBaseTransport(adapter dns.TransportAdapter, logger logger.ContextLogger) *BaseTransport { - ctx, cancel := context.WithCancel(context.Background()) - return &BaseTransport{ - TransportAdapter: adapter, - Logger: logger, - state: StateNew, - closeCtx: ctx, - closeCancel: cancel, - } -} - -func (t *BaseTransport) State() TransportState { - t.mutex.Lock() - defer t.mutex.Unlock() - return t.state -} - -func (t *BaseTransport) SetStarted() error { - t.mutex.Lock() - defer t.mutex.Unlock() - switch t.state { - case StateNew: - t.state = StateStarted - return nil - case StateStarted: - return nil - default: - return ErrTransportClosed - } -} - -func (t *BaseTransport) BeginQuery() bool { - t.mutex.Lock() - defer t.mutex.Unlock() - if t.state != StateStarted { - return false - } - t.inFlight++ - return true -} - -func (t *BaseTransport) EndQuery() { - t.mutex.Lock() - if t.inFlight > 0 { - t.inFlight-- - } - if t.inFlight == 0 && t.queriesComplete != nil { - close(t.queriesComplete) - t.queriesComplete = nil - } - t.mutex.Unlock() -} - -func (t *BaseTransport) CloseContext() context.Context { - return t.closeCtx -} - -func (t *BaseTransport) Shutdown(ctx context.Context) error { - t.mutex.Lock() - - if t.state >= StateClosing { - t.mutex.Unlock() - return nil - } - - if t.state == StateNew { - t.state = StateClosed - t.mutex.Unlock() - t.closeCancel() - return nil - } - - t.state = StateClosing - - if t.inFlight == 0 { - t.state = StateClosed - t.mutex.Unlock() - t.closeCancel() - return nil - } - - t.queriesComplete = make(chan struct{}) - queriesComplete := t.queriesComplete - t.mutex.Unlock() - - t.closeCancel() - - select { - case <-queriesComplete: - t.mutex.Lock() - t.state = StateClosed - t.mutex.Unlock() - return nil - case <-ctx.Done(): - t.mutex.Lock() - t.state = StateClosed - t.mutex.Unlock() - return ctx.Err() - } -} - -func (t *BaseTransport) Close() error { - ctx, cancel := context.WithTimeout(context.Background(), C.TCPTimeout) - defer cancel() - return t.Shutdown(ctx) -} diff --git a/dns/transport/conn_pool.go b/dns/transport/conn_pool.go new file mode 100644 index 00000000..6161e9bd --- /dev/null +++ b/dns/transport/conn_pool.go @@ -0,0 +1,547 @@ +package transport + +import ( + "context" + "net" + "sync" + "time" + + "github.com/sagernet/sing/common/x/list" +) + +type ConnPoolMode int + +const ( + ConnPoolSingle ConnPoolMode = iota + ConnPoolOrdered +) + +type ConnPoolOptions[T comparable] struct { + Mode ConnPoolMode + IsAlive func(T) bool + Close func(T, error) +} + +type ConnPool[T comparable] struct { + options ConnPoolOptions[T] + + access sync.Mutex + closed bool + state *connPoolState[T] +} + +type connPoolState[T comparable] struct { + ctx context.Context + cancel context.CancelCauseFunc + + all map[T]struct{} + + idle list.List[T] + idleElements map[T]*list.Element[T] + + shared T + hasShared bool + sharedClaimed bool + sharedCtx context.Context + sharedCancel context.CancelCauseFunc + + connecting *connPoolConnect[T] +} + +type connPoolConnect[T comparable] struct { + done chan struct{} + err error +} + +type connPoolDialContext struct { + context.Context + parent context.Context +} + +func (c connPoolDialContext) Deadline() (time.Time, bool) { + return c.parent.Deadline() +} + +func (c connPoolDialContext) Value(key any) any { + return c.parent.Value(key) +} + +func NewConnPool[T comparable](options ConnPoolOptions[T]) *ConnPool[T] { + return &ConnPool[T]{ + options: options, + state: newConnPoolState[T](options.Mode), + } +} + +func newConnPoolState[T comparable](mode ConnPoolMode) *connPoolState[T] { + ctx, cancel := context.WithCancelCause(context.Background()) + state := &connPoolState[T]{ + ctx: ctx, + cancel: cancel, + all: make(map[T]struct{}), + } + if mode == ConnPoolOrdered { + state.idleElements = make(map[T]*list.Element[T]) + } + return state +} + +func (p *ConnPool[T]) Acquire(ctx context.Context, dial func(context.Context) (T, error)) (T, bool, error) { + switch p.options.Mode { + case ConnPoolSingle: + conn, _, created, err := p.acquireShared(ctx, dial) + return conn, created, err + case ConnPoolOrdered: + return p.acquireOrdered(ctx, dial) + default: + var zero T + return zero, false, net.ErrClosed + } +} + +func (p *ConnPool[T]) AcquireShared(ctx context.Context, dial func(context.Context) (T, error)) (T, context.Context, bool, error) { + if p.options.Mode != ConnPoolSingle { + var zero T + return zero, nil, false, net.ErrClosed + } + return p.acquireShared(ctx, dial) +} + +func (p *ConnPool[T]) Release(conn T, reuse bool) { + var ( + closeConn bool + closeErr error + ) + + p.access.Lock() + if p.closed || p.state == nil { + closeConn = true + closeErr = net.ErrClosed + p.access.Unlock() + if closeConn { + p.options.Close(conn, closeErr) + } + return + } + + currentState := p.state + _, tracked := currentState.all[conn] + if !tracked { + closeConn = true + closeErr = p.closeCause(currentState) + p.access.Unlock() + if closeConn { + p.options.Close(conn, closeErr) + } + return + } + + if !reuse || !p.options.IsAlive(conn) { + delete(currentState.all, conn) + switch p.options.Mode { + case ConnPoolSingle: + if currentState.hasShared && currentState.shared == conn { + var zero T + currentState.shared = zero + currentState.hasShared = false + currentState.sharedClaimed = false + currentState.sharedCtx = nil + if currentState.sharedCancel != nil { + currentState.sharedCancel(net.ErrClosed) + currentState.sharedCancel = nil + } + } + case ConnPoolOrdered: + if element, loaded := currentState.idleElements[conn]; loaded { + currentState.idle.Remove(element) + delete(currentState.idleElements, conn) + } + } + closeConn = true + closeErr = net.ErrClosed + p.access.Unlock() + if closeConn { + p.options.Close(conn, closeErr) + } + return + } + + if p.options.Mode == ConnPoolOrdered { + if _, loaded := currentState.idleElements[conn]; !loaded { + currentState.idleElements[conn] = currentState.idle.PushBack(conn) + } + } + p.access.Unlock() +} + +func (p *ConnPool[T]) Invalidate(conn T, cause error) { + p.access.Lock() + if p.closed || p.state == nil { + p.access.Unlock() + p.options.Close(conn, cause) + return + } + + currentState := p.state + _, tracked := currentState.all[conn] + if !tracked { + p.access.Unlock() + return + } + + delete(currentState.all, conn) + switch p.options.Mode { + case ConnPoolSingle: + if currentState.hasShared && currentState.shared == conn { + var zero T + currentState.shared = zero + currentState.hasShared = false + currentState.sharedClaimed = false + currentState.sharedCtx = nil + if currentState.sharedCancel != nil { + currentState.sharedCancel(cause) + currentState.sharedCancel = nil + } + } + case ConnPoolOrdered: + if element, loaded := currentState.idleElements[conn]; loaded { + currentState.idle.Remove(element) + delete(currentState.idleElements, conn) + } + } + p.access.Unlock() + + p.options.Close(conn, cause) +} + +func (p *ConnPool[T]) Reset() { + p.access.Lock() + if p.closed { + p.access.Unlock() + return + } + + oldState := p.state + p.state = newConnPoolState[T](p.options.Mode) + p.access.Unlock() + + p.closeState(oldState, net.ErrClosed) +} + +func (p *ConnPool[T]) Close() error { + p.access.Lock() + if p.closed { + p.access.Unlock() + return nil + } + + p.closed = true + oldState := p.state + p.state = nil + p.access.Unlock() + + p.closeState(oldState, net.ErrClosed) + return nil +} + +func (p *ConnPool[T]) acquireOrdered(ctx context.Context, dial func(context.Context) (T, error)) (T, bool, error) { + var zero T + for { + var ( + staleConn T + hasStale bool + ) + + p.access.Lock() + if p.closed { + p.access.Unlock() + return zero, false, net.ErrClosed + } + + currentState := p.state + if element := currentState.idle.Front(); element != nil { + conn := currentState.idle.Remove(element) + delete(currentState.idleElements, conn) + if p.options.IsAlive(conn) { + p.access.Unlock() + return conn, false, nil + } + delete(currentState.all, conn) + staleConn = conn + hasStale = true + } + p.access.Unlock() + + if hasStale { + p.options.Close(staleConn, net.ErrClosed) + continue + } + + conn, err := p.dial(ctx, currentState, dial) + if err != nil { + return zero, false, err + } + + p.access.Lock() + if p.closed { + p.access.Unlock() + p.options.Close(conn, net.ErrClosed) + return zero, false, net.ErrClosed + } + if p.state != currentState { + cause := p.closeCause(currentState) + p.access.Unlock() + p.options.Close(conn, cause) + return zero, false, cause + } + currentState.all[conn] = struct{}{} + p.access.Unlock() + return conn, true, nil + } +} + +func (p *ConnPool[T]) acquireShared(ctx context.Context, dial func(context.Context) (T, error)) (T, context.Context, bool, error) { + var zero T + for { + var ( + staleConn T + hasStale bool + state *connPoolConnect[T] + current *connPoolState[T] + startDial bool + ) + + p.access.Lock() + if p.closed { + p.access.Unlock() + return zero, nil, false, net.ErrClosed + } + + current = p.state + if current.hasShared { + conn := current.shared + if p.options.IsAlive(conn) { + created := !current.sharedClaimed + current.sharedClaimed = true + connCtx := current.sharedCtx + p.access.Unlock() + return conn, connCtx, created, nil + } + delete(current.all, conn) + var zeroConn T + current.shared = zeroConn + current.hasShared = false + current.sharedClaimed = false + current.sharedCtx = nil + if current.sharedCancel != nil { + current.sharedCancel(net.ErrClosed) + current.sharedCancel = nil + } + staleConn = conn + hasStale = true + p.access.Unlock() + p.options.Close(staleConn, net.ErrClosed) + continue + } + + if current.connecting == nil { + current.connecting = &connPoolConnect[T]{ + done: make(chan struct{}), + } + startDial = true + } + state = current.connecting + p.access.Unlock() + + if hasStale { + continue + } + if startDial { + go p.connectSingle(current, state, ctx, dial) + } + + select { + case <-state.done: + conn, connCtx, created, retry, err := p.collectShared(current, state, startDial) + if retry { + continue + } + return conn, connCtx, created, err + case <-ctx.Done(): + return zero, nil, false, ctx.Err() + case <-current.ctx.Done(): + p.access.Lock() + closed := p.closed + p.access.Unlock() + if closed { + return zero, nil, false, net.ErrClosed + } + } + } +} + +func (p *ConnPool[T]) connectSingle(current *connPoolState[T], state *connPoolConnect[T], ctx context.Context, dial func(context.Context) (T, error)) { + conn, err := p.dial(ctx, current, dial) + if err != nil { + p.access.Lock() + if current.connecting == state { + current.connecting = nil + } + state.err = err + p.access.Unlock() + close(state.done) + return + } + + var closeErr error + + p.access.Lock() + if current.connecting == state { + current.connecting = nil + } + if p.closed { + closeErr = net.ErrClosed + state.err = closeErr + } else if p.state != current { + closeErr = p.closeCause(current) + state.err = closeErr + } else { + sharedCtx, sharedCancel := context.WithCancelCause(current.ctx) + current.shared = conn + current.hasShared = true + current.sharedClaimed = false + current.sharedCtx = sharedCtx + current.sharedCancel = sharedCancel + current.all[conn] = struct{}{} + } + p.access.Unlock() + + if closeErr != nil { + p.options.Close(conn, closeErr) + } + close(state.done) +} + +func (p *ConnPool[T]) collectShared(current *connPoolState[T], state *connPoolConnect[T], startDial bool) (T, context.Context, bool, bool, error) { + var zero T + + p.access.Lock() + if state.err != nil { + err := state.err + p.access.Unlock() + if startDial { + return zero, nil, false, false, err + } + return zero, nil, false, true, nil + } + if p.closed { + p.access.Unlock() + return zero, nil, false, false, net.ErrClosed + } + if p.state != current { + cause := p.closeCause(current) + p.access.Unlock() + return zero, nil, false, false, cause + } + if !current.hasShared { + p.access.Unlock() + return zero, nil, false, true, nil + } + + conn := current.shared + if !p.options.IsAlive(conn) { + delete(current.all, conn) + var zeroConn T + current.shared = zeroConn + current.hasShared = false + current.sharedClaimed = false + current.sharedCtx = nil + if current.sharedCancel != nil { + current.sharedCancel(net.ErrClosed) + current.sharedCancel = nil + } + p.access.Unlock() + p.options.Close(conn, net.ErrClosed) + return zero, nil, false, true, nil + } + + created := !current.sharedClaimed + current.sharedClaimed = true + connCtx := current.sharedCtx + p.access.Unlock() + return conn, connCtx, created, false, nil +} + +func (p *ConnPool[T]) dial(ctx context.Context, current *connPoolState[T], dial func(context.Context) (T, error)) (T, error) { + var zero T + + if err := ctx.Err(); err != nil { + return zero, err + } + if cause := context.Cause(current.ctx); cause != nil { + return zero, cause + } + + dialCtx, cancel := context.WithCancelCause(current.ctx) + var ( + stateAccess sync.Mutex + dialComplete bool + ) + stopCancel := context.AfterFunc(ctx, func() { + stateAccess.Lock() + if !dialComplete { + cancel(context.Cause(ctx)) + } + stateAccess.Unlock() + }) + + select { + case <-ctx.Done(): + stateAccess.Lock() + dialComplete = true + stateAccess.Unlock() + stopCancel() + cancel(context.Cause(ctx)) + return zero, ctx.Err() + default: + } + + conn, err := dial(connPoolDialContext{ + Context: dialCtx, + parent: ctx, + }) + stateAccess.Lock() + dialComplete = true + stateAccess.Unlock() + stopCancel() + if err != nil { + if cause := context.Cause(dialCtx); cause != nil { + return zero, cause + } + return zero, err + } + if cause := context.Cause(dialCtx); cause != nil { + p.options.Close(conn, cause) + return zero, cause + } + return conn, nil +} + +func (p *ConnPool[T]) closeState(state *connPoolState[T], cause error) { + if state == nil { + return + } + + state.cancel(cause) + if state.sharedCancel != nil { + state.sharedCancel(cause) + } + for conn := range state.all { + p.options.Close(conn, cause) + } +} + +func (p *ConnPool[T]) closeCause(state *connPoolState[T]) error { + _ = state + return net.ErrClosed +} diff --git a/dns/transport/connector.go b/dns/transport/connector.go deleted file mode 100644 index 769232f4..00000000 --- a/dns/transport/connector.go +++ /dev/null @@ -1,287 +0,0 @@ -package transport - -import ( - "context" - "net" - "sync" - "time" - - E "github.com/sagernet/sing/common/exceptions" -) - -type ConnectorCallbacks[T any] struct { - IsClosed func(connection T) bool - Close func(connection T) - Reset func(connection T) -} - -type Connector[T any] struct { - dial func(ctx context.Context) (T, error) - callbacks ConnectorCallbacks[T] - - access sync.Mutex - connection T - hasConnection bool - connectionCancel context.CancelFunc - connecting chan struct{} - - closeCtx context.Context - closed bool -} - -func NewConnector[T any](closeCtx context.Context, dial func(context.Context) (T, error), callbacks ConnectorCallbacks[T]) *Connector[T] { - return &Connector[T]{ - dial: dial, - callbacks: callbacks, - closeCtx: closeCtx, - } -} - -func NewSingleflightConnector(closeCtx context.Context, dial func(context.Context) (*Connection, error)) *Connector[*Connection] { - return NewConnector(closeCtx, dial, ConnectorCallbacks[*Connection]{ - IsClosed: func(connection *Connection) bool { - return connection.IsClosed() - }, - Close: func(connection *Connection) { - connection.CloseWithError(ErrTransportClosed) - }, - Reset: func(connection *Connection) { - connection.CloseWithError(ErrConnectionReset) - }, - }) -} - -type contextKeyConnecting struct{} - -var errRecursiveConnectorDial = E.New("recursive connector dial") - -func (c *Connector[T]) Get(ctx context.Context) (T, error) { - var zero T - for { - c.access.Lock() - - if c.closed { - c.access.Unlock() - return zero, ErrTransportClosed - } - - if c.hasConnection && !c.callbacks.IsClosed(c.connection) { - connection := c.connection - c.access.Unlock() - return connection, nil - } - - c.hasConnection = false - if c.connectionCancel != nil { - c.connectionCancel() - c.connectionCancel = nil - } - if isRecursiveConnectorDial(ctx, c) { - c.access.Unlock() - return zero, errRecursiveConnectorDial - } - - if c.connecting != nil { - connecting := c.connecting - c.access.Unlock() - - select { - case <-connecting: - continue - case <-ctx.Done(): - return zero, ctx.Err() - case <-c.closeCtx.Done(): - return zero, ErrTransportClosed - } - } - - if err := ctx.Err(); err != nil { - c.access.Unlock() - return zero, err - } - - c.connecting = make(chan struct{}) - c.access.Unlock() - - dialContext := context.WithValue(ctx, contextKeyConnecting{}, c) - connection, cancel, err := c.dialWithCancellation(dialContext) - - c.access.Lock() - close(c.connecting) - c.connecting = nil - - if err != nil { - c.access.Unlock() - return zero, err - } - - if c.closed { - cancel() - c.callbacks.Close(connection) - c.access.Unlock() - return zero, ErrTransportClosed - } - if err = ctx.Err(); err != nil { - cancel() - c.callbacks.Close(connection) - c.access.Unlock() - return zero, err - } - - c.connection = connection - c.hasConnection = true - c.connectionCancel = cancel - result := c.connection - c.access.Unlock() - - return result, nil - } -} - -func isRecursiveConnectorDial[T any](ctx context.Context, connector *Connector[T]) bool { - dialConnector, loaded := ctx.Value(contextKeyConnecting{}).(*Connector[T]) - return loaded && dialConnector == connector -} - -func (c *Connector[T]) dialWithCancellation(ctx context.Context) (T, context.CancelFunc, error) { - var zero T - if err := ctx.Err(); err != nil { - return zero, nil, err - } - connCtx, cancel := context.WithCancel(c.closeCtx) - - var ( - stateAccess sync.Mutex - dialComplete bool - ) - stopCancel := context.AfterFunc(ctx, func() { - stateAccess.Lock() - if !dialComplete { - cancel() - } - stateAccess.Unlock() - }) - select { - case <-ctx.Done(): - stateAccess.Lock() - dialComplete = true - stateAccess.Unlock() - stopCancel() - cancel() - return zero, nil, ctx.Err() - default: - } - - connection, err := c.dial(valueContext{connCtx, ctx}) - stateAccess.Lock() - dialComplete = true - stateAccess.Unlock() - stopCancel() - if err != nil { - cancel() - return zero, nil, err - } - return connection, cancel, nil -} - -type valueContext struct { - context.Context - parent context.Context -} - -func (v valueContext) Value(key any) any { - return v.parent.Value(key) -} - -func (v valueContext) Deadline() (time.Time, bool) { - return v.parent.Deadline() -} - -func (c *Connector[T]) Close() error { - c.access.Lock() - defer c.access.Unlock() - - if c.closed { - return nil - } - c.closed = true - - if c.connectionCancel != nil { - c.connectionCancel() - c.connectionCancel = nil - } - if c.hasConnection { - c.callbacks.Close(c.connection) - c.hasConnection = false - } - - return nil -} - -func (c *Connector[T]) Reset() { - c.access.Lock() - defer c.access.Unlock() - - if c.connectionCancel != nil { - c.connectionCancel() - c.connectionCancel = nil - } - if c.hasConnection { - c.callbacks.Reset(c.connection) - c.hasConnection = false - } -} - -type Connection struct { - net.Conn - - closeOnce sync.Once - done chan struct{} - closeError error -} - -func WrapConnection(conn net.Conn) *Connection { - return &Connection{ - Conn: conn, - done: make(chan struct{}), - } -} - -func (c *Connection) Done() <-chan struct{} { - return c.done -} - -func (c *Connection) IsClosed() bool { - select { - case <-c.done: - return true - default: - return false - } -} - -func (c *Connection) CloseError() error { - select { - case <-c.done: - if c.closeError != nil { - return c.closeError - } - return ErrTransportClosed - default: - return nil - } -} - -func (c *Connection) Close() error { - return c.CloseWithError(ErrTransportClosed) -} - -func (c *Connection) CloseWithError(err error) error { - var returnError error - c.closeOnce.Do(func() { - c.closeError = err - returnError = c.Conn.Close() - close(c.done) - }) - return returnError -} diff --git a/dns/transport/connector_test.go b/dns/transport/connector_test.go deleted file mode 100644 index 280e5da6..00000000 --- a/dns/transport/connector_test.go +++ /dev/null @@ -1,263 +0,0 @@ -package transport - -import ( - "context" - "sync/atomic" - "testing" - "time" - - "github.com/stretchr/testify/require" -) - -type testConnectorConnection struct{} - -func TestConnectorRecursiveGetFailsFast(t *testing.T) { - t.Parallel() - - var ( - dialCount atomic.Int32 - closeCount atomic.Int32 - connector *Connector[*testConnectorConnection] - ) - - dial := func(ctx context.Context) (*testConnectorConnection, error) { - dialCount.Add(1) - _, err := connector.Get(ctx) - if err != nil { - return nil, err - } - return &testConnectorConnection{}, nil - } - - connector = NewConnector(context.Background(), dial, ConnectorCallbacks[*testConnectorConnection]{ - IsClosed: func(connection *testConnectorConnection) bool { - return false - }, - Close: func(connection *testConnectorConnection) { - closeCount.Add(1) - }, - Reset: func(connection *testConnectorConnection) { - closeCount.Add(1) - }, - }) - - _, err := connector.Get(context.Background()) - require.ErrorIs(t, err, errRecursiveConnectorDial) - require.EqualValues(t, 1, dialCount.Load()) - require.EqualValues(t, 0, closeCount.Load()) -} - -func TestConnectorRecursiveGetAcrossConnectorsAllowed(t *testing.T) { - t.Parallel() - - var ( - outerDialCount atomic.Int32 - innerDialCount atomic.Int32 - outerConnector *Connector[*testConnectorConnection] - innerConnector *Connector[*testConnectorConnection] - ) - - innerConnector = NewConnector(context.Background(), func(ctx context.Context) (*testConnectorConnection, error) { - innerDialCount.Add(1) - return &testConnectorConnection{}, nil - }, ConnectorCallbacks[*testConnectorConnection]{ - IsClosed: func(connection *testConnectorConnection) bool { - return false - }, - Close: func(connection *testConnectorConnection) {}, - Reset: func(connection *testConnectorConnection) {}, - }) - - outerConnector = NewConnector(context.Background(), func(ctx context.Context) (*testConnectorConnection, error) { - outerDialCount.Add(1) - _, err := innerConnector.Get(ctx) - if err != nil { - return nil, err - } - return &testConnectorConnection{}, nil - }, ConnectorCallbacks[*testConnectorConnection]{ - IsClosed: func(connection *testConnectorConnection) bool { - return false - }, - Close: func(connection *testConnectorConnection) {}, - Reset: func(connection *testConnectorConnection) {}, - }) - - _, err := outerConnector.Get(context.Background()) - require.NoError(t, err) - require.EqualValues(t, 1, outerDialCount.Load()) - require.EqualValues(t, 1, innerDialCount.Load()) -} - -func TestConnectorDialContextPreservesValueAndDeadline(t *testing.T) { - t.Parallel() - - type contextKey struct{} - - var ( - dialValue any - dialDeadline time.Time - dialHasDeadline bool - ) - - connector := NewConnector(context.Background(), func(ctx context.Context) (*testConnectorConnection, error) { - dialValue = ctx.Value(contextKey{}) - dialDeadline, dialHasDeadline = ctx.Deadline() - return &testConnectorConnection{}, nil - }, ConnectorCallbacks[*testConnectorConnection]{ - IsClosed: func(connection *testConnectorConnection) bool { - return false - }, - Close: func(connection *testConnectorConnection) {}, - Reset: func(connection *testConnectorConnection) {}, - }) - - deadline := time.Now().Add(time.Minute) - requestContext, cancel := context.WithDeadline(context.WithValue(context.Background(), contextKey{}, "test-value"), deadline) - defer cancel() - - _, err := connector.Get(requestContext) - require.NoError(t, err) - require.Equal(t, "test-value", dialValue) - require.True(t, dialHasDeadline) - require.WithinDuration(t, deadline, dialDeadline, time.Second) -} - -func TestConnectorDialSkipsCanceledRequest(t *testing.T) { - t.Parallel() - - var dialCount atomic.Int32 - connector := NewConnector(context.Background(), func(ctx context.Context) (*testConnectorConnection, error) { - dialCount.Add(1) - return &testConnectorConnection{}, nil - }, ConnectorCallbacks[*testConnectorConnection]{ - IsClosed: func(connection *testConnectorConnection) bool { - return false - }, - Close: func(connection *testConnectorConnection) {}, - Reset: func(connection *testConnectorConnection) {}, - }) - - requestContext, cancel := context.WithCancel(context.Background()) - cancel() - - _, err := connector.Get(requestContext) - require.ErrorIs(t, err, context.Canceled) - require.EqualValues(t, 0, dialCount.Load()) -} - -func TestConnectorCanceledRequestDoesNotCacheConnection(t *testing.T) { - t.Parallel() - - var ( - dialCount atomic.Int32 - closeCount atomic.Int32 - ) - dialStarted := make(chan struct{}, 1) - releaseDial := make(chan struct{}) - - connector := NewConnector(context.Background(), func(ctx context.Context) (*testConnectorConnection, error) { - dialCount.Add(1) - select { - case dialStarted <- struct{}{}: - default: - } - <-releaseDial - return &testConnectorConnection{}, nil - }, ConnectorCallbacks[*testConnectorConnection]{ - IsClosed: func(connection *testConnectorConnection) bool { - return false - }, - Close: func(connection *testConnectorConnection) { - closeCount.Add(1) - }, - Reset: func(connection *testConnectorConnection) {}, - }) - - requestContext, cancel := context.WithCancel(context.Background()) - result := make(chan error, 1) - go func() { - _, err := connector.Get(requestContext) - result <- err - }() - - <-dialStarted - cancel() - close(releaseDial) - - err := <-result - require.ErrorIs(t, err, context.Canceled) - require.EqualValues(t, 1, dialCount.Load()) - require.EqualValues(t, 1, closeCount.Load()) - - _, err = connector.Get(context.Background()) - require.NoError(t, err) - require.EqualValues(t, 2, dialCount.Load()) -} - -func TestConnectorDialContextNotCanceledByRequestContextAfterDial(t *testing.T) { - t.Parallel() - - var dialContext context.Context - connector := NewConnector(context.Background(), func(ctx context.Context) (*testConnectorConnection, error) { - dialContext = ctx - return &testConnectorConnection{}, nil - }, ConnectorCallbacks[*testConnectorConnection]{ - IsClosed: func(connection *testConnectorConnection) bool { - return false - }, - Close: func(connection *testConnectorConnection) {}, - Reset: func(connection *testConnectorConnection) {}, - }) - - requestContext, cancel := context.WithCancel(context.Background()) - _, err := connector.Get(requestContext) - require.NoError(t, err) - require.NotNil(t, dialContext) - - cancel() - - select { - case <-dialContext.Done(): - t.Fatal("dial context canceled by request context after successful dial") - case <-time.After(100 * time.Millisecond): - } - - err = connector.Close() - require.NoError(t, err) -} - -func TestConnectorDialContextCanceledOnClose(t *testing.T) { - t.Parallel() - - var dialContext context.Context - connector := NewConnector(context.Background(), func(ctx context.Context) (*testConnectorConnection, error) { - dialContext = ctx - return &testConnectorConnection{}, nil - }, ConnectorCallbacks[*testConnectorConnection]{ - IsClosed: func(connection *testConnectorConnection) bool { - return false - }, - Close: func(connection *testConnectorConnection) {}, - Reset: func(connection *testConnectorConnection) {}, - }) - - _, err := connector.Get(context.Background()) - require.NoError(t, err) - require.NotNil(t, dialContext) - - select { - case <-dialContext.Done(): - t.Fatal("dial context canceled before connector close") - default: - } - - err = connector.Close() - require.NoError(t, err) - - select { - case <-dialContext.Done(): - case <-time.After(time.Second): - t.Fatal("dial context not canceled after connector close") - } -} diff --git a/dns/transport/dhcp/dhcp_shared.go b/dns/transport/dhcp/dhcp_shared.go index 6aa83361..20cd50c5 100644 --- a/dns/transport/dhcp/dhcp_shared.go +++ b/dns/transport/dhcp/dhcp_shared.go @@ -7,7 +7,6 @@ import ( "strings" "syscall" - "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" @@ -40,13 +39,6 @@ func (t *Transport) exchangeParallel(ctx context.Context, servers []M.Socksaddr, results := make(chan queryResult) startRacer := func(ctx context.Context, fqdn string) { response, err := t.tryOneName(ctx, servers, fqdn, message) - if err == nil { - if response.Rcode != mDNS.RcodeSuccess { - err = dns.RcodeError(response.Rcode) - } else if len(dns.MessageToAddresses(response)) == 0 { - err = dns.RcodeSuccess - } - } select { case results <- queryResult{response, err}: case <-returned: diff --git a/dns/transport/fakeip/fakeip.go b/dns/transport/fakeip/fakeip.go index 07f0fd09..9aa41e58 100644 --- a/dns/transport/fakeip/fakeip.go +++ b/dns/transport/fakeip/fakeip.go @@ -23,16 +23,25 @@ var _ adapter.FakeIPTransport = (*Transport)(nil) type Transport struct { dns.TransportAdapter - logger logger.ContextLogger - store adapter.FakeIPStore + logger logger.ContextLogger + store adapter.FakeIPStore + inet4Enabled bool + inet6Enabled bool } func NewTransport(ctx context.Context, logger log.ContextLogger, tag string, options option.FakeIPDNSServerOptions) (adapter.DNSTransport, error) { - store := NewStore(ctx, logger, options.Inet4Range.Build(netip.Prefix{}), options.Inet6Range.Build(netip.Prefix{})) + inet4Range := options.Inet4Range.Build(netip.Prefix{}) + inet6Range := options.Inet6Range.Build(netip.Prefix{}) + if !inet4Range.IsValid() && !inet6Range.IsValid() { + return nil, E.New("at least one of inet4_range or inet6_range must be set") + } + store := NewStore(ctx, logger, inet4Range, inet6Range) return &Transport{ TransportAdapter: dns.NewTransportAdapter(C.DNSTypeFakeIP, tag, nil), logger: logger, store: store, + inet4Enabled: inet4Range.IsValid(), + inet6Enabled: inet6Range.IsValid(), }, nil } @@ -55,6 +64,9 @@ func (t *Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, if question.Qtype != mDNS.TypeA && question.Qtype != mDNS.TypeAAAA { return nil, E.New("only IP queries are supported by fakeip") } + if question.Qtype == mDNS.TypeA && !t.inet4Enabled || question.Qtype == mDNS.TypeAAAA && !t.inet6Enabled { + return dns.FixedResponseStatus(message, mDNS.RcodeSuccess), nil + } address, err := t.store.Create(dns.FqdnToDomain(question.Name), question.Qtype == mDNS.TypeAAAA) if err != nil { return nil, err diff --git a/dns/transport/local/local.go b/dns/transport/local/local.go index a42abc76..a3909acc 100644 --- a/dns/transport/local/local.go +++ b/dns/transport/local/local.go @@ -81,10 +81,7 @@ func (t *Transport) Reset() { func (t *Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { if t.resolved != nil { - resolverObject := t.resolved.Object() - if resolverObject != nil { - return t.resolved.Exchange(resolverObject, ctx, message) - } + return t.resolved.Exchange(ctx, message) } question := message.Question[0] if question.Qtype == mDNS.TypeA || question.Qtype == mDNS.TypeAAAA { diff --git a/dns/transport/local/local_resolved.go b/dns/transport/local/local_resolved.go index 2a1a190f..e0128d6d 100644 --- a/dns/transport/local/local_resolved.go +++ b/dns/transport/local/local_resolved.go @@ -9,6 +9,5 @@ import ( type ResolvedResolver interface { Start() error Close() error - Object() any - Exchange(object any, ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) + Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) } diff --git a/dns/transport/local/local_resolved_linux.go b/dns/transport/local/local_resolved_linux.go index bac34c02..fc3ca2b7 100644 --- a/dns/transport/local/local_resolved_linux.go +++ b/dns/transport/local/local_resolved_linux.go @@ -4,19 +4,26 @@ import ( "bufio" "context" "errors" + "net/netip" "os" "strings" "sync" "sync/atomic" "github.com/sagernet/sing-box/adapter" + "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/dns" + dnsTransport "github.com/sagernet/sing-box/dns/transport" + "github.com/sagernet/sing-box/option" "github.com/sagernet/sing-box/service/resolved" "github.com/sagernet/sing-tun" - "github.com/sagernet/sing/common" "github.com/sagernet/sing/common/control" 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/x/list" "github.com/sagernet/sing/service" @@ -49,13 +56,23 @@ type DBusResolvedResolver struct { interfaceMonitor tun.DefaultInterfaceMonitor interfaceCallback *list.Element[tun.DefaultInterfaceUpdateCallback] systemBus *dbus.Conn - resoledObject atomic.Pointer[ResolvedObject] + savedServerSet atomic.Pointer[resolvedServerSet] closeOnce sync.Once } -type ResolvedObject struct { - dbus.BusObject - InterfaceIndex int32 +type resolvedServerSet struct { + servers []resolvedServer +} + +type resolvedServer struct { + primaryTransport adapter.DNSTransport + fallbackTransport adapter.DNSTransport +} + +type resolvedServerSpecification struct { + address netip.Addr + port uint16 + serverName string } func NewResolvedResolver(ctx context.Context, logger logger.ContextLogger) (ResolvedResolver, error) { @@ -82,17 +99,31 @@ func (t *DBusResolvedResolver) Start() error { "org.freedesktop.DBus", "NameOwnerChanged", dbus.WithMatchSender("org.freedesktop.DBus"), - dbus.WithMatchArg(0, "org.freedesktop.resolve1.Manager"), + dbus.WithMatchArg(0, "org.freedesktop.resolve1"), ).Err if err != nil { return E.Cause(err, "configure resolved restart listener") } + err = t.systemBus.BusObject().AddMatchSignal( + "org.freedesktop.DBus.Properties", + "PropertiesChanged", + dbus.WithMatchSender("org.freedesktop.resolve1"), + dbus.WithMatchArg(0, "org.freedesktop.resolve1.Manager"), + ).Err + if err != nil { + return E.Cause(err, "configure resolved properties listener") + } go t.loopUpdateStatus() return nil } func (t *DBusResolvedResolver) Close() error { + var closeErr error t.closeOnce.Do(func() { + serverSet := t.savedServerSet.Swap(nil) + if serverSet != nil { + closeErr = serverSet.Close() + } if t.interfaceCallback != nil { t.interfaceMonitor.UnregisterCallback(t.interfaceCallback) } @@ -100,99 +131,97 @@ func (t *DBusResolvedResolver) Close() error { _ = t.systemBus.Close() } }) - return nil + return closeErr } -func (t *DBusResolvedResolver) Object() any { - return common.PtrOrNil(t.resoledObject.Load()) -} - -func (t *DBusResolvedResolver) Exchange(object any, ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { - question := message.Question[0] - resolvedObject := object.(*ResolvedObject) - call := resolvedObject.CallWithContext( - ctx, - "org.freedesktop.resolve1.Manager.ResolveRecord", - 0, - resolvedObject.InterfaceIndex, - question.Name, - question.Qclass, - question.Qtype, - uint64(0), - ) - if call.Err != nil { - var dbusError dbus.Error - if errors.As(call.Err, &dbusError) && dbusError.Name == "org.freedesktop.resolve1.NoNameServers" { - t.updateStatus() +func (t *DBusResolvedResolver) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + serverSet := t.savedServerSet.Load() + if serverSet == nil { + var err error + serverSet, err = t.checkResolved(context.Background()) + if err != nil { + return nil, err + } + previousServerSet := t.savedServerSet.Swap(serverSet) + if previousServerSet != nil { + _ = previousServerSet.Close() } - return nil, E.Cause(call.Err, " resolve record via resolved") } - var ( - records []resolved.ResourceRecord - outflags uint64 - ) - err := call.Store(&records, &outflags) - if err != nil { + response, err := t.exchangeServerSet(ctx, message, serverSet) + if err == nil { + return response, nil + } + t.updateStatus() + refreshedServerSet := t.savedServerSet.Load() + if refreshedServerSet == nil || refreshedServerSet == serverSet { return nil, err } - response := &mDNS.Msg{ - MsgHdr: mDNS.MsgHdr{ - Id: message.Id, - Response: true, - Authoritative: true, - RecursionDesired: true, - RecursionAvailable: true, - Rcode: mDNS.RcodeSuccess, - }, - Question: []mDNS.Question{question}, - } - for _, record := range records { - var rr mDNS.RR - rr, _, err = mDNS.UnpackRR(record.Data, 0) - if err != nil { - return nil, E.Cause(err, "unpack resource record") - } - response.Answer = append(response.Answer, rr) - } - return response, nil + return t.exchangeServerSet(ctx, message, refreshedServerSet) } func (t *DBusResolvedResolver) loopUpdateStatus() { signalChan := make(chan *dbus.Signal, 1) t.systemBus.Signal(signalChan) for signal := range signalChan { - var restarted bool - if signal.Name == "org.freedesktop.DBus.NameOwnerChanged" { - if len(signal.Body) != 3 || signal.Body[2].(string) == "" { + switch signal.Name { + case "org.freedesktop.DBus.NameOwnerChanged": + if len(signal.Body) != 3 { + continue + } + newOwner, loaded := signal.Body[2].(string) + if !loaded || newOwner == "" { + continue + } + t.updateStatus() + case "org.freedesktop.DBus.Properties.PropertiesChanged": + if !shouldUpdateResolvedServerSet(signal) { continue - } else { - restarted = true } - } - if restarted { t.updateStatus() } } } func (t *DBusResolvedResolver) updateStatus() { - dbusObject, err := t.checkResolved(context.Background()) - oldValue := t.resoledObject.Swap(dbusObject) + serverSet, err := t.checkResolved(context.Background()) + oldServerSet := t.savedServerSet.Swap(serverSet) + if oldServerSet != nil { + _ = oldServerSet.Close() + } if err != nil { var dbusErr dbus.Error - if !errors.As(err, &dbusErr) || dbusErr.Name != "org.freedesktop.DBus.Error.NameHasNoOwnerCould" { + if !errors.As(err, &dbusErr) || dbusErr.Name != "org.freedesktop.DBus.Error.NameHasNoOwner" { t.logger.Debug(E.Cause(err, "systemd-resolved service unavailable")) } - if oldValue != nil { + if oldServerSet != nil { t.logger.Debug("systemd-resolved service is gone") } return - } else if oldValue == nil { + } else if oldServerSet == nil { t.logger.Debug("using systemd-resolved service as resolver") } } -func (t *DBusResolvedResolver) checkResolved(ctx context.Context) (*ResolvedObject, error) { +func (t *DBusResolvedResolver) exchangeServerSet(ctx context.Context, message *mDNS.Msg, serverSet *resolvedServerSet) (*mDNS.Msg, error) { + if serverSet == nil || len(serverSet.servers) == 0 { + return nil, E.New("link has no DNS servers configured") + } + var lastError error + for _, server := range serverSet.servers { + response, err := server.primaryTransport.Exchange(ctx, message) + if err != nil && server.fallbackTransport != nil { + response, err = server.fallbackTransport.Exchange(ctx, message) + } + if err != nil { + lastError = err + continue + } + return response, nil + } + return nil, lastError +} + +func (t *DBusResolvedResolver) checkResolved(ctx context.Context) (*resolvedServerSet, error) { dbusObject := t.systemBus.Object("org.freedesktop.resolve1", "/org/freedesktop/resolve1") err := dbusObject.Call("org.freedesktop.DBus.Peer.Ping", 0).Err if err != nil { @@ -220,16 +249,19 @@ func (t *DBusResolvedResolver) checkResolved(ctx context.Context) (*ResolvedObje if linkObject == nil { return nil, E.New("missing link object for default interface") } - dnsProp, err := linkObject.GetProperty("org.freedesktop.resolve1.Link.DNS") + dnsOverTLSMode, err := loadResolvedLinkDNSOverTLS(linkObject) if err != nil { return nil, err } - var linkDNS []resolved.LinkDNS - err = dnsProp.Store(&linkDNS) + linkDNSEx, err := loadResolvedLinkDNSEx(linkObject) if err != nil { return nil, err } - if len(linkDNS) == 0 { + linkDNS, err := loadResolvedLinkDNS(linkObject) + if err != nil { + return nil, err + } + if len(linkDNSEx) == 0 && len(linkDNS) == 0 { for _, inbound := range service.FromContext[adapter.InboundManager](t.ctx).Inbounds() { if inbound.Type() == C.TypeTun { return nil, E.New("No appropriate name servers or networks for name found") @@ -237,12 +269,233 @@ func (t *DBusResolvedResolver) checkResolved(ctx context.Context) (*ResolvedObje } return nil, E.New("link has no DNS servers configured") } - return &ResolvedObject{ - BusObject: dbusObject, - InterfaceIndex: int32(defaultInterface.Index), + serverDialer, err := dialer.NewDefault(t.ctx, option.DialerOptions{ + BindInterface: defaultInterface.Name, + UDPFragmentDefault: true, + }) + if err != nil { + return nil, err + } + var serverSpecifications []resolvedServerSpecification + if len(linkDNSEx) > 0 { + for _, entry := range linkDNSEx { + serverSpecification, loaded := buildResolvedServerSpecification(defaultInterface.Name, entry.Address, entry.Port, entry.Name) + if !loaded { + continue + } + serverSpecifications = append(serverSpecifications, serverSpecification) + } + } else { + for _, entry := range linkDNS { + serverSpecification, loaded := buildResolvedServerSpecification(defaultInterface.Name, entry.Address, 0, "") + if !loaded { + continue + } + serverSpecifications = append(serverSpecifications, serverSpecification) + } + } + if len(serverSpecifications) == 0 { + return nil, E.New("no valid DNS servers on link") + } + serverSet := &resolvedServerSet{ + servers: make([]resolvedServer, 0, len(serverSpecifications)), + } + for _, serverSpecification := range serverSpecifications { + server, createErr := t.createResolvedServer(serverDialer, dnsOverTLSMode, serverSpecification) + if createErr != nil { + _ = serverSet.Close() + return nil, createErr + } + serverSet.servers = append(serverSet.servers, server) + } + return serverSet, nil +} + +func (t *DBusResolvedResolver) createResolvedServer(serverDialer N.Dialer, dnsOverTLSMode string, serverSpecification resolvedServerSpecification) (resolvedServer, error) { + if dnsOverTLSMode == "yes" { + primaryTransport, err := t.createResolvedTransport(serverDialer, serverSpecification, true) + if err != nil { + return resolvedServer{}, err + } + return resolvedServer{ + primaryTransport: primaryTransport, + }, nil + } + if dnsOverTLSMode == "opportunistic" { + primaryTransport, err := t.createResolvedTransport(serverDialer, serverSpecification, true) + if err != nil { + return resolvedServer{}, err + } + fallbackTransport, err := t.createResolvedTransport(serverDialer, serverSpecification, false) + if err != nil { + _ = primaryTransport.Close() + return resolvedServer{}, err + } + return resolvedServer{ + primaryTransport: primaryTransport, + fallbackTransport: fallbackTransport, + }, nil + } + primaryTransport, err := t.createResolvedTransport(serverDialer, serverSpecification, false) + if err != nil { + return resolvedServer{}, err + } + return resolvedServer{ + primaryTransport: primaryTransport, }, nil } +func (t *DBusResolvedResolver) createResolvedTransport(serverDialer N.Dialer, serverSpecification resolvedServerSpecification, useTLS bool) (adapter.DNSTransport, error) { + serverAddress := M.SocksaddrFrom(serverSpecification.address, resolvedServerPort(serverSpecification.port, useTLS)) + if useTLS { + tlsAddress := serverSpecification.address + if tlsAddress.Zone() != "" { + tlsAddress = tlsAddress.WithZone("") + } + serverName := serverSpecification.serverName + if serverName == "" { + serverName = tlsAddress.String() + } + tlsConfig, err := tls.NewClient(t.ctx, t.logger, tlsAddress.String(), option.OutboundTLSOptions{ + Enabled: true, + ServerName: serverName, + }) + if err != nil { + return nil, err + } + serverTransport := dnsTransport.NewTLSRaw(t.logger, dns.NewTransportAdapter(C.DNSTypeTLS, "", nil), serverDialer, serverAddress, tlsConfig) + err = serverTransport.Start(adapter.StartStateStart) + if err != nil { + _ = serverTransport.Close() + return nil, err + } + return serverTransport, nil + } + serverTransport := dnsTransport.NewUDPRaw(t.logger, dns.NewTransportAdapter(C.DNSTypeUDP, "", nil), serverDialer, serverAddress) + err := serverTransport.Start(adapter.StartStateStart) + if err != nil { + _ = serverTransport.Close() + return nil, err + } + return serverTransport, nil +} + +func (s *resolvedServerSet) Close() error { + var errors []error + for _, server := range s.servers { + errors = append(errors, server.primaryTransport.Close()) + if server.fallbackTransport != nil { + errors = append(errors, server.fallbackTransport.Close()) + } + } + return E.Errors(errors...) +} + +func buildResolvedServerSpecification(interfaceName string, rawAddress []byte, port uint16, serverName string) (resolvedServerSpecification, bool) { + address, loaded := netip.AddrFromSlice(rawAddress) + if !loaded { + return resolvedServerSpecification{}, false + } + if address.Is6() && address.IsLinkLocalUnicast() && address.Zone() == "" { + address = address.WithZone(interfaceName) + } + return resolvedServerSpecification{ + address: address, + port: port, + serverName: serverName, + }, true +} + +func resolvedServerPort(port uint16, useTLS bool) uint16 { + if port > 0 { + return port + } + if useTLS { + return 853 + } + return 53 +} + +func loadResolvedLinkDNS(linkObject dbus.BusObject) ([]resolved.LinkDNS, error) { + dnsProperty, err := linkObject.GetProperty("org.freedesktop.resolve1.Link.DNS") + if err != nil { + if isResolvedUnknownPropertyError(err) { + return nil, nil + } + return nil, err + } + var linkDNS []resolved.LinkDNS + err = dnsProperty.Store(&linkDNS) + if err != nil { + return nil, err + } + return linkDNS, nil +} + +func loadResolvedLinkDNSEx(linkObject dbus.BusObject) ([]resolved.LinkDNSEx, error) { + dnsProperty, err := linkObject.GetProperty("org.freedesktop.resolve1.Link.DNSEx") + if err != nil { + if isResolvedUnknownPropertyError(err) { + return nil, nil + } + return nil, err + } + var linkDNSEx []resolved.LinkDNSEx + err = dnsProperty.Store(&linkDNSEx) + if err != nil { + return nil, err + } + return linkDNSEx, nil +} + +func loadResolvedLinkDNSOverTLS(linkObject dbus.BusObject) (string, error) { + dnsOverTLSProperty, err := linkObject.GetProperty("org.freedesktop.resolve1.Link.DNSOverTLS") + if err != nil { + if isResolvedUnknownPropertyError(err) { + return "", nil + } + return "", err + } + var dnsOverTLSMode string + err = dnsOverTLSProperty.Store(&dnsOverTLSMode) + if err != nil { + return "", err + } + return dnsOverTLSMode, nil +} + +func isResolvedUnknownPropertyError(err error) bool { + var dbusError dbus.Error + return errors.As(err, &dbusError) && dbusError.Name == "org.freedesktop.DBus.Error.UnknownProperty" +} + +func shouldUpdateResolvedServerSet(signal *dbus.Signal) bool { + if len(signal.Body) != 3 { + return true + } + changedProperties, loaded := signal.Body[1].(map[string]dbus.Variant) + if !loaded { + return true + } + for propertyName := range changedProperties { + switch propertyName { + case "DNS", "DNSEx", "DNSOverTLS": + return true + } + } + invalidatedProperties, loaded := signal.Body[2].([]string) + if !loaded { + return true + } + for _, propertyName := range invalidatedProperties { + switch propertyName { + case "DNS", "DNSEx", "DNSOverTLS": + return true + } + } + return false +} + func (t *DBusResolvedResolver) updateDefaultInterface(defaultInterface *control.Interface, flags int) { t.updateStatus() } diff --git a/dns/transport/local/local_shared.go b/dns/transport/local/local_shared.go index 3b05dac6..77635458 100644 --- a/dns/transport/local/local_shared.go +++ b/dns/transport/local/local_shared.go @@ -7,7 +7,6 @@ import ( "syscall" "time" - "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" @@ -49,13 +48,6 @@ func (t *Transport) exchangeParallel(ctx context.Context, systemConfig *dnsConfi results := make(chan queryResult) startRacer := func(ctx context.Context, fqdn string) { response, err := t.tryOneName(ctx, systemConfig, fqdn, message) - if err == nil { - if response.Rcode != mDNS.RcodeSuccess { - err = dns.RcodeError(response.Rcode) - } else if len(dns.MessageToAddresses(response)) == 0 { - err = E.New(fqdn, ": empty result") - } - } select { case results <- queryResult{response, err}: case <-returned: diff --git a/dns/transport/quic/quic.go b/dns/transport/quic/quic.go index 26461006..3a7b6163 100644 --- a/dns/transport/quic/quic.go +++ b/dns/transport/quic/quic.go @@ -31,14 +31,13 @@ func RegisterTransport(registry *dns.TransportRegistry) { } type Transport struct { - *transport.BaseTransport + dns.TransportAdapter - ctx context.Context dialer N.Dialer serverAddr M.Socksaddr tlsConfig tls.Config - connector *transport.Connector[*quic.Conn] + connection *transport.ConnPool[*quic.Conn] } func NewQUIC(ctx context.Context, logger log.ContextLogger, tag string, options option.RemoteTLSDNSServerOptions) (adapter.DNSTransport, error) { @@ -63,93 +62,76 @@ func NewQUIC(ctx context.Context, logger log.ContextLogger, tag string, options return nil, E.New("invalid server address: ", serverAddr) } - t := &Transport{ - BaseTransport: transport.NewBaseTransport( - dns.NewTransportAdapterWithRemoteOptions(C.DNSTypeQUIC, tag, options.RemoteDNSServerOptions), - logger, - ), - ctx: ctx, - dialer: transportDialer, - serverAddr: serverAddr, - tlsConfig: tlsConfig, - } - - t.connector = transport.NewConnector(t.CloseContext(), t.dial, transport.ConnectorCallbacks[*quic.Conn]{ - IsClosed: func(connection *quic.Conn) bool { - return common.Done(connection.Context()) - }, - Close: func(connection *quic.Conn) { - connection.CloseWithError(0, "") - }, - Reset: func(connection *quic.Conn) { - connection.CloseWithError(0, "") - }, - }) - - return t, nil -} - -func (t *Transport) dial(ctx context.Context) (*quic.Conn, error) { - conn, err := t.dialer.DialContext(ctx, N.NetworkUDP, t.serverAddr) - if err != nil { - return nil, E.Cause(err, "dial UDP connection") - } - earlyConnection, err := sQUIC.DialEarly( - ctx, - bufio.NewUnbindPacketConn(conn), - t.serverAddr.UDPAddr(), - t.tlsConfig, - nil, - ) - if err != nil { - conn.Close() - return nil, E.Cause(err, "establish QUIC connection") - } - return earlyConnection, nil + return &Transport{ + TransportAdapter: dns.NewTransportAdapterWithRemoteOptions(C.DNSTypeQUIC, tag, options.RemoteDNSServerOptions), + dialer: transportDialer, + serverAddr: serverAddr, + tlsConfig: tlsConfig, + connection: transport.NewConnPool(transport.ConnPoolOptions[*quic.Conn]{ + Mode: transport.ConnPoolSingle, + IsAlive: func(conn *quic.Conn) bool { + return conn != nil && !common.Done(conn.Context()) + }, + Close: func(conn *quic.Conn, _ error) { + conn.CloseWithError(0, "") + }, + }), + }, nil } func (t *Transport) Start(stage adapter.StartStage) error { if stage != adapter.StartStateStart { return nil } - err := t.SetStarted() - if err != nil { - return err - } return dialer.InitializeDetour(t.dialer) } func (t *Transport) Close() error { - return E.Errors(t.BaseTransport.Close(), t.connector.Close()) + return t.connection.Close() } func (t *Transport) Reset() { - t.connector.Reset() + t.connection.Reset() } func (t *Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { - if !t.BeginQuery() { - return nil, transport.ErrTransportClosed - } - defer t.EndQuery() - var ( conn *quic.Conn err error response *mDNS.Msg ) for i := 0; i < 2; i++ { - conn, err = t.connector.Get(ctx) + conn, _, err = t.connection.Acquire(ctx, func(ctx context.Context) (*quic.Conn, error) { + rawConn, err := t.dialer.DialContext(ctx, N.NetworkUDP, t.serverAddr) + if err != nil { + return nil, E.Cause(err, "dial UDP connection") + } + earlyConnection, err := sQUIC.DialEarly( + ctx, + bufio.NewUnbindPacketConn(rawConn), + t.serverAddr.UDPAddr(), + t.tlsConfig, + nil, + ) + if err != nil { + rawConn.Close() + return nil, E.Cause(err, "establish QUIC connection") + } + return earlyConnection, nil + }) if err != nil { return nil, err } response, err = t.exchange(ctx, message, conn) if err == nil { + t.connection.Release(conn, true) return response, nil } else if !isQUICRetryError(err) { + t.connection.Release(conn, true) return nil, err } else { - t.connector.Reset() + t.connection.Release(conn, true) + t.Reset() continue } } diff --git a/dns/transport/tls.go b/dns/transport/tls.go index 4d463296..43978b6f 100644 --- a/dns/transport/tls.go +++ b/dns/transport/tls.go @@ -2,7 +2,6 @@ package transport import ( "context" - "sync" "time" "github.com/sagernet/sing-box/adapter" @@ -17,7 +16,6 @@ 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/x/list" mDNS "github.com/miekg/dns" ) @@ -29,13 +27,13 @@ func RegisterTLS(registry *dns.TransportRegistry) { } type TLSTransport struct { - *BaseTransport + dns.TransportAdapter + logger logger.ContextLogger dialer tls.Dialer serverAddr M.Socksaddr tlsConfig tls.Config - access sync.Mutex - connections list.List[*tlsDNSConn] + connections *ConnPool[*tlsDNSConn] } type tlsDNSConn struct { @@ -66,10 +64,20 @@ 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{ - BaseTransport: NewBaseTransport(adapter, logger), - dialer: tls.NewDialer(dialer, tlsConfig), - serverAddr: serverAddr, - tlsConfig: tlsConfig, + TransportAdapter: adapter, + logger: logger, + dialer: tls.NewDialer(dialer, tlsConfig), + serverAddr: serverAddr, + tlsConfig: tlsConfig, + connections: NewConnPool(ConnPoolOptions[*tlsDNSConn]{ + Mode: ConnPoolOrdered, + IsAlive: func(conn *tlsDNSConn) bool { + return conn != nil + }, + Close: func(conn *tlsDNSConn, _ error) { + conn.Close() + }, + }), } } @@ -77,53 +85,43 @@ func (t *TLSTransport) Start(stage adapter.StartStage) error { if stage != adapter.StartStateStart { return nil } - err := t.SetStarted() - if err != nil { - return err - } return dialer.InitializeDetour(t.dialer) } func (t *TLSTransport) Close() error { - t.access.Lock() - for connection := t.connections.Front(); connection != nil; connection = connection.Next() { - connection.Value.Close() - } - t.connections.Init() - t.access.Unlock() - return t.BaseTransport.Close() + return t.connections.Close() } func (t *TLSTransport) Reset() { - t.access.Lock() - defer t.access.Unlock() - for connection := t.connections.Front(); connection != nil; connection = connection.Next() { - connection.Value.Close() - } - t.connections.Init() + t.connections.Reset() } func (t *TLSTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { - if !t.BeginQuery() { - return nil, ErrTransportClosed - } - defer t.EndQuery() - - t.access.Lock() - conn := t.connections.PopFront() - t.access.Unlock() - if conn != nil { + var lastErr error + for attempt := 0; attempt < 2; attempt++ { + 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}, 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 } - t.Logger.DebugContext(ctx, "discarded pooled connection: ", err) + lastErr = err + t.logger.DebugContext(ctx, "discarded pooled connection: ", err) + t.connections.Release(conn, false) + if created { + return nil, err + } } - tlsConn, err := t.dialer.DialTLSContext(ctx, t.serverAddr) - if err != nil { - return nil, E.Cause(err, "dial TLS connection") - } - return t.exchange(ctx, message, &tlsDNSConn{Conn: tlsConn}) + return nil, lastErr } func (t *TLSTransport) exchange(ctx context.Context, message *mDNS.Msg, conn *tlsDNSConn) (*mDNS.Msg, error) { @@ -133,22 +131,12 @@ func (t *TLSTransport) exchange(ctx context.Context, message *mDNS.Msg, conn *tl conn.queryId++ err := WriteMessage(conn, conn.queryId, message) if err != nil { - conn.Close() return nil, E.Cause(err, "write request") } response, err := ReadMessage(conn) if err != nil { - conn.Close() return nil, E.Cause(err, "read response") } - t.access.Lock() - if t.State() >= StateClosing { - t.access.Unlock() - conn.Close() - return response, nil - } conn.SetDeadline(time.Time{}) - t.connections.PushBack(conn) - t.access.Unlock() return response, nil } diff --git a/dns/transport/udp.go b/dns/transport/udp.go index a7272545..c9f520e3 100644 --- a/dns/transport/udp.go +++ b/dns/transport/udp.go @@ -2,6 +2,7 @@ package transport import ( "context" + "net" "sync" "sync/atomic" @@ -27,13 +28,14 @@ func RegisterUDP(registry *dns.TransportRegistry) { } type UDPTransport struct { - *BaseTransport + dns.TransportAdapter + logger logger.ContextLogger dialer N.Dialer serverAddr M.Socksaddr udpSize atomic.Int32 - connector *Connector[*Connection] + connection *ConnPool[net.Conn] callbackAccess sync.RWMutex queryId uint16 @@ -63,43 +65,38 @@ func NewUDP(ctx context.Context, logger log.ContextLogger, tag string, options o func NewUDPRaw(logger logger.ContextLogger, adapter dns.TransportAdapter, dialerInstance N.Dialer, serverAddr M.Socksaddr) *UDPTransport { t := &UDPTransport{ - BaseTransport: NewBaseTransport(adapter, logger), - dialer: dialerInstance, - serverAddr: serverAddr, - callbacks: make(map[uint16]*udpCallback), + TransportAdapter: adapter, + 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.connector = NewSingleflightConnector(t.CloseContext(), t.dial) return t } -func (t *UDPTransport) dial(ctx context.Context) (*Connection, error) { - rawConn, err := t.dialer.DialContext(ctx, N.NetworkUDP, t.serverAddr) - if err != nil { - return nil, E.Cause(err, "dial UDP connection") - } - conn := WrapConnection(rawConn) - go t.recvLoop(conn) - return conn, nil -} - func (t *UDPTransport) Start(stage adapter.StartStage) error { if stage != adapter.StartStateStart { return nil } - err := t.SetStarted() - if err != nil { - return err - } return dialer.InitializeDetour(t.dialer) } func (t *UDPTransport) Close() error { - return E.Errors(t.BaseTransport.Close(), t.connector.Close()) + return t.connection.Close() } func (t *UDPTransport) Reset() { - t.connector.Reset() + t.connection.Reset() } func (t *UDPTransport) nextAvailableQueryId() (uint16, error) { @@ -116,17 +113,12 @@ func (t *UDPTransport) nextAvailableQueryId() (uint16, error) { } func (t *UDPTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { - if !t.BeginQuery() { - return nil, ErrTransportClosed - } - defer t.EndQuery() - response, err := t.exchange(ctx, message) if err != nil { return nil, err } if response.Truncated { - t.Logger.InfoContext(ctx, "response truncated, retrying with TCP") + t.logger.InfoContext(ctx, "response truncated, retrying with TCP") return t.exchangeTCP(ctx, message) } return response, nil @@ -158,16 +150,25 @@ func (t *UDPTransport) exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.M break } if t.udpSize.CompareAndSwap(current, udpSize) { - t.connector.Reset() + t.Reset() break } } } - conn, err := t.connector.Get(ctx) + 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") + } + return rawConn, nil + }) if err != nil { return nil, err } + if created { + go t.recvLoop(conn) + } callback := &udpCallback{ done: make(chan struct{}), @@ -177,6 +178,7 @@ func (t *UDPTransport) exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.M queryId, err := t.nextAvailableQueryId() if err != nil { t.callbackAccess.Unlock() + t.connection.Release(conn, true) return nil, err } t.callbacks[queryId] = callback @@ -203,30 +205,30 @@ func (t *UDPTransport) exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.M _, err = conn.Write(rawMessage) if err != nil { - conn.CloseWithError(err) + t.connection.Invalidate(conn, err) return nil, E.Cause(err, "write request") } select { case <-callback.done: + t.connection.Release(conn, true) callback.response.Id = originalId return callback.response, nil - case <-conn.Done(): - return nil, conn.CloseError() - case <-t.CloseContext().Done(): - return nil, ErrTransportClosed + case <-connCtx.Done(): + return nil, context.Cause(connCtx) case <-ctx.Done(): + t.connection.Release(conn, true) return nil, ctx.Err() } } -func (t *UDPTransport) recvLoop(conn *Connection) { +func (t *UDPTransport) recvLoop(conn net.Conn) { for { buffer := buf.NewSize(int(t.udpSize.Load())) _, err := buffer.ReadOnceFrom(conn) if err != nil { buffer.Release() - conn.CloseWithError(err) + t.connection.Invalidate(conn, err) return } @@ -234,7 +236,7 @@ func (t *UDPTransport) recvLoop(conn *Connection) { err = message.Unpack(buffer.Bytes()) buffer.Release() if err != nil { - t.Logger.Debug("discarded malformed UDP response: ", err) + t.logger.Debug("discarded malformed UDP response: ", err) continue } diff --git a/docs/changelog.md b/docs/changelog.md index 29c48605..bd721377 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,68 @@ icon: material/alert-decagram --- +<<<<<<< HEAD +======= +#### 1.13.11 + +* Fix process searcher failure introduced in 1.13.9 +* Fixes and improvements + +#### 1.13.10 + +* Fix process searcher failure introduced in 1.13.9 + +#### 1.13.9 + +* Fixes and improvements + +#### 1.13.8 + +* Update naiveproxy to v147.0.7727.49-1 +* Fix fake-ip DNS server should return SUCCESS when address type is not configured +* Fixes and improvements + +#### 1.13.7 + +* Fixes and improvements + +#### 1.13.6 + +* Fixes and improvements + +#### 1.13.5 + +* Fixes and improvements + +#### 1.13.4 + +* Fixes and improvements + +#### 1.13.3 + +* Add OpenWrt and Alpine APK packages to release **1** +* Backport to macOS 10.13 High Sierra **2** +* OCM service: Add WebSocket support for Responses API **3** +* Fixes and improvements + +**1**: + +Alpine APK files use `linux` in the filename to distinguish from OpenWrt APKs which use the `openwrt` prefix: + +- OpenWrt: `sing-box_{version}_openwrt_{architecture}.apk` +- Alpine: `sing-box_{version}_linux_{architecture}.apk` + +**2**: + +Legacy macOS binaries (with `-legacy-macos-10.13` suffix) now support +macOS 10.13 High Sierra, built using Go 1.25 with patches +from [SagerNet/go](https://github.com/SagerNet/go). + +**3**: + +See [OCM](/configuration/service/ocm). + +>>>>>>> v1.13.11 #### 1.13.2 * Fixes and improvements diff --git a/docs/configuration/dns/fakeip.zh.md b/docs/configuration/dns/fakeip.zh.md index e4d77b35..c8d5dfe3 100644 --- a/docs/configuration/dns/fakeip.zh.md +++ b/docs/configuration/dns/fakeip.zh.md @@ -4,7 +4,7 @@ icon: material/delete-clock !!! failure "已在 sing-box 1.12.0 废弃" - 旧的 fake-ip 配置已废弃且将在 sing-box 1.14.0 中被移除,参阅 [迁移指南](/migration/#migrate-to-new-dns-servers)。 + 旧的 fake-ip 配置已废弃且将在 sing-box 1.14.0 中被移除,参阅 [迁移指南](/zh/migration/#迁移到新的-dns-服务器格式)。 ### 结构 diff --git a/docs/configuration/dns/rule.md b/docs/configuration/dns/rule.md index 6407e1bf..43486748 100644 --- a/docs/configuration/dns/rule.md +++ b/docs/configuration/dns/rule.md @@ -209,7 +209,7 @@ icon: material/alert-decagram (`source_port` || `source_port_range`) && `other fields` - Additionally, included rule-sets can be considered merged rather than as a single rule sub-item. + Additionally, each branch inside an included rule-set can be considered merged into the outer rule, while different branches keep OR semantics. #### inbound @@ -546,4 +546,4 @@ Match any IP with query response. #### rules -Included rules. \ No newline at end of file +Included rules. diff --git a/docs/configuration/dns/rule.zh.md b/docs/configuration/dns/rule.zh.md index 588e0736..f35cfc7e 100644 --- a/docs/configuration/dns/rule.zh.md +++ b/docs/configuration/dns/rule.zh.md @@ -208,7 +208,7 @@ icon: material/alert-decagram (`source_port` || `source_port_range`) && `other fields` - 另外,引用的规则集可视为被合并,而不是作为一个单独的规则子项。 + 另外,引用规则集中的每个分支都可视为与外层规则合并,不同分支之间仍保持 OR 语义。 #### inbound @@ -256,7 +256,7 @@ DNS 查询类型。值可以为整数或者类型名称字符串。 !!! failure "已在 sing-box 1.12.0 中被移除" - GeoSite 已在 sing-box 1.8.0 废弃且在 sing-box 1.12.0 中被移除,参阅 [迁移指南](/zh/migration/#geosite)。 + GeoSite 已在 sing-box 1.8.0 废弃且在 sing-box 1.12.0 中被移除,参阅 [迁移指南](/zh/migration/#迁移-geosite-到规则集)。 匹配 Geosite。 @@ -264,7 +264,7 @@ DNS 查询类型。值可以为整数或者类型名称字符串。 !!! failure "已在 sing-box 1.12.0 中被移除" - GeoIP 已在 sing-box 1.8.0 废弃且在 sing-box 1.12.0 中被移除,参阅 [迁移指南](/zh/migration/#geoip)。 + GeoIP 已在 sing-box 1.8.0 废弃且在 sing-box 1.12.0 中被移除,参阅 [迁移指南](/zh/migration/#迁移-geoip-到规则集)。 匹配源 GeoIP。 @@ -453,7 +453,7 @@ Available values: `wifi`, `cellular`, `ethernet` and `other`. !!! failure "已在 sing-box 1.12.0 废弃" - `outbound` 规则项已废弃且将在 sing-box 1.14.0 中被移除,参阅 [迁移指南](/migration/#migrate-outbound-dns-rule-items-to-domain-resolver)。 + `outbound` 规则项已废弃且将在 sing-box 1.14.0 中被移除,参阅 [迁移指南](/zh/migration/#迁移-outbound-dns-规则项到域解析选项)。 匹配出站。 @@ -505,7 +505,7 @@ Available values: `wifi`, `cellular`, `ethernet` and `other`. !!! failure "已在 sing-box 1.12.0 中被移除" - GeoIP 已在 sing-box 1.8.0 废弃且在 sing-box 1.12.0 中被移除,参阅 [迁移指南](/zh/migration/#geoip)。 + GeoIP 已在 sing-box 1.8.0 废弃且在 sing-box 1.12.0 中被移除,参阅 [迁移指南](/zh/migration/#迁移-geoip-到规则集)。 与查询响应匹配 GeoIP。 @@ -550,4 +550,4 @@ Available values: `wifi`, `cellular`, `ethernet` and `other`. ==必填== -包括的规则。 \ No newline at end of file +包括的规则。 diff --git a/docs/configuration/dns/server/http3.zh.md b/docs/configuration/dns/server/http3.zh.md index 70e13b10..1032fedb 100644 --- a/docs/configuration/dns/server/http3.zh.md +++ b/docs/configuration/dns/server/http3.zh.md @@ -64,7 +64,7 @@ DNS 服务器的路径。 #### tls -TLS 配置,参阅 [TLS](/zh/configuration/shared/tls/#outbound)。 +TLS 配置,参阅 [TLS](/zh/configuration/shared/tls/#出站)。 ### 拨号字段 diff --git a/docs/configuration/dns/server/https.zh.md b/docs/configuration/dns/server/https.zh.md index 691d5eb5..7aa73c3f 100644 --- a/docs/configuration/dns/server/https.zh.md +++ b/docs/configuration/dns/server/https.zh.md @@ -64,7 +64,7 @@ DNS 服务器的路径。 #### tls -TLS 配置,参阅 [TLS](/zh/configuration/shared/tls/#outbound)。 +TLS 配置,参阅 [TLS](/zh/configuration/shared/tls/#出站)。 ### 拨号字段 diff --git a/docs/configuration/dns/server/legacy.zh.md b/docs/configuration/dns/server/legacy.zh.md index 4365749e..906db47c 100644 --- a/docs/configuration/dns/server/legacy.zh.md +++ b/docs/configuration/dns/server/legacy.zh.md @@ -4,7 +4,7 @@ icon: material/delete-clock !!! failure "Deprecated in sing-box 1.12.0" - 旧的 DNS 服务器配置已废弃且将在 sing-box 1.14.0 中被移除,参阅 [迁移指南](/migration/#migrate-to-new-dns-servers)。 + 旧的 DNS 服务器配置已废弃且将在 sing-box 1.14.0 中被移除,参阅 [迁移指南](/zh/migration/#迁移到新的-dns-服务器格式)。 !!! quote "sing-box 1.9.0 中的更改" diff --git a/docs/configuration/dns/server/quic.zh.md b/docs/configuration/dns/server/quic.zh.md index 03b3002c..c18c18ed 100644 --- a/docs/configuration/dns/server/quic.zh.md +++ b/docs/configuration/dns/server/quic.zh.md @@ -51,7 +51,7 @@ DNS 服务器的端口。 #### tls -TLS 配置,参阅 [TLS](/zh/configuration/shared/tls/#outbound)。 +TLS 配置,参阅 [TLS](/zh/configuration/shared/tls/#出站)。 ### 拨号字段 diff --git a/docs/configuration/dns/server/tls.zh.md b/docs/configuration/dns/server/tls.zh.md index 7402e521..afd1111a 100644 --- a/docs/configuration/dns/server/tls.zh.md +++ b/docs/configuration/dns/server/tls.zh.md @@ -51,7 +51,7 @@ DNS 服务器的端口。 #### tls -TLS 配置,参阅 [TLS](/zh/configuration/shared/tls/#outbound)。 +TLS 配置,参阅 [TLS](/zh/configuration/shared/tls/#出站)。 ### 拨号字段 diff --git a/docs/configuration/experimental/cache-file.zh.md b/docs/configuration/experimental/cache-file.zh.md index db2ae205..309e13a1 100644 --- a/docs/configuration/experimental/cache-file.zh.md +++ b/docs/configuration/experimental/cache-file.zh.md @@ -42,7 +42,7 @@ 将拒绝的 DNS 响应缓存存储在缓存文件中。 -[地址筛选 DNS 规则项](/zh/configuration/dns/rule/#_3) 的检查结果将被缓存至过期。 +[地址筛选 DNS 规则项](/zh/configuration/dns/rule/#地址筛选字段) 的检查结果将被缓存至过期。 #### rdrc_timeout diff --git a/docs/configuration/experimental/v2ray-api.zh.md b/docs/configuration/experimental/v2ray-api.zh.md index 81fc8427..87d5c95d 100644 --- a/docs/configuration/experimental/v2ray-api.zh.md +++ b/docs/configuration/experimental/v2ray-api.zh.md @@ -1,6 +1,6 @@ !!! quote "" - 默认安装不包含 V2Ray API,参阅 [安装](/zh/installation/build-from-source/#_5)。 + 默认安装不包含 V2Ray API,参阅 [安装](/zh/installation/build-from-source/#构建标记)。 ### 结构 diff --git a/docs/configuration/inbound/anytls.zh.md b/docs/configuration/inbound/anytls.zh.md index 55b6749e..8c3d1daf 100644 --- a/docs/configuration/inbound/anytls.zh.md +++ b/docs/configuration/inbound/anytls.zh.md @@ -58,4 +58,4 @@ AnyTLS 填充方案行数组。 #### tls -TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#inbound)。 +TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#入站)。 diff --git a/docs/configuration/inbound/http.zh.md b/docs/configuration/inbound/http.zh.md index 2f3d44f5..e1dd876b 100644 --- a/docs/configuration/inbound/http.zh.md +++ b/docs/configuration/inbound/http.zh.md @@ -26,7 +26,7 @@ #### tls -TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#inbound)。 +TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#入站)。 #### users diff --git a/docs/configuration/inbound/hysteria.zh.md b/docs/configuration/inbound/hysteria.zh.md index b7566052..561d7102 100644 --- a/docs/configuration/inbound/hysteria.zh.md +++ b/docs/configuration/inbound/hysteria.zh.md @@ -104,4 +104,4 @@ base64 编码的认证密码。 ==必填== -TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#inbound)。 \ No newline at end of file +TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#入站)。 \ No newline at end of file diff --git a/docs/configuration/inbound/hysteria2.zh.md b/docs/configuration/inbound/hysteria2.zh.md index 5ad5d75d..35a3c25b 100644 --- a/docs/configuration/inbound/hysteria2.zh.md +++ b/docs/configuration/inbound/hysteria2.zh.md @@ -38,7 +38,7 @@ icon: material/alert-decagram !!! warning "与官方 Hysteria2 的区别" 官方程序支持一种名为 **userpass** 的验证方式, - 本质上上是将用户名与密码的组合 `:` 作为实际上的密码,而 sing-box 不提供此别名。 + 本质上是将用户名与密码的组合 `:` 作为实际上的密码,而 sing-box 不提供此别名。 要将 sing-box 与官方程序一起使用, 您需要填写该组合作为实际密码。 ### 监听字段 @@ -85,7 +85,7 @@ Hysteria 用户 ==必填== -TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#inbound)。 +TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#入站)。 #### masquerade diff --git a/docs/configuration/inbound/naive.zh.md b/docs/configuration/inbound/naive.zh.md index c9bfc917..0984d310 100644 --- a/docs/configuration/inbound/naive.zh.md +++ b/docs/configuration/inbound/naive.zh.md @@ -60,4 +60,4 @@ QUIC 拥塞控制算法。 #### tls -TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#inbound)。 \ No newline at end of file +TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#入站)。 \ No newline at end of file diff --git a/docs/configuration/inbound/shadowsocks.zh.md b/docs/configuration/inbound/shadowsocks.zh.md index c97e9bef..a991b0c3 100644 --- a/docs/configuration/inbound/shadowsocks.zh.md +++ b/docs/configuration/inbound/shadowsocks.zh.md @@ -93,4 +93,4 @@ #### multiplex -参阅 [多路复用](/zh/configuration/shared/multiplex#inbound)。 +参阅 [多路复用](/zh/configuration/shared/multiplex#入站)。 diff --git a/docs/configuration/inbound/trojan.zh.md b/docs/configuration/inbound/trojan.zh.md index fa86d613..d81b4c1d 100644 --- a/docs/configuration/inbound/trojan.zh.md +++ b/docs/configuration/inbound/trojan.zh.md @@ -43,7 +43,7 @@ Trojan 用户。 #### tls -TLS 配置,参阅 [TLS](/zh/configuration/shared/tls/#inbound)。 +TLS 配置,参阅 [TLS](/zh/configuration/shared/tls/#入站)。 #### fallback @@ -61,7 +61,7 @@ TLS 配置,参阅 [TLS](/zh/configuration/shared/tls/#inbound)。 #### multiplex -参阅 [多路复用](/zh/configuration/shared/multiplex#inbound)。 +参阅 [多路复用](/zh/configuration/shared/multiplex#入站)。 #### transport diff --git a/docs/configuration/inbound/tuic.zh.md b/docs/configuration/inbound/tuic.zh.md index 99252056..ae531635 100644 --- a/docs/configuration/inbound/tuic.zh.md +++ b/docs/configuration/inbound/tuic.zh.md @@ -75,4 +75,4 @@ QUIC 拥塞控制算法 ==必填== -TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#inbound)。 \ No newline at end of file +TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#入站)。 \ No newline at end of file diff --git a/docs/configuration/inbound/tun.md b/docs/configuration/inbound/tun.md index 7e67e488..74d02dc9 100644 --- a/docs/configuration/inbound/tun.md +++ b/docs/configuration/inbound/tun.md @@ -2,6 +2,15 @@ icon: material/new-box --- +!!! quote "Changes in sing-box 1.14.0" + + :material-plus: [include_mac_address](#include_mac_address) + :material-plus: [exclude_mac_address](#exclude_mac_address) + +!!! quote "Changes in sing-box 1.13.3" + + :material-alert: [strict_route](#strict_route) + !!! quote "Changes in sing-box 1.13.0" :material-plus: [auto_redirect_reset_mark](#auto_redirect_reset_mark) @@ -348,6 +357,9 @@ Enforce strict routing rules when `auto_route` is enabled: * Let unsupported network unreachable * For legacy reasons, when neither `strict_route` nor `auto_redirect` are enabled, all ICMP traffic will not go through TUN. +* When `auto_redirect` is enabled, `strict_route` also affects `SO_BINDTODEVICE` traffic: + * Enabled: `SO_BINDTODEVICE` traffic is redirected through sing-box. + * Disabled: `SO_BINDTODEVICE` traffic bypasses sing-box. *In Windows*: diff --git a/docs/configuration/inbound/tun.zh.md b/docs/configuration/inbound/tun.zh.md index d7368468..88a127f3 100644 --- a/docs/configuration/inbound/tun.zh.md +++ b/docs/configuration/inbound/tun.zh.md @@ -2,6 +2,10 @@ icon: material/new-box --- +!!! quote "sing-box 1.13.3 中的更改" + + :material-alert: [strict_route](#strict_route) + !!! quote "sing-box 1.13.0 中的更改" :material-plus: [auto_redirect_reset_mark](#auto_redirect_reset_mark) @@ -347,6 +351,9 @@ tun 接口的 IPv6 前缀。 * 使不支持的网络不可达。 * 出于历史遗留原因,当未启用 `strict_route` 或 `auto_redirect` 时,所有 ICMP 流量将不会通过 TUN。 +* 当启用 `auto_redirect` 时,`strict_route` 也影响 `SO_BINDTODEVICE` 流量: + * 启用:`SO_BINDTODEVICE` 流量被重定向通过 sing-box。 + * 禁用:`SO_BINDTODEVICE` 流量绕过 sing-box。 *在 Windows 中*: diff --git a/docs/configuration/inbound/vless.zh.md b/docs/configuration/inbound/vless.zh.md index 30b151da..2ce4785b 100644 --- a/docs/configuration/inbound/vless.zh.md +++ b/docs/configuration/inbound/vless.zh.md @@ -48,11 +48,11 @@ VLESS 子协议。 #### tls -TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#inbound)。 +TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#入站)。 #### multiplex -参阅 [多路复用](/zh/configuration/shared/multiplex#inbound)。 +参阅 [多路复用](/zh/configuration/shared/multiplex#入站)。 #### transport diff --git a/docs/configuration/inbound/vmess.zh.md b/docs/configuration/inbound/vmess.zh.md index 9aef44df..f741ed1b 100644 --- a/docs/configuration/inbound/vmess.zh.md +++ b/docs/configuration/inbound/vmess.zh.md @@ -43,11 +43,11 @@ VMess 用户。 #### tls -TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#inbound)。 +TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#入站)。 #### multiplex -参阅 [多路复用](/zh/configuration/shared/multiplex#inbound)。 +参阅 [多路复用](/zh/configuration/shared/multiplex#入站)。 #### transport diff --git a/docs/configuration/outbound/anytls.zh.md b/docs/configuration/outbound/anytls.zh.md index 1c888cfd..c1f8999e 100644 --- a/docs/configuration/outbound/anytls.zh.md +++ b/docs/configuration/outbound/anytls.zh.md @@ -59,7 +59,7 @@ AnyTLS 密码。 ==必填== -TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#outbound)。 +TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#出站)。 ### 拨号字段 diff --git a/docs/configuration/outbound/direct.zh.md b/docs/configuration/outbound/direct.zh.md index 55d3bf8c..824a3529 100644 --- a/docs/configuration/outbound/direct.zh.md +++ b/docs/configuration/outbound/direct.zh.md @@ -4,8 +4,8 @@ icon: material/alert-decagram !!! quote "sing-box 1.11.0 中的更改" - :material-alert-decagram: [override_address](#override_address) - :material-alert-decagram: [override_port](#override_port) + :material-delete-clock: [override_address](#override_address) + :material-delete-clock: [override_port](#override_port) `direct` 出站直接发送请求。 @@ -29,7 +29,7 @@ icon: material/alert-decagram !!! failure "已在 sing-box 1.11.0 废弃" - 目标覆盖字段在 sing-box 1.11.0 中已废弃,并将在 sing-box 1.13.0 中被移除,参阅 [迁移指南](/migration/#migrate-destination-override-fields-to-route-options)。 + 目标覆盖字段在 sing-box 1.11.0 中已废弃,并将在 sing-box 1.13.0 中被移除,参阅 [迁移指南](/zh/migration/#迁移-direct-出站中的目标地址覆盖字段到路由字段)。 覆盖连接目标地址。 @@ -37,7 +37,7 @@ icon: material/alert-decagram !!! failure "已在 sing-box 1.11.0 废弃" - 目标覆盖字段在 sing-box 1.11.0 中已废弃,并将在 sing-box 1.13.0 中被移除,参阅 [迁移指南](/migration/#migrate-destination-override-fields-to-route-options)。 + 目标覆盖字段在 sing-box 1.11.0 中已废弃,并将在 sing-box 1.13.0 中被移除,参阅 [迁移指南](/zh/migration/#迁移-direct-出站中的目标地址覆盖字段到路由字段)。 覆盖连接目标端口。 diff --git a/docs/configuration/outbound/dns.zh.md b/docs/configuration/outbound/dns.zh.md index 3db2fefb..592075b3 100644 --- a/docs/configuration/outbound/dns.zh.md +++ b/docs/configuration/outbound/dns.zh.md @@ -4,7 +4,7 @@ icon: material/delete-clock !!! failure "已在 sing-box 1.11.0 废弃" - 旧的特殊出站已被弃用,且将在 sing-box 1.13.0 中被移除, 参阅 [迁移指南](/migration/#migrate-legacy-special-outbounds-to-rule-actions). + 旧的特殊出站已被弃用,且将在 sing-box 1.13.0 中被移除, 参阅 [迁移指南](/zh/migration/#迁移旧的特殊出站到规则动作). `dns` 出站是一个内部 DNS 服务器。 diff --git a/docs/configuration/outbound/http.zh.md b/docs/configuration/outbound/http.zh.md index 53dd1b6a..55387a63 100644 --- a/docs/configuration/outbound/http.zh.md +++ b/docs/configuration/outbound/http.zh.md @@ -51,7 +51,7 @@ HTTP 请求的额外标头。 #### tls -TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#outbound)。 +TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#出站)。 ### 拨号字段 diff --git a/docs/configuration/outbound/hysteria.zh.md b/docs/configuration/outbound/hysteria.zh.md index 4f4c00bb..ae1d3590 100644 --- a/docs/configuration/outbound/hysteria.zh.md +++ b/docs/configuration/outbound/hysteria.zh.md @@ -134,7 +134,7 @@ base64 编码的认证密码。 ==必填== -TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#outbound)。 +TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#出站)。 ### 拨号字段 diff --git a/docs/configuration/outbound/hysteria2.zh.md b/docs/configuration/outbound/hysteria2.zh.md index d2a8598f..bc77f4ec 100644 --- a/docs/configuration/outbound/hysteria2.zh.md +++ b/docs/configuration/outbound/hysteria2.zh.md @@ -38,7 +38,7 @@ !!! warning "与官方 Hysteria2 的区别" 官方程序支持一种名为 **userpass** 的验证方式, - 本质上上是将用户名与密码的组合 `:` 作为实际上的密码,而 sing-box 不提供此别名。 + 本质上是将用户名与密码的组合 `:` 作为实际上的密码,而 sing-box 不提供此别名。 要将 sing-box 与官方程序一起使用, 您需要填写该组合作为实际密码。 ### 字段 @@ -105,7 +105,7 @@ QUIC 流量混淆器密码. ==必填== -TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#outbound)。 +TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#出站)。 #### brutal_debug diff --git a/docs/configuration/outbound/naive.md b/docs/configuration/outbound/naive.md index d9af4fb1..5ed9d2b8 100644 --- a/docs/configuration/outbound/naive.md +++ b/docs/configuration/outbound/naive.md @@ -34,10 +34,12 @@ icon: material/new-box | Build Variant | Platforms | Description | |---------------|-----------|-------------| - | (default) | Linux amd64/arm64 | purego build with `libcronet.so` included | - | `-glibc` | Linux 386/amd64/arm/arm64 | CGO build dynamically linked with glibc, requires glibc >= 2.31 | - | `-musl` | Linux 386/amd64/arm/arm64 | CGO build statically linked with musl, no system requirements | - | (default) | Windows amd64/arm64 | purego build with `libcronet.dll` included | + | (no suffix) | Linux amd64/arm64 | purego build, `libcronet.so` included | + | `-glibc` | Linux 386/amd64/arm/arm64/mipsle/mips64le/riscv64/loong64 | CGO build, dynamically linked with glibc, requires glibc >= 2.31 (loong64: >= 2.36) | + | `-musl` | Linux 386/amd64/arm/arm64/mipsle/riscv64/loong64 | CGO build, statically linked with musl | + | (no suffix) | Windows amd64/arm64 | purego build, `libcronet.dll` included | + + For Linux, choose the glibc or musl variant based on your distribution's libc type. **Runtime Requirements:** diff --git a/docs/configuration/outbound/naive.zh.md b/docs/configuration/outbound/naive.zh.md index 07896407..dbfd7fbf 100644 --- a/docs/configuration/outbound/naive.zh.md +++ b/docs/configuration/outbound/naive.zh.md @@ -32,12 +32,14 @@ icon: material/new-box **官方发布版本区别:** - | 构建变体 | 平台 | 说明 | - |-----------|------------------------|------------------------------------------| - | (默认) | Linux amd64/arm64 | purego 构建,包含 `libcronet.so` | - | `-glibc` | Linux 386/amd64/arm/arm64 | CGO 构建,动态链接 glibc,要求 glibc >= 2.31 | - | `-musl` | Linux 386/amd64/arm/arm64 | CGO 构建,静态链接 musl,无系统要求 | - | (默认) | Windows amd64/arm64 | purego 构建,包含 `libcronet.dll` | + | 构建变体 | 平台 | 说明 | + |---|---|---| + | (无后缀) | Linux amd64/arm64 | purego 构建,包含 `libcronet.so` | + | `-glibc` | Linux 386/amd64/arm/arm64/mipsle/mips64le/riscv64/loong64 | CGO 构建,动态链接 glibc,要求 glibc >= 2.31(loong64: >= 2.36) | + | `-musl` | Linux 386/amd64/arm/arm64/mipsle/riscv64/loong64 | CGO 构建,静态链接 musl | + | (无后缀) | Windows amd64/arm64 | purego 构建,包含 `libcronet.dll` | + + 对于 Linux,请根据发行版的 libc 类型选择 glibc 或 musl 变体。 **运行时要求:** @@ -103,7 +105,7 @@ QUIC 拥塞控制算法。 ==必填== -TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#outbound)。 +TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#出站)。 只有 `server_name`、`certificate`、`certificate_path` 和 `ech` 是被支持的。 diff --git a/docs/configuration/outbound/selector.zh.md b/docs/configuration/outbound/selector.zh.md index ffe2d70a..520fb15c 100644 --- a/docs/configuration/outbound/selector.zh.md +++ b/docs/configuration/outbound/selector.zh.md @@ -17,7 +17,7 @@ !!! quote "" - 选择器目前只能通过 [Clash API](/zh/configuration/experimental#clash-api) 来控制。 + 选择器目前只能通过 [Clash API](/zh/configuration/experimental/clash-api/) 来控制。 ### 字段 diff --git a/docs/configuration/outbound/shadowsocks.zh.md b/docs/configuration/outbound/shadowsocks.zh.md index 818a4fa9..7b4ff560 100644 --- a/docs/configuration/outbound/shadowsocks.zh.md +++ b/docs/configuration/outbound/shadowsocks.zh.md @@ -95,7 +95,7 @@ UDP over TCP 配置。 #### multiplex -参阅 [多路复用](/zh/configuration/shared/multiplex#outbound)。 +参阅 [多路复用](/zh/configuration/shared/multiplex#出站)。 ### 拨号字段 diff --git a/docs/configuration/outbound/shadowtls.zh.md b/docs/configuration/outbound/shadowtls.zh.md index bb8d0e87..72a73d7d 100644 --- a/docs/configuration/outbound/shadowtls.zh.md +++ b/docs/configuration/outbound/shadowtls.zh.md @@ -49,7 +49,7 @@ ShadowTLS 协议版本。 ==必填== -TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#outbound)。 +TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#出站)。 ### 拨号字段 diff --git a/docs/configuration/outbound/tor.zh.md b/docs/configuration/outbound/tor.zh.md index be505964..a49eb323 100644 --- a/docs/configuration/outbound/tor.zh.md +++ b/docs/configuration/outbound/tor.zh.md @@ -18,7 +18,7 @@ !!! info "" - 默认安装不包含嵌入式 Tor, 参阅 [安装](/zh/installation/build-from-source/#_5)。 + 默认安装不包含嵌入式 Tor, 参阅 [安装](/zh/installation/build-from-source/#构建标记)。 ### 字段 diff --git a/docs/configuration/outbound/trojan.zh.md b/docs/configuration/outbound/trojan.zh.md index 2248c739..8a78ca2d 100644 --- a/docs/configuration/outbound/trojan.zh.md +++ b/docs/configuration/outbound/trojan.zh.md @@ -47,11 +47,11 @@ Trojan 密码。 #### tls -TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#outbound)。 +TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#出站)。 #### multiplex -参阅 [多路复用](/zh/configuration/shared/multiplex#outbound)。 +参阅 [多路复用](/zh/configuration/shared/multiplex#出站)。 #### transport diff --git a/docs/configuration/outbound/tuic.zh.md b/docs/configuration/outbound/tuic.zh.md index 4511711a..6d31d7bc 100644 --- a/docs/configuration/outbound/tuic.zh.md +++ b/docs/configuration/outbound/tuic.zh.md @@ -97,7 +97,7 @@ UDP 包中继模式 ==必填== -TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#outbound)。 +TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#出站)。 ### 拨号字段 diff --git a/docs/configuration/outbound/vless.zh.md b/docs/configuration/outbound/vless.zh.md index 8978a6ac..f3bc9a08 100644 --- a/docs/configuration/outbound/vless.zh.md +++ b/docs/configuration/outbound/vless.zh.md @@ -57,7 +57,7 @@ VLESS 子协议。 #### tls -TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#outbound)。 +TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#出站)。 #### packet_encoding @@ -71,7 +71,7 @@ UDP 包编码,默认使用 xudp。 #### multiplex -参阅 [多路复用](/zh/configuration/shared/multiplex#outbound)。 +参阅 [多路复用](/zh/configuration/shared/multiplex#出站)。 #### transport diff --git a/docs/configuration/outbound/vmess.zh.md b/docs/configuration/outbound/vmess.zh.md index 295b8dde..cbc8bdee 100644 --- a/docs/configuration/outbound/vmess.zh.md +++ b/docs/configuration/outbound/vmess.zh.md @@ -82,7 +82,7 @@ VMess 用户 ID。 #### tls -TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#outbound)。 +TLS 配置, 参阅 [TLS](/zh/configuration/shared/tls/#出站)。 #### packet_encoding @@ -96,7 +96,7 @@ UDP 包编码。 #### multiplex -参阅 [多路复用](/zh/configuration/shared/multiplex#outbound)。 +参阅 [多路复用](/zh/configuration/shared/multiplex#出站)。 #### transport diff --git a/docs/configuration/outbound/wireguard.zh.md b/docs/configuration/outbound/wireguard.zh.md index 3b22affd..2b6d4a0a 100644 --- a/docs/configuration/outbound/wireguard.zh.md +++ b/docs/configuration/outbound/wireguard.zh.md @@ -4,7 +4,7 @@ icon: material/delete-clock !!! failure "已在 sing-box 1.11.0 废弃" - WireGuard 出站已被弃用,且将在 sing-box 1.13.0 中被移除,参阅 [迁移指南](/migration/#migrate-wireguard-outbound-to-endpoint)。 + WireGuard 出站已被弃用,且将在 sing-box 1.13.0 中被移除,参阅 [迁移指南](/zh/migration/#迁移-wireguard-出站到端点)。 !!! quote "sing-box 1.11.0 中的更改" diff --git a/docs/configuration/route/geoip.zh.md b/docs/configuration/route/geoip.zh.md index 3d63a3b7..17559a46 100644 --- a/docs/configuration/route/geoip.zh.md +++ b/docs/configuration/route/geoip.zh.md @@ -4,7 +4,7 @@ icon: material/note-remove !!! failure "已在 sing-box 1.12.0 中被移除" - GeoIP 已在 sing-box 1.8.0 废弃且在 sing-box 1.12.0 中被移除,参阅 [迁移指南](/zh/migration/#geoip)。 + GeoIP 已在 sing-box 1.8.0 废弃且在 sing-box 1.12.0 中被移除,参阅 [迁移指南](/zh/migration/#迁移-geoip-到规则集)。 ### 结构 diff --git a/docs/configuration/route/geosite.zh.md b/docs/configuration/route/geosite.zh.md index 9afea3d7..1ea0752a 100644 --- a/docs/configuration/route/geosite.zh.md +++ b/docs/configuration/route/geosite.zh.md @@ -4,7 +4,7 @@ icon: material/note-remove !!! failure "已在 sing-box 1.12.0 中被移除" - Geosite 已在 sing-box 1.8.0 废弃且在 sing-box 1.12.0 中被移除,参阅 [迁移指南](/zh/migration/#geosite)。 + Geosite 已在 sing-box 1.8.0 废弃且在 sing-box 1.12.0 中被移除,参阅 [迁移指南](/zh/migration/#迁移-geosite-到规则集)。 ### 结构 diff --git a/docs/configuration/route/index.zh.md b/docs/configuration/route/index.zh.md index fa50bfe7..1a50d3e3 100644 --- a/docs/configuration/route/index.zh.md +++ b/docs/configuration/route/index.zh.md @@ -12,7 +12,7 @@ icon: material/alert-decagram !!! quote "sing-box 1.11.0 中的更改" - :material-plus: [network_strategy](#network_strategy) + :material-plus: [default_network_strategy](#default_network_strategy) :material-plus: [default_network_type](#default_network_type) :material-plus: [default_fallback_network_type](#default_fallback_network_type) :material-plus: [default_fallback_delay](#default_fallback_delay) @@ -110,7 +110,7 @@ icon: material/alert-decagram !!! question "自 sing-box 1.12.0 起" -详情参阅 [拨号字段](/configuration/shared/dial/#domain_resolver)。 +详情参阅 [拨号字段](/zh/configuration/shared/dial/#domain_resolver)。 可以被 `outbound.domain_resolver` 覆盖。 @@ -118,7 +118,7 @@ icon: material/alert-decagram !!! question "自 sing-box 1.11.0 起" -详情参阅 [拨号字段](/configuration/shared/dial/#network_strategy)。 +详情参阅 [拨号字段](/zh/configuration/shared/dial/#network_strategy)。 当 `outbound.bind_interface`, `outbound.inet4_bind_address` 或 `outbound.inet6_bind_address` 已设置时不生效。 @@ -130,16 +130,16 @@ icon: material/alert-decagram !!! question "自 sing-box 1.11.0 起" -详情参阅 [拨号字段](/configuration/shared/dial/#default_network_type)。 +详情参阅 [拨号字段](/zh/configuration/shared/dial/#default_network_type)。 #### default_fallback_network_type !!! question "自 sing-box 1.11.0 起" -详情参阅 [拨号字段](/configuration/shared/dial/#default_fallback_network_type)。 +详情参阅 [拨号字段](/zh/configuration/shared/dial/#default_fallback_network_type)。 #### default_fallback_delay !!! question "自 sing-box 1.11.0 起" -详情参阅 [拨号字段](/configuration/shared/dial/#fallback_delay)。 +详情参阅 [拨号字段](/zh/configuration/shared/dial/#fallback_delay)。 diff --git a/docs/configuration/route/rule.md b/docs/configuration/route/rule.md index 31f768fe..92518726 100644 --- a/docs/configuration/route/rule.md +++ b/docs/configuration/route/rule.md @@ -199,7 +199,7 @@ icon: material/new-box (`source_port` || `source_port_range`) && `other fields` - Additionally, included rule-sets can be considered merged rather than as a single rule sub-item. + Additionally, each branch inside an included rule-set can be considered merged into the outer rule, while different branches keep OR semantics. #### inbound diff --git a/docs/configuration/route/rule.zh.md b/docs/configuration/route/rule.zh.md index 1ffe57d6..53da4475 100644 --- a/docs/configuration/route/rule.zh.md +++ b/docs/configuration/route/rule.zh.md @@ -22,6 +22,7 @@ icon: material/new-box :material-plus: [client](#client) :material-delete-clock: [rule_set_ipcidr_match_source](#rule_set_ipcidr_match_source) + :material-plus: [rule_set_ip_cidr_match_source](#rule_set_ip_cidr_match_source) :material-plus: [process_path_regex](#process_path_regex) !!! quote "sing-box 1.8.0 中的更改" @@ -196,7 +197,7 @@ icon: material/new-box (`source_port` || `source_port_range`) && `other fields` - 另外,引用的规则集可视为被合并,而不是作为一个单独的规则子项。 + 另外,引用规则集中的每个分支都可视为与外层规则合并,不同分支之间仍保持 OR 语义。 #### inbound @@ -254,7 +255,7 @@ icon: material/new-box !!! failure "已在 sing-box 1.8.0 废弃" - Geosite 已废弃且可能在不久的将来移除,参阅 [迁移指南](/zh/migration/#geosite)。 + Geosite 已废弃且可能在不久的将来移除,参阅 [迁移指南](/zh/migration/#迁移-geosite-到规则集)。 匹配 Geosite。 @@ -262,7 +263,7 @@ icon: material/new-box !!! failure "已在 sing-box 1.8.0 废弃" - GeoIP 已废弃且可能在不久的将来移除,参阅 [迁移指南](/zh/migration/#geoip)。 + GeoIP 已废弃且可能在不久的将来移除,参阅 [迁移指南](/zh/migration/#迁移-geoip-到规则集)。 匹配源 GeoIP。 @@ -270,7 +271,7 @@ icon: material/new-box !!! failure "已在 sing-box 1.8.0 废弃" - GeoIP 已废弃且可能在不久的将来移除,参阅 [迁移指南](/zh/migration/#geoip)。 + GeoIP 已废弃且可能在不久的将来移除,参阅 [迁移指南](/zh/migration/#迁移-geoip-到规则集)。 匹配 GeoIP。 @@ -500,4 +501,4 @@ icon: material/new-box ==必填== -包括的规则。 \ No newline at end of file +包括的规则。 diff --git a/docs/configuration/route/rule_action.zh.md b/docs/configuration/route/rule_action.zh.md index 16efb53a..16e16180 100644 --- a/docs/configuration/route/rule_action.zh.md +++ b/docs/configuration/route/rule_action.zh.md @@ -66,7 +66,7 @@ icon: material/new-box 目标出站的标签。 -如果未指定,规则仅在来自 auto redirect 的[预匹配](/configuration/shared/pre-match/)中匹配,在其他场景中将被跳过。 +如果未指定,规则仅在来自 auto redirect 的[预匹配](/zh/configuration/shared/pre-match/)中匹配,在其他场景中将被跳过。 #### route-options 字段 @@ -154,22 +154,22 @@ icon: material/new-box #### network_strategy -详情参阅 [拨号字段](/configuration/shared/dial/#network_strategy)。 +详情参阅 [拨号字段](/zh/configuration/shared/dial/#network_strategy)。 仅当出站为 `direct` 且 `outbound.bind_interface`, `outbound.inet4_bind_address` 且 `outbound.inet6_bind_address` 未设置时生效。 #### network_type -详情参阅 [拨号字段](/configuration/shared/dial/#network_type)。 +详情参阅 [拨号字段](/zh/configuration/shared/dial/#network_type)。 #### fallback_network_type -详情参阅 [拨号字段](/configuration/shared/dial/#fallback_network_type)。 +详情参阅 [拨号字段](/zh/configuration/shared/dial/#fallback_network_type)。 #### fallback_delay -详情参阅 [拨号字段](/configuration/shared/dial/#fallback_delay)。 +详情参阅 [拨号字段](/zh/configuration/shared/dial/#fallback_delay)。 #### udp_disable_domain_unmapping diff --git a/docs/configuration/rule-set/headless-rule.zh.md b/docs/configuration/rule-set/headless-rule.zh.md index d539d710..f2b88631 100644 --- a/docs/configuration/rule-set/headless-rule.zh.md +++ b/docs/configuration/rule-set/headless-rule.zh.md @@ -10,8 +10,8 @@ icon: material/new-box !!! quote "sing-box 1.11.0 中的更改" :material-plus: [network_type](#network_type) - :material-alert: [network_is_expensive](#network_is_expensive) - :material-alert: [network_is_constrained](#network_is_constrained) + :material-plus: [network_is_expensive](#network_is_expensive) + :material-plus: [network_is_constrained](#network_is_constrained) ### 结构 diff --git a/docs/configuration/service/ccm.md b/docs/configuration/service/ccm.md index 28b82710..337cacb1 100644 --- a/docs/configuration/service/ccm.md +++ b/docs/configuration/service/ccm.md @@ -66,7 +66,19 @@ List of authorized users for token authentication. If empty, no authentication is required. -Claude Code authenticates by setting the `ANTHROPIC_AUTH_TOKEN` environment variable to their token value. +Object format: + +```json +{ + "name": "", + "token": "" +} +``` + +Object fields: + +- `name`: Username identifier for tracking purposes. +- `token`: Bearer token for authentication. Claude Code authenticates by setting the `ANTHROPIC_AUTH_TOKEN` environment variable to their token value. #### headers @@ -84,23 +96,36 @@ TLS configuration, see [TLS](/configuration/shared/tls/#inbound). ### Example +#### Server + ```json { "services": [ { "type": "ccm", - "listen": "127.0.0.1", - "listen_port": 8080 + "listen": "0.0.0.0", + "listen_port": 8080, + "usages_path": "./claude-usages.json", + "users": [ + { + "name": "alice", + "token": "ak-ccm-hello-world" + }, + { + "name": "bob", + "token": "ak-ccm-hello-bob" + } + ] } ] } ``` -Connect to the CCM service: +#### Client ```bash export ANTHROPIC_BASE_URL="http://127.0.0.1:8080" -export ANTHROPIC_AUTH_TOKEN="sk-ant-ccm-auth-token-not-required-in-this-context" +export ANTHROPIC_AUTH_TOKEN="ak-ccm-hello-world" claude ``` diff --git a/docs/configuration/service/ccm.zh.md b/docs/configuration/service/ccm.zh.md index cd5d3471..f6490b5e 100644 --- a/docs/configuration/service/ccm.zh.md +++ b/docs/configuration/service/ccm.zh.md @@ -66,7 +66,19 @@ Claude Code OAuth 凭据文件的路径。 如果为空,则不需要身份验证。 -Claude Code 通过设置 `ANTHROPIC_AUTH_TOKEN` 环境变量为其令牌值进行身份验证。 +对象格式: + +```json +{ + "name": "", + "token": "" +} +``` + +对象字段: + +- `name`:用于跟踪的用户名标识符。 +- `token`:用于身份验证的 Bearer 令牌。Claude Code 通过设置 `ANTHROPIC_AUTH_TOKEN` 环境变量为其令牌值进行身份验证。 #### headers @@ -80,27 +92,40 @@ Claude Code 通过设置 `ANTHROPIC_AUTH_TOKEN` 环境变量为其令牌值进 #### tls -TLS 配置,参阅 [TLS](/zh/configuration/shared/tls/#inbound)。 +TLS 配置,参阅 [TLS](/zh/configuration/shared/tls/#入站)。 ### 示例 +#### 服务端 + ```json { "services": [ { "type": "ccm", - "listen": "127.0.0.1", - "listen_port": 8080 + "listen": "0.0.0.0", + "listen_port": 8080, + "usages_path": "./claude-usages.json", + "users": [ + { + "name": "alice", + "token": "ak-ccm-hello-world" + }, + { + "name": "bob", + "token": "ak-ccm-hello-bob" + } + ] } ] } ``` -连接到 CCM 服务: +#### 客户端 ```bash export ANTHROPIC_BASE_URL="http://127.0.0.1:8080" -export ANTHROPIC_AUTH_TOKEN="sk-ant-ccm-auth-token-not-required-in-this-context" +export ANTHROPIC_AUTH_TOKEN="ak-ccm-hello-world" claude ``` diff --git a/docs/configuration/service/derp.zh.md b/docs/configuration/service/derp.zh.md index ab89ac08..b22ff413 100644 --- a/docs/configuration/service/derp.zh.md +++ b/docs/configuration/service/derp.zh.md @@ -36,7 +36,7 @@ DERP 服务是一个 Tailscale DERP 服务器,类似于 [derper](https://pkg.g #### tls -TLS 配置,参阅 [TLS](/zh/configuration/shared/tls/#inbound)。 +TLS 配置,参阅 [TLS](/zh/configuration/shared/tls/#入站)。 #### config_path @@ -96,7 +96,7 @@ Derper 配置文件路径。 - `server`:**必填** DERP 服务器地址。 - `server_port`:**必填** DERP 服务器端口。 - `host`:自定义 DERP 主机名。 -- `tls`:[TLS](/zh/configuration/shared/tls/#outbound) +- `tls`:[TLS](/zh/configuration/shared/tls/#出站) - `拨号字段`:[拨号字段](/zh/configuration/shared/dial/) #### mesh_psk diff --git a/docs/configuration/service/ocm.md b/docs/configuration/service/ocm.md index 59dba7da..5fdf2b6b 100644 --- a/docs/configuration/service/ocm.md +++ b/docs/configuration/service/ocm.md @@ -37,7 +37,9 @@ See [Listen Fields](/configuration/shared/listen/) for details. Path to the OpenAI OAuth credentials file. -If not specified, defaults to `~/.codex/auth.json`. +If not specified, defaults to: +- `$CODEX_HOME/auth.json` if `CODEX_HOME` environment variable is set +- `~/.codex/auth.json` otherwise Refreshed tokens are automatically written back to the same location. @@ -111,17 +113,23 @@ TLS configuration, see [TLS](/configuration/shared/tls/#inbound). Add to `~/.codex/config.toml`: ```toml +# profile = "ocm" # set as default profile + [model_providers.ocm] name = "OCM Proxy" base_url = "http://127.0.0.1:8080/v1" -wire_api = "responses" -requires_openai_auth = false +supports_websockets = true + +[profiles.ocm] +model_provider = "ocm" +# model = "gpt-5.4" # if the latest model is not yet publicly released +# model_reasoning_effort = "xhigh" ``` Then run: ```bash -codex --model-provider ocm +codex --profile ocm ``` ### Example with Authentication @@ -139,11 +147,11 @@ codex --model-provider ocm "users": [ { "name": "alice", - "token": "sk-alice-secret-token" + "token": "sk-ocm-hello-world" }, { "name": "bob", - "token": "sk-bob-secret-token" + "token": "sk-ocm-hello-bob" } ] } @@ -156,16 +164,22 @@ codex --model-provider ocm Add to `~/.codex/config.toml`: ```toml +# profile = "ocm" # set as default profile + [model_providers.ocm] name = "OCM Proxy" base_url = "http://127.0.0.1:8080/v1" -wire_api = "responses" -requires_openai_auth = false -experimental_bearer_token = "sk-alice-secret-token" +supports_websockets = true +experimental_bearer_token = "sk-ocm-hello-world" + +[profiles.ocm] +model_provider = "ocm" +# model = "gpt-5.4" # if the latest model is not yet publicly released +# model_reasoning_effort = "xhigh" ``` Then run: ```bash -codex --model-provider ocm +codex --profile ocm ``` diff --git a/docs/configuration/service/ocm.zh.md b/docs/configuration/service/ocm.zh.md index ee1d8510..90394006 100644 --- a/docs/configuration/service/ocm.zh.md +++ b/docs/configuration/service/ocm.zh.md @@ -37,7 +37,9 @@ OCM(OpenAI Codex 多路复用器)服务是一个多路复用服务,允许 OpenAI OAuth 凭据文件的路径。 -如果未指定,默认值为 `~/.codex/auth.json`。 +如果未指定,默认值为: +- 如果设置了 `CODEX_HOME` 环境变量,则使用 `$CODEX_HOME/auth.json` +- 否则使用 `~/.codex/auth.json` 刷新的令牌会自动写回相同位置。 @@ -88,7 +90,7 @@ OpenAI OAuth 凭据文件的路径。 #### tls -TLS 配置,参阅 [TLS](/zh/configuration/shared/tls/#inbound)。 +TLS 配置,参阅 [TLS](/zh/configuration/shared/tls/#入站)。 ### 示例 @@ -111,17 +113,24 @@ TLS 配置,参阅 [TLS](/zh/configuration/shared/tls/#inbound)。 在 `~/.codex/config.toml` 中添加: ```toml +# profile = "ocm" # 设为默认配置 + + [model_providers.ocm] name = "OCM Proxy" base_url = "http://127.0.0.1:8080/v1" -wire_api = "responses" -requires_openai_auth = false +supports_websockets = true + +[profiles.ocm] +model_provider = "ocm" +# model = "gpt-5.4" # 如果最新模型尚未公开发布 +# model_reasoning_effort = "xhigh" ``` 然后运行: ```bash -codex --model-provider ocm +codex --profile ocm ``` ### 带身份验证的示例 @@ -139,11 +148,11 @@ codex --model-provider ocm "users": [ { "name": "alice", - "token": "sk-alice-secret-token" + "token": "sk-ocm-hello-world" }, { "name": "bob", - "token": "sk-bob-secret-token" + "token": "sk-ocm-hello-bob" } ] } @@ -156,16 +165,22 @@ codex --model-provider ocm 在 `~/.codex/config.toml` 中添加: ```toml +# profile = "ocm" # 设为默认配置 + [model_providers.ocm] name = "OCM Proxy" base_url = "http://127.0.0.1:8080/v1" -wire_api = "responses" -requires_openai_auth = false -experimental_bearer_token = "sk-alice-secret-token" +supports_websockets = true +experimental_bearer_token = "sk-ocm-hello-world" + +[profiles.ocm] +model_provider = "ocm" +# model = "gpt-5.4" # 如果最新模型尚未公开发布 +# model_reasoning_effort = "xhigh" ``` 然后运行: ```bash -codex --model-provider ocm +codex --profile ocm ``` diff --git a/docs/configuration/service/ssm-api.zh.md b/docs/configuration/service/ssm-api.zh.md index 66e3e922..fbe45ebb 100644 --- a/docs/configuration/service/ssm-api.zh.md +++ b/docs/configuration/service/ssm-api.zh.md @@ -55,4 +55,4 @@ SSM API 服务是一个用于管理 Shadowsocks 服务器的 RESTful API 服务 #### tls -TLS 配置,参阅 [TLS](/zh/configuration/shared/tls/#inbound)。 \ No newline at end of file +TLS 配置,参阅 [TLS](/zh/configuration/shared/tls/#入站)。 \ No newline at end of file diff --git a/docs/configuration/shared/dial.zh.md b/docs/configuration/shared/dial.zh.md index 49309351..daf7f8e0 100644 --- a/docs/configuration/shared/dial.zh.md +++ b/docs/configuration/shared/dial.zh.md @@ -173,7 +173,7 @@ TCP keep alive 间隔。 用于设置解析域名的域名解析器。 -此选项的格式与 [路由 DNS 规则动作](/configuration/dns/rule_action/#route) 相同,但不包含 `action` 字段。 +此选项的格式与 [路由 DNS 规则动作](/zh/configuration/dns/rule_action/#route) 相同,但不包含 `action` 字段。 若直接将此选项设置为字符串,则等同于设置该选项的 `server` 字段。 @@ -246,7 +246,7 @@ TCP keep alive 间隔。 !!! failure "已在 sing-box 1.12.0 废弃" - `domain_strategy` 已废弃且将在 sing-box 1.14.0 中被移除,参阅 [迁移指南](/migration/#migrate-outbound-domain-strategy-option-to-domain-resolver)。 + `domain_strategy` 已废弃且将在 sing-box 1.14.0 中被移除,参阅 [迁移指南](/zh/migration/#迁移出站域名策略选项到域名解析器)。 可选值:`prefer_ipv4` `prefer_ipv6` `ipv4_only` `ipv6_only`。 diff --git a/docs/configuration/shared/listen.zh.md b/docs/configuration/shared/listen.zh.md index 905cea3c..0afcbc46 100644 --- a/docs/configuration/shared/listen.zh.md +++ b/docs/configuration/shared/listen.zh.md @@ -145,13 +145,13 @@ UDP NAT 过期时间。 如果设置,连接将被转发到指定的入站。 -需要目标入站支持,参阅 [注入支持](/zh/configuration/inbound/#_3)。 +需要目标入站支持,参阅 [注入支持](/zh/configuration/inbound/#字段)。 #### sniff !!! failure "已在 sing-box 1.11.0 废弃" - 入站字段已废弃且将在 sing-box 1.12.0 中被移除,参阅 [迁移指南](/migration/#migrate-legacy-inbound-fields-to-rule-actions). + 入站字段已废弃且将在 sing-box 1.12.0 中被移除,参阅 [迁移指南](/zh/migration/#迁移旧的入站字段到规则动作). 启用协议探测。 @@ -171,7 +171,7 @@ UDP NAT 过期时间。 !!! failure "已在 sing-box 1.11.0 废弃" - 入站字段已废弃且将在 sing-box 1.12.0 中被移除,参阅 [迁移指南](/migration/#migrate-legacy-inbound-fields-to-rule-actions). + 入站字段已废弃且将在 sing-box 1.12.0 中被移除,参阅 [迁移指南](/zh/migration/#迁移旧的入站字段到规则动作). 探测超时时间。 @@ -181,7 +181,7 @@ UDP NAT 过期时间。 !!! failure "已在 sing-box 1.11.0 废弃" - 入站字段已废弃且将在 sing-box 1.12.0 中被移除,参阅 [迁移指南](/migration/#migrate-legacy-inbound-fields-to-rule-actions). + 入站字段已废弃且将在 sing-box 1.12.0 中被移除,参阅 [迁移指南](/zh/migration/#迁移旧的入站字段到规则动作). 可选值: `prefer_ipv4` `prefer_ipv6` `ipv4_only` `ipv6_only`。 @@ -193,7 +193,7 @@ UDP NAT 过期时间。 !!! failure "已在 sing-box 1.11.0 废弃" - 入站字段已废弃且将在 sing-box 1.12.0 中被移除,参阅 [迁移指南](/migration/#migrate-legacy-inbound-fields-to-rule-actions). + 入站字段已废弃且将在 sing-box 1.12.0 中被移除,参阅 [迁移指南](/zh/migration/#迁移旧的入站字段到规则动作). 如果启用,对于地址为域的 UDP 代理请求,将在响应中发送原始包地址而不是映射的域。 diff --git a/docs/configuration/shared/pre-match.zh.md b/docs/configuration/shared/pre-match.zh.md index 615400b0..06d78f10 100644 --- a/docs/configuration/shared/pre-match.zh.md +++ b/docs/configuration/shared/pre-match.zh.md @@ -22,13 +22,13 @@ icon: material/new-box 以 TCP RST / ICMP 不可达拒绝。 -详情参阅 [reject](/configuration/route/rule_action/#reject)。 +详情参阅 [reject](/zh/configuration/route/rule_action/#reject)。 #### route 将 ICMP 连接路由到指定出站以直接回复。 -详情参阅 [route](/configuration/route/rule_action/#route)。 +详情参阅 [route](/zh/configuration/route/rule_action/#route)。 #### bypass @@ -44,4 +44,4 @@ icon: material/new-box 对于其他所有场景,指定了 `outbound` 的 bypass 行为与 `route` 相同。 -详情参阅 [bypass](/configuration/route/rule_action/#bypass)。 +详情参阅 [bypass](/zh/configuration/route/rule_action/#bypass)。 diff --git a/docs/configuration/shared/tls.zh.md b/docs/configuration/shared/tls.zh.md index e0460983..0b47189b 100644 --- a/docs/configuration/shared/tls.zh.md +++ b/docs/configuration/shared/tls.zh.md @@ -426,7 +426,7 @@ echo | openssl s_client -servername example.com -connect example.com:443 2>/dev/ 其实现行为无法通过简单复制握手格式来复现,其行为细节必然存在差异,使得检测成为可能。 此外,此库缺乏积极维护,且代码质量较差,不建议用于反审查场景。 - 如需 TLS 指纹抵抗,请改用 [NaiveProxy](/configuration/inbound/naive/)。 + 如需 TLS 指纹抵抗,请改用 [NaiveProxy](/zh/configuration/inbound/naive/)。 uTLS 是 "crypto/tls" 的一个分支,它提供了 ClientHello 指纹识别阻力。 diff --git a/docs/configuration/shared/v2ray-transport.zh.md b/docs/configuration/shared/v2ray-transport.zh.md index e5bd7de7..e5ea3ed6 100644 --- a/docs/configuration/shared/v2ray-transport.zh.md +++ b/docs/configuration/shared/v2ray-transport.zh.md @@ -144,7 +144,7 @@ HTTP 请求的额外标头 !!! note "" - 默认安装不包含标准 gRPC (兼容性好,但性能较差), 参阅 [安装](/zh/installation/build-from-source/#_5)。 + 默认安装不包含标准 gRPC (兼容性好,但性能较差), 参阅 [安装](/zh/installation/build-from-source/#构建标记)。 ```json { diff --git a/docs/configuration/shared/wifi-state.zh.md b/docs/configuration/shared/wifi-state.zh.md index 02e8b6c9..c7d4db5f 100644 --- a/docs/configuration/shared/wifi-state.zh.md +++ b/docs/configuration/shared/wifi-state.zh.md @@ -4,7 +4,7 @@ icon: material/new-box # Wi-Fi 状态 -!!! quote "sing-box 1.13.0 的变更" +!!! quote "sing-box 1.13.0 中的更改" :material-plus: Linux 支持 :material-plus: Windows 支持 diff --git a/docs/deprecated.zh.md b/docs/deprecated.zh.md index 78c46053..82b6db04 100644 --- a/docs/deprecated.zh.md +++ b/docs/deprecated.zh.md @@ -7,7 +7,7 @@ icon: material/delete-alert #### 旧的 DNS 服务器格式 DNS 服务器已重构, -参阅 [迁移指南](/migration/#migrate-to-new-dns-servers). +参阅 [迁移指南](/zh/migration/#迁移到新的-dns-服务器格式). 对旧格式的兼容性将在 sing-box 1.14.0 中被移除。 @@ -15,7 +15,7 @@ DNS 服务器已重构, 旧的 `outbound` DNS 规则已废弃, 且可被拨号字段代替, -参阅 [迁移指南](/migration/#migrate-outbound-dns-rule-items-to-domain-resolver). +参阅 [迁移指南](/zh/migration/#迁移-outbound-dns-规则项到域解析选项). #### 旧的 ECH 字段 @@ -31,28 +31,28 @@ ECH 支持已在 sing-box 1.12.0 迁移至使用标准库,但标准库不支 #### 旧的特殊出站 旧的特殊出站(`block` / `dns`)已废弃且可以通过规则动作替代, -参阅 [迁移指南](/migration/#migrate-legacy-special-outbounds-to-rule-actions)。 +参阅 [迁移指南](/zh/migration/#迁移旧的特殊出站到规则动作)。 旧字段将在 sing-box 1.13.0 中被移除。 #### 旧的入站字段 旧的入站字段(`inbound.`)已废弃且可以通过规则动作替代, -参阅 [迁移指南](/migration/#migrate-legacy-inbound-fields-to-rule-actions)。 +参阅 [迁移指南](/zh/migration/#迁移旧的入站字段到规则动作)。 旧字段将在 sing-box 1.13.0 中被移除。 #### direct 出站中的目标地址覆盖字段 direct 出站中的目标地址覆盖字段(`override_address` / `override_port`)已废弃且可以通过规则动作替代, -参阅 [迁移指南](/migration/#migrate-destination-override-fields-to-route-options)。 +参阅 [迁移指南](/zh/migration/#迁移-direct-出站中的目标地址覆盖字段到路由字段)。 旧字段将在 sing-box 1.13.0 中被移除。 #### WireGuard 出站 WireGuard 出站已废弃且可以通过端点替代, -参阅 [迁移指南](/migration/#migrate-wireguard-outbound-to-endpoint)。 +参阅 [迁移指南](/zh/migration/#迁移-wireguard-出站到端点)。 旧出站将在 sing-box 1.13.0 中被移除。 @@ -86,7 +86,7 @@ GSO 对透明代理场景没有优势,已废弃且在 TUN 中不再起作用 #### Clash API 中的 Cache file 及相关功能 Clash API 中的 `cache_file` 及相关功能已废弃且已迁移到独立的 `cache_file` 设置, -参阅 [迁移指南](/zh/migration/#clash-api)。 +参阅 [迁移指南](/zh/migration/#将缓存文件从-clash-api-迁移到独立选项)。 #### GeoIP @@ -96,7 +96,7 @@ maxmind GeoIP 国家数据库作为 IP 分类数据库,不完全适合流量 且现有的实现均存在内存使用大与管理困难的问题。 sing-box 1.8.0 引入了[规则集](/zh/configuration/rule-set/), -可以完全替代 GeoIP, 参阅 [迁移指南](/zh/migration/#geoip)。 +可以完全替代 GeoIP, 参阅 [迁移指南](/zh/migration/#迁移-geoip-到规则集)。 #### Geosite @@ -106,7 +106,7 @@ Geosite,即由 V2Ray 维护的 domain-list-community 项目,作为早期流 存在着包括缺少维护、规则不准确和管理困难内的大量问题。 sing-box 1.8.0 引入了[规则集](/zh/configuration/rule-set/), -可以完全替代 Geosite,参阅 [迁移指南](/zh/migration/#geosite)。 +可以完全替代 Geosite,参阅 [迁移指南](/zh/migration/#迁移-geosite-到规则集)。 ## 1.6.0 diff --git a/docs/installation/build-from-source.md b/docs/installation/build-from-source.md index 552ec3fe..8152a89e 100644 --- a/docs/installation/build-from-source.md +++ b/docs/installation/build-from-source.md @@ -92,14 +92,14 @@ NaiveProxy outbound requires special build configurations depending on your targ ### Supported Platforms -| Platform | Architectures | Mode | Requirements | -|-----------------|------------------------|--------|---------------------------------------------------| -| Linux | amd64, arm64 | purego | None (library included in official releases) | -| Linux | 386, amd64, arm, arm64 | CGO | Chromium toolchain, glibc >= 2.31 at runtime | -| Linux (musl) | 386, amd64, arm, arm64 | CGO | Chromium toolchain | -| Windows | amd64, arm64 | purego | None (library included in official releases) | -| Apple platforms | * | CGO | Xcode | -| Android | * | CGO | Android NDK | +| Platform | Architectures | Mode | Requirements | +|-----------------|--------------------------------------------------------|--------|-----------------------------------------------------------------| +| Linux | amd64, arm64 | purego | None (library included in official releases) | +| Linux | 386, amd64, arm, arm64, mipsle, mips64le, riscv64, loong64 | CGO | Chromium toolchain, glibc >= 2.31 (loong64: >= 2.36) at runtime | +| Linux (musl) | 386, amd64, arm, arm64, mipsle, riscv64, loong64 | CGO | Chromium toolchain | +| Windows | amd64, arm64 | purego | None (library included in official releases) | +| Apple platforms | * | CGO | Xcode | +| Android | * | CGO | Android NDK | ### Windows diff --git a/docs/installation/build-from-source.zh.md b/docs/installation/build-from-source.zh.md index 0baf63c3..d6cd03b5 100644 --- a/docs/installation/build-from-source.zh.md +++ b/docs/installation/build-from-source.zh.md @@ -51,20 +51,20 @@ go build -tags "tag_a tag_b" ./cmd/sing-box | 构建标记 | 默认启动 | 说明 | |------------------------------------|-------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `with_quic` | :material-check: | Build with QUIC support, see [QUIC and HTTP3 DNS transports](/configuration/dns/server/), [Naive inbound](/configuration/inbound/naive/), [Hysteria Inbound](/configuration/inbound/hysteria/), [Hysteria Outbound](/configuration/outbound/hysteria/) and [V2Ray Transport#QUIC](/configuration/shared/v2ray-transport#quic). | -| `with_grpc` | :material-close:️ | Build with standard gRPC support, see [V2Ray Transport#gRPC](/configuration/shared/v2ray-transport#grpc). | -| `with_dhcp` | :material-check: | Build with DHCP support, see [DHCP DNS transport](/configuration/dns/server/). | -| `with_wireguard` | :material-check: | Build with WireGuard support, see [WireGuard outbound](/configuration/outbound/wireguard/). | -| `with_utls` | :material-check: | Build with [uTLS](https://github.com/refraction-networking/utls) support for TLS outbound, see [TLS](/configuration/shared/tls#utls). | -| `with_acme` | :material-check: | Build with ACME TLS certificate issuer support, see [TLS](/configuration/shared/tls/). | -| `with_clash_api` | :material-check: | Build with Clash API support, see [Experimental](/configuration/experimental#clash-api-fields). | -| `with_v2ray_api` | :material-close:️ | Build with V2Ray API support, see [Experimental](/configuration/experimental#v2ray-api-fields). | -| `with_gvisor` | :material-check: | Build with gVisor support, see [Tun inbound](/configuration/inbound/tun#stack) and [WireGuard outbound](/configuration/outbound/wireguard#system_interface). | -| `with_embedded_tor` (CGO required) | :material-close:️ | Build with embedded Tor support, see [Tor outbound](/configuration/outbound/tor/). | -| `with_tailscale` | :material-check: | 构建 Tailscale 支持,参阅 [Tailscale 端点](/configuration/endpoint/tailscale)。 | +| `with_quic` | :material-check: | Build with QUIC support, see [QUIC and HTTP3 DNS transports](/zh/configuration/dns/server/), [Naive inbound](/zh/configuration/inbound/naive/), [Hysteria Inbound](/zh/configuration/inbound/hysteria/), [Hysteria Outbound](/zh/configuration/outbound/hysteria/) and [V2Ray Transport#QUIC](/zh/configuration/shared/v2ray-transport#quic). | +| `with_grpc` | :material-close:️ | Build with standard gRPC support, see [V2Ray Transport#gRPC](/zh/configuration/shared/v2ray-transport#grpc). | +| `with_dhcp` | :material-check: | Build with DHCP support, see [DHCP DNS transport](/zh/configuration/dns/server/). | +| `with_wireguard` | :material-check: | Build with WireGuard support, see [WireGuard outbound](/zh/configuration/outbound/wireguard/). | +| `with_utls` | :material-check: | Build with [uTLS](https://github.com/refraction-networking/utls) support for TLS outbound, see [TLS](/zh/configuration/shared/tls#utls). | +| `with_acme` | :material-check: | Build with ACME TLS certificate issuer support, see [TLS](/zh/configuration/shared/tls/). | +| `with_clash_api` | :material-check: | Build with Clash API support, see [Experimental](/zh/configuration/experimental#clash-api-fields). | +| `with_v2ray_api` | :material-close:️ | Build with V2Ray API support, see [Experimental](/zh/configuration/experimental#v2ray-api-fields). | +| `with_gvisor` | :material-check: | Build with gVisor support, see [Tun inbound](/zh/configuration/inbound/tun#stack) and [WireGuard outbound](/zh/configuration/outbound/wireguard#system_interface). | +| `with_embedded_tor` (CGO required) | :material-close:️ | Build with embedded Tor support, see [Tor outbound](/zh/configuration/outbound/tor/). | +| `with_tailscale` | :material-check: | 构建 Tailscale 支持,参阅 [Tailscale 端点](/zh/configuration/endpoint/tailscale)。 | | `with_ccm` | :material-check: | 构建 Claude Code Multiplexer 服务支持。 | | `with_ocm` | :material-check: | 构建 OpenAI Codex Multiplexer 服务支持。 | -| `with_naive_outbound` | :material-check: | 构建 NaiveProxy 出站支持,参阅 [NaiveProxy 出站](/configuration/outbound/naive/)。 | +| `with_naive_outbound` | :material-check: | 构建 NaiveProxy 出站支持,参阅 [NaiveProxy 出站](/zh/configuration/outbound/naive/)。 | | `badlinkname` | :material-check: | 启用 `go:linkname` 以访问标准库内部函数。Go 标准库未提供本项目需要的许多底层 API,且在外部重新实现不切实际。用于 kTLS(内核 TLS 卸载)和原始 TLS 记录操作。 | | `tfogo_checklinkname0` | :material-check: | `badlinkname` 的伴随标记。Go 1.23+ 链接器强制限制 `go:linkname` 使用;此标记表示构建使用 `-checklinkname=0` 以绕过该限制。 | @@ -96,14 +96,14 @@ NaiveProxy 出站需要根据目标平台进行特殊的构建配置。 ### 支持的平台 -| 平台 | 架构 | 模式 | 要求 | -|---------------|------------------------|--------|--------------------------------| -| Linux | amd64, arm64 | purego | 无(官方发布版本已包含库文件) | -| Linux | 386, amd64, arm, arm64 | CGO | Chromium 工具链,运行时需要 glibc >= 2.31 | -| Linux (musl) | 386, amd64, arm, arm64 | CGO | Chromium 工具链 | -| Windows | amd64, arm64 | purego | 无(官方发布版本已包含库文件) | -| Apple 平台 | * | CGO | Xcode | -| Android | * | CGO | Android NDK | +| 平台 | 架构 | 模式 | 要求 | +|--------------|----------------------------------------------------------|--------|-----------------------------------------------------| +| Linux | amd64, arm64 | purego | 无(官方发布版本已包含库文件) | +| Linux | 386, amd64, arm, arm64, mipsle, mips64le, riscv64, loong64 | CGO | Chromium 工具链,运行时需要 glibc >= 2.31(loong64: >= 2.36) | +| Linux (musl) | 386, amd64, arm, arm64, mipsle, riscv64, loong64 | CGO | Chromium 工具链 | +| Windows | amd64, arm64 | purego | 无(官方发布版本已包含库文件) | +| Apple 平台 | * | CGO | Xcode | +| Android | * | CGO | Android NDK | ### Windows diff --git a/docs/installation/tools/install.sh b/docs/installation/tools/install.sh index 74166f02..7bfbc365 100755 --- a/docs/installation/tools/install.sh +++ b/docs/installation/tools/install.sh @@ -47,6 +47,17 @@ elif command -v rpm >/dev/null 2>&1; then arch=$(uname -m) package_suffix=".rpm" package_install="rpm -i" +elif command -v apk >/dev/null 2>&1 && [ -f /etc/os-release ] && grep -q OPENWRT_ARCH /etc/os-release; then + os="openwrt" + . /etc/os-release + arch="$OPENWRT_ARCH" + package_suffix=".apk" + package_install="apk add --allow-untrusted" +elif command -v apk >/dev/null 2>&1; then + os="linux" + arch=$(apk --print-arch) + package_suffix=".apk" + package_install="apk add --allow-untrusted" elif command -v opkg >/dev/null 2>&1; then os="openwrt" . /etc/os-release diff --git a/docs/migration.zh.md b/docs/migration.zh.md index 6f8ba62a..c08be78f 100644 --- a/docs/migration.zh.md +++ b/docs/migration.zh.md @@ -518,9 +518,9 @@ DNS 服务器已经重构。 !!! info "参考" - [DNS 规则](/configuration/dns/rule/#outbound) / - [拨号字段](/configuration/shared/dial/#domain_resolver) / - [路由](/configuration/route/#default_domain_resolver) + [DNS 规则](/zh/configuration/dns/rule/#outbound) / + [拨号字段](/zh/configuration/shared/dial/#domain_resolver) / + [路由](/zh/configuration/route/#default_domain_resolver) === ":material-card-remove: 废弃的" @@ -596,7 +596,7 @@ DNS 服务器已经重构。 !!! info "参考" - [拨号字段](/configuration/shared/dial/#domain_strategy) + [拨号字段](/zh/configuration/shared/dial/#domain_strategy) === ":material-card-remove: 弃用的" diff --git a/docs/sponsors.md b/docs/sponsors.md index 33898928..4e4dd07c 100644 --- a/docs/sponsors.md +++ b/docs/sponsors.md @@ -11,12 +11,6 @@ the project maintainer via [GitHub Sponsors](https://github.com/sponsors/nekohas ![](https://nekohasekai.github.io/sponsor-images/sponsors.svg) -## Commercial Sponsors - -> [Warp](https://go.warp.dev/sing-box), Built for coding with multiple AI agents. - -[![](https://github.com/warpdotdev/brand-assets/raw/refs/heads/main/Github/Sponsor/Warp-Github-LG-02.png)](https://go.warp.dev/sing-box) - ## Special Sponsors > Viral Tech, Inc. diff --git a/experimental/cachefile/rdrc.go b/experimental/cachefile/rdrc.go index d27ea8b2..dde324c3 100644 --- a/experimental/cachefile/rdrc.go +++ b/experimental/cachefile/rdrc.go @@ -72,6 +72,9 @@ func (c *CacheFile) LoadRDRC(transportName string, qName string, qType uint16) ( } func (c *CacheFile) SaveRDRC(transportName string, qName string, qType uint16) error { + expiresAt := buf.Get(8) + defer buf.Put(expiresAt) + binary.BigEndian.PutUint64(expiresAt, uint64(time.Now().Add(c.rdrcTimeout).Unix())) return c.batch(func(tx *bbolt.Tx) error { bucket, err := c.createBucket(tx, bucketRDRC) if err != nil { @@ -85,9 +88,6 @@ func (c *CacheFile) SaveRDRC(transportName string, qName string, qType uint16) e binary.BigEndian.PutUint16(key, qType) copy(key[2:], qName) defer buf.Put(key) - expiresAt := buf.Get(8) - defer buf.Put(expiresAt) - binary.BigEndian.PutUint64(expiresAt, uint64(time.Now().Add(c.rdrcTimeout).Unix())) return bucket.Put(key, expiresAt) }) } diff --git a/experimental/clashapi/api_meta.go b/experimental/clashapi/api_meta.go index 77dad797..8b31d8a4 100644 --- a/experimental/clashapi/api_meta.go +++ b/experimental/clashapi/api_meta.go @@ -2,6 +2,7 @@ package clashapi import ( "bytes" + "context" "net" "net/http" "runtime/debug" @@ -27,7 +28,7 @@ func (s *Server) setupMetaAPI(r chi.Router) { }) r.Mount("/", middleware.Profiler()) } - r.Get("/memory", memory(s.trafficManager)) + r.Get("/memory", memory(s.ctx, s.trafficManager)) r.Mount("/group", groupRouter(s)) r.Mount("/upgrade", upgradeRouter(s)) } @@ -37,7 +38,7 @@ type Memory struct { OSLimit uint64 `json:"oslimit"` // maybe we need it in the future } -func memory(trafficManager *trafficontrol.Manager) func(w http.ResponseWriter, r *http.Request) { +func memory(ctx context.Context, trafficManager *trafficontrol.Manager) func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) { var conn net.Conn if r.Header.Get("Upgrade") == "websocket" { @@ -46,6 +47,7 @@ func memory(trafficManager *trafficontrol.Manager) func(w http.ResponseWriter, r if err != nil { return } + defer conn.Close() } if conn == nil { @@ -58,7 +60,12 @@ func memory(trafficManager *trafficontrol.Manager) func(w http.ResponseWriter, r buf := &bytes.Buffer{} var err error first := true - for range tick.C { + for { + select { + case <-ctx.Done(): + return + case <-tick.C: + } buf.Reset() inuse := trafficManager.Snapshot().Memory diff --git a/experimental/clashapi/connections.go b/experimental/clashapi/connections.go index 5074adf7..14274b31 100644 --- a/experimental/clashapi/connections.go +++ b/experimental/clashapi/connections.go @@ -38,6 +38,7 @@ func getConnections(ctx context.Context, trafficManager *trafficontrol.Manager) if err != nil { return } + defer conn.Close() intervalStr := r.URL.Query().Get("interval") interval := 1000 diff --git a/experimental/clashapi/server.go b/experimental/clashapi/server.go index c3661182..ec40a95f 100644 --- a/experimental/clashapi/server.go +++ b/experimental/clashapi/server.go @@ -115,7 +115,7 @@ func NewServer(ctx context.Context, logFactory log.ObservableFactory, options op chiRouter.Group(func(r chi.Router) { r.Use(authentication(options.Secret)) r.Get("/", hello(options.ExternalUI != "")) - r.Get("/logs", getLogs(logFactory)) + r.Get("/logs", getLogs(s.ctx, logFactory)) r.Get("/traffic", traffic(s.ctx, trafficManager)) r.Get("/version", version) r.Mount("/configs", configRouter(s, logFactory)) @@ -360,7 +360,7 @@ type Log struct { Payload string `json:"payload"` } -func getLogs(logFactory log.ObservableFactory) func(w http.ResponseWriter, r *http.Request) { +func getLogs(ctx context.Context, logFactory log.ObservableFactory) func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) { levelText := r.URL.Query().Get("level") if levelText == "" { @@ -399,6 +399,8 @@ func getLogs(logFactory log.ObservableFactory) func(w http.ResponseWriter, r *ht var logEntry log.Entry for { select { + case <-ctx.Done(): + return case <-done: return case logEntry = <-subscription: diff --git a/experimental/clashapi/trafficontrol/tracker.go b/experimental/clashapi/trafficontrol/tracker.go index 23500cd0..f001b77b 100644 --- a/experimental/clashapi/trafficontrol/tracker.go +++ b/experimental/clashapi/trafficontrol/tracker.go @@ -45,8 +45,8 @@ func (t TrackerMetadata) MarshalJSON() ([]byte, error) { if t.Metadata.ProcessInfo != nil { if t.Metadata.ProcessInfo.ProcessPath != "" { processPath = t.Metadata.ProcessInfo.ProcessPath - } else if t.Metadata.ProcessInfo.AndroidPackageName != "" { - processPath = t.Metadata.ProcessInfo.AndroidPackageName + } else if len(t.Metadata.ProcessInfo.AndroidPackageNames) > 0 { + processPath = t.Metadata.ProcessInfo.AndroidPackageNames[0] } if processPath == "" { if t.Metadata.ProcessInfo.UserId != -1 { diff --git a/experimental/libbox/command_types.go b/experimental/libbox/command_types.go index 39027ac7..c330dd4b 100644 --- a/experimental/libbox/command_types.go +++ b/experimental/libbox/command_types.go @@ -239,11 +239,15 @@ func (c *Connections) Iterator() ConnectionIterator { } type ProcessInfo struct { - ProcessID int64 - UserID int32 - UserName string - ProcessPath string - PackageName string + ProcessID int64 + UserID int32 + UserName string + ProcessPath string + packageNames []string +} + +func (p *ProcessInfo) PackageNames() StringIterator { + return newIterator(p.packageNames) } type Connection struct { @@ -339,11 +343,11 @@ func connectionFromGRPC(conn *daemon.Connection) Connection { var processInfo *ProcessInfo if conn.ProcessInfo != nil { processInfo = &ProcessInfo{ - ProcessID: int64(conn.ProcessInfo.ProcessId), - UserID: conn.ProcessInfo.UserId, - UserName: conn.ProcessInfo.UserName, - ProcessPath: conn.ProcessInfo.ProcessPath, - PackageName: conn.ProcessInfo.PackageName, + ProcessID: int64(conn.ProcessInfo.ProcessId), + UserID: conn.ProcessInfo.UserId, + UserName: conn.ProcessInfo.UserName, + ProcessPath: conn.ProcessInfo.ProcessPath, + packageNames: conn.ProcessInfo.PackageNames, } } return Connection{ diff --git a/experimental/libbox/config.go b/experimental/libbox/config.go index 122425d2..b1676ab6 100644 --- a/experimental/libbox/config.go +++ b/experimental/libbox/config.go @@ -3,6 +3,7 @@ package libbox import ( "bytes" "context" + "net/netip" "os" box "github.com/sagernet/sing-box" @@ -144,6 +145,10 @@ func (s *platformInterfaceStub) SendNotification(notification *adapter.Notificat return nil } +func (s *platformInterfaceStub) MyInterfaceAddress() []netip.Addr { + return nil +} + func (s *platformInterfaceStub) UsePlatformLocalDNSTransport() bool { return false } diff --git a/experimental/libbox/connection_owner_darwin.go b/experimental/libbox/connection_owner_darwin.go new file mode 100644 index 00000000..20220106 --- /dev/null +++ b/experimental/libbox/connection_owner_darwin.go @@ -0,0 +1,57 @@ +package libbox + +import ( + "net/netip" + "os/user" + "syscall" + + "github.com/sagernet/sing-box/common/process" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" +) + +func FindConnectionOwner(ipProtocol int32, sourceAddress string, sourcePort int32, destinationAddress string, destinationPort int32) (*ConnectionOwner, error) { + source, err := parseConnectionOwnerAddrPort(sourceAddress, sourcePort) + if err != nil { + return nil, E.Cause(err, "parse source") + } + destination, err := parseConnectionOwnerAddrPort(destinationAddress, destinationPort) + if err != nil { + return nil, E.Cause(err, "parse destination") + } + var network string + switch ipProtocol { + case syscall.IPPROTO_TCP: + network = "tcp" + case syscall.IPPROTO_UDP: + network = "udp" + default: + return nil, E.New("unknown protocol: ", ipProtocol) + } + owner, err := process.FindDarwinConnectionOwner(network, source, destination) + if err != nil { + return nil, err + } + result := &ConnectionOwner{ + UserId: owner.UserId, + ProcessPath: owner.ProcessPath, + } + if owner.UserId != -1 && owner.UserName == "" { + osUser, _ := user.LookupId(F.ToString(owner.UserId)) + if osUser != nil { + result.UserName = osUser.Username + } + } + return result, nil +} + +func parseConnectionOwnerAddrPort(address string, port int32) (netip.AddrPort, error) { + if port < 0 || port > 65535 { + return netip.AddrPort{}, E.New("invalid port: ", port) + } + addr, err := netip.ParseAddr(address) + if err != nil { + return netip.AddrPort{}, err + } + return netip.AddrPortFrom(addr.Unmap(), uint16(port)), nil +} diff --git a/experimental/libbox/fdroid.go b/experimental/libbox/fdroid.go new file mode 100644 index 00000000..d574ffd8 --- /dev/null +++ b/experimental/libbox/fdroid.go @@ -0,0 +1,493 @@ +package libbox + +import ( + "archive/zip" + "bytes" + "crypto/tls" + "encoding/json" + "io" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + "time" + + E "github.com/sagernet/sing/common/exceptions" +) + +const fdroidUserAgent = "F-Droid 1.21.1" + +type FDroidUpdateInfo struct { + VersionCode int32 + VersionName string + DownloadURL string + FileSize int64 + FileSHA256 string +} + +type FDroidPingResult struct { + URL string + LatencyMs int32 + Error string +} + +type FDroidPingResultIterator interface { + Len() int32 + HasNext() bool + Next() *FDroidPingResult +} + +type fdroidAPIResponse struct { + PackageName string `json:"packageName"` + SuggestedVersionCode int32 `json:"suggestedVersionCode"` + Packages []fdroidAPIPackage `json:"packages"` +} + +type fdroidAPIPackage struct { + VersionName string `json:"versionName"` + VersionCode int32 `json:"versionCode"` +} + +type fdroidEntry struct { + Timestamp int64 `json:"timestamp"` + Version int `json:"version"` + Index fdroidEntryFile `json:"index"` + Diffs map[string]fdroidEntryFile `json:"diffs"` +} + +type fdroidEntryFile struct { + Name string `json:"name"` + SHA256 string `json:"sha256"` + Size int64 `json:"size"` + NumPackages int `json:"numPackages"` +} + +type fdroidIndexV2 struct { + Packages map[string]fdroidV2Package `json:"packages"` +} + +type fdroidV2Package struct { + Versions map[string]fdroidV2Version `json:"versions"` +} + +type fdroidV2Version struct { + Manifest fdroidV2Manifest `json:"manifest"` + File fdroidV2File `json:"file"` +} + +type fdroidV2Manifest struct { + VersionCode int32 `json:"versionCode"` + VersionName string `json:"versionName"` +} + +type fdroidV2File struct { + Name string `json:"name"` + SHA256 string `json:"sha256"` + Size int64 `json:"size"` +} + +type fdroidIndexV1 struct { + Packages map[string][]fdroidV1Package `json:"packages"` +} + +type fdroidV1Package struct { + VersionCode int32 `json:"versionCode"` + VersionName string `json:"versionName"` + ApkName string `json:"apkName"` + Size int64 `json:"size"` + Hash string `json:"hash"` + HashType string `json:"hashType"` +} + +type fdroidCache struct { + MirrorURL string `json:"mirrorURL"` + Timestamp int64 `json:"timestamp"` + ETag string `json:"etag"` + IsV1 bool `json:"isV1,omitempty"` +} + +func CheckFDroidUpdate(mirrorURL, packageName string, currentVersionCode int32, cachePath string) (*FDroidUpdateInfo, error) { + mirrorURL = strings.TrimRight(mirrorURL, "/") + if strings.Contains(mirrorURL, "f-droid.org") { + return checkFDroidAPI(mirrorURL, packageName, currentVersionCode) + } + client := newFDroidHTTPClient() + defer client.CloseIdleConnections() + cache := loadFDroidCache(cachePath, mirrorURL) + if cache != nil && cache.IsV1 { + return checkFDroidV1(client, mirrorURL, packageName, currentVersionCode, cachePath, cache) + } + return checkFDroidV2(client, mirrorURL, packageName, currentVersionCode, cachePath, cache) +} + +func PingFDroidMirrors(mirrorURLs string) (FDroidPingResultIterator, error) { + urls := strings.Split(mirrorURLs, ",") + results := make([]*FDroidPingResult, len(urls)) + var waitGroup sync.WaitGroup + for i, rawURL := range urls { + waitGroup.Add(1) + go func(index int, target string) { + defer waitGroup.Done() + target = strings.TrimSpace(target) + result := &FDroidPingResult{URL: target} + latency, err := pingTLS(target) + if err != nil { + result.LatencyMs = -1 + result.Error = err.Error() + } else { + result.LatencyMs = int32(latency.Milliseconds()) + } + results[index] = result + }(i, rawURL) + } + waitGroup.Wait() + sort.Slice(results, func(i, j int) bool { + if results[i].LatencyMs < 0 { + return false + } + if results[j].LatencyMs < 0 { + return true + } + return results[i].LatencyMs < results[j].LatencyMs + }) + return newIterator(results), nil +} + +func PingFDroidMirror(mirrorURL string) *FDroidPingResult { + mirrorURL = strings.TrimSpace(mirrorURL) + result := &FDroidPingResult{URL: mirrorURL} + latency, err := pingTLS(mirrorURL) + if err != nil { + result.LatencyMs = -1 + result.Error = err.Error() + } else { + result.LatencyMs = int32(latency.Milliseconds()) + } + return result +} + +func newFDroidHTTPClient() *http.Client { + return &http.Client{ + Timeout: 30 * time.Second, + } +} + +func newFDroidRequest(requestURL string) (*http.Request, error) { + request, err := http.NewRequest("GET", requestURL, nil) + if err != nil { + return nil, err + } + request.Header.Set("User-Agent", fdroidUserAgent) + return request, nil +} + +func checkFDroidAPI(mirrorURL, packageName string, currentVersionCode int32) (*FDroidUpdateInfo, error) { + client := newFDroidHTTPClient() + defer client.CloseIdleConnections() + + apiURL := "https://f-droid.org/api/v1/packages/" + packageName + request, err := newFDroidRequest(apiURL) + if err != nil { + return nil, err + } + + response, err := client.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return nil, E.New("HTTP ", response.Status) + } + + body, err := io.ReadAll(response.Body) + if err != nil { + return nil, err + } + + var apiResponse fdroidAPIResponse + err = json.Unmarshal(body, &apiResponse) + if err != nil { + return nil, err + } + + var bestCode int32 + var bestName string + for _, pkg := range apiResponse.Packages { + if pkg.VersionCode > currentVersionCode && pkg.VersionCode > bestCode { + bestCode = pkg.VersionCode + bestName = pkg.VersionName + } + } + + if bestCode == 0 { + return nil, nil + } + + return &FDroidUpdateInfo{ + VersionCode: bestCode, + VersionName: bestName, + DownloadURL: "https://f-droid.org/repo/" + packageName + "_" + strconv.FormatInt(int64(bestCode), 10) + ".apk", + }, nil +} + +func checkFDroidV2(client *http.Client, mirrorURL, packageName string, currentVersionCode int32, cachePath string, cache *fdroidCache) (*FDroidUpdateInfo, error) { + entryURL := mirrorURL + "/entry.jar" + request, err := newFDroidRequest(entryURL) + if err != nil { + return nil, err + } + if cache != nil && cache.ETag != "" { + request.Header.Set("If-None-Match", cache.ETag) + } + + response, err := client.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + + if response.StatusCode == http.StatusNotModified { + return nil, nil + } + if response.StatusCode == http.StatusNotFound { + writeFDroidCache(cachePath, mirrorURL, 0, "", true) + return checkFDroidV1(client, mirrorURL, packageName, currentVersionCode, cachePath, nil) + } + if response.StatusCode != http.StatusOK { + return nil, E.New("HTTP ", response.Status, ": ", entryURL) + } + + jarData, err := io.ReadAll(response.Body) + if err != nil { + return nil, err + } + etag := response.Header.Get("ETag") + + var entry fdroidEntry + err = readJSONFromJar(jarData, "entry.json", &entry) + if err != nil { + return nil, E.Cause(err, "read entry.jar") + } + + if entry.Timestamp == 0 { + return nil, E.New("entry.json not found in entry.jar") + } + + if cache != nil && cache.Timestamp == entry.Timestamp { + writeFDroidCache(cachePath, mirrorURL, entry.Timestamp, etag, false) + return nil, nil + } + + var indexURL string + if cache != nil { + cachedTimestamp := strconv.FormatInt(cache.Timestamp, 10) + if diff, ok := entry.Diffs[cachedTimestamp]; ok { + indexURL = mirrorURL + "/" + diff.Name + } + } + if indexURL == "" { + indexURL = mirrorURL + "/" + entry.Index.Name + } + + indexRequest, err := newFDroidRequest(indexURL) + if err != nil { + return nil, err + } + + indexResponse, err := client.Do(indexRequest) + if err != nil { + return nil, err + } + defer indexResponse.Body.Close() + + if indexResponse.StatusCode != http.StatusOK { + return nil, E.New("HTTP ", indexResponse.Status, ": ", indexURL) + } + + indexData, err := io.ReadAll(indexResponse.Body) + if err != nil { + return nil, err + } + + var index fdroidIndexV2 + err = json.Unmarshal(indexData, &index) + if err != nil { + return nil, err + } + + writeFDroidCache(cachePath, mirrorURL, entry.Timestamp, etag, false) + + pkg, ok := index.Packages[packageName] + if !ok { + return nil, nil + } + + var bestCode int32 + var bestVersion fdroidV2Version + for _, version := range pkg.Versions { + if version.Manifest.VersionCode > currentVersionCode && version.Manifest.VersionCode > bestCode { + bestCode = version.Manifest.VersionCode + bestVersion = version + } + } + + if bestCode == 0 { + return nil, nil + } + + return &FDroidUpdateInfo{ + VersionCode: bestCode, + VersionName: bestVersion.Manifest.VersionName, + DownloadURL: mirrorURL + "/" + bestVersion.File.Name, + FileSize: bestVersion.File.Size, + FileSHA256: bestVersion.File.SHA256, + }, nil +} + +func checkFDroidV1(client *http.Client, mirrorURL, packageName string, currentVersionCode int32, cachePath string, cache *fdroidCache) (*FDroidUpdateInfo, error) { + indexURL := mirrorURL + "/index-v1.jar" + + request, err := newFDroidRequest(indexURL) + if err != nil { + return nil, err + } + if cache != nil && cache.ETag != "" { + request.Header.Set("If-None-Match", cache.ETag) + } + + response, err := client.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + + if response.StatusCode == http.StatusNotModified { + return nil, nil + } + if response.StatusCode != http.StatusOK { + return nil, E.New("HTTP ", response.Status, ": ", indexURL) + } + + jarData, err := io.ReadAll(response.Body) + if err != nil { + return nil, err + } + etag := response.Header.Get("ETag") + + var index fdroidIndexV1 + err = readJSONFromJar(jarData, "index-v1.json", &index) + if err != nil { + return nil, E.Cause(err, "read index-v1.jar") + } + + writeFDroidCache(cachePath, mirrorURL, 0, etag, true) + + packages, ok := index.Packages[packageName] + if !ok { + return nil, nil + } + + var bestCode int32 + var bestPackage fdroidV1Package + for _, pkg := range packages { + if pkg.VersionCode > currentVersionCode && pkg.VersionCode > bestCode { + bestCode = pkg.VersionCode + bestPackage = pkg + } + } + + if bestCode == 0 { + return nil, nil + } + + return &FDroidUpdateInfo{ + VersionCode: bestCode, + VersionName: bestPackage.VersionName, + DownloadURL: mirrorURL + "/" + bestPackage.ApkName, + FileSize: bestPackage.Size, + FileSHA256: bestPackage.Hash, + }, nil +} + +func readJSONFromJar(jarData []byte, fileName string, destination any) error { + zipReader, err := zip.NewReader(bytes.NewReader(jarData), int64(len(jarData))) + if err != nil { + return err + } + for _, file := range zipReader.File { + if file.Name != fileName { + continue + } + reader, err := file.Open() + if err != nil { + return err + } + data, err := io.ReadAll(reader) + reader.Close() + if err != nil { + return err + } + return json.Unmarshal(data, destination) + } + return nil +} + +func pingTLS(mirrorURL string) (time.Duration, error) { + parsed, err := url.Parse(mirrorURL) + if err != nil { + return 0, err + } + host := parsed.Host + if !strings.Contains(host, ":") { + host = host + ":443" + } + + dialer := &net.Dialer{Timeout: 5 * time.Second} + start := time.Now() + conn, err := tls.DialWithDialer(dialer, "tcp", host, &tls.Config{}) + if err != nil { + return 0, err + } + latency := time.Since(start) + conn.Close() + return latency, nil +} + +func loadFDroidCache(cachePath, mirrorURL string) *fdroidCache { + cacheFile := filepath.Join(cachePath, "fdroid_cache.json") + data, err := os.ReadFile(cacheFile) + if err != nil { + return nil + } + var cache fdroidCache + err = json.Unmarshal(data, &cache) + if err != nil { + return nil + } + if cache.MirrorURL != mirrorURL { + return nil + } + return &cache +} + +func writeFDroidCache(cachePath, mirrorURL string, timestamp int64, etag string, isV1 bool) { + cache := fdroidCache{ + MirrorURL: mirrorURL, + Timestamp: timestamp, + ETag: etag, + IsV1: isV1, + } + data, err := json.Marshal(cache) + if err != nil { + return + } + os.MkdirAll(cachePath, 0o755) + os.WriteFile(filepath.Join(cachePath, "fdroid_cache.json"), data, 0o644) +} diff --git a/experimental/libbox/fdroid_mirrors.go b/experimental/libbox/fdroid_mirrors.go new file mode 100644 index 00000000..4ca82555 --- /dev/null +++ b/experimental/libbox/fdroid_mirrors.go @@ -0,0 +1,92 @@ +package libbox + +type FDroidMirror struct { + URL string + Country string + Name string +} + +type FDroidMirrorIterator interface { + Len() int32 + HasNext() bool + Next() *FDroidMirror +} + +var builtinFDroidMirrors = []FDroidMirror{ + // Official + {URL: "https://f-droid.org/repo", Country: "Official", Name: "f-droid.org"}, + {URL: "https://cloudflare.f-droid.org/repo", Country: "Official", Name: "Cloudflare CDN"}, + + // China + {URL: "https://mirrors.tuna.tsinghua.edu.cn/fdroid/repo", Country: "China", Name: "Tsinghua TUNA"}, + {URL: "https://mirrors.nju.edu.cn/fdroid/repo", Country: "China", Name: "Nanjing University"}, + {URL: "https://mirror.iscas.ac.cn/fdroid/repo", Country: "China", Name: "ISCAS"}, + {URL: "https://mirror.nyist.edu.cn/fdroid/repo", Country: "China", Name: "NYIST"}, + {URL: "https://mirrors.cqupt.edu.cn/fdroid/repo", Country: "China", Name: "CQUPT"}, + {URL: "https://mirrors.shanghaitech.edu.cn/fdroid/repo", Country: "China", Name: "ShanghaiTech"}, + + // India + {URL: "https://mirror.hyd.albony.in/fdroid/repo", Country: "India", Name: "Albony Hyderabad"}, + {URL: "https://mirror.del2.albony.in/fdroid/repo", Country: "India", Name: "Albony Delhi"}, + + // Taiwan + {URL: "https://mirror.ossplanet.net/fdroid/repo", Country: "Taiwan", Name: "OSSPlanet"}, + + // France + {URL: "https://fdroid.tetaneutral.net/fdroid/repo", Country: "France", Name: "tetaneutral.net"}, + {URL: "https://mirror.freedif.org/fdroid/repo", Country: "France", Name: "FreeDif"}, + + // Germany + {URL: "https://ftp.fau.de/fdroid/repo", Country: "Germany", Name: "FAU Erlangen"}, + {URL: "https://ftp.agdsn.de/fdroid/repo", Country: "Germany", Name: "AGDSN Dresden"}, + {URL: "https://ftp.gwdg.de/pub/android/fdroid/repo", Country: "Germany", Name: "GWDG"}, + {URL: "https://mirror.level66.network/fdroid/repo", Country: "Germany", Name: "Level66"}, + {URL: "https://mirror.mci-1.serverforge.org/fdroid/repo", Country: "Germany", Name: "ServerForge"}, + + // Netherlands + {URL: "https://ftp.snt.utwente.nl/pub/software/fdroid/repo", Country: "Netherlands", Name: "University of Twente"}, + + // Sweden + {URL: "https://ftp.lysator.liu.se/pub/fdroid/repo", Country: "Sweden", Name: "Lysator"}, + + // Denmark + {URL: "https://mirrors.dotsrc.org/fdroid/repo", Country: "Denmark", Name: "dotsrc.org"}, + + // Austria + {URL: "https://mirror.kumi.systems/fdroid/repo", Country: "Austria", Name: "Kumi Systems"}, + + // Switzerland + {URL: "https://mirror.init7.net/fdroid/repo", Country: "Switzerland", Name: "Init7"}, + + // Romania + {URL: "https://mirrors.hostico.ro/fdroid/repo", Country: "Romania", Name: "Hostico"}, + {URL: "https://mirrors.chroot.ro/fdroid/repo", Country: "Romania", Name: "Chroot"}, + {URL: "https://ftp.lug.ro/fdroid/repo", Country: "Romania", Name: "LUG Romania"}, + + // US + {URL: "https://plug-mirror.rcac.purdue.edu/fdroid/repo", Country: "US", Name: "Purdue"}, + {URL: "https://mirror.fcix.net/fdroid/repo", Country: "US", Name: "FCIX"}, + {URL: "https://opencolo.mm.fcix.net/fdroid/repo", Country: "US", Name: "OpenColo"}, + {URL: "https://forksystems.mm.fcix.net/fdroid/repo", Country: "US", Name: "Fork Systems"}, + {URL: "https://southfront.mm.fcix.net/fdroid/repo", Country: "US", Name: "South Front"}, + {URL: "https://ziply.mm.fcix.net/fdroid/repo", Country: "US", Name: "Ziply"}, + + // Canada + {URL: "https://mirror.quantum5.ca/fdroid/repo", Country: "Canada", Name: "Quantum5"}, + + // Australia + {URL: "https://mirror.aarnet.edu.au/fdroid/repo", Country: "Australia", Name: "AARNet"}, + + // Other + {URL: "https://mirror.cyberbits.eu/fdroid/repo", Country: "Europe", Name: "Cyberbits EU"}, + {URL: "https://mirror.eu.ossplanet.net/fdroid/repo", Country: "Europe", Name: "OSSPlanet EU"}, + {URL: "https://mirror.cyberbits.asia/fdroid/repo", Country: "Asia", Name: "Cyberbits Asia"}, + {URL: "https://mirrors.jevincanders.net/fdroid/repo", Country: "US", Name: "Jevincanders"}, + {URL: "https://mirrors.komogoto.com/fdroid/repo", Country: "US", Name: "Komogoto"}, + {URL: "https://fdroid.rasp.sh/fdroid/repo", Country: "Europe", Name: "rasp.sh"}, + {URL: "https://mirror.gofoss.xyz/fdroid/repo", Country: "Europe", Name: "GoFOSS"}, +} + +func GetFDroidMirrors() FDroidMirrorIterator { + return newPtrIterator(builtinFDroidMirrors) +} diff --git a/experimental/libbox/http.go b/experimental/libbox/http.go index 9f4b2915..69a23d26 100644 --- a/experimental/libbox/http.go +++ b/experimental/libbox/http.go @@ -52,6 +52,11 @@ type HTTPRequest interface { type HTTPResponse interface { GetContent() (*StringBox, error) WriteTo(path string) error + WriteToWithProgress(path string, handler HTTPResponseWriteToProgressHandler) error +} + +type HTTPResponseWriteToProgressHandler interface { + Update(progress int64, total int64) } var ( @@ -239,3 +244,31 @@ func (h *httpResponse) WriteTo(path string) error { defer file.Close() return common.Error(bufio.Copy(file, h.Body)) } + +func (h *httpResponse) WriteToWithProgress(path string, handler HTTPResponseWriteToProgressHandler) error { + defer h.Body.Close() + file, err := os.Create(path) + if err != nil { + return err + } + defer file.Close() + return common.Error(bufio.Copy(&progressWriter{ + writer: file, + handler: handler, + total: h.ContentLength, + }, h.Body)) +} + +type progressWriter struct { + writer io.Writer + handler HTTPResponseWriteToProgressHandler + total int64 + written int64 +} + +func (w *progressWriter) Write(p []byte) (int, error) { + n, err := w.writer.Write(p) + w.written += int64(n) + w.handler.Update(w.written, w.total) + return n, err +} diff --git a/experimental/libbox/platform.go b/experimental/libbox/platform.go index 63c54ccf..4db32a22 100644 --- a/experimental/libbox/platform.go +++ b/experimental/libbox/platform.go @@ -24,10 +24,18 @@ type PlatformInterface interface { } type ConnectionOwner struct { - UserId int32 - UserName string - ProcessPath string - AndroidPackageName string + UserId int32 + UserName string + ProcessPath string + androidPackageNames []string +} + +func (c *ConnectionOwner) SetAndroidPackageNames(names StringIterator) { + c.androidPackageNames = iteratorToArray[string](names) +} + +func (c *ConnectionOwner) AndroidPackageNames() StringIterator { + return newIterator(c.androidPackageNames) } type InterfaceUpdateListener interface { diff --git a/experimental/libbox/service.go b/experimental/libbox/service.go index 3a13f6d1..37fd56c9 100644 --- a/experimental/libbox/service.go +++ b/experimental/libbox/service.go @@ -29,6 +29,7 @@ type platformInterfaceWrapper struct { useProcFS bool networkManager adapter.NetworkManager myTunName string + myTunAddress []netip.Addr defaultInterfaceAccess sync.Mutex defaultInterface *control.Interface isExpensive bool @@ -78,9 +79,25 @@ func (w *platformInterfaceWrapper) OpenInterface(options *tun.Options, platformO } options.FileDescriptor = dupFd w.myTunName = options.Name + w.myTunAddress = myTunAddress(options) return tun.New(*options) } +func myTunAddress(options *tun.Options) []netip.Addr { + addresses := make([]netip.Addr, 0, len(options.Inet4Address)+len(options.Inet6Address)) + for _, prefix := range options.Inet4Address { + addresses = append(addresses, prefix.Addr()) + } + for _, prefix := range options.Inet6Address { + addresses = append(addresses, prefix.Addr()) + } + return addresses +} + +func (w *platformInterfaceWrapper) MyInterfaceAddress() []netip.Addr { + return w.myTunAddress +} + func (w *platformInterfaceWrapper) UsePlatformDefaultInterfaceMonitor() bool { return true } @@ -103,14 +120,11 @@ func (w *platformInterfaceWrapper) NetworkInterfaces() ([]adapter.NetworkInterfa } var interfaces []adapter.NetworkInterface for _, netInterface := range iteratorToArray[*NetworkInterface](interfaceIterator) { - if netInterface.Name == w.myTunName { - continue - } w.defaultInterfaceAccess.Lock() // (GOOS=windows) SA4006: this value of `isDefault` is never used // Why not used? //nolint:staticcheck - isDefault := w.defaultInterface != nil && int(netInterface.Index) == w.defaultInterface.Index + isDefault := netInterface.Name != w.myTunName && w.defaultInterface != nil && int(netInterface.Index) == w.defaultInterface.Index w.defaultInterfaceAccess.Unlock() interfaces = append(interfaces, adapter.NetworkInterface{ Interface: control.Interface{ @@ -201,10 +215,10 @@ func (w *platformInterfaceWrapper) FindConnectionOwner(request *adapter.FindConn return nil, err } return &adapter.ConnectionOwner{ - UserId: result.UserId, - UserName: result.UserName, - ProcessPath: result.ProcessPath, - AndroidPackageName: result.AndroidPackageName, + UserId: result.UserId, + UserName: result.UserName, + ProcessPath: result.ProcessPath, + AndroidPackageNames: result.androidPackageNames, }, nil } diff --git a/go.mod b/go.mod index 9ba295f2..926f4d47 100644 --- a/go.mod +++ b/go.mod @@ -23,27 +23,27 @@ require ( github.com/metacubex/utls v1.8.4 github.com/mholt/acmez/v3 v3.1.6 github.com/miekg/dns v1.1.72 - github.com/openai/openai-go/v3 v3.24.0 + github.com/openai/openai-go/v3 v3.26.0 github.com/oschwald/maxminddb-golang v1.13.1 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-20260303101018-cba7b9ac0399 - github.com/sagernet/cronet-go/all v0.0.0-20260303101018-cba7b9ac0399 - github.com/sagernet/fswatch v0.1.1 + github.com/sagernet/cronet-go v0.0.0-20260413093659-e4926ba205fa + github.com/sagernet/cronet-go/all v0.0.0-20260413093659-e4926ba205fa + github.com/sagernet/fswatch v0.1.2 github.com/sagernet/gomobile v0.1.12 github.com/sagernet/gvisor v0.0.0-20250811.0-sing-box-mod.1 github.com/sagernet/quic-go v0.59.0-sing-box-mod.4 - github.com/sagernet/sing v0.8.2 + github.com/sagernet/sing v0.8.9 github.com/sagernet/sing-mux v0.3.4 - github.com/sagernet/sing-quic v0.6.0 + github.com/sagernet/sing-quic v0.6.1 github.com/sagernet/sing-shadowsocks v0.2.8 github.com/sagernet/sing-shadowsocks2 v0.2.1 github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11 - github.com/sagernet/sing-tun v0.8.2 + github.com/sagernet/sing-tun v0.8.9 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.6.0.20260303140313-3bcf9a4b9349 + github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7 github.com/sagernet/wireguard-go v0.0.2-beta.1.0.20260224074747-506b7631853c github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 github.com/spf13/cobra v1.10.2 @@ -80,7 +80,7 @@ require ( github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1 // indirect github.com/ebitengine/purego v0.9.1 // indirect github.com/florianl/go-nfqueue/v2 v2.0.2 // 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-json-experiment/json v0.0.0-20250813024750-ebf49471dced // indirect @@ -109,37 +109,37 @@ require ( 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-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260303100323-125d0d93b3e6 // indirect - github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260303100323-125d0d93b3e6 // 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/netlink v0.0.0-20240612041022-b9a21c07ac6a // indirect - github.com/sagernet/nftables v0.3.0-beta.4 // indirect + github.com/sagernet/nftables v0.3.0-mod.2 // 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 diff --git a/go.sum b/go.sum index 83a7fc16..9eabbd90 100644 --- a/go.sum +++ b/go.sum @@ -54,8 +54,8 @@ github.com/enfein/mieru/v3 v3.17.1 h1:pIKbspsKRYNyUrORVI33t1/yz2syaaUkIanskAbGBH github.com/enfein/mieru/v3 v3.17.1/go.mod h1:zJBUCsi5rxyvHM8fjFf+GLaEl4OEjjBXr1s5F6Qd3hM= 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/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= @@ -144,8 +144,8 @@ 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/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/openai/openai-go/v3 v3.24.0 h1:08x6GnYiB+AAejTo6yzPY8RkZMJQ8NpreiOyM5QfyYU= -github.com/openai/openai-go/v3 v3.24.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= +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/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/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= @@ -168,94 +168,94 @@ 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-20260303101018-cba7b9ac0399 h1:x3tVYQHdqqnKbEd9/H4KIGhtHTjA+KfiiaXedI3/w8Q= -github.com/sagernet/cronet-go v0.0.0-20260303101018-cba7b9ac0399/go.mod h1:hwFHBEjjthyEquDULbr4c4ucMedp8Drb6Jvm2kt/0Bw= -github.com/sagernet/cronet-go/all v0.0.0-20260303101018-cba7b9ac0399 h1:mD3ehudpYf1IFgCTv25d/B6KnBc/lLFq1jmSQIK24y0= -github.com/sagernet/cronet-go/all v0.0.0-20260303101018-cba7b9ac0399/go.mod h1:MbYagcGGIaRo9tNrgafbCTO+Qc7eVEh32ZWMprSB8b0= -github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260303100323-125d0d93b3e6 h1:ghRKgSaswefPwQF8AYtUlNyumILOB0ptJWxgZ8MFrEE= -github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:XXDwdjX/T8xftoeJxQmbBoYXZp8MAPFR2CwbFuTpEtw= -github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260303100323-125d0d93b3e6 h1:Behr7YCnQP2dsvzAJDIoMd5nTVU9/d6MMtk/S3MctwA= -github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:iNiUGoLtnr8/JTuVNj7XJbmpOAp2C6+B81KDrPxwaZM= -github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260303100323-125d0d93b3e6 h1:6UL9XdGU/44oTHj36e+EBDJ0RonFoObmd299NG/qQCU= -github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:19ILNUOGIzRdOqa2mq+iY0JoHxuieB7/lnjYeaA2vEc= -github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260303100323-125d0d93b3e6 h1:Q9apxjtkj6iMIBQlTo71QsOTrNlhHneaXQb1Q0IshU8= -github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:JxzGyQf94Cr6sBShKqODGDyRUlESfJK/Njcz9Lz6qMQ= -github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260303100323-125d0d93b3e6 h1:0N+xlnMkFEeqgFe3X/PEvHt+/t+BPgxmbx7wzNcYppg= -github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:KN+9T9TBycGOLzmKU4QdcHAJEj6Nlx48ifnlTvvHMvs= -github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260303100323-125d0d93b3e6 h1:7f2vTXtePikBSV1bdD0zs5/WuZM+bRuej3mREpWL/qQ= -github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:kojvtUc29KKnk8hs2QIANynVR59921SnGWA9kXohHc0= -github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260303100323-125d0d93b3e6 h1:HMlnhEYs+axOa0tAJ79se3QsYB8CpRCQo9mewWWFeeg= -github.com/sagernet/cronet-go/lib/ios_amd64_simulator v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:hkQzRE5GDbaH1/ioqYh0Taho4L6i0yLRCVEZ5xHz5M0= -github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260303100323-125d0d93b3e6 h1:Ux/U6vF+1AoGLSJK3jVa9Kqkn64MX4Ivv7fy0ikDrpQ= -github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:tzVJFTOm66UxLxy6K0ZN5Ic2PC79e+sKKnt+V9puEa4= -github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260303100323-125d0d93b3e6 h1:5Dhuere2bQFzfGvKxA7TFgA5MoTtgcZMmJQuKwQKlyA= -github.com/sagernet/cronet-go/lib/ios_arm64_simulator v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:M/pN6m3j0HFU6/y83n0HU6GLYys3tYdr/xTE8hVEGMo= -github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260303100323-125d0d93b3e6 h1:aMRcLow4UpZWZ28fR9FjveTL/4okrigZySIkEVZnlgA= -github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:cGh5hO6eljCo6KMQ/Cel8Xgq4+etL0awZLRBDVG1EZQ= -github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260303100323-125d0d93b3e6 h1:y4g8oNtEfSdcKrBKsH5vMAjzGthvhHFNU80sanYDQEM= -github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:JFE0/cxaKkx0wqPMZU7MgaplQlU0zudv82dROJjClKU= -github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260303100323-125d0d93b3e6 h1:CXN6OPILi5trwffmYiiJ9rqJL3XAWx1menLrBBwA0gU= -github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:vU8VftFeSt7fURCa3JXD6+k6ss1YAX+idQjPvHmJ2tI= -github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260303100323-125d0d93b3e6 h1:ZphFHQeFOTpqCWPwFcQRnrePXajml8LbKlYFJ5n0isU= -github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:vCe4OUuL+XOUge9v3MyTD45BnuAXiH+DkjN9quDXJzQ= -github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260303100323-125d0d93b3e6 h1:nKzFK84oANHz7I6bab+25bBY+pdpAbO0b3NJroyLldo= -github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:w9amBWrvjtohQzBGCKJ7LCh22LhTIJs4sE7cYaKQzM0= -github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260303100323-125d0d93b3e6 h1:HqqZUGRXcWvvwlbuvjk/efo8TKW1H/aHdqQTde+Xs9Q= -github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:TqlsFtcYS/etTeck46kHBeT8Le0Igw1Q/AV88UnMS3s= -github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260303100323-125d0d93b3e6 h1:D2v9lZZG5sm4x/CkG7uqc6ZU3YlhFQ+GmJfvZMK0h/s= -github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:B6Qd0vys8sv9OKVRN6J9RqDzYRGE938Fb2zrYdBDyTQ= -github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260303100323-125d0d93b3e6 h1:TWveNeXHrA5r8XOlf+vw7U2b2M0ip6GNF89jcUi1ogw= -github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:3tXMMFY7AHugOVBZ5Al7cL7JKsnFOe5bMVr0hZPk3ow= -github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260303100323-125d0d93b3e6 h1:DVCBoXOZI4PNG0cbCLg8lrphRXoLFcAIDLNmzsCVg3I= -github.com/sagernet/cronet-go/lib/linux_loong64 v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:Wt5uFdU3tnmm8YzobYewwdF7Mt6SucRQg6xeTNWC3Tk= -github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260303100323-125d0d93b3e6 h1:7s5xqNlBUWkIXdruPYi3/txXekQhGWxrYxbnB0cnARo= -github.com/sagernet/cronet-go/lib/linux_loong64_musl v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:lyIF6wKBLwWa5ZXaAKbAoewewl+yCHo2iYev39Mbj4E= -github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260303100323-125d0d93b3e6 h1:eyEb+Q7VH4hpE1nV+EmEnN2XX5WilgBpIsfCw4C/7no= -github.com/sagernet/cronet-go/lib/linux_mips64le v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:H46PnSTTZNcZokLLiDeMDaHiS1l14PH3tzWi0eykjD8= -github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260303100323-125d0d93b3e6 h1:9F1W7+z1hHST6GSzdpQ8Q0NCkneAL18dkRA1HfxH09A= -github.com/sagernet/cronet-go/lib/linux_mipsle v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:RBhSUDAKWq7fswtV4nQUQhuaTLcX3ettR7teA7/yf2w= -github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260303100323-125d0d93b3e6 h1:MmQIR3iJsdvw1ONBP3geK57i9c3+v9dXPMNdZYcYGKw= -github.com/sagernet/cronet-go/lib/linux_mipsle_musl v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:wRzoIOGG4xbpp3Gh3triLKwMwYriScXzFtunLYhY4w0= -github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260303100323-125d0d93b3e6 h1:j6Pk1Wsl+PCbKRXtp7a912D2D6zqX5Nk51wDQU9TEDc= -github.com/sagernet/cronet-go/lib/linux_riscv64 v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:LNiZXmWil1OPwKCheqQjtakZlJuKGFz+iv2eGF76Hhs= -github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260303100323-125d0d93b3e6 h1:0DnFhbRfNqwguNCxiinA7BowQ/RaFt627sjW09JNp80= -github.com/sagernet/cronet-go/lib/linux_riscv64_musl v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:YFDGKTkpkJGc5+hnX/RYosZyTWg9h+68VB55fYRRLYc= -github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260303100323-125d0d93b3e6 h1:3CZmlEk2/WW5UHLFJZxXPJ9IJxX3td8U3PyqWSGMl3c= -github.com/sagernet/cronet-go/lib/tvos_amd64_simulator v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:aaX0YGl8nhGmfRWI8bc3BtDjY8Vzx6O0cS/e1uqxDq4= -github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260303100323-125d0d93b3e6 h1:eHkVRptoZf3BuuskkjcclO2dwQrX4zluoVGODMrX7n0= -github.com/sagernet/cronet-go/lib/tvos_arm64 v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:EdzMKA96xITc42QEI+ct4SwqX8Dn3ltKK8wzdkLWpSc= -github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260303100323-125d0d93b3e6 h1:UgFmE0cZo9euu8/7sTAhj1G8lldavwXBdcPNyTE29CQ= -github.com/sagernet/cronet-go/lib/tvos_arm64_simulator v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:qix4kv1TTAJ5tY4lJ9vjhe9EY4mM+B7H5giOhbxDVcc= -github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260303100323-125d0d93b3e6 h1:xbg3ZB9tLMGDQe4+aewG0Z4bEP/2pLtYBcDzILv5eEc= -github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20260303100323-125d0d93b3e6/go.mod h1:lm9w/oCCRyBiUa3G8lDQTT8x/ONUvgVR2iV9fVzUZB8= -github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260303100323-125d0d93b3e6 h1:M0bTSTSTnSMlPY2WaZT6fL5TFICqk8v4cm+QVf8Fcao= -github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20260303100323-125d0d93b3e6/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/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/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/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_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_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_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/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_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/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_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_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/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_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_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_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_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_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_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_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_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_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_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_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_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_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_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/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_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_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/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_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/fswatch v0.1.2 h1:/TT7k4mkce1qFPxamLO842WjqBgbTBiXP2mlUjp9PFk= +github.com/sagernet/fswatch v0.1.2/go.mod h1:5BpGmpUQVd3Mc5r313HRpvADHRg3/rKn5QbwFteB880= github.com/sagernet/gomobile v0.1.12 h1:XwzjZaclFF96deLqwAgK8gU3w0M2A8qxgDmhV+A0wjg= github.com/sagernet/gomobile v0.1.12/go.mod h1:A8l3FlHi2D/+mfcd4HHvk5DGFPW/ShFb9jHP5VmSiDY= github.com/sagernet/gvisor v0.0.0-20250811.0-sing-box-mod.1 h1:AzCE2RhBjLJ4WIWc/GejpNh+z30d5H1hwaB0nD9eY3o= 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-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.2 h1:ck2KMU02OxL1eDFgGaWYglMDpoOZ7OHzxje+vW5Q0OQ= +github.com/sagernet/nftables v0.3.0-mod.2/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.2 h1:kX1IH9SWJv4S0T9M8O+HNahWgbOuY1VauxbF7NU5lOg= -github.com/sagernet/sing v0.8.2/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak= +github.com/sagernet/sing v0.8.9 h1:iX8FyMrWNl/divVgTe7cLT9n36v6bfzfnCYlcM1cLaU= +github.com/sagernet/sing v0.8.9/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak= 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-quic v0.6.0 h1:dhrFnP45wgVKEOT1EvtsToxdzRnHIDIAgj6WHV9pLyM= -github.com/sagernet/sing-quic v0.6.0/go.mod h1:K5bWvITOm4vE10fwLfrWpw27bCoVJ+tfQ79tOWg+Ko8= +github.com/sagernet/sing-quic v0.6.1 h1:lx0tcm99wIA1RkyvILNzRSsMy1k7TTQYIhx71E/WBlw= +github.com/sagernet/sing-quic v0.6.1/go.mod h1:K5bWvITOm4vE10fwLfrWpw27bCoVJ+tfQ79tOWg+Ko8= 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-tun v0.8.2 h1:rQr/x3eQCHh3oleIaoJdPdJwqzZp4+QWcJLT0Wz2xKY= -github.com/sagernet/sing-tun v0.8.2/go.mod h1:pLCo4o+LacXEzz0bhwhJkKBjLlKOGPBNOAZ97ZVZWzs= +github.com/sagernet/sing-tun v0.8.9 h1:ixFKKUGdVcJl4wb0xbL36hobiw9l6DIH497EQf5ILpM= +github.com/sagernet/sing-tun v0.8.9/go.mod h1:QvarqUtHfj1ULaRR+6kZOS/OoCE+pYGq67A5tyIy+dQ= 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= diff --git a/mkdocs.yml b/mkdocs.yml index 081ba3aa..e2959266 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -182,6 +182,10 @@ nav: - CCM: configuration/service/ccm.md - OCM: configuration/service/ocm.md markdown_extensions: + - toc: + slugify: !!python/object/apply:pymdownx.slugs.slugify + kwds: + case: lower - pymdownx.inlinehilite - pymdownx.snippets - pymdownx.superfences diff --git a/option/dns.go b/option/dns.go index 9b40f880..ce7cfda9 100644 --- a/option/dns.go +++ b/option/dns.go @@ -352,7 +352,7 @@ func (o DNSServerAddressOptions) Build() M.Socksaddr { } func (o DNSServerAddressOptions) ServerIsDomain() bool { - return M.IsDomainName(o.Server) + return o.Build().IsDomain() } func (o *DNSServerAddressOptions) TakeServerOptions() ServerOptions { diff --git a/option/inbound.go b/option/inbound.go index 4fb6081d..21497a3f 100644 --- a/option/inbound.go +++ b/option/inbound.go @@ -44,6 +44,12 @@ func (h *Inbound) UnmarshalJSONContext(ctx context.Context, content []byte) erro if err != nil { return err } + if listenWrapper, isListen := options.(ListenOptionsWrapper); isListen { + //nolint:staticcheck + if listenWrapper.TakeListenOptions().InboundOptions != (InboundOptions{}) { + return E.New("legacy inbound fields are deprecated in sing-box 1.11.0 and removed in sing-box 1.13.0, checkout migration: https://sing-box.sagernet.org/migration/#migrate-legacy-inbound-fields-to-rule-actions") + } + } h.Options = options return nil } diff --git a/option/outbound.go b/option/outbound.go index cb388c44..6676a3e9 100644 --- a/option/outbound.go +++ b/option/outbound.go @@ -155,7 +155,7 @@ func (o ServerOptions) Build() M.Socksaddr { } func (o ServerOptions) ServerIsDomain() bool { - return M.IsDomainName(o.Server) + return o.Build().IsDomain() } func (o *ServerOptions) TakeServerOptions() ServerOptions { diff --git a/option/tailscale.go b/option/tailscale.go index dac8e866..68a14369 100644 --- a/option/tailscale.go +++ b/option/tailscale.go @@ -61,7 +61,7 @@ func (d DERPVerifyClientURLOptions) ServerIsDomain() bool { if err != nil { return false } - return M.IsDomainName(verifyURL.Host) + return M.ParseSocksaddr(verifyURL.Hostname()).IsDomain() } func (d DERPVerifyClientURLOptions) MarshalJSON() ([]byte, error) { diff --git a/protocol/naive/inbound.go b/protocol/naive/inbound.go index 48c35926..5613f196 100644 --- a/protocol/naive/inbound.go +++ b/protocol/naive/inbound.go @@ -29,7 +29,10 @@ import ( "golang.org/x/net/http2/h2c" ) -var ConfigureHTTP3ListenerFunc func(ctx context.Context, logger logger.Logger, listener *listener.Listener, handler http.Handler, tlsConfig tls.ServerConfig, options option.NaiveInboundOptions) (io.Closer, error) +var ( + ConfigureHTTP3ListenerFunc func(ctx context.Context, logger logger.Logger, listener *listener.Listener, handler http.Handler, tlsConfig tls.ServerConfig, options option.NaiveInboundOptions) (io.Closer, error) + WrapError func(error) error +) func RegisterInbound(registry *inbound.Registry) { inbound.Register[option.NaiveInboundOptions](registry, C.TypeNaive, NewInbound) diff --git a/protocol/naive/inbound_conn.go b/protocol/naive/inbound_conn.go index 0711b637..8cc3ded2 100644 --- a/protocol/naive/inbound_conn.go +++ b/protocol/naive/inbound_conn.go @@ -95,7 +95,7 @@ func (p *paddingConn) writeWithPadding(writer io.Writer, data []byte) (n int, er binary.BigEndian.PutUint16(header, uint16(len(data))) header[2] = byte(paddingSize) common.Must1(buffer.Write(data)) - buffer.Extend(paddingSize) + common.Must(buffer.WriteZeroN(paddingSize)) _, err = writer.Write(buffer.Bytes()) if err == nil { n = len(data) @@ -117,7 +117,7 @@ func (p *paddingConn) writeBufferWithPadding(writer io.Writer, buffer *buf.Buffe header := buffer.ExtendHeader(3) binary.BigEndian.PutUint16(header, uint16(bufferLen)) header[2] = byte(paddingSize) - buffer.Extend(paddingSize) + common.Must(buffer.WriteZeroN(paddingSize)) p.writePadding++ } return common.Error(writer.Write(buffer.Bytes())) @@ -179,18 +179,18 @@ type naiveConn struct { func (c *naiveConn) Read(p []byte) (n int, err error) { n, err = c.readWithPadding(c.Conn, p) - return n, baderror.WrapH2(err) + return n, wrapError(err) } func (c *naiveConn) Write(p []byte) (n int, err error) { n, err = c.writeChunked(c.Conn, p) - return n, baderror.WrapH2(err) + return n, wrapError(err) } func (c *naiveConn) WriteBuffer(buffer *buf.Buffer) error { defer buffer.Release() err := c.writeBufferWithPadding(c.Conn, buffer) - return baderror.WrapH2(err) + return wrapError(err) } func (c *naiveConn) FrontHeadroom() int { return c.frontHeadroom() } @@ -210,7 +210,7 @@ type naiveH2Conn struct { func (c *naiveH2Conn) Read(p []byte) (n int, err error) { n, err = c.readWithPadding(c.reader, p) - return n, baderror.WrapH2(err) + return n, wrapError(err) } func (c *naiveH2Conn) Write(p []byte) (n int, err error) { @@ -218,7 +218,7 @@ func (c *naiveH2Conn) Write(p []byte) (n int, err error) { if err == nil { c.flusher.Flush() } - return n, baderror.WrapH2(err) + return n, wrapError(err) } func (c *naiveH2Conn) WriteBuffer(buffer *buf.Buffer) error { @@ -227,7 +227,15 @@ func (c *naiveH2Conn) WriteBuffer(buffer *buf.Buffer) error { if err == nil { c.flusher.Flush() } - return baderror.WrapH2(err) + return wrapError(err) +} + +func wrapError(err error) error { + err = baderror.WrapH2(err) + if WrapError != nil { + err = WrapError(err) + } + return err } func (c *naiveH2Conn) Close() error { diff --git a/protocol/naive/quic/inbound_init.go b/protocol/naive/quic/inbound_init.go index a356cfae..1f868267 100644 --- a/protocol/naive/quic/inbound_init.go +++ b/protocol/naive/quic/inbound_init.go @@ -124,4 +124,5 @@ func init() { return quicListener, nil } + naive.WrapError = qtls.WrapError } diff --git a/protocol/socks/outbound.go b/protocol/socks/outbound.go index 851412ff..344c7988 100644 --- a/protocol/socks/outbound.go +++ b/protocol/socks/outbound.go @@ -83,7 +83,7 @@ func (h *Outbound) DialContext(ctx context.Context, network string, destination default: return nil, E.Extend(N.ErrUnknownNetwork, network) } - if h.resolve && destination.IsFqdn() { + if h.resolve && destination.IsDomain() { destinationAddresses, err := h.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{}) if err != nil { return nil, err @@ -101,7 +101,7 @@ func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (n h.logger.InfoContext(ctx, "outbound UoT packet connection to ", destination) return h.uotClient.ListenPacket(ctx, destination) } - if h.resolve && destination.IsFqdn() { + if h.resolve && destination.IsDomain() { destinationAddresses, err := h.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{}) if err != nil { return nil, err diff --git a/protocol/tailscale/dns_transport.go b/protocol/tailscale/dns_transport.go index 521bb551..4195235c 100644 --- a/protocol/tailscale/dns_transport.go +++ b/protocol/tailscale/dns_transport.go @@ -1,3 +1,5 @@ +//go:build with_gvisor + package tailscale import ( @@ -47,6 +49,7 @@ type DNSTransport struct { dnsRouter adapter.DNSRouter endpointManager adapter.EndpointManager endpoint *Endpoint + access sync.RWMutex routePrefixes []netip.Prefix routes map[string][]adapter.DNSTransport hosts map[string][]netip.Addr @@ -89,6 +92,12 @@ func (t *DNSTransport) Start(stage adapter.StartStage) error { } func (t *DNSTransport) Reset() { + t.access.RLock() + transports := t.collectResolversLocked() + t.access.RUnlock() + for _, transport := range transports { + transport.Reset() + } } func (t *DNSTransport) onReconfig(cfg *wgcfg.Config, routerCfg *router.Config, dnsCfg *nDNS.Config) { @@ -99,7 +108,7 @@ func (t *DNSTransport) onReconfig(cfg *wgcfg.Config, routerCfg *router.Config, d } func (t *DNSTransport) updateDNSServers(routeConfig *router.Config, dnsConfig *nDNS.Config) error { - t.routePrefixes = buildRoutePrefixes(routeConfig) + routePrefixes := buildRoutePrefixes(routeConfig) directDialerOnce := sync.OnceValue(func() N.Dialer { directDialer := common.Must1(dialer.NewDefault(t.ctx, option.DialerOptions{})) return &DNSDialer{transport: t, fallbackDialer: directDialer} @@ -128,9 +137,19 @@ func (t *DNSTransport) updateDNSServers(routeConfig *router.Config, dnsConfig *n } defaultResolvers = append(defaultResolvers, myResolver) } + + t.access.Lock() + oldResolvers := t.collectResolversLocked() + t.routePrefixes = routePrefixes t.routes = routes t.hosts = hosts t.defaultResolvers = defaultResolvers + t.access.Unlock() + + for _, transport := range oldResolvers { + transport.Close() + } + if len(defaultResolvers) > 0 { t.logger.Info("updated ", len(routes), " routes, ", len(hosts), " hosts, default resolvers: ", strings.Join(common.Map(dnsConfig.DefaultResolvers, func(it *dnstype.Resolver) string { return it.Addr }), " ")) @@ -205,7 +224,22 @@ func buildRoutePrefixes(routeConfig *router.Config) []netip.Prefix { } func (t *DNSTransport) Close() error { - return nil + t.access.Lock() + transports := t.collectResolversLocked() + t.routePrefixes = nil + t.routes = nil + t.hosts = nil + t.defaultResolvers = nil + t.access.Unlock() + + var err error + for _, transport := range transports { + name := "resolver/" + transport.Type() + "[" + transport.Tag() + "]" + err = E.Append(err, transport.Close(), func(err error) error { + return E.Cause(err, "close ", name) + }) + } + return err } func (t *DNSTransport) Raw() bool { @@ -217,7 +251,15 @@ func (t *DNSTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.M return nil, os.ErrInvalid } question := message.Question[0] - addresses, hostsLoaded := t.hosts[question.Name] + + t.access.RLock() + hosts := t.hosts + routes := t.routes + defaultResolvers := t.defaultResolvers + acceptDefaultResolvers := t.acceptDefaultResolvers + t.access.RUnlock() + + addresses, hostsLoaded := hosts[question.Name] if hostsLoaded { switch question.Qtype { case mDNS.TypeA: @@ -236,7 +278,7 @@ func (t *DNSTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.M } } } - for domainSuffix, transports := range t.routes { + for domainSuffix, transports := range routes { if strings.HasSuffix(question.Name, domainSuffix) { if len(transports) == 0 { return &mDNS.Msg{ @@ -260,10 +302,10 @@ func (t *DNSTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.M return nil, lastErr } } - if t.acceptDefaultResolvers { - if len(t.defaultResolvers) > 0 { + if acceptDefaultResolvers { + if len(defaultResolvers) > 0 { var lastErr error - for _, resolver := range t.defaultResolvers { + for _, resolver := range defaultResolvers { response, err := resolver.Exchange(ctx, message) if err != nil { lastErr = err @@ -279,16 +321,26 @@ func (t *DNSTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.M return nil, dns.RcodeNameError } +func (t *DNSTransport) collectResolversLocked() []adapter.DNSTransport { + var transports []adapter.DNSTransport + for _, resolvers := range t.routes { + transports = append(transports, resolvers...) + } + transports = append(transports, t.defaultResolvers...) + return transports +} + type DNSDialer struct { transport *DNSTransport fallbackDialer N.Dialer } func (d *DNSDialer) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { - if destination.IsFqdn() { + if destination.IsDomain() { panic("invalid request here") } - for _, prefix := range d.transport.routePrefixes { + routePrefixes := d.transport.routePrefixesSnapshot() + for _, prefix := range routePrefixes { if prefix.Contains(destination.Addr) { return d.transport.endpoint.DialContext(ctx, network, destination) } @@ -297,13 +349,20 @@ func (d *DNSDialer) DialContext(ctx context.Context, network string, destination } func (d *DNSDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { - if destination.IsFqdn() { + if destination.IsDomain() { panic("invalid request here") } - for _, prefix := range d.transport.routePrefixes { + routePrefixes := d.transport.routePrefixesSnapshot() + for _, prefix := range routePrefixes { if prefix.Contains(destination.Addr) { return d.transport.endpoint.ListenPacket(ctx, destination) } } return d.fallbackDialer.ListenPacket(ctx, destination) } + +func (t *DNSTransport) routePrefixesSnapshot() []netip.Prefix { + t.access.RLock() + defer t.access.RUnlock() + return append([]netip.Prefix(nil), t.routePrefixes...) +} diff --git a/protocol/tailscale/endpoint.go b/protocol/tailscale/endpoint.go index ff82ef86..30db4b6a 100644 --- a/protocol/tailscale/endpoint.go +++ b/protocol/tailscale/endpoint.go @@ -1,3 +1,5 @@ +//go:build with_gvisor + package tailscale import ( @@ -46,6 +48,7 @@ import ( "github.com/sagernet/tailscale/ipn" tsDNS "github.com/sagernet/tailscale/net/dns" "github.com/sagernet/tailscale/net/netmon" + "github.com/sagernet/tailscale/net/netns" "github.com/sagernet/tailscale/net/tsaddr" tsTUN "github.com/sagernet/tailscale/net/tstun" "github.com/sagernet/tailscale/tsnet" @@ -107,7 +110,9 @@ type Endpoint struct { systemInterface bool systemInterfaceName string systemInterfaceMTU uint32 + serverStarted bool systemTun tun.Tun + systemDialer *dialer.DefaultDialer fallbackTCPCloser func() } @@ -144,7 +149,7 @@ func (t *Endpoint) registerNetstackHandlers() { ctx := log.ContextWithNewID(t.ctx) source := M.SocksaddrFrom(src.Addr(), src.Port()) destination := M.SocksaddrFrom(dst.Addr(), dst.Port()) - packetConn := bufio.NewPacketConn(conn) + packetConn := bufio.NewUnbindPacketConnWithAddr(conn, destination) t.NewPacketConnectionEx(ctx, packetConn, source, destination, nil) }, true } @@ -186,7 +191,7 @@ func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextL if err != nil { return nil, E.Cause(err, "parse control URL") } - remoteIsDomain = M.IsDomainName(controlURL.Hostname()) + remoteIsDomain = M.ParseSocksaddr(controlURL.Hostname()).IsDomain() } else { // controlplane.tailscale.com remoteIsDomain = true @@ -258,9 +263,16 @@ func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextL } func (t *Endpoint) Start(stage adapter.StartStage) error { - if stage != adapter.StartStateStart { - return nil + switch stage { + case adapter.StartStateStart: + return t.start() + case adapter.StartStatePostStart: + return t.postStart() } + return nil +} + +func (t *Endpoint) start() error { if t.platformInterface != nil { err := t.network.UpdateInterfaces() if err != nil { @@ -285,9 +297,6 @@ func (t *Endpoint) Start(stage adapter.StartStage) error { } }), nil }) - if runtime.GOOS == "android" { - setAndroidProtectFunc(t.platformInterface) - } } if t.systemInterface { mtu := t.systemInterfaceMTU @@ -322,9 +331,34 @@ func (t *Endpoint) Start(stage adapter.StartStage) error { _ = systemTun.Close() return err } + systemDialer, err := dialer.NewDefault(t.ctx, option.DialerOptions{ + BindInterface: tunName, + }) + if err != nil { + _ = systemTun.Close() + return err + } t.systemTun = systemTun + t.systemDialer = systemDialer t.server.TunDevice = wgTunDevice } + if mark := t.network.AutoRedirectOutputMark(); mark > 0 { + controlFunc := t.network.AutoRedirectOutputMarkFunc() + if bindFunc := t.network.AutoDetectInterfaceFunc(); bindFunc != nil { + controlFunc = control.Append(controlFunc, bindFunc) + } + netns.SetControlFunc(controlFunc) + } else if runtime.GOOS == "android" && t.platformInterface != nil { + netns.SetControlFunc(func(network, address string, c syscall.RawConn) error { + return control.Raw(c, func(fd uintptr) error { + return t.platformInterface.AutoDetectInterfaceControl(int(fd)) + }) + }) + } + return nil +} + +func (t *Endpoint) postStart() error { err := t.server.Start() if err != nil { if t.systemTun != nil { @@ -332,6 +366,7 @@ func (t *Endpoint) Start(stage adapter.StartStage) error { } return err } + t.serverStarted = true if t.fallbackTCPCloser == nil { t.fallbackTCPCloser = t.server.RegisterFallbackTCPHandler(func(src, dst netip.AddrPort) (handler func(net.Conn), intercept bool) { return func(conn net.Conn) { @@ -449,15 +484,22 @@ func (t *Endpoint) watchState() { } func (t *Endpoint) Close() error { - netmon.RegisterInterfaceGetter(nil) - if runtime.GOOS == "android" { - setAndroidProtectFunc(nil) + var err error + if t.serverStarted { + err = common.Close(common.PtrOrNil(t.server)) + t.serverStarted = false } + netmon.RegisterInterfaceGetter(nil) + netns.SetControlFunc(nil) if t.fallbackTCPCloser != nil { t.fallbackTCPCloser() t.fallbackTCPCloser = nil } - return common.Close(common.PtrOrNil(t.server)) + if t.systemTun != nil { + t.systemTun.Close() + t.systemTun = nil + } + return err } func (t *Endpoint) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { @@ -467,13 +509,16 @@ func (t *Endpoint) DialContext(ctx context.Context, network string, destination case N.NetworkUDP: t.logger.InfoContext(ctx, "outbound packet connection to ", destination) } - if destination.IsFqdn() { + if destination.IsDomain() { destinationAddresses, err := t.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{}) if err != nil { return nil, err } return N.DialSerial(ctx, t, network, destination, destinationAddresses) } + if t.systemDialer != nil { + return t.systemDialer.DialContext(ctx, network, destination) + } addr4, addr6 := t.server.TailscaleIPs() remoteAddr := tcpip.FullAddress{ NIC: 1, @@ -520,6 +565,9 @@ func (t *Endpoint) DialContext(ctx context.Context, network string, destination } func (t *Endpoint) listenPacketWithAddress(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + if t.systemDialer != nil { + return t.systemDialer.ListenPacket(ctx, destination) + } addr4, addr6 := t.server.TailscaleIPs() bind := tcpip.FullAddress{ NIC: 1, @@ -547,7 +595,7 @@ func (t *Endpoint) listenPacketWithAddress(ctx context.Context, destination M.So func (t *Endpoint) ListenPacketWithDestination(ctx context.Context, destination M.Socksaddr) (net.PacketConn, netip.Addr, error) { t.logger.InfoContext(ctx, "outbound packet connection to ", destination) - if destination.IsFqdn() { + if destination.IsDomain() { destinationAddresses, err := t.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{}) if err != nil { return nil, netip.Addr{}, err @@ -677,19 +725,29 @@ func (t *Endpoint) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, } func (t *Endpoint) NewDirectRouteConnection(metadata adapter.InboundContext, routeContext tun.DirectRouteContext, timeout time.Duration) (tun.DirectRouteDestination, error) { - inet4Address, inet6Address := t.server.TailscaleIPs() - if metadata.Destination.Addr.Is4() && !inet4Address.IsValid() || metadata.Destination.Addr.Is6() && !inet6Address.IsValid() { - return nil, E.New("Tailscale is not ready yet") - } ctx := log.ContextWithNewID(t.ctx) - destination, err := ping.ConnectGVisor( - ctx, t.logger, - metadata.Source.Addr, metadata.Destination.Addr, - routeContext, - t.stack, - inet4Address, inet6Address, - timeout, - ) + var destination tun.DirectRouteDestination + var err error + if t.systemDialer != nil { + destination, err = ping.ConnectDestination( + ctx, t.logger, + t.systemDialer.DialerForICMPDestination(metadata.Destination.Addr).Control, + metadata.Destination.Addr, routeContext, timeout, + ) + } else { + inet4Address, inet6Address := t.server.TailscaleIPs() + if metadata.Destination.Addr.Is4() && !inet4Address.IsValid() || metadata.Destination.Addr.Is6() && !inet6Address.IsValid() { + return nil, E.New("Tailscale is not ready yet") + } + destination, err = ping.ConnectGVisor( + ctx, t.logger, + metadata.Source.Addr, metadata.Destination.Addr, + routeContext, + t.stack, + inet4Address, inet6Address, + timeout, + ) + } if err != nil { return nil, err } diff --git a/protocol/tailscale/protect_android.go b/protocol/tailscale/protect_android.go deleted file mode 100644 index 37dd33bd..00000000 --- a/protocol/tailscale/protect_android.go +++ /dev/null @@ -1,16 +0,0 @@ -package tailscale - -import ( - "github.com/sagernet/sing-box/adapter" - "github.com/sagernet/tailscale/net/netns" -) - -func setAndroidProtectFunc(platformInterface adapter.PlatformInterface) { - if platformInterface != nil { - netns.SetAndroidProtectFunc(func(fd int) error { - return platformInterface.AutoDetectInterfaceControl(fd) - }) - } else { - netns.SetAndroidProtectFunc(nil) - } -} diff --git a/protocol/tailscale/protect_nonandroid.go b/protocol/tailscale/protect_nonandroid.go deleted file mode 100644 index f315c2ea..00000000 --- a/protocol/tailscale/protect_nonandroid.go +++ /dev/null @@ -1,8 +0,0 @@ -//go:build !android - -package tailscale - -import "github.com/sagernet/sing-box/adapter" - -func setAndroidProtectFunc(platformInterface adapter.PlatformInterface) { -} diff --git a/protocol/tailscale/tun_device_unix.go b/protocol/tailscale/tun_device_unix.go index 77f2955b..a8d237ab 100644 --- a/protocol/tailscale/tun_device_unix.go +++ b/protocol/tailscale/tun_device_unix.go @@ -1,4 +1,4 @@ -//go:build !windows +//go:build with_gvisor && !windows package tailscale diff --git a/protocol/tailscale/tun_device_windows.go b/protocol/tailscale/tun_device_windows.go index 3b0e3440..8c9e87ce 100644 --- a/protocol/tailscale/tun_device_windows.go +++ b/protocol/tailscale/tun_device_windows.go @@ -1,4 +1,4 @@ -//go:build windows +//go:build with_gvisor && windows package tailscale diff --git a/protocol/tun/inbound.go b/protocol/tun/inbound.go index 1cdd7884..dec6aa9d 100644 --- a/protocol/tun/inbound.go +++ b/protocol/tun/inbound.go @@ -67,6 +67,10 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo if options.GSO { return nil, E.New("GSO option in tun is deprecated in sing-box 1.11.0 and removed in sing-box 1.12.0") } + //nolint:staticcheck + if options.InboundOptions != (option.InboundOptions{}) { + return nil, E.New("legacy inbound fields are deprecated in sing-box 1.11.0 and removed in sing-box 1.13.0, checkout migration: https://sing-box.sagernet.org/migration/#migrate-legacy-inbound-fields-to-rule-actions") + } address := options.Address inet4Address := common.Filter(address, func(it netip.Prefix) bool { diff --git a/protocol/vless/outbound.go b/protocol/vless/outbound.go index dca442b6..d11d03a3 100644 --- a/protocol/vless/outbound.go +++ b/protocol/vless/outbound.go @@ -167,7 +167,7 @@ func (h *vlessDialer) DialContext(ctx context.Context, network string, destinati if h.xudp { return h.client.DialEarlyXUDPPacketConn(conn, destination) } else if h.packetAddr { - if destination.IsFqdn() { + if destination.IsDomain() { return nil, E.New("packetaddr: domain destination is not supported") } packetConn, err := h.client.DialEarlyPacketConn(conn, M.Socksaddr{Fqdn: packetaddr.SeqPacketMagicAddress}) @@ -204,7 +204,7 @@ func (h *vlessDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) if h.xudp { return h.client.DialEarlyXUDPPacketConn(conn, destination) } else if h.packetAddr { - if destination.IsFqdn() { + if destination.IsDomain() { return nil, E.New("packetaddr: domain destination is not supported") } conn, err := h.client.DialEarlyPacketConn(conn, M.Socksaddr{Fqdn: packetaddr.SeqPacketMagicAddress}) diff --git a/protocol/vmess/outbound.go b/protocol/vmess/outbound.go index f122f6e7..cba0e7f9 100644 --- a/protocol/vmess/outbound.go +++ b/protocol/vmess/outbound.go @@ -194,7 +194,7 @@ func (h *vmessDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) return nil, err } if h.packetAddr { - if destination.IsFqdn() { + if destination.IsDomain() { return nil, E.New("packetaddr: domain destination is not supported") } return packetaddr.NewConn(h.client.DialEarlyPacketConn(conn, M.Socksaddr{Fqdn: packetaddr.SeqPacketMagicAddress}), destination), nil diff --git a/protocol/wireguard/endpoint.go b/protocol/wireguard/endpoint.go index dd5234ae..c9d57be5 100644 --- a/protocol/wireguard/endpoint.go +++ b/protocol/wireguard/endpoint.go @@ -238,7 +238,7 @@ func (w *Endpoint) DialContext(ctx context.Context, network string, destination case N.NetworkUDP: w.logger.InfoContext(ctx, "outbound packet connection to ", destination) } - if destination.IsFqdn() { + if destination.IsDomain() { destinationAddresses, err := w.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{}) if err != nil { return nil, err @@ -252,7 +252,7 @@ func (w *Endpoint) DialContext(ctx context.Context, network string, destination func (w *Endpoint) ListenPacketWithDestination(ctx context.Context, destination M.Socksaddr) (net.PacketConn, netip.Addr, error) { w.logger.InfoContext(ctx, "outbound packet connection to ", destination) - if destination.IsFqdn() { + if destination.IsDomain() { destinationAddresses, err := w.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{}) if err != nil { return nil, netip.Addr{}, err diff --git a/release/config/config.json b/release/config/config.json index bdc78d40..e5a2a663 100644 --- a/release/config/config.json +++ b/release/config/config.json @@ -5,7 +5,9 @@ "dns": { "servers": [ { - "address": "tls://8.8.8.8" + "type": "tls", + "tag": "google", + "server": "8.8.8.8" } ] }, @@ -26,17 +28,13 @@ "outbounds": [ { "type": "direct" - }, - { - "type": "dns", - "tag": "dns-out" } ], "route": { "rules": [ { "port": 53, - "outbound": "dns-out" + "action": "hijack-dns" } ] } diff --git a/release/config/sing-box.confd b/release/config/sing-box.confd new file mode 100644 index 00000000..506caa32 --- /dev/null +++ b/release/config/sing-box.confd @@ -0,0 +1,6 @@ +# /etc/conf.d/sing-box: config file for /etc/init.d/sing-box + +# sing-box configuration path, could be file or directory +# SINGBOX_CONFIG=/etc/sing-box + +# SINGBOX_WORKDIR=/var/lib/sing-box diff --git a/release/config/sing-box.initd b/release/config/sing-box.initd old mode 100644 new mode 100755 index db96e478..1541518a --- a/release/config/sing-box.initd +++ b/release/config/sing-box.initd @@ -4,15 +4,41 @@ name=$RC_SVCNAME description="sing-box service" supervisor="supervise-daemon" command="/usr/bin/sing-box" -command_args="-D /var/lib/sing-box -C /etc/sing-box run" +extra_commands="checkconfig" extra_started_commands="reload" +: ${SINGBOX_CONFIG:=${config:-"/etc/sing-box"}} + +if [ -d "$SINGBOX_CONFIG" ]; then + _config_opt="-C $SINGBOX_CONFIG" +elif [ -z "$SINGBOX_CONFIG" ]; then + _config_opt="" +else + _config_opt="-c $SINGBOX_CONFIG" +fi + +_workdir=${SINGBOX_WORKDIR:-${workdir:-"/var/lib/sing-box"}} + +command_args="run --disable-color + -D $_workdir + $_config_opt" + depend() { after net dns } +checkconfig() { + ebegin "Checking $RC_SVCNAME configuration" + sing-box check -D "$_workdir" $_config_opt + eend $? +} + +start_pre() { + checkconfig +} + reload() { ebegin "Reloading $RC_SVCNAME" - $supervisor "$RC_SVCNAME" --signal HUP + checkconfig && $supervisor "$RC_SVCNAME" --signal HUP eend $? -} \ No newline at end of file +} diff --git a/route/network.go b/route/network.go index b8eefdc0..03e94879 100644 --- a/route/network.go +++ b/route/network.go @@ -424,6 +424,7 @@ func (r *NetworkManager) WIFIState() adapter.WIFIState { } func (r *NetworkManager) onWIFIStateChanged(state adapter.WIFIState) { + state.BSSID = adapter.NormalizeWIFIBSSID(state.BSSID) r.wifiStateMutex.Lock() if state != r.wifiState { r.wifiState = state diff --git a/route/platform_searcher.go b/route/platform_searcher.go index f6a4e764..20fbda3f 100644 --- a/route/platform_searcher.go +++ b/route/platform_searcher.go @@ -43,3 +43,7 @@ func (s *platformSearcher) FindProcessInfo(ctx context.Context, network string, return s.platform.FindConnectionOwner(request) } + +func (s *platformSearcher) Close() error { + return nil +} diff --git a/route/process_cache.go b/route/process_cache.go new file mode 100644 index 00000000..44ee3fcf --- /dev/null +++ b/route/process_cache.go @@ -0,0 +1,95 @@ +package route + +import ( + "context" + "net/netip" + "strings" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/process" +) + +type processCacheKey struct { + Network string + Source netip.AddrPort + Destination netip.AddrPort +} + +type processCacheEntry struct { + result *adapter.ConnectionOwner + err error +} + +func (r *Router) findProcessInfoCached(ctx context.Context, network string, source netip.AddrPort, destination netip.AddrPort) (*adapter.ConnectionOwner, error) { + key := processCacheKey{ + Network: network, + Source: source, + Destination: destination, + } + if entry, ok := r.processCache.Get(key); ok { + return entry.result, entry.err + } + result, err := process.FindProcessInfo(r.processSearcher, ctx, network, source, destination) + r.processCache.Add(key, processCacheEntry{result: result, err: err}) + return result, err +} + +func (r *Router) searchProcessInfo(ctx context.Context, metadata *adapter.InboundContext) { + if r.processSearcher == nil || metadata.ProcessInfo != nil || !r.isLocalSource(metadata.Source.Addr) { + return + } + var originDestination netip.AddrPort + if metadata.OriginDestination.IsValid() { + originDestination = metadata.OriginDestination.AddrPort() + } else if metadata.Destination.IsIP() { + originDestination = metadata.Destination.AddrPort() + } + processInfo, err := r.findProcessInfoCached(ctx, metadata.Network, metadata.Source.AddrPort(), originDestination) + if err != nil { + r.logger.InfoContext(ctx, "failed to search process: ", err) + return + } + metadata.ProcessInfo = processInfo + if processInfo.ProcessPath != "" { + if processInfo.UserName != "" { + r.logger.InfoContext(ctx, "found process path: ", processInfo.ProcessPath, ", user: ", processInfo.UserName) + } else if processInfo.UserId != -1 { + r.logger.InfoContext(ctx, "found process path: ", processInfo.ProcessPath, ", user id: ", processInfo.UserId) + } else { + r.logger.InfoContext(ctx, "found process path: ", processInfo.ProcessPath) + } + return + } + if len(processInfo.AndroidPackageNames) > 0 { + r.logger.InfoContext(ctx, "found package name: ", strings.Join(processInfo.AndroidPackageNames, ", ")) + return + } + if processInfo.UserId != -1 { + if processInfo.UserName != "" { + r.logger.InfoContext(ctx, "found user: ", processInfo.UserName) + } else { + r.logger.InfoContext(ctx, "found user id: ", processInfo.UserId) + } + } +} + +func (r *Router) isLocalSource(source netip.Addr) bool { + if source.IsLoopback() { + return true + } + if r.platformInterface != nil { + for _, addr := range r.platformInterface.MyInterfaceAddress() { + if addr == source { + return true + } + } + } + for _, netInterface := range r.network.InterfaceFinder().Interfaces() { + for _, prefix := range netInterface.Addresses { + if prefix.Addr() == source { + return true + } + } + } + return false +} diff --git a/route/route.go b/route/route.go index be9eed4a..c8291b0a 100644 --- a/route/route.go +++ b/route/route.go @@ -9,7 +9,6 @@ import ( "time" "github.com/sagernet/sing-box/adapter" - "github.com/sagernet/sing-box/common/process" "github.com/sagernet/sing-box/common/sniff" C "github.com/sagernet/sing-box/constant" R "github.com/sagernet/sing-box/route/rule" @@ -349,7 +348,7 @@ func (r *Router) PreMatch(metadata adapter.InboundContext, routeContext tun.Dire } directRouteOutbound = defaultOutbound.(adapter.DirectRouteOutbound) } - if metadata.Destination.IsFqdn() { + if metadata.Destination.IsDomain() { if len(metadata.DestinationAddresses) == 0 { var strategy C.DomainStrategy if metadata.Source.IsIPv4() { @@ -408,37 +407,7 @@ func (r *Router) matchRule( selectedRule adapter.Rule, selectedRuleIndex int, buffers []*buf.Buffer, packetBuffers []*N.PacketBuffer, fatalErr error, ) { - if r.processSearcher != nil && metadata.ProcessInfo == nil { - var originDestination netip.AddrPort - if metadata.OriginDestination.IsValid() { - originDestination = metadata.OriginDestination.AddrPort() - } else if metadata.Destination.IsIP() { - originDestination = metadata.Destination.AddrPort() - } - processInfo, fErr := process.FindProcessInfo(r.processSearcher, ctx, metadata.Network, metadata.Source.AddrPort(), originDestination) - if fErr != nil { - r.logger.InfoContext(ctx, "failed to search process: ", fErr) - } else { - if processInfo.ProcessPath != "" { - if processInfo.UserName != "" { - r.logger.InfoContext(ctx, "found process path: ", processInfo.ProcessPath, ", user: ", processInfo.UserName) - } else if processInfo.UserId != -1 { - r.logger.InfoContext(ctx, "found process path: ", processInfo.ProcessPath, ", user id: ", processInfo.UserId) - } else { - r.logger.InfoContext(ctx, "found process path: ", processInfo.ProcessPath) - } - } else if processInfo.AndroidPackageName != "" { - r.logger.InfoContext(ctx, "found package name: ", processInfo.AndroidPackageName) - } else if processInfo.UserId != -1 { - if processInfo.UserName != "" { - r.logger.InfoContext(ctx, "found user: ", processInfo.UserName) - } else { - r.logger.InfoContext(ctx, "found user id: ", processInfo.UserId) - } - } - metadata.ProcessInfo = processInfo - } - } + r.searchProcessInfo(ctx, metadata) if metadata.Destination.Addr.IsValid() && r.dnsTransport.FakeIP() != nil && r.dnsTransport.FakeIP().Store().Contains(metadata.Destination.Addr) { domain, loaded := r.dnsTransport.FakeIP().Store().Lookup(metadata.Destination.Addr) if !loaded { @@ -793,7 +762,7 @@ func (r *Router) actionSniff( } func (r *Router) actionResolve(ctx context.Context, metadata *adapter.InboundContext, action *R.RuleActionResolve) error { - if metadata.Destination.IsFqdn() { + if metadata.Destination.IsDomain() { var transport adapter.DNSTransport if action.Server != "" { var loaded bool diff --git a/route/router.go b/route/router.go index 5c73cb1c..bc19b5d3 100644 --- a/route/router.go +++ b/route/router.go @@ -4,6 +4,7 @@ import ( "context" "os" "runtime" + "time" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/common/process" @@ -12,8 +13,11 @@ 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/common" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/task" + "github.com/sagernet/sing/contrab/freelru" + "github.com/sagernet/sing/contrab/maphash" "github.com/sagernet/sing/service" "github.com/sagernet/sing/service/pause" ) @@ -34,6 +38,7 @@ type Router struct { ruleSets []adapter.RuleSet ruleSetMap map[string]adapter.RuleSet processSearcher process.Searcher + processCache freelru.Cache[processCacheKey, processCacheEntry] pauseManager pause.Manager trackers []adapter.ConnectionTracker platformInterface adapter.PlatformInterface @@ -141,6 +146,11 @@ 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.SetLifetime(200 * time.Millisecond) + r.processCache = processCache + } case adapter.StartStatePostStart: for i, rule := range r.rules { monitor.Start("initialize rule[", i, "]") @@ -186,6 +196,13 @@ func (r *Router) Close() error { }) monitor.Finish() } + if r.processSearcher != nil { + monitor.Start("close process searcher") + err = E.Append(err, r.processSearcher.Close(), func(err error) error { + return E.Cause(err, "close process searcher") + }) + monitor.Finish() + } return err } diff --git a/route/rule/match_state.go b/route/rule/match_state.go new file mode 100644 index 00000000..feac8418 --- /dev/null +++ b/route/rule/match_state.go @@ -0,0 +1,126 @@ +package rule + +import "github.com/sagernet/sing-box/adapter" + +type ruleMatchState uint8 + +const ( + ruleMatchSourceAddress ruleMatchState = 1 << iota + ruleMatchSourcePort + ruleMatchDestinationAddress + ruleMatchDestinationPort +) + +type ruleMatchStateSet uint16 + +func singleRuleMatchState(state ruleMatchState) ruleMatchStateSet { + return 1 << state +} + +func emptyRuleMatchState() ruleMatchStateSet { + return singleRuleMatchState(0) +} + +func (s ruleMatchStateSet) isEmpty() bool { + return s == 0 +} + +func (s ruleMatchStateSet) contains(state ruleMatchState) bool { + return s&(1< 0 +} + +func (r *abstractDefaultRule) destinationIPCIDRMatchesDestination(metadata *adapter.InboundContext) bool { + return !metadata.IgnoreDestinationIPCIDRMatch && !metadata.IPCIDRMatchSource && len(r.destinationIPCIDRItems) > 0 +} + +func (r *abstractDefaultRule) requiresSourceAddressMatch(metadata *adapter.InboundContext) bool { + return len(r.sourceAddressItems) > 0 || r.destinationIPCIDRMatchesSource(metadata) +} + +func (r *abstractDefaultRule) requiresDestinationAddressMatch(metadata *adapter.InboundContext) bool { + return len(r.destinationAddressItems) > 0 || r.destinationIPCIDRMatchesDestination(metadata) +} + +func (r *abstractDefaultRule) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { + return r.matchStatesWithBase(metadata, 0) +} + +func (r *abstractDefaultRule) matchStatesWithBase(metadata *adapter.InboundContext, inheritedBase ruleMatchState) ruleMatchStateSet { if len(r.allItems) == 0 { - return true + return emptyRuleMatchState().withBase(inheritedBase) } - - if len(r.sourceAddressItems) > 0 && !metadata.SourceAddressMatch { + evaluationBase := inheritedBase + if r.invert { + evaluationBase = 0 + } + baseState := evaluationBase + if len(r.sourceAddressItems) > 0 { metadata.DidMatch = true - for _, item := range r.sourceAddressItems { - if item.Match(metadata) { - metadata.SourceAddressMatch = true - break - } + if matchAnyItem(r.sourceAddressItems, metadata) { + baseState |= ruleMatchSourceAddress } } - - if len(r.sourcePortItems) > 0 && !metadata.SourcePortMatch { + if r.destinationIPCIDRMatchesSource(metadata) && !baseState.has(ruleMatchSourceAddress) { metadata.DidMatch = true - for _, item := range r.sourcePortItems { - if item.Match(metadata) { - metadata.SourcePortMatch = true - break - } + if matchAnyItem(r.destinationIPCIDRItems, metadata) { + baseState |= ruleMatchSourceAddress + } + } else if r.destinationIPCIDRMatchesSource(metadata) { + metadata.DidMatch = true + } + if len(r.sourcePortItems) > 0 { + metadata.DidMatch = true + if matchAnyItem(r.sourcePortItems, metadata) { + baseState |= ruleMatchSourcePort } } - - if len(r.destinationAddressItems) > 0 && !metadata.DestinationAddressMatch { + if len(r.destinationAddressItems) > 0 { metadata.DidMatch = true - for _, item := range r.destinationAddressItems { - if item.Match(metadata) { - metadata.DestinationAddressMatch = true - break - } + if matchAnyItem(r.destinationAddressItems, metadata) { + baseState |= ruleMatchDestinationAddress } } - - if !metadata.IgnoreDestinationIPCIDRMatch && len(r.destinationIPCIDRItems) > 0 && !metadata.DestinationAddressMatch { + if r.destinationIPCIDRMatchesDestination(metadata) && !baseState.has(ruleMatchDestinationAddress) { metadata.DidMatch = true - for _, item := range r.destinationIPCIDRItems { - if item.Match(metadata) { - metadata.DestinationAddressMatch = true - break - } + if matchAnyItem(r.destinationIPCIDRItems, metadata) { + baseState |= ruleMatchDestinationAddress + } + } else if r.destinationIPCIDRMatchesDestination(metadata) { + metadata.DidMatch = true + } + if len(r.destinationPortItems) > 0 { + metadata.DidMatch = true + if matchAnyItem(r.destinationPortItems, metadata) { + baseState |= ruleMatchDestinationPort } } - - if len(r.destinationPortItems) > 0 && !metadata.DestinationPortMatch { - metadata.DidMatch = true - for _, item := range r.destinationPortItems { - if item.Match(metadata) { - metadata.DestinationPortMatch = true - break - } - } - } - for _, item := range r.items { metadata.DidMatch = true if !item.Match(metadata) { - return r.invert + return r.invertedFailure(inheritedBase) } } - - if len(r.sourceAddressItems) > 0 && !metadata.SourceAddressMatch { - return r.invert + var stateSet ruleMatchStateSet + if r.ruleSetItem != nil { + metadata.DidMatch = true + stateSet = matchRuleItemStatesWithBase(r.ruleSetItem, metadata, baseState) + } else { + stateSet = singleRuleMatchState(baseState) } - - if len(r.sourcePortItems) > 0 && !metadata.SourcePortMatch { - return r.invert - } - - if ((!metadata.IgnoreDestinationIPCIDRMatch && len(r.destinationIPCIDRItems) > 0) || len(r.destinationAddressItems) > 0) && !metadata.DestinationAddressMatch { - return r.invert - } - - if len(r.destinationPortItems) > 0 && !metadata.DestinationPortMatch { - return r.invert - } - - if !metadata.DidMatch { + 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 { + // DNS pre-lookup defers destination address-limit checks until the response phase. + if metadata.IgnoreDestinationIPCIDRMatch && stateSet == emptyRuleMatchState() && !metadata.DidMatch && len(r.destinationIPCIDRItems) > 0 { + return emptyRuleMatchState().withBase(inheritedBase) + } + return 0 + } + return stateSet +} - return !r.invert +func (r *abstractDefaultRule) invertedFailure(base ruleMatchState) ruleMatchStateSet { + if r.invert { + return emptyRuleMatchState().withBase(base) + } + return 0 } func (r *abstractDefaultRule) Action() adapter.RuleAction { @@ -191,17 +227,50 @@ func (r *abstractLogicalRule) Close() error { } func (r *abstractLogicalRule) Match(metadata *adapter.InboundContext) bool { - if r.mode == C.LogicalTypeAnd { - return common.All(r.rules, func(it adapter.HeadlessRule) bool { - metadata.ResetRuleCache() - return it.Match(metadata) - }) != r.invert - } else { - return common.Any(r.rules, func(it adapter.HeadlessRule) bool { - metadata.ResetRuleCache() - return it.Match(metadata) - }) != r.invert + 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 + if r.mode == C.LogicalTypeAnd { + stateSet = emptyRuleMatchState().withBase(evaluationBase) + 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 + } + 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) + } + return 0 + } + } + if r.invert { + return 0 + } + return stateSet } func (r *abstractLogicalRule) Action() adapter.RuleAction { @@ -222,3 +291,13 @@ func (r *abstractLogicalRule) String() string { return "!(" + strings.Join(F.MapToString(r.rules), " "+op+" ") + ")" } } + +func matchAnyItem(items []RuleItem, metadata *adapter.InboundContext) bool { + return common.Any(items, func(it RuleItem) bool { + return it.Match(metadata) + }) +} + +func (s ruleMatchState) has(target ruleMatchState) bool { + return s&target != 0 +} diff --git a/route/rule/rule_abstract_test.go b/route/rule/rule_abstract_test.go index 2d2e8ba8..ace3dec6 100644 --- a/route/rule/rule_abstract_test.go +++ b/route/rule/rule_abstract_test.go @@ -78,9 +78,9 @@ func newRuleSetOnlyRule(ruleSetMatched bool, invert bool) *DefaultRule { } return &DefaultRule{ abstractDefaultRule: abstractDefaultRule{ - items: []RuleItem{ruleSetItem}, - allItems: []RuleItem{ruleSetItem}, - invert: invert, + ruleSetItem: ruleSetItem, + allItems: []RuleItem{ruleSetItem}, + invert: invert, }, } } diff --git a/route/rule/rule_default.go b/route/rule/rule_default.go index 084d7ebe..1eef862d 100644 --- a/route/rule/rule_default.go +++ b/route/rule/rule_default.go @@ -47,6 +47,10 @@ 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 @@ -285,7 +289,7 @@ func NewDefaultRule(ctx context.Context, logger log.ContextLogger, options optio matchSource = true } item := NewRuleSetItem(router, options.RuleSet, matchSource, false) - rule.items = append(rule.items, item) + rule.ruleSetItem = item rule.allItems = append(rule.allItems, item) } return rule, nil @@ -297,6 +301,10 @@ 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 9ebb73ac..dad49503 100644 --- a/route/rule/rule_dns.go +++ b/route/rule/rule_dns.go @@ -47,6 +47,10 @@ type DefaultDNSRule struct { abstractDefaultRule } +func (r *DefaultDNSRule) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { + return r.abstractDefaultRule.matchStates(metadata) +} + func NewDefaultDNSRule(ctx context.Context, logger log.ContextLogger, options option.DefaultDNSRule) (*DefaultDNSRule, error) { rule := &DefaultDNSRule{ abstractDefaultRule: abstractDefaultRule{ @@ -281,7 +285,7 @@ func NewDefaultDNSRule(ctx context.Context, logger log.ContextLogger, options op matchSource = true } item := NewRuleSetItem(router, options.RuleSet, matchSource, options.RuleSetIPCIDRAcceptEmpty) - rule.items = append(rule.items, item) + rule.ruleSetItem = item rule.allItems = append(rule.allItems, item) } return rule, nil @@ -295,12 +299,9 @@ func (r *DefaultDNSRule) WithAddressLimit() bool { if len(r.destinationIPCIDRItems) > 0 { return true } - for _, rawRule := range r.items { - ruleSet, isRuleSet := rawRule.(*RuleSetItem) - if !isRuleSet { - continue - } - if ruleSet.ContainsDestinationIPCIDRRule() { + if r.ruleSetItem != nil { + ruleSet, isRuleSet := r.ruleSetItem.(*RuleSetItem) + if isRuleSet && ruleSet.ContainsDestinationIPCIDRRule() { return true } } @@ -312,11 +313,11 @@ func (r *DefaultDNSRule) Match(metadata *adapter.InboundContext) bool { defer func() { metadata.IgnoreDestinationIPCIDRMatch = false }() - return r.abstractDefaultRule.Match(metadata) + return !r.matchStates(metadata).isEmpty() } func (r *DefaultDNSRule) MatchAddressLimit(metadata *adapter.InboundContext) bool { - return r.abstractDefaultRule.Match(metadata) + return !r.matchStates(metadata).isEmpty() } var _ adapter.DNSRule = (*LogicalDNSRule)(nil) @@ -325,6 +326,10 @@ type LogicalDNSRule struct { abstractLogicalRule } +func (r *LogicalDNSRule) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { + return r.abstractLogicalRule.matchStates(metadata) +} + func NewLogicalDNSRule(ctx context.Context, logger log.ContextLogger, options option.LogicalDNSRule) (*LogicalDNSRule, error) { r := &LogicalDNSRule{ abstractLogicalRule: abstractLogicalRule{ @@ -372,29 +377,13 @@ func (r *LogicalDNSRule) WithAddressLimit() bool { } func (r *LogicalDNSRule) Match(metadata *adapter.InboundContext) bool { - if r.mode == C.LogicalTypeAnd { - return common.All(r.rules, func(it adapter.HeadlessRule) bool { - metadata.ResetRuleCache() - return it.(adapter.DNSRule).Match(metadata) - }) != r.invert - } else { - return common.Any(r.rules, func(it adapter.HeadlessRule) bool { - metadata.ResetRuleCache() - return it.(adapter.DNSRule).Match(metadata) - }) != r.invert - } + metadata.IgnoreDestinationIPCIDRMatch = true + defer func() { + metadata.IgnoreDestinationIPCIDRMatch = false + }() + return !r.matchStates(metadata).isEmpty() } func (r *LogicalDNSRule) MatchAddressLimit(metadata *adapter.InboundContext) bool { - if r.mode == C.LogicalTypeAnd { - return common.All(r.rules, func(it adapter.HeadlessRule) bool { - metadata.ResetRuleCache() - return it.(adapter.DNSRule).MatchAddressLimit(metadata) - }) != r.invert - } else { - return common.Any(r.rules, func(it adapter.HeadlessRule) bool { - metadata.ResetRuleCache() - return it.(adapter.DNSRule).MatchAddressLimit(metadata) - }) != r.invert - } + return !r.matchStates(metadata).isEmpty() } diff --git a/route/rule/rule_headless.go b/route/rule/rule_headless.go index d0a65acb..f11d1126 100644 --- a/route/rule/rule_headless.go +++ b/route/rule/rule_headless.go @@ -34,6 +34,10 @@ 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{ @@ -41,6 +45,11 @@ func NewDefaultHeadlessRule(ctx context.Context, options option.DefaultHeadlessR invert: options.Invert, }, } + if len(options.QueryType) > 0 { + item := NewQueryTypeItem(options.QueryType) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } if len(options.Network) > 0 { item := NewNetworkItem(options.Network) rule.items = append(rule.items, item) @@ -209,6 +218,10 @@ 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_package_name.go b/route/rule/rule_item_package_name.go index fa227587..514768de 100644 --- a/route/rule/rule_item_package_name.go +++ b/route/rule/rule_item_package_name.go @@ -25,10 +25,15 @@ func NewPackageNameItem(packageNameList []string) *PackageNameItem { } func (r *PackageNameItem) Match(metadata *adapter.InboundContext) bool { - if metadata.ProcessInfo == nil || metadata.ProcessInfo.AndroidPackageName == "" { + if metadata.ProcessInfo == nil || len(metadata.ProcessInfo.AndroidPackageNames) == 0 { return false } - return r.packageMap[metadata.ProcessInfo.AndroidPackageName] + for _, packageName := range metadata.ProcessInfo.AndroidPackageNames { + if r.packageMap[packageName] { + return true + } + } + return false } func (r *PackageNameItem) String() string { diff --git a/route/rule/rule_item_process_path.go b/route/rule/rule_item_process_path.go index 75dee476..ac5c6a18 100644 --- a/route/rule/rule_item_process_path.go +++ b/route/rule/rule_item_process_path.go @@ -4,6 +4,7 @@ import ( "strings" "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" ) var _ RuleItem = (*ProcessPathItem)(nil) @@ -25,10 +26,20 @@ func NewProcessPathItem(processNameList []string) *ProcessPathItem { } func (r *ProcessPathItem) Match(metadata *adapter.InboundContext) bool { - if metadata.ProcessInfo == nil || metadata.ProcessInfo.ProcessPath == "" { + if metadata.ProcessInfo == nil { return false } - return r.processMap[metadata.ProcessInfo.ProcessPath] + if metadata.ProcessInfo.ProcessPath != "" && r.processMap[metadata.ProcessInfo.ProcessPath] { + return true + } + if C.IsAndroid { + for _, packageName := range metadata.ProcessInfo.AndroidPackageNames { + if r.processMap[packageName] { + return true + } + } + } + return false } func (r *ProcessPathItem) String() string { diff --git a/route/rule/rule_item_rule_set.go b/route/rule/rule_item_rule_set.go index a0115a04..3467843b 100644 --- a/route/rule/rule_item_rule_set.go +++ b/route/rule/rule_item_rule_set.go @@ -41,14 +41,23 @@ func (r *RuleSetItem) Start() error { } func (r *RuleSetItem) Match(metadata *adapter.InboundContext) bool { - metadata.IPCIDRMatchSource = r.ipCidrMatchSource - metadata.IPCIDRAcceptEmpty = r.ipCidrAcceptEmpty + return !r.matchStates(metadata).isEmpty() +} + +func (r *RuleSetItem) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { + return r.matchStatesWithBase(metadata, 0) +} + +func (r *RuleSetItem) matchStatesWithBase(metadata *adapter.InboundContext, base ruleMatchState) ruleMatchStateSet { + var stateSet ruleMatchStateSet for _, ruleSet := range r.setList { - if ruleSet.Match(metadata) { - return true - } + nestedMetadata := *metadata + nestedMetadata.ResetRuleMatchCache() + nestedMetadata.IPCIDRMatchSource = r.ipCidrMatchSource + nestedMetadata.IPCIDRAcceptEmpty = r.ipCidrAcceptEmpty + stateSet = stateSet.merge(matchHeadlessRuleStatesWithBase(ruleSet, &nestedMetadata, base)) } - return false + return stateSet } func (r *RuleSetItem) ContainsDestinationIPCIDRRule() bool { diff --git a/route/rule/rule_item_wifi_bssid.go b/route/rule/rule_item_wifi_bssid.go index 8f887322..703562ba 100644 --- a/route/rule/rule_item_wifi_bssid.go +++ b/route/rule/rule_item_wifi_bssid.go @@ -18,7 +18,7 @@ type WIFIBSSIDItem struct { func NewWIFIBSSIDItem(networkManager adapter.NetworkManager, bssidList []string) *WIFIBSSIDItem { bssidMap := make(map[string]bool) for _, bssid := range bssidList { - bssidMap[bssid] = true + bssidMap[adapter.NormalizeWIFIBSSID(bssid)] = true } return &WIFIBSSIDItem{ bssidList, diff --git a/route/rule/rule_network_interface_address.go b/route/rule/rule_network_interface_address.go index c699c593..135a703e 100644 --- a/route/rule/rule_network_interface_address.go +++ b/route/rule/rule_network_interface_address.go @@ -40,9 +40,13 @@ func NewNetworkInterfaceAddressItem(networkManager adapter.NetworkManager, inter func (r *NetworkInterfaceAddressItem) Match(metadata *adapter.InboundContext) bool { interfaces := r.networkManager.NetworkInterfaces() + myInterface := r.networkManager.InterfaceMonitor().MyInterface() match: for ifType, addresses := range r.interfaceAddresses { for _, networkInterface := range interfaces { + if networkInterface.Name == myInterface { + continue + } if networkInterface.Type != ifType { continue } diff --git a/route/rule/rule_set_local.go b/route/rule/rule_set_local.go index b09915ed..ed873d70 100644 --- a/route/rule/rule_set_local.go +++ b/route/rule/rule_set_local.go @@ -202,10 +202,19 @@ func (s *LocalRuleSet) Close() error { } func (s *LocalRuleSet) Match(metadata *adapter.InboundContext) bool { - for _, rule := range s.rules { - if rule.Match(metadata) { - return true - } - } - return false + return !s.matchStates(metadata).isEmpty() +} + +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 } diff --git a/route/rule/rule_set_remote.go b/route/rule/rule_set_remote.go index 3aba76ba..bda6e23f 100644 --- a/route/rule/rule_set_remote.go +++ b/route/rule/rule_set_remote.go @@ -322,10 +322,19 @@ func (s *RemoteRuleSet) Close() error { } func (s *RemoteRuleSet) Match(metadata *adapter.InboundContext) bool { - for _, rule := range s.rules { - if rule.Match(metadata) { - return true - } - } - return false + return !s.matchStates(metadata).isEmpty() +} + +func (s *RemoteRuleSet) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { + return s.matchStatesWithBase(metadata, 0) +} + +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 } diff --git a/route/rule/rule_set_semantics_test.go b/route/rule/rule_set_semantics_test.go new file mode 100644 index 00000000..a01defe6 --- /dev/null +++ b/route/rule/rule_set_semantics_test.go @@ -0,0 +1,852 @@ +package rule + +import ( + "context" + "net/netip" + "strings" + "testing" + + "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" + + "github.com/stretchr/testify/require" +) + +func TestRouteRuleSetMergeDestinationAddressGroup(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + metadata adapter.InboundContext + inner adapter.HeadlessRule + }{ + { + name: "domain", + metadata: testMetadata("www.example.com"), + inner: headlessDefaultRule(t, func(rule *abstractDefaultRule) { addDestinationAddressItem(t, rule, []string{"www.example.com"}, nil) }), + }, + { + name: "domain_suffix", + metadata: testMetadata("www.example.com"), + inner: headlessDefaultRule(t, func(rule *abstractDefaultRule) { addDestinationAddressItem(t, rule, nil, []string{"example.com"}) }), + }, + { + name: "domain_keyword", + metadata: testMetadata("www.example.com"), + inner: headlessDefaultRule(t, func(rule *abstractDefaultRule) { addDestinationKeywordItem(rule, []string{"example"}) }), + }, + { + name: "domain_regex", + metadata: testMetadata("www.example.com"), + inner: headlessDefaultRule(t, func(rule *abstractDefaultRule) { addDestinationRegexItem(t, rule, []string{`^www\.example\.com$`}) }), + }, + { + name: "ip_cidr", + metadata: func() adapter.InboundContext { + metadata := testMetadata("lookup.example") + metadata.DestinationAddresses = []netip.Addr{netip.MustParseAddr("8.8.8.8")} + return metadata + }(), + inner: headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationIPCIDRItem(t, rule, []string{"8.8.8.0/24"}) + }), + }, + } + for _, testCase := range testCases { + testCase := testCase + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + ruleSet := newLocalRuleSetForTest("merge-destination", testCase.inner) + rule := 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(&testCase.metadata)) + }) + } +} + +func TestRouteRuleSetMergeSourceAndPortGroups(t *testing.T) { + t.Parallel() + t.Run("source address", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("merge-source-address", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addSourceAddressItem(t, rule, []string{"10.0.0.0/8"}) + })) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + addSourceAddressItem(t, rule, []string{"198.51.100.0/24"}) + }) + require.True(t, rule.Match(&metadata)) + }) + t.Run("source address via ruleset ipcidr match source", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("merge-source-address-ipcidr", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationIPCIDRItem(t, rule, []string{"10.0.0.0/8"}) + })) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{ + setList: []adapter.RuleSet{ruleSet}, + ipCidrMatchSource: true, + }) + addSourceAddressItem(t, rule, []string{"198.51.100.0/24"}) + }) + require.True(t, rule.Match(&metadata)) + }) + t.Run("destination port", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("merge-destination-port", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationPortItem(rule, []uint16{443}) + })) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + addDestinationPortItem(rule, []uint16{8443}) + }) + require.True(t, rule.Match(&metadata)) + }) + t.Run("destination port range", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("merge-destination-port-range", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationPortRangeItem(t, rule, []string{"400:500"}) + })) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + addDestinationPortItem(rule, []uint16{8443}) + }) + require.True(t, rule.Match(&metadata)) + }) + t.Run("source port", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("merge-source-port", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addSourcePortItem(rule, []uint16{1000}) + })) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + addSourcePortItem(rule, []uint16{2000}) + }) + require.True(t, rule.Match(&metadata)) + }) + t.Run("source port range", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("merge-source-port-range", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addSourcePortRangeItem(t, rule, []string{"900:1100"}) + })) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + addSourcePortItem(rule, []uint16{2000}) + }) + require.True(t, rule.Match(&metadata)) + }) +} + +func TestRouteRuleSetOuterGroupedStateMergesIntoSameGroup(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + metadata adapter.InboundContext + buildOuter func(*testing.T, *abstractDefaultRule) + buildInner func(*testing.T, *abstractDefaultRule) + }{ + { + name: "destination address", + metadata: testMetadata("www.example.com"), + buildOuter: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + }, + buildInner: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addDestinationAddressItem(t, rule, nil, []string{"google.com"}) + }, + }, + { + name: "source address", + metadata: testMetadata("www.example.com"), + buildOuter: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addSourceAddressItem(t, rule, []string{"10.0.0.0/8"}) + }, + buildInner: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addSourceAddressItem(t, rule, []string{"198.51.100.0/24"}) + }, + }, + { + name: "source port", + metadata: testMetadata("www.example.com"), + buildOuter: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addSourcePortItem(rule, []uint16{1000}) + }, + buildInner: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addSourcePortItem(rule, []uint16{2000}) + }, + }, + { + name: "destination port", + metadata: testMetadata("www.example.com"), + buildOuter: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addDestinationPortItem(rule, []uint16{443}) + }, + buildInner: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addDestinationPortItem(rule, []uint16{8443}) + }, + }, + { + name: "destination ip cidr", + metadata: func() adapter.InboundContext { + metadata := testMetadata("lookup.example") + metadata.DestinationAddresses = []netip.Addr{netip.MustParseAddr("203.0.113.1")} + return metadata + }(), + buildOuter: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) + }, + buildInner: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addDestinationIPCIDRItem(t, rule, []string{"198.51.100.0/24"}) + }, + }, + } + for _, testCase := range testCases { + testCase := testCase + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + ruleSet := newLocalRuleSetForTest("outer-merge-"+testCase.name, headlessDefaultRule(t, func(rule *abstractDefaultRule) { + testCase.buildInner(t, rule) + })) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + testCase.buildOuter(t, rule) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + require.True(t, rule.Match(&testCase.metadata)) + }) + } +} + +func TestRouteRuleSetOtherFieldsStayAnd(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("other-fields-and", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + })) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + addOtherItem(rule, NewNetworkItem([]string{N.NetworkUDP})) + }) + require.False(t, rule.Match(&metadata)) +} + +func TestRouteRuleSetMergedBranchKeepsAndConstraints(t *testing.T) { + t.Parallel() + t.Run("outer group does not bypass inner non grouped condition", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("network-and", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addOtherItem(rule, NewNetworkItem([]string{N.NetworkUDP})) + })) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + require.False(t, rule.Match(&metadata)) + }) + t.Run("outer group does not satisfy different grouped branch", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("different-group", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"google.com"}) + })) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addSourcePortItem(rule, []uint16{1000}) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + require.False(t, rule.Match(&metadata)) + }) +} + +func TestRouteRuleSetOrSemantics(t *testing.T) { + t.Parallel() + t.Run("later ruleset can satisfy outer group", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + emptyStateSet := newLocalRuleSetForTest("network-only", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addOtherItem(rule, NewNetworkItem([]string{N.NetworkTCP})) + })) + destinationStateSet := newLocalRuleSetForTest("domain-only", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + })) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{emptyStateSet, destinationStateSet}}) + addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) + }) + require.True(t, rule.Match(&metadata)) + }) + t.Run("later rule in same set can satisfy outer group", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest( + "rule-set-or", + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addOtherItem(rule, NewNetworkItem([]string{N.NetworkTCP})) + }), + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + }), + ) + rule := 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)) + }) + t.Run("cross ruleset union is not allowed", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + sourceStateSet := newLocalRuleSetForTest("source-only", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addSourcePortItem(rule, []uint16{1000}) + })) + destinationStateSet := newLocalRuleSetForTest("destination-only", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + })) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{sourceStateSet, destinationStateSet}}) + addSourcePortItem(rule, []uint16{2000}) + addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) + }) + require.False(t, rule.Match(&metadata)) + }) +} + +func TestRouteRuleSetLogicalSemantics(t *testing.T) { + t.Parallel() + t.Run("logical or keeps all successful branch states", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("logical-or", headlessLogicalRule( + C.LogicalTypeOr, + false, + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addOtherItem(rule, NewNetworkItem([]string{N.NetworkTCP})) + }), + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + }), + )) + rule := 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)) + }) + t.Run("logical and unions child states", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("logical-and", 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}}) + addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) + addSourcePortItem(rule, []uint16{2000}) + }) + require.True(t, rule.Match(&metadata)) + }) + t.Run("invert success does not contribute positive state", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("invert", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + rule.invert = true + addDestinationAddressItem(t, rule, nil, []string{"cn"}) + })) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) + }) + require.False(t, rule.Match(&metadata)) + }) +} + +func TestRouteRuleSetInvertMergedBranchSemantics(t *testing.T) { + t.Parallel() + t.Run("default invert keeps inherited group outside grouped predicate", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("invert-grouped", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + rule.invert = true + addDestinationAddressItem(t, rule, nil, []string{"google.com"}) + })) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + require.True(t, rule.Match(&metadata)) + }) + t.Run("default invert keeps inherited group after negation succeeds", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("invert-network", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + rule.invert = true + addOtherItem(rule, NewNetworkItem([]string{N.NetworkUDP})) + })) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + require.True(t, rule.Match(&metadata)) + }) + t.Run("logical invert keeps inherited group outside grouped predicate", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("logical-invert-grouped", headlessLogicalRule( + C.LogicalTypeOr, + true, + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"google.com"}) + }), + )) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + require.True(t, rule.Match(&metadata)) + }) + t.Run("logical invert keeps inherited group after negation succeeds", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("logical-invert-network", headlessLogicalRule( + C.LogicalTypeOr, + true, + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addOtherItem(rule, NewNetworkItem([]string{N.NetworkUDP})) + }), + )) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + require.True(t, rule.Match(&metadata)) + }) +} + +func TestRouteRuleSetNoLeakageRegressions(t *testing.T) { + t.Parallel() + t.Run("same ruleset failed branch does not leak", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest( + "same-set", + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + addSourcePortItem(rule, []uint16{1}) + }), + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) + addSourcePortItem(rule, []uint16{1000}) + }), + ) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + require.False(t, rule.Match(&metadata)) + }) + t.Run("adguard exclusion remains isolated across rulesets", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("im.qq.com") + excludeSet := newLocalRuleSetForTest("adguard", mustAdGuardRule(t, "@@||im.qq.com^\n||whatever1.com^\n")) + otherSet := newLocalRuleSetForTest("other", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"whatever2.com"}) + })) + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{excludeSet, otherSet}}) + }) + require.False(t, rule.Match(&metadata)) + }) +} + +func TestDefaultRuleDoesNotReuseGroupedMatchCacheAcrossEvaluations(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + rule := routeRuleForTest(func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + }) + require.True(t, rule.Match(&metadata)) + + metadata.Destination.Fqdn = "www.example.org" + require.False(t, rule.Match(&metadata)) +} + +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})) + }), + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + }), + ) + rule := 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)) +} + +func TestDNSRuleSetSemantics(t *testing.T) { + t.Parallel() + t.Run("outer destination group merges into matching ruleset branch", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.baidu.com") + ruleSet := newLocalRuleSetForTest("dns-merged-branch", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"google.com"}) + })) + rule := dnsRuleForTest(func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"baidu.com"}) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + require.True(t, rule.Match(&metadata)) + }) + t.Run("outer destination group does not bypass ruleset non grouped condition", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("dns-network-and", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addOtherItem(rule, NewNetworkItem([]string{N.NetworkUDP})) + })) + rule := dnsRuleForTest(func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + require.False(t, rule.Match(&metadata)) + }) + t.Run("outer destination group stays outside inverted grouped branch", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.baidu.com") + ruleSet := newLocalRuleSetForTest("dns-invert-grouped", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + rule.invert = true + addDestinationAddressItem(t, rule, nil, []string{"google.com"}) + })) + rule := dnsRuleForTest(func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"baidu.com"}) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + require.True(t, rule.Match(&metadata)) + }) + t.Run("outer destination group stays outside inverted logical branch", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("dns-logical-invert-network", headlessLogicalRule( + C.LogicalTypeOr, + true, + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addOtherItem(rule, NewNetworkItem([]string{N.NetworkUDP})) + }), + )) + rule := dnsRuleForTest(func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + require.True(t, rule.Match(&metadata)) + }) + t.Run("match address limit merges destination group", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("dns-merge", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + })) + rule := dnsRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) + }) + require.True(t, rule.MatchAddressLimit(&metadata)) + }) + t.Run("dns keeps ruleset or semantics", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + emptyStateSet := newLocalRuleSetForTest("dns-empty", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addOtherItem(rule, NewNetworkItem([]string{N.NetworkTCP})) + })) + destinationStateSet := newLocalRuleSetForTest("dns-destination", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + })) + rule := dnsRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{emptyStateSet, destinationStateSet}}) + addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) + }) + require.True(t, rule.MatchAddressLimit(&metadata)) + }) + t.Run("ruleset ip cidr flags stay scoped", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest("dns-ipcidr", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) + })) + rule := dnsRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{ + setList: []adapter.RuleSet{ruleSet}, + ipCidrAcceptEmpty: true, + }) + }) + require.True(t, rule.MatchAddressLimit(&metadata)) + require.False(t, metadata.IPCIDRMatchSource) + require.False(t, metadata.IPCIDRAcceptEmpty) + }) +} + +func TestDNSInvertAddressLimitPreLookupRegression(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + build func(*testing.T, *abstractDefaultRule) + matchedAddrs []netip.Addr + unmatchedAddrs []netip.Addr + }{ + { + name: "ip_cidr", + build: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) + }, + matchedAddrs: []netip.Addr{netip.MustParseAddr("203.0.113.1")}, + unmatchedAddrs: []netip.Addr{netip.MustParseAddr("8.8.8.8")}, + }, + { + name: "ip_is_private", + build: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addDestinationIPIsPrivateItem(rule) + }, + matchedAddrs: []netip.Addr{netip.MustParseAddr("10.0.0.1")}, + unmatchedAddrs: []netip.Addr{netip.MustParseAddr("8.8.8.8")}, + }, + { + name: "ip_accept_any", + build: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addDestinationIPAcceptAnyItem(rule) + }, + matchedAddrs: []netip.Addr{netip.MustParseAddr("203.0.113.1")}, + }, + } + for _, testCase := range testCases { + testCase := testCase + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + rule := dnsRuleForTest(func(rule *abstractDefaultRule) { + rule.invert = true + testCase.build(t, rule) + }) + + preLookupMetadata := testMetadata("lookup.example") + require.True(t, rule.Match(&preLookupMetadata)) + + matchedMetadata := testMetadata("lookup.example") + matchedMetadata.DestinationAddresses = testCase.matchedAddrs + require.False(t, rule.MatchAddressLimit(&matchedMetadata)) + + unmatchedMetadata := testMetadata("lookup.example") + unmatchedMetadata.DestinationAddresses = testCase.unmatchedAddrs + require.True(t, rule.MatchAddressLimit(&unmatchedMetadata)) + }) + } + t.Run("mixed resolved and deferred fields keep old pre lookup false", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("lookup.example") + rule := dnsRuleForTest(func(rule *abstractDefaultRule) { + rule.invert = true + addOtherItem(rule, NewNetworkItem([]string{N.NetworkTCP})) + addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) + }) + require.False(t, rule.Match(&metadata)) + }) + t.Run("ruleset only deferred fields keep old pre lookup false", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("lookup.example") + ruleSet := newLocalRuleSetForTest("dns-ruleset-ipcidr", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) + })) + rule := dnsRuleForTest(func(rule *abstractDefaultRule) { + rule.invert = true + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + require.False(t, rule.Match(&metadata)) + }) +} + +func routeRuleForTest(build func(*abstractDefaultRule)) *DefaultRule { + rule := &DefaultRule{} + build(&rule.abstractDefaultRule) + return rule +} + +func dnsRuleForTest(build func(*abstractDefaultRule)) *DefaultDNSRule { + rule := &DefaultDNSRule{} + build(&rule.abstractDefaultRule) + return rule +} + +func headlessDefaultRule(t *testing.T, build func(*abstractDefaultRule)) *DefaultHeadlessRule { + t.Helper() + rule := &DefaultHeadlessRule{} + build(&rule.abstractDefaultRule) + return rule +} + +func headlessLogicalRule(mode string, invert bool, rules ...adapter.HeadlessRule) *LogicalHeadlessRule { + return &LogicalHeadlessRule{ + abstractLogicalRule: abstractLogicalRule{ + rules: rules, + mode: mode, + invert: invert, + }, + } +} + +func newLocalRuleSetForTest(tag string, rules ...adapter.HeadlessRule) *LocalRuleSet { + return &LocalRuleSet{ + tag: tag, + rules: rules, + } +} + +func newRemoteRuleSetForTest(tag string, rules ...adapter.HeadlessRule) *RemoteRuleSet { + return &RemoteRuleSet{ + options: option.RuleSet{Tag: tag}, + rules: rules, + } +} + +func mustAdGuardRule(t *testing.T, content string) adapter.HeadlessRule { + t.Helper() + rules, err := adguard.ToOptions(strings.NewReader(content), slogger.NOP()) + require.NoError(t, err) + require.Len(t, rules, 1) + rule, err := NewHeadlessRule(context.Background(), rules[0]) + require.NoError(t, err) + return rule +} + +func testMetadata(domain string) adapter.InboundContext { + return adapter.InboundContext{ + Network: N.NetworkTCP, + Source: M.Socksaddr{ + Addr: netip.MustParseAddr("10.0.0.1"), + Port: 1000, + }, + Destination: M.Socksaddr{ + Fqdn: domain, + Port: 443, + }, + } +} + +func addRuleSetItem(rule *abstractDefaultRule, item *RuleSetItem) { + rule.ruleSetItem = item + rule.allItems = append(rule.allItems, item) +} + +func addOtherItem(rule *abstractDefaultRule, item RuleItem) { + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) +} + +func addSourceAddressItem(t *testing.T, rule *abstractDefaultRule, cidrs []string) { + t.Helper() + item, err := NewIPCIDRItem(true, cidrs) + require.NoError(t, err) + rule.sourceAddressItems = append(rule.sourceAddressItems, item) + rule.allItems = append(rule.allItems, item) +} + +func addDestinationAddressItem(t *testing.T, rule *abstractDefaultRule, domains []string, suffixes []string) { + t.Helper() + item, err := NewDomainItem(domains, suffixes) + require.NoError(t, err) + rule.destinationAddressItems = append(rule.destinationAddressItems, item) + rule.allItems = append(rule.allItems, item) +} + +func addDestinationKeywordItem(rule *abstractDefaultRule, keywords []string) { + item := NewDomainKeywordItem(keywords) + rule.destinationAddressItems = append(rule.destinationAddressItems, item) + rule.allItems = append(rule.allItems, item) +} + +func addDestinationRegexItem(t *testing.T, rule *abstractDefaultRule, regexes []string) { + t.Helper() + item, err := NewDomainRegexItem(regexes) + require.NoError(t, err) + rule.destinationAddressItems = append(rule.destinationAddressItems, item) + rule.allItems = append(rule.allItems, item) +} + +func addDestinationIPCIDRItem(t *testing.T, rule *abstractDefaultRule, cidrs []string) { + t.Helper() + item, err := NewIPCIDRItem(false, cidrs) + require.NoError(t, err) + rule.destinationIPCIDRItems = append(rule.destinationIPCIDRItems, item) + rule.allItems = append(rule.allItems, item) +} + +func addDestinationIPIsPrivateItem(rule *abstractDefaultRule) { + item := NewIPIsPrivateItem(false) + rule.destinationIPCIDRItems = append(rule.destinationIPCIDRItems, item) + rule.allItems = append(rule.allItems, item) +} + +func addDestinationIPAcceptAnyItem(rule *abstractDefaultRule) { + item := NewIPAcceptAnyItem() + rule.destinationIPCIDRItems = append(rule.destinationIPCIDRItems, item) + rule.allItems = append(rule.allItems, item) +} + +func addSourcePortItem(rule *abstractDefaultRule, ports []uint16) { + item := NewPortItem(true, ports) + rule.sourcePortItems = append(rule.sourcePortItems, item) + rule.allItems = append(rule.allItems, item) +} + +func addSourcePortRangeItem(t *testing.T, rule *abstractDefaultRule, ranges []string) { + t.Helper() + item, err := NewPortRangeItem(true, ranges) + require.NoError(t, err) + rule.sourcePortItems = append(rule.sourcePortItems, item) + rule.allItems = append(rule.allItems, item) +} + +func addDestinationPortItem(rule *abstractDefaultRule, ports []uint16) { + item := NewPortItem(false, ports) + rule.destinationPortItems = append(rule.destinationPortItems, item) + rule.allItems = append(rule.allItems, item) +} + +func addDestinationPortRangeItem(t *testing.T, rule *abstractDefaultRule, ranges []string) { + t.Helper() + item, err := NewPortRangeItem(false, ranges) + require.NoError(t, err) + rule.destinationPortItems = append(rule.destinationPortItems, item) + rule.allItems = append(rule.allItems, item) +} diff --git a/service/ccm/service.go b/service/ccm/service.go index ba428060..34c38824 100644 --- a/service/ccm/service.go +++ b/service/ccm/service.go @@ -281,11 +281,11 @@ func (s *Service) getAccessToken() (string, error) { return newCredentials.AccessToken, nil } -func detectContextWindow(betaHeader string, inputTokens int64) int { - if inputTokens > premiumContextThreshold { +func detectContextWindow(betaHeader string, totalInputTokens int64) int { + if totalInputTokens > premiumContextThreshold { features := strings.Split(betaHeader, ",") for _, feature := range features { - if strings.TrimSpace(feature) == "context-1m" { + if strings.HasPrefix(strings.TrimSpace(feature), "context-1m") { return contextWindowPremium } } @@ -362,6 +362,13 @@ func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } + serviceOverridesAcceptEncoding := len(s.httpHeaders.Values("Accept-Encoding")) > 0 + if s.usageTracker != nil && !serviceOverridesAcceptEncoding { + // Strip Accept-Encoding so Go Transport adds it automatically + // and transparently decompresses the response for correct usage counting. + proxyRequest.Header.Del("Accept-Encoding") + } + anthropicBetaHeader := proxyRequest.Header.Get("anthropic-beta") if anthropicBetaHeader != "" { proxyRequest.Header.Set("anthropic-beta", anthropicBetaOAuthValue+","+anthropicBetaHeader) @@ -447,7 +454,8 @@ func (s *Service) handleResponseWithTracking(writer http.ResponseWriter, respons if usage.InputTokens > 0 || usage.OutputTokens > 0 { if responseModel != "" { - contextWindow := detectContextWindow(anthropicBetaHeader, usage.InputTokens) + totalInputTokens := usage.InputTokens + usage.CacheCreationInputTokens + usage.CacheReadInputTokens + contextWindow := detectContextWindow(anthropicBetaHeader, totalInputTokens) s.usageTracker.AddUsageWithCycleHint( responseModel, contextWindow, @@ -547,7 +555,8 @@ func (s *Service) handleResponseWithTracking(writer http.ResponseWriter, respons if accumulatedUsage.InputTokens > 0 || accumulatedUsage.OutputTokens > 0 { if responseModel != "" { - contextWindow := detectContextWindow(anthropicBetaHeader, accumulatedUsage.InputTokens) + totalInputTokens := accumulatedUsage.InputTokens + accumulatedUsage.CacheCreationInputTokens + accumulatedUsage.CacheReadInputTokens + contextWindow := detectContextWindow(anthropicBetaHeader, totalInputTokens) s.usageTracker.AddUsageWithCycleHint( responseModel, contextWindow, diff --git a/service/ccm/service_usage.go b/service/ccm/service_usage.go index 7d776774..36e9ee65 100644 --- a/service/ccm/service_usage.go +++ b/service/ccm/service_usage.go @@ -65,9 +65,10 @@ type CostCombinationJSON struct { } type CostsSummaryJSON struct { - TotalUSD float64 `json:"total_usd"` - ByUser map[string]float64 `json:"by_user"` - ByWeek map[string]float64 `json:"by_week,omitempty"` + TotalUSD float64 `json:"total_usd"` + ByUser map[string]float64 `json:"by_user"` + ByWeek map[string]float64 `json:"by_week,omitempty"` + ByUserAndWeek map[string]map[string]float64 `json:"by_user_and_week,omitempty"` } type AggregatedUsageJSON struct { @@ -492,6 +493,31 @@ func buildByWeekCost(combinations []CostCombination) map[string]float64 { return byWeek } +func buildByUserAndWeekCost(combinations []CostCombination) map[string]map[string]float64 { + byUserAndWeek := make(map[string]map[string]float64) + for _, combination := range combinations { + if combination.WeekStartUnix <= 0 { + continue + } + weekStartAt := time.Unix(combination.WeekStartUnix, 0).UTC() + weekKey := formatWeekStartKey(weekStartAt) + for user, userStats := range combination.ByUser { + userWeeks, exists := byUserAndWeek[user] + if !exists { + userWeeks = make(map[string]float64) + byUserAndWeek[user] = userWeeks + } + userWeeks[weekKey] += calculateCost(userStats, combination.Model, combination.ContextWindow) + } + } + for _, weekCosts := range byUserAndWeek { + for weekKey, cost := range weekCosts { + weekCosts[weekKey] = roundCost(cost) + } + } + return byUserAndWeek +} + func deriveWeekStartUnix(cycleHint *WeeklyCycleHint) int64 { if cycleHint == nil || cycleHint.WindowMinutes <= 0 || cycleHint.ResetAt.IsZero() { return 0 @@ -522,6 +548,11 @@ func (u *AggregatedUsage) ToJSON() *AggregatedUsageJSON { result.Costs.ByWeek = nil } + result.Costs.ByUserAndWeek = buildByUserAndWeekCost(u.Combinations) + if len(result.Costs.ByUserAndWeek) == 0 { + result.Costs.ByUserAndWeek = nil + } + for user, cost := range result.Costs.ByUser { result.Costs.ByUser[user] = roundCost(cost) } diff --git a/service/derp/service.go b/service/derp/service.go index 6cc1b9b6..02dac60b 100644 --- a/service/derp/service.go +++ b/service/derp/service.go @@ -1,3 +1,5 @@ +//go:build with_gvisor + package derp import ( diff --git a/service/ocm/service.go b/service/ocm/service.go index 2354d159..8b66964a 100644 --- a/service/ocm/service.go +++ b/service/ocm/service.go @@ -130,6 +130,7 @@ type Service struct { credentialPath string credentials *oauthCredentials users []option.OCMUser + dialer N.Dialer httpClient *http.Client httpHeaders http.Header listener *listener.Listener @@ -138,7 +139,9 @@ type Service struct { userManager *UserManager accessMutex sync.RWMutex usageTracker *AggregatedUsage - trackingGroup sync.WaitGroup + webSocketMutex sync.Mutex + webSocketGroup sync.WaitGroup + webSocketConns map[*webSocketSession]struct{} shuttingDown bool } @@ -187,6 +190,7 @@ func NewService(ctx context.Context, logger log.ContextLogger, tag string, optio logger: logger, credentialPath: options.CredentialPath, users: options.Users, + dialer: serviceDialer, httpClient: httpClient, httpHeaders: options.Headers.Build(), listener: listener.New(listener.Options{ @@ -195,8 +199,9 @@ func NewService(ctx context.Context, logger log.ContextLogger, tag string, optio Network: []string{N.NetworkTCP}, Listen: options.ListenOptions, }), - userManager: userManager, - usageTracker: usageTracker, + userManager: userManager, + usageTracker: usageTracker, + webSocketConns: make(map[*webSocketSession]struct{}), } if options.TLS != nil { @@ -356,6 +361,11 @@ func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } + if strings.EqualFold(r.Header.Get("Upgrade"), "websocket") && strings.HasPrefix(path, "/v1/responses") { + s.handleWebSocket(w, r, proxyPath, username) + return + } + var requestModel string if s.usageTracker != nil && r.Body != nil { @@ -497,8 +507,10 @@ func (s *Service) handleResponseWithTracking(writer http.ResponseWriter, respons responseModel = requestModel } if responseModel != "" { + contextWindow := detectContextWindow(responseModel, serviceTier, inputTokens) s.usageTracker.AddUsageWithCycleHint( responseModel, + contextWindow, inputTokens, outputTokens, cachedTokens, @@ -606,8 +618,10 @@ func (s *Service) handleResponseWithTracking(writer http.ResponseWriter, respons if inputTokens > 0 || outputTokens > 0 { if responseModel != "" { + contextWindow := detectContextWindow(responseModel, serviceTier, inputTokens) s.usageTracker.AddUsageWithCycleHint( responseModel, + contextWindow, inputTokens, outputTokens, cachedTokens, @@ -624,11 +638,17 @@ func (s *Service) handleResponseWithTracking(writer http.ResponseWriter, respons } func (s *Service) Close() error { + webSocketSessions := s.startWebSocketShutdown() + err := common.Close( common.PtrOrNil(s.httpServer), common.PtrOrNil(s.listener), s.tlsConfig, ) + for _, session := range webSocketSessions { + session.Close() + } + s.webSocketGroup.Wait() if s.usageTracker != nil { s.usageTracker.cancelPendingSave() @@ -640,3 +660,48 @@ func (s *Service) Close() error { return err } + +func (s *Service) registerWebSocketSession(session *webSocketSession) bool { + s.webSocketMutex.Lock() + defer s.webSocketMutex.Unlock() + + if s.shuttingDown { + return false + } + + s.webSocketConns[session] = struct{}{} + s.webSocketGroup.Add(1) + return true +} + +func (s *Service) unregisterWebSocketSession(session *webSocketSession) { + s.webSocketMutex.Lock() + _, loaded := s.webSocketConns[session] + if loaded { + delete(s.webSocketConns, session) + } + s.webSocketMutex.Unlock() + + if loaded { + s.webSocketGroup.Done() + } +} + +func (s *Service) isShuttingDown() bool { + s.webSocketMutex.Lock() + defer s.webSocketMutex.Unlock() + return s.shuttingDown +} + +func (s *Service) startWebSocketShutdown() []*webSocketSession { + s.webSocketMutex.Lock() + defer s.webSocketMutex.Unlock() + + s.shuttingDown = true + + webSocketSessions := make([]*webSocketSession, 0, len(s.webSocketConns)) + for session := range s.webSocketConns { + webSocketSessions = append(webSocketSessions, session) + } + return webSocketSessions +} diff --git a/service/ocm/service_usage.go b/service/ocm/service_usage.go index a4c1d1c8..589fd093 100644 --- a/service/ocm/service_usage.go +++ b/service/ocm/service_usage.go @@ -46,6 +46,7 @@ func (u *UsageStats) UnmarshalJSON(data []byte) error { type CostCombination struct { Model string `json:"model"` ServiceTier string `json:"service_tier,omitempty"` + ContextWindow int `json:"context_window"` WeekStartUnix int64 `json:"week_start_unix,omitempty"` Total UsageStats `json:"total"` ByUser map[string]UsageStats `json:"by_user"` @@ -74,15 +75,17 @@ type UsageStatsJSON struct { type CostCombinationJSON struct { Model string `json:"model"` ServiceTier string `json:"service_tier,omitempty"` + ContextWindow int `json:"context_window"` WeekStartUnix int64 `json:"week_start_unix,omitempty"` Total UsageStatsJSON `json:"total"` ByUser map[string]UsageStatsJSON `json:"by_user"` } type CostsSummaryJSON struct { - TotalUSD float64 `json:"total_usd"` - ByUser map[string]float64 `json:"by_user"` - ByWeek map[string]float64 `json:"by_week,omitempty"` + TotalUSD float64 `json:"total_usd"` + ByUser map[string]float64 `json:"by_user"` + ByWeek map[string]float64 `json:"by_week,omitempty"` + ByUserAndWeek map[string]map[string]float64 `json:"by_user_and_week,omitempty"` } type AggregatedUsageJSON struct { @@ -103,8 +106,9 @@ type ModelPricing struct { } type modelFamily struct { - pattern *regexp.Regexp - pricing ModelPricing + pattern *regexp.Regexp + pricing ModelPricing + premiumPricing *ModelPricing } const ( @@ -115,6 +119,12 @@ const ( serviceTierScale = "scale" ) +const ( + contextWindowStandard = 272000 + contextWindowPremium = 1050000 + premiumContextThreshold = 272000 +) + var ( gpt52Pricing = ModelPricing{ InputPrice: 1.75, @@ -158,6 +168,30 @@ var ( CachedInputPrice: 0.025, } + gpt54StandardPricing = ModelPricing{ + InputPrice: 2.5, + OutputPrice: 15.0, + CachedInputPrice: 0.25, + } + + gpt54PremiumPricing = ModelPricing{ + InputPrice: 5.0, + OutputPrice: 22.5, + CachedInputPrice: 0.5, + } + + gpt54ProPricing = ModelPricing{ + InputPrice: 30.0, + OutputPrice: 180.0, + CachedInputPrice: 30.0, + } + + gpt54ProPremiumPricing = ModelPricing{ + InputPrice: 60.0, + OutputPrice: 270.0, + CachedInputPrice: 60.0, + } + gpt52ProPricing = ModelPricing{ InputPrice: 21.0, OutputPrice: 168.0, @@ -170,6 +204,30 @@ var ( CachedInputPrice: 15.0, } + gpt54FlexPricing = ModelPricing{ + InputPrice: 1.25, + OutputPrice: 7.5, + CachedInputPrice: 0.125, + } + + gpt54PremiumFlexPricing = ModelPricing{ + InputPrice: 2.5, + OutputPrice: 11.25, + CachedInputPrice: 0.25, + } + + gpt54ProFlexPricing = ModelPricing{ + InputPrice: 15.0, + OutputPrice: 90.0, + CachedInputPrice: 15.0, + } + + gpt54ProPremiumFlexPricing = ModelPricing{ + InputPrice: 30.0, + OutputPrice: 135.0, + CachedInputPrice: 30.0, + } + gpt52FlexPricing = ModelPricing{ InputPrice: 0.875, OutputPrice: 7.0, @@ -194,6 +252,18 @@ var ( CachedInputPrice: 0.0025, } + gpt54PriorityPricing = ModelPricing{ + InputPrice: 5.0, + OutputPrice: 30.0, + CachedInputPrice: 0.5, + } + + gpt54PremiumPriorityPricing = ModelPricing{ + InputPrice: 10.0, + OutputPrice: 45.0, + CachedInputPrice: 1.0, + } + gpt52PriorityPricing = ModelPricing{ InputPrice: 3.5, OutputPrice: 28.0, @@ -381,6 +451,16 @@ var ( } standardModelFamilies = []modelFamily{ + { + pattern: regexp.MustCompile(`^gpt-5\.4-pro(?:$|-)`), + pricing: gpt54ProPricing, + premiumPricing: &gpt54ProPremiumPricing, + }, + { + pattern: regexp.MustCompile(`^gpt-5\.4(?:$|-)`), + pricing: gpt54StandardPricing, + premiumPricing: &gpt54PremiumPricing, + }, { pattern: regexp.MustCompile(`^gpt-5\.3-codex(?:$|-)`), pricing: gpt52CodexPricing, @@ -524,6 +604,16 @@ var ( } flexModelFamilies = []modelFamily{ + { + pattern: regexp.MustCompile(`^gpt-5\.4-pro(?:$|-)`), + pricing: gpt54ProFlexPricing, + premiumPricing: &gpt54ProPremiumFlexPricing, + }, + { + pattern: regexp.MustCompile(`^gpt-5\.4(?:$|-)`), + pricing: gpt54FlexPricing, + premiumPricing: &gpt54PremiumFlexPricing, + }, { pattern: regexp.MustCompile(`^gpt-5-mini(?:$|-)`), pricing: gpt5MiniFlexPricing, @@ -555,6 +645,11 @@ var ( } priorityModelFamilies = []modelFamily{ + { + pattern: regexp.MustCompile(`^gpt-5\.4(?:$|-)`), + pricing: gpt54PriorityPricing, + premiumPricing: &gpt54PremiumPriorityPricing, + }, { pattern: regexp.MustCompile(`^gpt-5\.3-codex(?:$|-)`), pricing: gpt52CodexPriorityPricing, @@ -637,15 +732,28 @@ func modelFamiliesForTier(serviceTier string) []modelFamily { } } -func findPricingInFamilies(model string, modelFamilies []modelFamily) (ModelPricing, bool) { +func findPricingInFamilies(model string, contextWindow int, modelFamilies []modelFamily) (ModelPricing, bool) { + isPremium := contextWindow >= contextWindowPremium for _, family := range modelFamilies { if family.pattern.MatchString(model) { + if isPremium && family.premiumPricing != nil { + return *family.premiumPricing, true + } return family.pricing, true } } return ModelPricing{}, false } +func hasPremiumPricingInFamilies(model string, modelFamilies []modelFamily) bool { + for _, family := range modelFamilies { + if family.pattern.MatchString(model) { + return family.premiumPricing != nil + } + } + return false +} + func normalizeServiceTier(serviceTier string) string { switch strings.ToLower(strings.TrimSpace(serviceTier)) { case "", serviceTierAuto, serviceTierDefault: @@ -662,27 +770,27 @@ func normalizeServiceTier(serviceTier string) string { } } -func getPricing(model string, serviceTier string) ModelPricing { +func getPricing(model string, serviceTier string, contextWindow int) ModelPricing { normalizedServiceTier := normalizeServiceTier(serviceTier) - modelFamilies := modelFamiliesForTier(normalizedServiceTier) + families := modelFamiliesForTier(normalizedServiceTier) - if pricing, found := findPricingInFamilies(model, modelFamilies); found { + if pricing, found := findPricingInFamilies(model, contextWindow, families); found { return pricing } normalizedModel := normalizeGPT5Model(model) if normalizedModel != model { - if pricing, found := findPricingInFamilies(normalizedModel, modelFamilies); found { + if pricing, found := findPricingInFamilies(normalizedModel, contextWindow, families); found { return pricing } } if normalizedServiceTier != serviceTierDefault { - if pricing, found := findPricingInFamilies(model, standardModelFamilies); found { + if pricing, found := findPricingInFamilies(model, contextWindow, standardModelFamilies); found { return pricing } if normalizedModel != model { - if pricing, found := findPricingInFamilies(normalizedModel, standardModelFamilies); found { + if pricing, found := findPricingInFamilies(normalizedModel, contextWindow, standardModelFamilies); found { return pricing } } @@ -691,6 +799,30 @@ func getPricing(model string, serviceTier string) ModelPricing { return gpt4oPricing } +func detectContextWindow(model string, serviceTier string, inputTokens int64) int { + if inputTokens <= premiumContextThreshold { + return contextWindowStandard + } + normalizedServiceTier := normalizeServiceTier(serviceTier) + families := modelFamiliesForTier(normalizedServiceTier) + if hasPremiumPricingInFamilies(model, families) { + return contextWindowPremium + } + normalizedModel := normalizeGPT5Model(model) + if normalizedModel != model && hasPremiumPricingInFamilies(normalizedModel, families) { + return contextWindowPremium + } + if normalizedServiceTier != serviceTierDefault { + if hasPremiumPricingInFamilies(model, standardModelFamilies) { + return contextWindowPremium + } + if normalizedModel != model && hasPremiumPricingInFamilies(normalizedModel, standardModelFamilies) { + return contextWindowPremium + } + } + return contextWindowStandard +} + func normalizeGPT5Model(model string) string { if !strings.HasPrefix(model, "gpt-5.") { return model @@ -706,18 +838,18 @@ func normalizeGPT5Model(model string) string { case strings.Contains(model, "-chat-latest"): return "gpt-5.2-chat-latest" case strings.Contains(model, "-pro"): - return "gpt-5.2-pro" + return "gpt-5.4-pro" case strings.Contains(model, "-mini"): return "gpt-5-mini" case strings.Contains(model, "-nano"): return "gpt-5-nano" default: - return "gpt-5.2" + return "gpt-5.4" } } -func calculateCost(stats UsageStats, model string, serviceTier string) float64 { - pricing := getPricing(model, serviceTier) +func calculateCost(stats UsageStats, model string, serviceTier string, contextWindow int) float64 { + pricing := getPricing(model, serviceTier, contextWindow) regularInputTokens := stats.InputTokens - stats.CachedTokens if regularInputTokens < 0 { @@ -738,13 +870,16 @@ func roundCost(cost float64) float64 { func normalizeCombinations(combinations []CostCombination) { for index := range combinations { combinations[index].ServiceTier = normalizeServiceTier(combinations[index].ServiceTier) + if combinations[index].ContextWindow <= 0 { + combinations[index].ContextWindow = contextWindowStandard + } if combinations[index].ByUser == nil { combinations[index].ByUser = make(map[string]UsageStats) } } } -func addUsageToCombinations(combinations *[]CostCombination, model string, serviceTier string, weekStartUnix int64, user string, inputTokens, outputTokens, cachedTokens int64) { +func addUsageToCombinations(combinations *[]CostCombination, model string, serviceTier string, contextWindow int, weekStartUnix int64, user string, inputTokens, outputTokens, cachedTokens int64) { var matchedCombination *CostCombination for index := range *combinations { combination := &(*combinations)[index] @@ -752,7 +887,7 @@ func addUsageToCombinations(combinations *[]CostCombination, model string, servi if combination.ServiceTier != combinationServiceTier { combination.ServiceTier = combinationServiceTier } - if combination.Model == model && combinationServiceTier == serviceTier && combination.WeekStartUnix == weekStartUnix { + if combination.Model == model && combinationServiceTier == serviceTier && combination.ContextWindow == contextWindow && combination.WeekStartUnix == weekStartUnix { matchedCombination = combination break } @@ -762,6 +897,7 @@ func addUsageToCombinations(combinations *[]CostCombination, model string, servi newCombination := CostCombination{ Model: model, ServiceTier: serviceTier, + ContextWindow: contextWindow, WeekStartUnix: weekStartUnix, Total: UsageStats{}, ByUser: make(map[string]UsageStats), @@ -790,12 +926,13 @@ func buildCombinationJSON(combinations []CostCombination, aggregateUserCosts map var totalCost float64 for index, combination := range combinations { - combinationTotalCost := calculateCost(combination.Total, combination.Model, combination.ServiceTier) + combinationTotalCost := calculateCost(combination.Total, combination.Model, combination.ServiceTier, combination.ContextWindow) totalCost += combinationTotalCost combinationJSON := CostCombinationJSON{ Model: combination.Model, ServiceTier: combination.ServiceTier, + ContextWindow: combination.ContextWindow, WeekStartUnix: combination.WeekStartUnix, Total: UsageStatsJSON{ RequestCount: combination.Total.RequestCount, @@ -808,7 +945,7 @@ func buildCombinationJSON(combinations []CostCombination, aggregateUserCosts map } for user, userStats := range combination.ByUser { - userCost := calculateCost(userStats, combination.Model, combination.ServiceTier) + userCost := calculateCost(userStats, combination.Model, combination.ServiceTier, combination.ContextWindow) if aggregateUserCosts != nil { aggregateUserCosts[user] += userCost } @@ -856,7 +993,7 @@ func buildByWeekCost(combinations []CostCombination) map[string]float64 { } weekStartAt := time.Unix(combination.WeekStartUnix, 0).UTC() weekKey := formatWeekStartKey(weekStartAt) - byWeek[weekKey] += calculateCost(combination.Total, combination.Model, combination.ServiceTier) + byWeek[weekKey] += calculateCost(combination.Total, combination.Model, combination.ServiceTier, combination.ContextWindow) } for weekKey, weekCost := range byWeek { byWeek[weekKey] = roundCost(weekCost) @@ -864,6 +1001,31 @@ func buildByWeekCost(combinations []CostCombination) map[string]float64 { return byWeek } +func buildByUserAndWeekCost(combinations []CostCombination) map[string]map[string]float64 { + byUserAndWeek := make(map[string]map[string]float64) + for _, combination := range combinations { + if combination.WeekStartUnix <= 0 { + continue + } + weekStartAt := time.Unix(combination.WeekStartUnix, 0).UTC() + weekKey := formatWeekStartKey(weekStartAt) + for user, userStats := range combination.ByUser { + userWeeks, exists := byUserAndWeek[user] + if !exists { + userWeeks = make(map[string]float64) + byUserAndWeek[user] = userWeeks + } + userWeeks[weekKey] += calculateCost(userStats, combination.Model, combination.ServiceTier, combination.ContextWindow) + } + } + for _, weekCosts := range byUserAndWeek { + for weekKey, cost := range weekCosts { + weekCosts[weekKey] = roundCost(cost) + } + } + return byUserAndWeek +} + func deriveWeekStartUnix(cycleHint *WeeklyCycleHint) int64 { if cycleHint == nil || cycleHint.WindowMinutes <= 0 || cycleHint.ResetAt.IsZero() { return 0 @@ -894,6 +1056,11 @@ func (u *AggregatedUsage) ToJSON() *AggregatedUsageJSON { result.Costs.ByWeek = nil } + result.Costs.ByUserAndWeek = buildByUserAndWeekCost(u.Combinations) + if len(result.Costs.ByUserAndWeek) == 0 { + result.Costs.ByUserAndWeek = nil + } + for user, cost := range result.Costs.ByUser { result.Costs.ByUser[user] = roundCost(cost) } @@ -956,14 +1123,17 @@ func (u *AggregatedUsage) Save() error { return err } -func (u *AggregatedUsage) AddUsage(model string, inputTokens, outputTokens, cachedTokens int64, serviceTier string, user string) error { - return u.AddUsageWithCycleHint(model, inputTokens, outputTokens, cachedTokens, serviceTier, user, time.Now(), nil) +func (u *AggregatedUsage) AddUsage(model string, contextWindow int, inputTokens, outputTokens, cachedTokens int64, serviceTier string, user string) error { + return u.AddUsageWithCycleHint(model, contextWindow, inputTokens, outputTokens, cachedTokens, serviceTier, user, time.Now(), nil) } -func (u *AggregatedUsage) AddUsageWithCycleHint(model string, inputTokens, outputTokens, cachedTokens int64, serviceTier string, user string, observedAt time.Time, cycleHint *WeeklyCycleHint) error { +func (u *AggregatedUsage) AddUsageWithCycleHint(model string, contextWindow int, inputTokens, outputTokens, cachedTokens int64, serviceTier string, user string, observedAt time.Time, cycleHint *WeeklyCycleHint) error { if model == "" { return E.New("model cannot be empty") } + if contextWindow <= 0 { + return E.New("contextWindow must be positive") + } normalizedServiceTier := normalizeServiceTier(serviceTier) if observedAt.IsZero() { @@ -976,7 +1146,7 @@ func (u *AggregatedUsage) AddUsageWithCycleHint(model string, inputTokens, outpu u.LastUpdated = observedAt weekStartUnix := deriveWeekStartUnix(cycleHint) - addUsageToCombinations(&u.Combinations, model, normalizedServiceTier, weekStartUnix, user, inputTokens, outputTokens, cachedTokens) + addUsageToCombinations(&u.Combinations, model, normalizedServiceTier, contextWindow, weekStartUnix, user, inputTokens, outputTokens, cachedTokens) go u.scheduleSave() diff --git a/service/ocm/service_websocket.go b/service/ocm/service_websocket.go new file mode 100644 index 00000000..d19f2df8 --- /dev/null +++ b/service/ocm/service_websocket.go @@ -0,0 +1,285 @@ +package ocm + +import ( + "context" + stdTLS "crypto/tls" + "encoding/json" + "io" + "net" + "net/http" + "strings" + "sync" + "time" + + "github.com/sagernet/sing-box/adapter" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + "github.com/sagernet/sing/common/ntp" + "github.com/sagernet/ws" + "github.com/sagernet/ws/wsutil" + + "github.com/openai/openai-go/v3/responses" +) + +type webSocketSession struct { + clientConn net.Conn + upstreamConn net.Conn + closeOnce sync.Once +} + +func (s *webSocketSession) Close() { + s.closeOnce.Do(func() { + s.clientConn.Close() + s.upstreamConn.Close() + }) +} + +func buildUpstreamWebSocketURL(baseURL string, proxyPath string) string { + upstreamURL := baseURL + if strings.HasPrefix(upstreamURL, "https://") { + upstreamURL = "wss://" + upstreamURL[len("https://"):] + } else if strings.HasPrefix(upstreamURL, "http://") { + upstreamURL = "ws://" + upstreamURL[len("http://"):] + } + return upstreamURL + proxyPath +} + +func isForwardableResponseHeader(key string) bool { + lowerKey := strings.ToLower(key) + switch { + case strings.HasPrefix(lowerKey, "x-codex-"): + return true + case strings.HasPrefix(lowerKey, "x-reasoning"): + return true + case lowerKey == "openai-model": + return true + case strings.Contains(lowerKey, "-secondary-"): + return true + default: + return false + } +} + +func isForwardableWebSocketRequestHeader(key string) bool { + if isHopByHopHeader(key) { + return false + } + + lowerKey := strings.ToLower(key) + switch { + case lowerKey == "authorization": + return false + case strings.HasPrefix(lowerKey, "sec-websocket-"): + return false + default: + return true + } +} + +func (s *Service) handleWebSocket(w http.ResponseWriter, r *http.Request, proxyPath string, username string) { + accessToken, err := s.getAccessToken() + if err != nil { + s.logger.Error("get access token for websocket: ", err) + writeJSONError(w, r, http.StatusUnauthorized, "authentication_error", "authentication failed") + return + } + + upstreamURL := buildUpstreamWebSocketURL(s.getBaseURL(), proxyPath) + if r.URL.RawQuery != "" { + upstreamURL += "?" + r.URL.RawQuery + } + + upstreamHeaders := make(http.Header) + for key, values := range r.Header { + if isForwardableWebSocketRequestHeader(key) { + upstreamHeaders[key] = values + } + } + for key, values := range s.httpHeaders { + upstreamHeaders.Del(key) + upstreamHeaders[key] = values + } + upstreamHeaders.Set("Authorization", "Bearer "+accessToken) + if accountID := s.getAccountID(); accountID != "" { + upstreamHeaders.Set("ChatGPT-Account-Id", accountID) + } + + upstreamResponseHeaders := make(http.Header) + upstreamDialer := ws.Dialer{ + NetDial: func(ctx context.Context, network, addr string) (net.Conn, error) { + return s.dialer.DialContext(ctx, network, M.ParseSocksaddr(addr)) + }, + TLSConfig: &stdTLS.Config{ + RootCAs: adapter.RootPoolFromContext(s.ctx), + Time: ntp.TimeFuncFromContext(s.ctx), + }, + Header: ws.HandshakeHeaderHTTP(upstreamHeaders), + OnHeader: func(key, value []byte) error { + upstreamResponseHeaders.Add(string(key), string(value)) + return nil + }, + } + + upstreamConn, upstreamBufferedReader, _, err := upstreamDialer.Dial(r.Context(), upstreamURL) + if err != nil { + s.logger.Error("dial upstream websocket: ", err) + writeJSONError(w, r, http.StatusBadGateway, "api_error", "upstream websocket connection failed") + return + } + + weeklyCycleHint := extractWeeklyCycleHint(upstreamResponseHeaders) + + clientResponseHeaders := make(http.Header) + for key, values := range upstreamResponseHeaders { + if isForwardableResponseHeader(key) { + clientResponseHeaders[key] = values + } + } + + clientUpgrader := ws.HTTPUpgrader{ + Header: clientResponseHeaders, + } + if s.isShuttingDown() { + upstreamConn.Close() + writeJSONError(w, r, http.StatusServiceUnavailable, "api_error", "service is shutting down") + return + } + clientConn, _, _, err := clientUpgrader.Upgrade(r, w) + if err != nil { + s.logger.Error("upgrade client websocket: ", err) + upstreamConn.Close() + return + } + session := &webSocketSession{ + clientConn: clientConn, + upstreamConn: upstreamConn, + } + if !s.registerWebSocketSession(session) { + session.Close() + return + } + defer s.unregisterWebSocketSession(session) + + var upstreamReadWriter io.ReadWriter + if upstreamBufferedReader != nil { + upstreamReadWriter = struct { + io.Reader + io.Writer + }{upstreamBufferedReader, upstreamConn} + } else { + upstreamReadWriter = upstreamConn + } + + modelChannel := make(chan string, 1) + var waitGroup sync.WaitGroup + + waitGroup.Add(2) + go func() { + defer waitGroup.Done() + defer session.Close() + s.proxyWebSocketClientToUpstream(clientConn, upstreamConn, modelChannel) + }() + go func() { + defer waitGroup.Done() + defer session.Close() + s.proxyWebSocketUpstreamToClient(upstreamReadWriter, clientConn, modelChannel, username, weeklyCycleHint) + }() + waitGroup.Wait() +} + +func (s *Service) proxyWebSocketClientToUpstream(clientConn net.Conn, upstreamConn net.Conn, modelChannel chan<- string) { + for { + data, opCode, err := wsutil.ReadClientData(clientConn) + if err != nil { + if !E.IsClosedOrCanceled(err) { + s.logger.Debug("read client websocket: ", err) + } + return + } + + if opCode == ws.OpText && s.usageTracker != nil { + var request struct { + Type string `json:"type"` + Model string `json:"model"` + } + if json.Unmarshal(data, &request) == nil && request.Type == "response.create" && request.Model != "" { + select { + case modelChannel <- request.Model: + default: + } + } + } + + err = wsutil.WriteClientMessage(upstreamConn, opCode, data) + if err != nil { + if !E.IsClosedOrCanceled(err) { + s.logger.Debug("write upstream websocket: ", err) + } + return + } + } +} + +func (s *Service) proxyWebSocketUpstreamToClient(upstreamReadWriter io.ReadWriter, clientConn net.Conn, modelChannel <-chan string, username string, weeklyCycleHint *WeeklyCycleHint) { + var requestModel string + for { + data, opCode, err := wsutil.ReadServerData(upstreamReadWriter) + if err != nil { + if !E.IsClosedOrCanceled(err) { + s.logger.Debug("read upstream websocket: ", err) + } + return + } + + if opCode == ws.OpText && s.usageTracker != nil { + select { + case model := <-modelChannel: + requestModel = model + default: + } + + var event struct { + Type string `json:"type"` + } + if json.Unmarshal(data, &event) == nil && event.Type == "response.completed" { + var streamEvent responses.ResponseStreamEventUnion + if json.Unmarshal(data, &streamEvent) == nil { + completedEvent := streamEvent.AsResponseCompleted() + responseModel := string(completedEvent.Response.Model) + serviceTier := string(completedEvent.Response.ServiceTier) + inputTokens := completedEvent.Response.Usage.InputTokens + outputTokens := completedEvent.Response.Usage.OutputTokens + cachedTokens := completedEvent.Response.Usage.InputTokensDetails.CachedTokens + + if inputTokens > 0 || outputTokens > 0 { + if responseModel == "" { + responseModel = requestModel + } + if responseModel != "" { + contextWindow := detectContextWindow(responseModel, serviceTier, inputTokens) + s.usageTracker.AddUsageWithCycleHint( + responseModel, + contextWindow, + inputTokens, + outputTokens, + cachedTokens, + serviceTier, + username, + time.Now(), + weeklyCycleHint, + ) + } + } + } + } + } + + err = wsutil.WriteServerMessage(clientConn, opCode, data) + if err != nil { + if !E.IsClosedOrCanceled(err) { + s.logger.Debug("write client websocket: ", err) + } + return + } + } +} diff --git a/transport/trojan/protocol.go b/transport/trojan/protocol.go index 6369d86d..0456b6b9 100644 --- a/transport/trojan/protocol.go +++ b/transport/trojan/protocol.go @@ -136,7 +136,7 @@ func (c *ClientPacketConn) ReadFrom(p []byte) (n int, addr net.Addr, err error) return } n = buffer.Len() - if destination.IsFqdn() { + if destination.IsDomain() { addr = destination } else { addr = destination.UDPAddr() diff --git a/transport/wireguard/endpoint.go b/transport/wireguard/endpoint.go index 6c4ed57c..ae12ec0b 100644 --- a/transport/wireguard/endpoint.go +++ b/transport/wireguard/endpoint.go @@ -64,7 +64,7 @@ func NewEndpoint(options EndpointOptions) (*Endpoint, error) { } if rawPeer.Endpoint.Addr.IsValid() { peer.endpoint = rawPeer.Endpoint.AddrPort() - } else if rawPeer.Endpoint.IsFqdn() { + } else if rawPeer.Endpoint.IsDomain() { peer.destination = rawPeer.Endpoint } publicKeyBytes, err := base64.StdEncoding.DecodeString(rawPeer.PublicKey) @@ -136,13 +136,13 @@ func NewEndpoint(options EndpointOptions) (*Endpoint, error) { func (e *Endpoint) Start(resolve bool) error { if common.Any(e.peers, func(peer peerConfig) bool { - return !peer.endpoint.IsValid() && peer.destination.IsFqdn() + return !peer.endpoint.IsValid() && peer.destination.IsDomain() }) { if !resolve { return nil } for peerIndex, peer := range e.peers { - if peer.endpoint.IsValid() || !peer.destination.IsFqdn() { + if peer.endpoint.IsValid() || !peer.destination.IsDomain() { continue } destinationAddress, err := e.options.ResolvePeer(peer.destination.Fqdn) @@ -292,12 +292,13 @@ func (e *Endpoint) ListenPacket(ctx context.Context, destination M.Socksaddr) (n } func (e *Endpoint) Close() error { - if e.device != nil { - e.device.Close() - } if e.pauseCallback != nil { e.pause.UnregisterCallback(e.pauseCallback) } + if e.device != nil { + e.device.Down() + e.device.Close() + } return nil }