Compare commits

..

82 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
dfb4ef2deb Initial plan 2025-12-09 15:50:26 +00:00
世界
9ec1549a23 Add naiveproxy outbound 2025-12-09 22:20:27 +08:00
世界
76d447d7d9 Add Windows WI-FI state support 2025-12-07 15:47:15 +08:00
世界
ab3caabde1 Add Linux WI-FI state support
Support monitoring WIFI state on Linux through:
- NetworkManager (D-Bus)
- IWD (D-Bus)
- wpa_supplicant (control socket)
- ConnMan (D-Bus)
2025-12-07 13:54:38 +08:00
世界
1338c1e1a9 documentation: Bump version 2025-12-07 10:09:13 +08:00
世界
822e140eed Add more tcp keep alive options
Also update default TCP keep-alive initial period from 10 minutes to 5 minutes.
2025-12-07 10:09:07 +08:00
世界
c726237522 Update quic-go to v0.57.1 2025-12-06 11:36:56 +08:00
世界
466925f636 Fix read credentials for ccm service 2025-12-06 11:36:56 +08:00
世界
517e1503e9 Add claude code multiplexer service 2025-12-06 11:36:56 +08:00
世界
b5f62c1b88 Fix compatibility with MPTCP 2025-12-06 11:36:55 +08:00
世界
6bdfa2d99e Use a more conservative strategy for resolving with systemd-resolved for local DNS server 2025-12-06 11:36:55 +08:00
世界
1662802db8 Fix missing mTLS support in client options 2025-12-06 11:36:55 +08:00
世界
c09a10074b Add curve preferences, pinned public key SHA256 and mTLS for TLS options 2025-12-06 11:36:55 +08:00
世界
269e4a40f7 Fix WireGuard input packet 2025-12-06 11:36:55 +08:00
世界
6a1640fe71 Update tfo-go to latest 2025-12-06 11:36:54 +08:00
世界
8cf6842a86 Remove compatibility codes 2025-12-06 11:36:54 +08:00
世界
2e6af5185c Do not use linkname by default to simplify debugging 2025-12-06 11:36:53 +08:00
世界
9c36b00526 documentation: Update chinese translations 2025-12-06 11:36:53 +08:00
世界
9d0510f384 Update quic-go to v0.55.0 2025-12-06 11:36:53 +08:00
世界
6a0ceb7839 Update WireGuard and Tailscale 2025-12-06 11:36:53 +08:00
世界
13ed7fce55 Fix preConnectionCopy 2025-12-06 11:36:53 +08:00
世界
788fd491d4 Fix ping domain 2025-12-06 11:36:52 +08:00
世界
c651b9b14c release: Fix linux build 2025-12-06 11:36:52 +08:00
世界
07a7530df0 Improve ktls rx error handling 2025-12-06 11:36:51 +08:00
世界
362edd2731 Improve compatibility for kTLS 2025-12-06 11:36:51 +08:00
世界
42e842a7da ktls: Add warning for inappropriate scenarios 2025-12-06 11:36:51 +08:00
世界
63121f18c0 Add support for kTLS
Reference: https://gitlab.com/go-extension/tls
2025-12-06 11:36:51 +08:00
世界
b13643bd42 Add proxy support for ICMP echo request 2025-12-06 11:36:50 +08:00
世界
9078f20860 Fix resolve using resolved 2025-12-06 11:36:50 +08:00
世界
a31ecd79c3 documentation: Update behavior of local DNS server on darwin 2025-12-06 11:36:50 +08:00
世界
4f4d18bfaf Remove use of ldflags -checklinkname=0 on darwin 2025-12-06 11:36:50 +08:00
世界
af2ea8f8b9 Fix legacy DNS config 2025-12-06 11:36:49 +08:00
世界
5452980523 Fix rule-set format 2025-12-06 11:36:49 +08:00
世界
1f421c04ac documentation: Remove outdated icons 2025-12-06 11:36:49 +08:00
世界
159610556d documentation: Improve local DNS server 2025-12-06 11:36:49 +08:00
世界
16de8b394f Stop using DHCP on iOS and tvOS
We do not have the `com.apple.developer.networking.multicast` entitlement and are unable to obtain it for non-technical reasons.
2025-12-06 11:36:49 +08:00
世界
52a0134af0 Improve local DNS server on darwin
We mistakenly believed that `libresolv`'s `search` function worked correctly in NetworkExtension, but it seems only `getaddrinfo` does.

This commit changes the behavior of the `local` DNS server in NetworkExtension to prefer DHCP, falling back to `getaddrinfo` if DHCP servers are unavailable.

It's worth noting that `prefer_go` does not disable DHCP since it respects Dial Fields, but `getaddrinfo` does the opposite. The new behavior only applies to NetworkExtension, not to all scenarios (primarily command-line binaries) as it did previously.

In addition, this commit also improves the DHCP DNS server to use the same robust query logic as `local`.
2025-12-06 11:36:49 +08:00
世界
d1bee96553 Use resolved in local DNS server if available 2025-12-06 11:36:48 +08:00
xchacha20-poly1305
0572e6c436 Fix rule set version 2025-12-06 11:36:48 +08:00
世界
7c1ad09c4c documentation: Add preferred_by route rule item 2025-12-06 11:36:48 +08:00
世界
89b8212ebb Add preferred_by route rule item 2025-12-06 11:36:48 +08:00
世界
fb765bc0da documentation: Add interface address rule items 2025-12-06 11:36:48 +08:00
世界
83cb784de4 Add interface address rule items 2025-12-06 11:36:47 +08:00
世界
73df4a7665 Fix ECH retry support 2025-12-06 11:36:47 +08:00
neletor
044057168c Add support for ech retry configs 2025-12-06 11:36:47 +08:00
Zephyruso
4407105f11 Add /dns/flush-clash meta api 2025-12-06 11:36:47 +08:00
世界
fdeea12514 Bump version 2025-12-06 11:36:30 +08:00
世界
2747a00ba2 Fix tailscale destination 2025-12-01 15:02:04 +08:00
世界
48e76038d0 Update Go to 1.25.4 2025-11-16 09:53:10 +08:00
世界
6421252d44 release: Fix windows7 build 2025-11-16 09:09:34 +08:00
世界
216c4c8bd4 Fix adapter handler 2025-11-16 08:34:46 +08:00
世界
5841d410a1 ssm-api: Fix save cache 2025-11-04 11:00:43 +08:00
Kumiko as a Service
63c8207d7a Use --no-cache --upgrade option in apk add
No need for separate upgrade / cache cleanup steps.

Signed-off-by: Kumiko as a Service <Dreista@users.noreply.github.com>
2025-11-04 11:00:41 +08:00
世界
54ed58499d Bump version 2025-10-27 18:04:24 +08:00
世界
b1bdc18c85 Fix socks response 2025-10-27 18:03:05 +08:00
世界
a38030cc0b Fix memory leak in hysteria2 2025-10-24 10:52:08 +08:00
世界
4626aa2cb0 Bump version 2025-10-21 21:39:28 +08:00
世界
5a40b673a4 Update dependencies 2025-10-21 21:39:28 +08:00
世界
541f63fee4 redirect: Fix compatibility with /product/bin/su 2025-10-21 21:27:15 +08:00
世界
5de6f4a14f Fix tailscale not enforcing NoLogsNoSupport 2025-10-16 22:30:08 +08:00
世界
5658830077 Fix trailing dot handling in local DNS transport 2025-10-16 21:43:12 +08:00
世界
0e50edc009 documentation: Add appreciate for Warp 2025-10-16 21:43:12 +08:00
世界
444f454810 Bump version 2025-10-14 23:43:36 +08:00
世界
d0e1fd6c7e Update Go to 1.25.3 2025-10-14 23:43:36 +08:00
世界
17b4d1e010 Update uTLS to v1.8.1 2025-10-14 23:40:19 +08:00
世界
06791470c9 Fix DNS reject panic 2025-10-14 23:40:19 +08:00
世界
ef14c8ca0e Disable TCP slow open for anytls
Fixes #3459
2025-10-14 23:40:19 +08:00
世界
36dc883c7c Fix DNS negative caching to comply with RFC 2308 2025-10-09 23:45:23 +08:00
Mahdi
6557bd7029 Fix dns cache in lookup 2025-10-09 23:45:23 +08:00
世界
41b30c91d9 Improve HTTPS DNS transport 2025-10-09 23:45:23 +08:00
世界
0f767d5ce1 Update .gitignore 2025-10-07 13:37:11 +08:00
世界
328a6de797 Bump version 2025-10-05 17:58:21 +08:00
Mahdi
886be6414d Fix dns truncate 2025-10-05 17:58:21 +08:00
世界
9362d3cab3 Attempt to fix leak in quic-go 2025-10-01 11:59:17 +08:00
世界
ced2e39dbf Update dependencies 2025-10-01 11:55:33 +08:00
anytls
2159d8877b Update anytls v0.0.11
Co-authored-by: anytls <anytls>
2025-10-01 10:23:15 +08:00
世界
cb7dba3eff release: Improve publish testflight 2025-10-01 10:22:44 +08:00
世界
d9d7f7880d Pin gofumpt and golangci-lint versions
As we don't want to remove naked returns
2025-09-23 16:33:42 +08:00
世界
a031aaf2c0 Do not reset network on sleep or wake 2025-09-23 16:17:44 +08:00
世界
4bca951773 Fix adguard matcher 2025-09-23 16:12:29 +08:00
世界
140735dbde Fix websocket log handling 2025-09-23 16:12:29 +08:00
世界
714a68bba1 Update .gitignore 2025-09-23 16:12:29 +08:00
107 changed files with 4901 additions and 1143 deletions

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env bash
VERSION="1.25.1"
VERSION="1.25.4"
mkdir -p $HOME/go
cd $HOME/go

11
.github/setup_musl_cross.sh vendored Executable file
View File

@@ -0,0 +1,11 @@
#!/bin/bash
set -xeuo pipefail
TARGET="$1"
# Download musl-cross toolchain from musl.cc
cd "$HOME"
wget -q "https://musl.cc/${TARGET}-cross.tgz"
mkdir -p musl-cross
tar -xf "${TARGET}-cross.tgz" -C musl-cross --strip-components=1
rm "${TARGET}-cross.tgz"

7
.github/update_cronet.sh vendored Executable file
View File

@@ -0,0 +1,7 @@
#!/usr/bin/env bash
PROJECTS=$(dirname "$0")/../..
git -C $PROJECTS/cronet-go fetch origin go
go get -x github.com/sagernet/cronet-go/all@$(git -C $PROJECTS/cronet-go rev-parse origin/go)
go mod tidy

View File

@@ -46,7 +46,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ^1.25.1
go-version: ^1.25.4
- name: Check input version
if: github.event_name == 'workflow_dispatch'
run: |-
@@ -93,10 +93,6 @@ jobs:
- { os: windows, arch: "386", legacy_win7: true, legacy_name: "windows-7" }
- { os: windows, arch: arm64 }
- { os: darwin, arch: amd64 }
- { os: darwin, arch: arm64 }
- { os: darwin, arch: amd64, legacy_go124: true, legacy_name: "macos-11" }
- { os: android, arch: arm64, ndk: "aarch64-linux-android21" }
- { os: android, arch: arm, ndk: "armv7a-linux-androideabi21" }
- { os: android, arch: amd64, ndk: "x86_64-linux-android21" }
@@ -107,15 +103,15 @@ jobs:
with:
fetch-depth: 0
- name: Setup Go
if: ${{ ! (matrix.legacy_go123 || matrix.legacy_go124) }}
if: ${{ ! (matrix.legacy_win7 || matrix.legacy_go124) }}
uses: actions/setup-go@v5
with:
go-version: ^1.25.1
go-version: ^1.25.4
- name: Setup Go 1.24
if: matrix.legacy_go124
uses: actions/setup-go@v5
with:
go-version: ~1.24.6
go-version: ~1.24.10
- name: Cache Go for Windows 7
if: matrix.legacy_win7
id: cache-go-for-windows7
@@ -123,7 +119,7 @@ jobs:
with:
path: |
~/go/go_win7
key: go_win7_1251
key: go_win7_1254
- name: Setup Go for Windows 7
if: matrix.legacy_win7 && steps.cache-go-for-windows7.outputs.cache-hit != 'true'
run: |-
@@ -146,7 +142,10 @@ jobs:
- name: Set build tags
run: |
set -xeuo pipefail
TAGS='with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,badlinkname'
TAGS='with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,badlinkname,tfogo_checklinkname0'
if [[ "${{ matrix.os }}" == "android" ]]; then
TAGS="${TAGS},with_naive_outbound"
fi
echo "BUILD_TAGS=${TAGS}" >> "${GITHUB_ENV}"
- name: Build
if: matrix.os != 'android'
@@ -285,6 +284,199 @@ jobs:
with:
name: binary-${{ matrix.os }}_${{ matrix.arch }}${{ matrix.goarm && format('v{0}', matrix.goarm) }}${{ matrix.go386 && format('_{0}', matrix.go386) }}${{ matrix.gomips && format('_{0}', matrix.gomips) }}${{ matrix.legacy_name && format('-legacy-{0}', matrix.legacy_name) }}
path: "dist"
build_darwin:
name: Build Darwin binaries
if: github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Binary'
runs-on: macos-latest
needs:
- calculate_version
strategy:
matrix:
include:
- { arch: amd64 }
- { arch: arm64 }
- { arch: amd64, legacy_go124: true, legacy_name: "macos-11" }
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
fetch-depth: 0
- name: Setup Go
if: ${{ ! matrix.legacy_go124 }}
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
with:
go-version: ~1.24.6
- name: Set tag
run: |-
git ls-remote --exit-code --tags origin v${{ needs.calculate_version.outputs.version }} || echo "PUBLISHED=false" >> "$GITHUB_ENV"
git tag v${{ needs.calculate_version.outputs.version }} -f
- name: Set build tags
run: |
set -xeuo pipefail
TAGS='with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,with_naive_outbound,badlinkname,tfogo_checklinkname0'
echo "BUILD_TAGS=${TAGS}" >> "${GITHUB_ENV}"
- name: Build
run: |
set -xeuo pipefail
mkdir -p dist
go build -v -trimpath -o dist/sing-box -tags "${BUILD_TAGS}" \
-ldflags '-s -buildid= -X github.com/sagernet/sing-box/constant.Version=${{ needs.calculate_version.outputs.version }} -checklinkname=0' \
./cmd/sing-box
env:
CGO_ENABLED: "1"
GOOS: darwin
GOARCH: ${{ matrix.arch }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Set name
run: |-
DIR_NAME="sing-box-${{ needs.calculate_version.outputs.version }}-darwin-${{ matrix.arch }}"
if [[ -n "${{ matrix.legacy_name }}" ]]; then
DIR_NAME="${DIR_NAME}-legacy-${{ matrix.legacy_name }}"
fi
echo "DIR_NAME=${DIR_NAME}" >> "${GITHUB_ENV}"
- name: Archive
run: |
set -xeuo pipefail
cd dist
mkdir -p "${DIR_NAME}"
cp ../LICENSE "${DIR_NAME}"
cp sing-box "${DIR_NAME}"
tar -czvf "${DIR_NAME}.tar.gz" "${DIR_NAME}"
rm -r "${DIR_NAME}"
- name: Cleanup
run: rm dist/sing-box
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: binary-darwin_${{ matrix.arch }}${{ matrix.legacy_name && format('-legacy-{0}', matrix.legacy_name) }}
path: "dist"
build_naive_linux:
name: Build Linux with naive outbound
if: github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Binary'
runs-on: ubuntu-latest
needs:
- calculate_version
strategy:
matrix:
include:
# Linux glibc (dynamic linking with Debian Bullseye sysroot)
- { arch: amd64, sysroot_arch: amd64, sysroot_sha: "36a164623d03f525e3dfb783a5e9b8a00e98e1ddd2b5cff4e449bd016dd27e50", cc_target: "x86_64-linux-gnu", suffix: "-naive" }
- { arch: arm64, sysroot_arch: arm64, sysroot_sha: "2f915d821eec27515c0c6d21b69898e23762908d8d7ccc1aa2a8f5f25e8b7e18", cc_target: "aarch64-linux-gnu", suffix: "-naive" }
- { arch: "386", sysroot_arch: i386, sysroot_sha: "63f0e5128b84f7b0421956a4a40affa472be8da0e58caf27e9acbc84072daee7", cc_target: "i686-linux-gnu", suffix: "-naive" }
- { arch: arm, goarm: "7", sysroot_arch: armhf, sysroot_sha: "47b3a0b161ca011b2b33d4fc1ef6ef269b8208a0b7e4c900700c345acdfd1814", cc_target: "arm-linux-gnueabihf", suffix: "-naive" }
# Linux musl (static linking)
- { arch: amd64, musl: true, cc_target: "x86_64-linux-musl", suffix: "-naive-musl" }
- { arch: arm64, musl: true, cc_target: "aarch64-linux-musl", suffix: "-naive-musl" }
- { arch: "386", musl: true, cc_target: "i686-linux-musl", suffix: "-naive-musl" }
- { arch: arm, goarm: "7", musl: true, cc_target: "arm-linux-musleabihf", suffix: "-naive-musl" }
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ^1.25.4
- name: Set tag
run: |-
git ls-remote --exit-code --tags origin v${{ needs.calculate_version.outputs.version }} || echo "PUBLISHED=false" >> "$GITHUB_ENV"
git tag v${{ needs.calculate_version.outputs.version }} -f
- name: Download sysroot (glibc)
if: ${{ ! matrix.musl }}
run: |
set -xeuo pipefail
wget -q "https://commondatastorage.googleapis.com/chrome-linux-sysroot/${{ matrix.sysroot_sha }}" -O sysroot.tar.xz
mkdir -p /tmp/sysroot
tar -xf sysroot.tar.xz -C /tmp/sysroot
- name: Install cross compiler (glibc)
if: ${{ ! matrix.musl }}
run: |
set -xeuo pipefail
sudo apt-get update
sudo apt-get install -y clang lld
if [[ "${{ matrix.arch }}" == "arm64" ]]; then
sudo apt-get install -y libc6-dev-arm64-cross
elif [[ "${{ matrix.arch }}" == "386" ]]; then
sudo apt-get install -y libc6-dev-i386-cross
elif [[ "${{ matrix.arch }}" == "arm" ]]; then
sudo apt-get install -y libc6-dev-armhf-cross
fi
- name: Install musl cross compiler
if: matrix.musl
run: |
set -xeuo pipefail
.github/setup_musl_cross.sh "${{ matrix.cc_target }}"
echo "PATH=$HOME/musl-cross/bin:$PATH" >> $GITHUB_ENV
- name: Set build tags
run: |
set -xeuo pipefail
TAGS='with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,with_naive_outbound,badlinkname,tfogo_checklinkname0'
if [[ "${{ matrix.musl }}" == "true" ]]; then
TAGS="${TAGS},with_musl"
fi
echo "BUILD_TAGS=${TAGS}" >> "${GITHUB_ENV}"
- name: Build (glibc)
if: ${{ ! matrix.musl }}
run: |
set -xeuo pipefail
mkdir -p dist
go build -v -trimpath -o dist/sing-box -tags "${BUILD_TAGS}" \
-ldflags '-s -buildid= -X github.com/sagernet/sing-box/constant.Version=${{ needs.calculate_version.outputs.version }} -checklinkname=0 -linkmode=external -extldflags "-fuse-ld=lld --sysroot=/tmp/sysroot"' \
./cmd/sing-box
env:
CGO_ENABLED: "1"
GOOS: linux
GOARCH: ${{ matrix.arch }}
GOARM: ${{ matrix.goarm }}
CC: "clang --target=${{ matrix.cc_target }} --sysroot=/tmp/sysroot"
CXX: "clang++ --target=${{ matrix.cc_target }} --sysroot=/tmp/sysroot"
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build (musl)
if: matrix.musl
run: |
set -xeuo pipefail
mkdir -p dist
go build -v -trimpath -o dist/sing-box -tags "${BUILD_TAGS}" \
-ldflags '-s -buildid= -X github.com/sagernet/sing-box/constant.Version=${{ needs.calculate_version.outputs.version }} -checklinkname=0 -linkmode=external -extldflags "-static"' \
./cmd/sing-box
env:
CGO_ENABLED: "1"
GOOS: linux
GOARCH: ${{ matrix.arch }}
GOARM: ${{ matrix.goarm }}
CC: "${{ matrix.cc_target }}-gcc"
CXX: "${{ matrix.cc_target }}-g++"
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Set name
run: |-
DIR_NAME="sing-box-${{ needs.calculate_version.outputs.version }}-linux-${{ matrix.arch }}"
if [[ -n "${{ matrix.goarm }}" ]]; then
DIR_NAME="${DIR_NAME}v${{ matrix.goarm }}"
fi
DIR_NAME="${DIR_NAME}${{ matrix.suffix }}"
echo "DIR_NAME=${DIR_NAME}" >> "${GITHUB_ENV}"
- name: Archive
run: |
set -xeuo pipefail
cd dist
mkdir -p "${DIR_NAME}"
cp ../LICENSE "${DIR_NAME}"
cp sing-box "${DIR_NAME}"
tar -czvf "${DIR_NAME}.tar.gz" "${DIR_NAME}"
rm -r "${DIR_NAME}"
- name: Cleanup
run: rm dist/sing-box
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: binary-linux_${{ matrix.arch }}${{ matrix.goarm && format('v{0}', matrix.goarm) }}${{ matrix.suffix }}
path: "dist"
build_android:
name: Build Android
if: github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Android'
@@ -300,7 +492,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ^1.25.1
go-version: ^1.25.4
- name: Setup Android NDK
id: setup-ndk
uses: nttld/setup-ndk@v1
@@ -380,7 +572,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ^1.25.1
go-version: ^1.25.4
- name: Setup Android NDK
id: setup-ndk
uses: nttld/setup-ndk@v1
@@ -479,7 +671,7 @@ jobs:
if: matrix.if
uses: actions/setup-go@v5
with:
go-version: ^1.25.1
go-version: ^1.25.4
- name: Set tag
if: matrix.if
run: |-
@@ -619,6 +811,8 @@ jobs:
needs:
- calculate_version
- build
- build_darwin
- build_naive_linux
- build_android
- build_apple
steps:

View File

@@ -32,7 +32,7 @@ jobs:
- name: golangci-lint
uses: golangci/golangci-lint-action@v8
with:
version: latest
version: v2.4.0
args: --timeout=30m
install-mode: binary
verify: false

View File

@@ -30,7 +30,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ^1.25.1
go-version: ^1.25.4
- name: Check input version
if: github.event_name == 'workflow_dispatch'
run: |-
@@ -71,7 +71,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ^1.25.1
go-version: ^1.25.4
- name: Setup Android NDK
if: matrix.os == 'android'
uses: nttld/setup-ndk@v1
@@ -85,7 +85,7 @@ jobs:
- name: Set build tags
run: |
set -xeuo pipefail
TAGS='with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,badlinkname'
TAGS='with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,badlinkname,tfogo_checklinkname0'
echo "BUILD_TAGS=${TAGS}" >> "${GITHUB_ENV}"
- name: Build
run: |

5
.gitignore vendored
View File

@@ -15,5 +15,6 @@
.DS_Store
/config.d/
/venv/
!/README.md
/*.md
CLAUDE.md
AGENTS.md
/.claude/

View File

@@ -1,103 +0,0 @@
project_name: sing-box
builds:
- id: main
main: ./cmd/sing-box
flags:
- -v
- -trimpath
ldflags:
- -X github.com/sagernet/sing-box/constant.Version={{ .Version }}
- -s
- -buildid=
tags:
- with_gvisor
- with_quic
- with_dhcp
- with_wireguard
- with_utls
- with_acme
- with_clash_api
- with_tailscale
env:
- CGO_ENABLED=0
targets:
- linux_386
- linux_amd64_v1
- linux_arm64
- linux_arm_7
- linux_s390x
- linux_riscv64
- linux_mips64le
mod_timestamp: '{{ .CommitTimestamp }}'
snapshot:
name_template: "{{ .Version }}.{{ .ShortCommit }}"
nfpms:
- &template
id: package
package_name: sing-box
file_name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ with .Mips }}_{{ . }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}'
builds:
- main
homepage: https://sing-box.sagernet.org/
maintainer: nekohasekai <contact-git@sekai.icu>
description: The universal proxy platform.
license: GPLv3 or later
formats:
- deb
- rpm
priority: extra
contents:
- src: release/config/config.json
dst: /etc/sing-box/config.json
type: "config|noreplace"
- src: release/config/sing-box.service
dst: /usr/lib/systemd/system/sing-box.service
- src: release/config/sing-box@.service
dst: /usr/lib/systemd/system/sing-box@.service
- src: release/config/sing-box.sysusers
dst: /usr/lib/sysusers.d/sing-box.conf
- src: release/config/sing-box.rules
dst: /usr/share/polkit-1/rules.d/sing-box.rules
- src: release/config/sing-box-split-dns.xml
dst: /usr/share/dbus-1/system.d/sing-box-split-dns.conf
- src: release/completions/sing-box.bash
dst: /usr/share/bash-completion/completions/sing-box.bash
- src: release/completions/sing-box.fish
dst: /usr/share/fish/vendor_completions.d/sing-box.fish
- src: release/completions/sing-box.zsh
dst: /usr/share/zsh/site-functions/_sing-box
- src: LICENSE
dst: /usr/share/licenses/sing-box/LICENSE
deb:
signature:
key_file: "{{ .Env.NFPM_KEY_PATH }}"
fields:
Bugs: https://github.com/SagerNet/sing-box/issues
rpm:
signature:
key_file: "{{ .Env.NFPM_KEY_PATH }}"
conflicts:
- sing-box-beta
- id: package_beta
<<: *template
package_name: sing-box-beta
file_name_template: '{{ .ProjectName }}-beta_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ with .Mips }}_{{ . }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}'
formats:
- deb
- rpm
conflicts:
- sing-box
release:
disable: true
furies:
- account: sagernet
ids:
- package
disable: "{{ not (not .Prerelease) }}"
- account: sagernet
ids:
- package_beta
disable: "{{ not .Prerelease }}"

View File

@@ -1,213 +0,0 @@
version: 2
project_name: sing-box
builds:
- &template
id: main
main: ./cmd/sing-box
flags:
- -v
- -trimpath
ldflags:
- -X github.com/sagernet/sing-box/constant.Version={{ .Version }}
- -s
- -buildid=
tags:
- with_gvisor
- with_quic
- with_dhcp
- with_wireguard
- with_utls
- with_acme
- with_clash_api
- with_tailscale
env:
- CGO_ENABLED=0
- GOTOOLCHAIN=local
targets:
- linux_386
- linux_amd64_v1
- linux_arm64
- linux_arm_6
- linux_arm_7
- linux_s390x
- linux_riscv64
- linux_mips64le
- windows_amd64_v1
- windows_386
- windows_arm64
- darwin_amd64_v1
- darwin_arm64
mod_timestamp: '{{ .CommitTimestamp }}'
- id: legacy
<<: *template
tags:
- with_gvisor
- with_quic
- with_dhcp
- with_wireguard
- with_utls
- with_acme
- with_clash_api
- with_tailscale
env:
- CGO_ENABLED=0
- GOROOT={{ .Env.GOPATH }}/go_legacy
tool: "{{ .Env.GOPATH }}/go_legacy/bin/go"
targets:
- windows_amd64_v1
- windows_386
- id: android
<<: *template
env:
- CGO_ENABLED=1
- GOTOOLCHAIN=local
overrides:
- goos: android
goarch: arm
goarm: 7
env:
- CC=armv7a-linux-androideabi21-clang
- CXX=armv7a-linux-androideabi21-clang++
- goos: android
goarch: arm64
env:
- CC=aarch64-linux-android21-clang
- CXX=aarch64-linux-android21-clang++
- goos: android
goarch: 386
env:
- CC=i686-linux-android21-clang
- CXX=i686-linux-android21-clang++
- goos: android
goarch: amd64
goamd64: v1
env:
- CC=x86_64-linux-android21-clang
- CXX=x86_64-linux-android21-clang++
targets:
- android_arm_7
- android_arm64
- android_386
- android_amd64
archives:
- &template
id: archive
builds:
- main
- android
formats:
- tar.gz
format_overrides:
- goos: windows
formats:
- zip
wrap_in_directory: true
files:
- LICENSE
name_template: '{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ if and .Mips (not (eq .Mips "hardfloat")) }}_{{ .Mips }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}'
- id: archive-legacy
<<: *template
builds:
- legacy
name_template: '{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}-legacy'
nfpms:
- id: package
package_name: sing-box
file_name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ if and .Mips (not (eq .Mips "hardfloat")) }}_{{ .Mips }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}'
builds:
- main
homepage: https://sing-box.sagernet.org/
maintainer: nekohasekai <contact-git@sekai.icu>
description: The universal proxy platform.
license: GPLv3 or later
formats:
- deb
- rpm
- archlinux
# - apk
# - ipk
priority: extra
contents:
- src: release/config/config.json
dst: /etc/sing-box/config.json
type: "config|noreplace"
- src: release/config/sing-box.service
dst: /usr/lib/systemd/system/sing-box.service
- src: release/config/sing-box@.service
dst: /usr/lib/systemd/system/sing-box@.service
- src: release/config/sing-box.sysusers
dst: /usr/lib/sysusers.d/sing-box.conf
- src: release/config/sing-box.rules
dst: /usr/share/polkit-1/rules.d/sing-box.rules
- src: release/config/sing-box-split-dns.xml
dst: /usr/share/dbus-1/system.d/sing-box-split-dns.conf
- src: release/completions/sing-box.bash
dst: /usr/share/bash-completion/completions/sing-box.bash
- src: release/completions/sing-box.fish
dst: /usr/share/fish/vendor_completions.d/sing-box.fish
- src: release/completions/sing-box.zsh
dst: /usr/share/zsh/site-functions/_sing-box
- src: LICENSE
dst: /usr/share/licenses/sing-box/LICENSE
deb:
signature:
key_file: "{{ .Env.NFPM_KEY_PATH }}"
fields:
Bugs: https://github.com/SagerNet/sing-box/issues
rpm:
signature:
key_file: "{{ .Env.NFPM_KEY_PATH }}"
overrides:
apk:
contents:
- src: release/config/config.json
dst: /etc/sing-box/config.json
type: config
- src: release/config/sing-box.initd
dst: /etc/init.d/sing-box
- src: release/completions/sing-box.bash
dst: /usr/share/bash-completion/completions/sing-box.bash
- src: release/completions/sing-box.fish
dst: /usr/share/fish/vendor_completions.d/sing-box.fish
- src: release/completions/sing-box.zsh
dst: /usr/share/zsh/site-functions/_sing-box
- src: LICENSE
dst: /usr/share/licenses/sing-box/LICENSE
ipk:
contents:
- src: release/config/config.json
dst: /etc/sing-box/config.json
type: config
- src: release/config/openwrt.init
dst: /etc/init.d/sing-box
- src: release/config/openwrt.conf
dst: /etc/config/sing-box
source:
enabled: false
name_template: '{{ .ProjectName }}-{{ .Version }}.source'
prefix_template: '{{ .ProjectName }}-{{ .Version }}/'
checksum:
disable: true
name_template: '{{ .ProjectName }}-{{ .Version }}.checksum'
signs:
- artifacts: checksum
release:
github:
owner: SagerNet
name: sing-box
draft: true
prerelease: auto
mode: replace
ids:
- archive
- package
skip_upload: true
partial:
by: target

View File

@@ -13,15 +13,13 @@ RUN set -ex \
&& export COMMIT=$(git rev-parse --short HEAD) \
&& export VERSION=$(go run ./cmd/internal/read_tag) \
&& go build -v -trimpath -tags \
"with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,badlinkname" \
"with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,badlinkname,tfogo_checklinkname0" \
-o /go/bin/sing-box \
-ldflags "-X \"github.com/sagernet/sing-box/constant.Version=$VERSION\" -s -w -buildid= -checklinkname=0" \
./cmd/sing-box
FROM --platform=$TARGETPLATFORM alpine AS dist
LABEL maintainer="nekohasekai <contact-git@sekai.icu>"
RUN set -ex \
&& apk upgrade \
&& apk add bash tzdata ca-certificates nftables \
&& rm -rf /var/cache/apk/*
&& apk add --no-cache --upgrade bash tzdata ca-certificates nftables
COPY --from=builder /go/bin/sing-box /usr/local/bin/sing-box
ENTRYPOINT ["sing-box"]

View File

@@ -1,6 +1,6 @@
NAME = sing-box
COMMIT = $(shell git rev-parse --short HEAD)
TAGS ?= with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,badlinkname
TAGS ?= with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,badlinkname,tfogo_checklinkname0
GOHOSTOS = $(shell go env GOHOSTOS)
GOHOSTARCH = $(shell go env GOHOSTARCH)
@@ -38,7 +38,7 @@ fmt:
@gci write --custom-order -s standard -s "prefix(github.com/sagernet/)" -s "default" .
fmt_install:
go install -v mvdan.cc/gofumpt@latest
go install -v mvdan.cc/gofumpt@v0.8.0
go install -v github.com/daixiang0/gci@latest
lint:
@@ -49,7 +49,7 @@ lint:
GOOS=freebsd golangci-lint run ./...
lint_install:
go install -v github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
go install -v github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.4.0
proto:
@go run ./cmd/internal/protogen

View File

@@ -1,3 +1,11 @@
> Sponsored by [Warp](https://go.warp.dev/sing-box), built for coding with multiple AI agents
<a href="https://go.warp.dev/sing-box">
<img alt="Warp sponsorship" width="400" src="https://github.com/warpdotdev/brand-assets/raw/refs/heads/main/Github/Sponsor/Warp-Github-LG-02.png">
</a>
---
# sing-box
The universal proxy platform.

View File

@@ -10,6 +10,7 @@ import (
type NetworkManager interface {
Lifecycle
Initialize(ruleSets []RuleSet)
InterfaceFinder() control.InterfaceFinder
UpdateInterfaces() error
DefaultNetworkInterface() *NetworkInterface
@@ -24,9 +25,10 @@ type NetworkManager interface {
NetworkMonitor() tun.NetworkUpdateMonitor
InterfaceMonitor() tun.DefaultInterfaceMonitor
PackageManager() tun.PackageManager
NeedWIFIState() bool
WIFIState() WIFIState
ResetNetwork()
UpdateWIFIState()
ResetNetwork()
}
type NetworkOptions struct {

View File

@@ -24,7 +24,6 @@ type Router interface {
PreMatch(metadata InboundContext, context tun.DirectRouteContext, timeout time.Duration) (tun.DirectRouteDestination, error)
ConnectionRouterEx
RuleSet(tag string) (RuleSet, bool)
NeedWIFIState() bool
Rules() []Rule
AppendTracker(tracker ConnectionTracker)
ResetNetwork()

View File

@@ -73,7 +73,7 @@ func NewUpstreamContextHandlerEx(
}
func (w *myUpstreamContextHandlerWrapperEx) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
myMetadata := ContextFrom(ctx)
_, myMetadata := ExtendContext(ctx)
if source.IsValid() {
myMetadata.Source = source
}
@@ -84,7 +84,7 @@ func (w *myUpstreamContextHandlerWrapperEx) NewConnectionEx(ctx context.Context,
}
func (w *myUpstreamContextHandlerWrapperEx) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
myMetadata := ContextFrom(ctx)
_, myMetadata := ExtendContext(ctx)
if source.IsValid() {
myMetadata.Source = source
}
@@ -146,7 +146,7 @@ type routeContextHandlerWrapperEx struct {
}
func (r *routeContextHandlerWrapperEx) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
metadata := ContextFrom(ctx)
_, metadata := ExtendContext(ctx)
if source.IsValid() {
metadata.Source = source
}
@@ -157,7 +157,7 @@ func (r *routeContextHandlerWrapperEx) NewConnectionEx(ctx context.Context, conn
}
func (r *routeContextHandlerWrapperEx) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
metadata := ContextFrom(ctx)
_, metadata := ExtendContext(ctx)
if source.IsValid() {
metadata.Source = source
}

2
box.go
View File

@@ -184,7 +184,7 @@ func New(options Options) (*Box, error) {
service.MustRegister[adapter.ServiceManager](ctx, serviceManager)
dnsRouter := dns.NewRouter(ctx, logFactory, dnsOptions)
service.MustRegister[adapter.DNSRouter](ctx, dnsRouter)
networkManager, err := route.NewNetworkManager(ctx, logFactory.NewLogger("network"), routeOptions)
networkManager, err := route.NewNetworkManager(ctx, logFactory.NewLogger("network"), routeOptions, dnsOptions)
if err != nil {
return nil, E.Cause(err, "initialize network manager")
}

View File

@@ -62,7 +62,7 @@ func init() {
sharedFlags = append(sharedFlags, "-ldflags", "-X github.com/sagernet/sing-box/constant.Version="+currentTag+" -s -w -buildid= -checklinkname=0")
debugFlags = append(debugFlags, "-ldflags", "-X github.com/sagernet/sing-box/constant.Version="+currentTag+" -checklinkname=0")
sharedTags = append(sharedTags, "with_gvisor", "with_quic", "with_wireguard", "with_utls", "with_clash_api", "with_conntrack", "badlinkname")
sharedTags = append(sharedTags, "with_gvisor", "with_quic", "with_wireguard", "with_utls", "with_clash_api", "with_conntrack", "badlinkname", "tfogo_checklinkname0")
macOSTags = append(macOSTags, "with_dhcp")
memcTags = append(memcTags, "with_tailscale")
notMemcTags = append(notMemcTags, "with_low_memory")

View File

@@ -143,9 +143,18 @@ func NewDefault(ctx context.Context, options option.DialerOptions) (*DefaultDial
} else {
dialer.Timeout = C.TCPConnectTimeout
}
// TODO: Add an option to customize the keep alive period
dialer.KeepAlive = C.TCPKeepAliveInitial
dialer.Control = control.Append(dialer.Control, control.SetKeepAlivePeriod(C.TCPKeepAliveInitial, C.TCPKeepAliveInterval))
if !options.DisableTCPKeepAlive {
keepIdle := time.Duration(options.TCPKeepAlive)
if keepIdle == 0 {
keepIdle = C.TCPKeepAliveInitial
}
keepInterval := time.Duration(options.TCPKeepAliveInterval)
if keepInterval == 0 {
keepInterval = C.TCPKeepAliveInterval
}
dialer.KeepAlive = keepIdle
dialer.Control = control.Append(dialer.Control, control.SetKeepAlivePeriod(keepIdle, keepInterval))
}
var udpFragment bool
if options.UDPFragment != nil {
udpFragment = *options.UDPFragment

View File

@@ -37,7 +37,7 @@ func (l *Listener) ListenTCP() (net.Listener, error) {
if l.listenOptions.ReuseAddr {
listenConfig.Control = control.Append(listenConfig.Control, control.ReuseAddr())
}
if l.listenOptions.TCPKeepAlive >= 0 {
if !l.listenOptions.DisableTCPKeepAlive {
keepIdle := time.Duration(l.listenOptions.TCPKeepAlive)
if keepIdle == 0 {
keepIdle = C.TCPKeepAliveInitial

9
common/settings/wifi.go Normal file
View File

@@ -0,0 +1,9 @@
package settings
import "github.com/sagernet/sing-box/adapter"
type WIFIMonitor interface {
ReadWIFIState() adapter.WIFIState
Start() error
Close() error
}

View File

@@ -0,0 +1,46 @@
package settings
import (
"github.com/sagernet/sing-box/adapter"
E "github.com/sagernet/sing/common/exceptions"
)
type LinuxWIFIMonitor struct {
monitor WIFIMonitor
}
func NewWIFIMonitor(callback func(adapter.WIFIState)) (WIFIMonitor, error) {
monitors := []func(func(adapter.WIFIState)) (WIFIMonitor, error){
newNetworkManagerMonitor,
newIWDMonitor,
newWpaSupplicantMonitor,
newConnManMonitor,
}
var errors []error
for _, factory := range monitors {
monitor, err := factory(callback)
if err == nil {
return &LinuxWIFIMonitor{monitor: monitor}, nil
}
errors = append(errors, err)
}
return nil, E.Cause(E.Errors(errors...), "no supported WIFI manager found")
}
func (m *LinuxWIFIMonitor) ReadWIFIState() adapter.WIFIState {
return m.monitor.ReadWIFIState()
}
func (m *LinuxWIFIMonitor) Start() error {
if m.monitor != nil {
return m.monitor.Start()
}
return nil
}
func (m *LinuxWIFIMonitor) Close() error {
if m.monitor != nil {
return m.monitor.Close()
}
return nil
}

View File

@@ -0,0 +1,166 @@
package settings
import (
"context"
"strings"
"time"
"github.com/sagernet/sing-box/adapter"
"github.com/godbus/dbus/v5"
)
type connmanMonitor struct {
conn *dbus.Conn
callback func(adapter.WIFIState)
cancel context.CancelFunc
signalChan chan *dbus.Signal
}
func newConnManMonitor(callback func(adapter.WIFIState)) (WIFIMonitor, error) {
conn, err := dbus.ConnectSystemBus()
if err != nil {
return nil, err
}
cmObj := conn.Object("net.connman", "/")
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
call := cmObj.CallWithContext(ctx, "net.connman.Manager.GetServices", 0)
if call.Err != nil {
conn.Close()
return nil, call.Err
}
return &connmanMonitor{conn: conn, callback: callback}, nil
}
func (m *connmanMonitor) ReadWIFIState() adapter.WIFIState {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
cmObj := m.conn.Object("net.connman", "/")
var services []interface{}
err := cmObj.CallWithContext(ctx, "net.connman.Manager.GetServices", 0).Store(&services)
if err != nil {
return adapter.WIFIState{}
}
for _, service := range services {
servicePair, ok := service.([]interface{})
if !ok || len(servicePair) != 2 {
continue
}
serviceProps, ok := servicePair[1].(map[string]dbus.Variant)
if !ok {
continue
}
typeVariant, hasType := serviceProps["Type"]
if !hasType {
continue
}
serviceType, ok := typeVariant.Value().(string)
if !ok || serviceType != "wifi" {
continue
}
stateVariant, hasState := serviceProps["State"]
if !hasState {
continue
}
state, ok := stateVariant.Value().(string)
if !ok || (state != "online" && state != "ready") {
continue
}
nameVariant, hasName := serviceProps["Name"]
if !hasName {
continue
}
ssid, ok := nameVariant.Value().(string)
if !ok || ssid == "" {
continue
}
bssidVariant, hasBSSID := serviceProps["BSSID"]
if !hasBSSID {
return adapter.WIFIState{SSID: ssid}
}
bssid, ok := bssidVariant.Value().(string)
if !ok {
return adapter.WIFIState{SSID: ssid}
}
return adapter.WIFIState{
SSID: ssid,
BSSID: strings.ToUpper(strings.ReplaceAll(bssid, ":", "")),
}
}
return adapter.WIFIState{}
}
func (m *connmanMonitor) Start() error {
if m.callback == nil {
return nil
}
ctx, cancel := context.WithCancel(context.Background())
m.cancel = cancel
m.signalChan = make(chan *dbus.Signal, 10)
m.conn.Signal(m.signalChan)
err := m.conn.AddMatchSignal(
dbus.WithMatchInterface("net.connman.Service"),
dbus.WithMatchSender("net.connman"),
)
if err != nil {
return err
}
state := m.ReadWIFIState()
go m.monitorSignals(ctx, m.signalChan, state)
m.callback(state)
return nil
}
func (m *connmanMonitor) monitorSignals(ctx context.Context, signalChan chan *dbus.Signal, lastState adapter.WIFIState) {
for {
select {
case <-ctx.Done():
return
case signal, ok := <-signalChan:
if !ok {
return
}
// godbus Signal.Name uses "interface.member" format (e.g. "net.connman.Service.PropertyChanged"),
// not just the member name. This differs from the D-Bus signal member in the match rule.
if signal.Name == "net.connman.Service.PropertyChanged" {
state := m.ReadWIFIState()
if state != lastState {
lastState = state
m.callback(state)
}
}
}
}
}
func (m *connmanMonitor) Close() error {
if m.cancel != nil {
m.cancel()
}
if m.signalChan != nil {
m.conn.RemoveSignal(m.signalChan)
close(m.signalChan)
}
if m.conn != nil {
m.conn.RemoveMatchSignal(
dbus.WithMatchInterface("net.connman.Service"),
dbus.WithMatchSender("net.connman"),
)
return m.conn.Close()
}
return nil
}

View File

@@ -0,0 +1,188 @@
package settings
import (
"context"
"strings"
"time"
"github.com/sagernet/sing-box/adapter"
"github.com/godbus/dbus/v5"
)
type iwdMonitor struct {
conn *dbus.Conn
callback func(adapter.WIFIState)
cancel context.CancelFunc
signalChan chan *dbus.Signal
}
func newIWDMonitor(callback func(adapter.WIFIState)) (WIFIMonitor, error) {
conn, err := dbus.ConnectSystemBus()
if err != nil {
return nil, err
}
iwdObj := conn.Object("net.connman.iwd", "/")
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
call := iwdObj.CallWithContext(ctx, "org.freedesktop.DBus.ObjectManager.GetManagedObjects", 0)
if call.Err != nil {
conn.Close()
return nil, call.Err
}
return &iwdMonitor{conn: conn, callback: callback}, nil
}
func (m *iwdMonitor) ReadWIFIState() adapter.WIFIState {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
iwdObj := m.conn.Object("net.connman.iwd", "/")
var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
err := iwdObj.CallWithContext(ctx, "org.freedesktop.DBus.ObjectManager.GetManagedObjects", 0).Store(&objects)
if err != nil {
return adapter.WIFIState{}
}
for _, interfaces := range objects {
stationProps, hasStation := interfaces["net.connman.iwd.Station"]
if !hasStation {
continue
}
stateVariant, hasState := stationProps["State"]
if !hasState {
continue
}
state, ok := stateVariant.Value().(string)
if !ok || state != "connected" {
continue
}
connectedNetworkVariant, hasNetwork := stationProps["ConnectedNetwork"]
if !hasNetwork {
continue
}
networkPath, ok := connectedNetworkVariant.Value().(dbus.ObjectPath)
if !ok || networkPath == "/" {
continue
}
networkInterfaces, hasNetworkPath := objects[networkPath]
if !hasNetworkPath {
continue
}
networkProps, hasNetworkInterface := networkInterfaces["net.connman.iwd.Network"]
if !hasNetworkInterface {
continue
}
nameVariant, hasName := networkProps["Name"]
if !hasName {
continue
}
ssid, ok := nameVariant.Value().(string)
if !ok {
continue
}
connectedBSSVariant, hasBSS := stationProps["ConnectedAccessPoint"]
if !hasBSS {
return adapter.WIFIState{SSID: ssid}
}
bssPath, ok := connectedBSSVariant.Value().(dbus.ObjectPath)
if !ok || bssPath == "/" {
return adapter.WIFIState{SSID: ssid}
}
bssInterfaces, hasBSSPath := objects[bssPath]
if !hasBSSPath {
return adapter.WIFIState{SSID: ssid}
}
bssProps, hasBSSInterface := bssInterfaces["net.connman.iwd.BasicServiceSet"]
if !hasBSSInterface {
return adapter.WIFIState{SSID: ssid}
}
addressVariant, hasAddress := bssProps["Address"]
if !hasAddress {
return adapter.WIFIState{SSID: ssid}
}
bssid, ok := addressVariant.Value().(string)
if !ok {
return adapter.WIFIState{SSID: ssid}
}
return adapter.WIFIState{
SSID: ssid,
BSSID: strings.ToUpper(strings.ReplaceAll(bssid, ":", "")),
}
}
return adapter.WIFIState{}
}
func (m *iwdMonitor) Start() error {
if m.callback == nil {
return nil
}
ctx, cancel := context.WithCancel(context.Background())
m.cancel = cancel
m.signalChan = make(chan *dbus.Signal, 10)
m.conn.Signal(m.signalChan)
err := m.conn.AddMatchSignal(
dbus.WithMatchInterface("org.freedesktop.DBus.Properties"),
dbus.WithMatchSender("net.connman.iwd"),
)
if err != nil {
return err
}
state := m.ReadWIFIState()
go m.monitorSignals(ctx, m.signalChan, state)
m.callback(state)
return nil
}
func (m *iwdMonitor) monitorSignals(ctx context.Context, signalChan chan *dbus.Signal, lastState adapter.WIFIState) {
for {
select {
case <-ctx.Done():
return
case signal, ok := <-signalChan:
if !ok {
return
}
if signal.Name == "org.freedesktop.DBus.Properties.PropertiesChanged" {
state := m.ReadWIFIState()
if state != lastState {
lastState = state
m.callback(state)
}
}
}
}
}
func (m *iwdMonitor) Close() error {
if m.cancel != nil {
m.cancel()
}
if m.signalChan != nil {
m.conn.RemoveSignal(m.signalChan)
close(m.signalChan)
}
if m.conn != nil {
m.conn.RemoveMatchSignal(
dbus.WithMatchInterface("org.freedesktop.DBus.Properties"),
dbus.WithMatchSender("net.connman.iwd"),
)
return m.conn.Close()
}
return nil
}

View File

@@ -0,0 +1,163 @@
package settings
import (
"context"
"strings"
"time"
"github.com/sagernet/sing-box/adapter"
"github.com/godbus/dbus/v5"
)
type networkManagerMonitor struct {
conn *dbus.Conn
callback func(adapter.WIFIState)
cancel context.CancelFunc
signalChan chan *dbus.Signal
}
func newNetworkManagerMonitor(callback func(adapter.WIFIState)) (WIFIMonitor, error) {
conn, err := dbus.ConnectSystemBus()
if err != nil {
return nil, err
}
nmObj := conn.Object("org.freedesktop.NetworkManager", "/org/freedesktop/NetworkManager")
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
var state uint32
err = nmObj.CallWithContext(ctx, "org.freedesktop.DBus.Properties.Get", 0, "org.freedesktop.NetworkManager", "State").Store(&state)
if err != nil {
conn.Close()
return nil, err
}
return &networkManagerMonitor{conn: conn, callback: callback}, nil
}
func (m *networkManagerMonitor) ReadWIFIState() adapter.WIFIState {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
nmObj := m.conn.Object("org.freedesktop.NetworkManager", "/org/freedesktop/NetworkManager")
var activeConnectionPaths []dbus.ObjectPath
err := nmObj.CallWithContext(ctx, "org.freedesktop.DBus.Properties.Get", 0, "org.freedesktop.NetworkManager", "ActiveConnections").Store(&activeConnectionPaths)
if err != nil || len(activeConnectionPaths) == 0 {
return adapter.WIFIState{}
}
for _, connectionPath := range activeConnectionPaths {
connObj := m.conn.Object("org.freedesktop.NetworkManager", connectionPath)
var devicePaths []dbus.ObjectPath
err = connObj.CallWithContext(ctx, "org.freedesktop.DBus.Properties.Get", 0, "org.freedesktop.NetworkManager.Connection.Active", "Devices").Store(&devicePaths)
if err != nil || len(devicePaths) == 0 {
continue
}
for _, devicePath := range devicePaths {
deviceObj := m.conn.Object("org.freedesktop.NetworkManager", devicePath)
var deviceType uint32
err = deviceObj.CallWithContext(ctx, "org.freedesktop.DBus.Properties.Get", 0, "org.freedesktop.NetworkManager.Device", "DeviceType").Store(&deviceType)
if err != nil || deviceType != 2 {
continue
}
var accessPointPath dbus.ObjectPath
err = deviceObj.CallWithContext(ctx, "org.freedesktop.DBus.Properties.Get", 0, "org.freedesktop.NetworkManager.Device.Wireless", "ActiveAccessPoint").Store(&accessPointPath)
if err != nil || accessPointPath == "/" {
continue
}
apObj := m.conn.Object("org.freedesktop.NetworkManager", accessPointPath)
var ssidBytes []byte
err = apObj.CallWithContext(ctx, "org.freedesktop.DBus.Properties.Get", 0, "org.freedesktop.NetworkManager.AccessPoint", "Ssid").Store(&ssidBytes)
if err != nil {
continue
}
var hwAddress string
err = apObj.CallWithContext(ctx, "org.freedesktop.DBus.Properties.Get", 0, "org.freedesktop.NetworkManager.AccessPoint", "HwAddress").Store(&hwAddress)
if err != nil {
continue
}
ssid := strings.TrimSpace(string(ssidBytes))
if ssid == "" {
continue
}
return adapter.WIFIState{
SSID: ssid,
BSSID: strings.ToUpper(strings.ReplaceAll(hwAddress, ":", "")),
}
}
}
return adapter.WIFIState{}
}
func (m *networkManagerMonitor) Start() error {
if m.callback == nil {
return nil
}
ctx, cancel := context.WithCancel(context.Background())
m.cancel = cancel
m.signalChan = make(chan *dbus.Signal, 10)
m.conn.Signal(m.signalChan)
err := m.conn.AddMatchSignal(
dbus.WithMatchSender("org.freedesktop.NetworkManager"),
dbus.WithMatchInterface("org.freedesktop.DBus.Properties"),
)
if err != nil {
return err
}
state := m.ReadWIFIState()
go m.monitorSignals(ctx, m.signalChan, state)
m.callback(state)
return nil
}
func (m *networkManagerMonitor) monitorSignals(ctx context.Context, signalChan chan *dbus.Signal, lastState adapter.WIFIState) {
for {
select {
case <-ctx.Done():
return
case signal, ok := <-signalChan:
if !ok {
return
}
if signal.Name == "org.freedesktop.DBus.Properties.PropertiesChanged" {
state := m.ReadWIFIState()
if state != lastState {
lastState = state
m.callback(state)
}
}
}
}
}
func (m *networkManagerMonitor) Close() error {
if m.cancel != nil {
m.cancel()
}
if m.signalChan != nil {
m.conn.RemoveSignal(m.signalChan)
close(m.signalChan)
}
if m.conn != nil {
m.conn.RemoveMatchSignal(
dbus.WithMatchSender("org.freedesktop.NetworkManager"),
dbus.WithMatchInterface("org.freedesktop.DBus.Properties"),
)
return m.conn.Close()
}
return nil
}

View File

@@ -0,0 +1,225 @@
package settings
import (
"bufio"
"context"
"fmt"
"net"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/sagernet/sing-box/adapter"
)
var wpaSocketCounter atomic.Uint64
type wpaSupplicantMonitor struct {
socketPath string
callback func(adapter.WIFIState)
cancel context.CancelFunc
monitorConn *net.UnixConn
connMutex sync.Mutex
}
func newWpaSupplicantMonitor(callback func(adapter.WIFIState)) (WIFIMonitor, error) {
socketDirs := []string{"/var/run/wpa_supplicant", "/run/wpa_supplicant"}
for _, socketDir := range socketDirs {
entries, err := os.ReadDir(socketDir)
if err != nil {
continue
}
for _, entry := range entries {
if entry.IsDir() || entry.Name() == "." || entry.Name() == ".." {
continue
}
socketPath := filepath.Join(socketDir, entry.Name())
id := wpaSocketCounter.Add(1)
localAddr := &net.UnixAddr{Name: fmt.Sprintf("@sing-box-wpa-%d-%d", os.Getpid(), id), Net: "unixgram"}
remoteAddr := &net.UnixAddr{Name: socketPath, Net: "unixgram"}
conn, err := net.DialUnix("unixgram", localAddr, remoteAddr)
if err != nil {
continue
}
conn.Close()
return &wpaSupplicantMonitor{socketPath: socketPath, callback: callback}, nil
}
}
return nil, os.ErrNotExist
}
func (m *wpaSupplicantMonitor) ReadWIFIState() adapter.WIFIState {
id := wpaSocketCounter.Add(1)
localAddr := &net.UnixAddr{Name: fmt.Sprintf("@sing-box-wpa-%d-%d", os.Getpid(), id), Net: "unixgram"}
remoteAddr := &net.UnixAddr{Name: m.socketPath, Net: "unixgram"}
conn, err := net.DialUnix("unixgram", localAddr, remoteAddr)
if err != nil {
return adapter.WIFIState{}
}
defer conn.Close()
conn.SetDeadline(time.Now().Add(3 * time.Second))
status, err := m.sendCommand(conn, "STATUS")
if err != nil {
return adapter.WIFIState{}
}
var ssid, bssid string
var connected bool
scanner := bufio.NewScanner(strings.NewReader(status))
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "wpa_state=") {
state := strings.TrimPrefix(line, "wpa_state=")
connected = state == "COMPLETED"
} else if strings.HasPrefix(line, "ssid=") {
ssid = strings.TrimPrefix(line, "ssid=")
} else if strings.HasPrefix(line, "bssid=") {
bssid = strings.TrimPrefix(line, "bssid=")
}
}
if !connected || ssid == "" {
return adapter.WIFIState{}
}
return adapter.WIFIState{
SSID: ssid,
BSSID: strings.ToUpper(strings.ReplaceAll(bssid, ":", "")),
}
}
// sendCommand sends a command to wpa_supplicant and returns the response.
// Commands are sent without trailing newlines per the wpa_supplicant control
// interface protocol - the official wpa_ctrl.c sends raw command strings.
func (m *wpaSupplicantMonitor) sendCommand(conn *net.UnixConn, command string) (string, error) {
_, err := conn.Write([]byte(command))
if err != nil {
return "", err
}
buf := make([]byte, 4096)
n, err := conn.Read(buf)
if err != nil {
return "", err
}
response := string(buf[:n])
if strings.HasPrefix(response, "FAIL") {
return "", os.ErrInvalid
}
return strings.TrimSpace(response), nil
}
func (m *wpaSupplicantMonitor) Start() error {
if m.callback == nil {
return nil
}
ctx, cancel := context.WithCancel(context.Background())
m.cancel = cancel
state := m.ReadWIFIState()
go m.monitorEvents(ctx, state)
m.callback(state)
return nil
}
func (m *wpaSupplicantMonitor) monitorEvents(ctx context.Context, lastState adapter.WIFIState) {
var consecutiveErrors int
var debounceTimer *time.Timer
var debounceMutex sync.Mutex
localAddr := &net.UnixAddr{Name: fmt.Sprintf("@sing-box-wpa-mon-%d", os.Getpid()), Net: "unixgram"}
remoteAddr := &net.UnixAddr{Name: m.socketPath, Net: "unixgram"}
conn, err := net.DialUnix("unixgram", localAddr, remoteAddr)
if err != nil {
return
}
defer conn.Close()
m.connMutex.Lock()
m.monitorConn = conn
m.connMutex.Unlock()
// ATTACH/DETACH commands use os_strcmp() for exact matching in wpa_supplicant,
// so they must be sent without trailing newlines.
// See: https://w1.fi/cgit/hostap/tree/wpa_supplicant/ctrl_iface_unix.c
_, err = conn.Write([]byte("ATTACH"))
if err != nil {
return
}
buf := make([]byte, 4096)
n, err := conn.Read(buf)
if err != nil || !strings.HasPrefix(string(buf[:n]), "OK") {
return
}
for {
select {
case <-ctx.Done():
debounceMutex.Lock()
if debounceTimer != nil {
debounceTimer.Stop()
}
debounceMutex.Unlock()
conn.Write([]byte("DETACH"))
return
default:
}
conn.SetReadDeadline(time.Now().Add(30 * time.Second))
n, err := conn.Read(buf)
if err != nil {
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
continue
}
select {
case <-ctx.Done():
return
default:
}
consecutiveErrors++
if consecutiveErrors > 10 {
return
}
time.Sleep(time.Second)
continue
}
consecutiveErrors = 0
msg := string(buf[:n])
if strings.Contains(msg, "CTRL-EVENT-CONNECTED") || strings.Contains(msg, "CTRL-EVENT-DISCONNECTED") {
debounceMutex.Lock()
if debounceTimer != nil {
debounceTimer.Stop()
}
debounceTimer = time.AfterFunc(500*time.Millisecond, func() {
state := m.ReadWIFIState()
if state != lastState {
lastState = state
m.callback(state)
}
})
debounceMutex.Unlock()
}
}
}
func (m *wpaSupplicantMonitor) Close() error {
if m.cancel != nil {
m.cancel()
}
m.connMutex.Lock()
if m.monitorConn != nil {
m.monitorConn.Close()
}
m.connMutex.Unlock()
return nil
}

View File

@@ -0,0 +1,27 @@
//go:build !linux && !windows
package settings
import (
"os"
"github.com/sagernet/sing-box/adapter"
)
type stubWIFIMonitor struct{}
func NewWIFIMonitor(callback func(adapter.WIFIState)) (WIFIMonitor, error) {
return nil, os.ErrInvalid
}
func (m *stubWIFIMonitor) ReadWIFIState() adapter.WIFIState {
return adapter.WIFIState{}
}
func (m *stubWIFIMonitor) Start() error {
return nil
}
func (m *stubWIFIMonitor) Close() error {
return nil
}

View File

@@ -0,0 +1,144 @@
//go:build windows
package settings
import (
"context"
"fmt"
"strings"
"sync"
"syscall"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing/common/winwlanapi"
"golang.org/x/sys/windows"
)
type windowsWIFIMonitor struct {
handle windows.Handle
callback func(adapter.WIFIState)
cancel context.CancelFunc
lastState adapter.WIFIState
mutex sync.Mutex
}
func NewWIFIMonitor(callback func(adapter.WIFIState)) (WIFIMonitor, error) {
handle, err := winwlanapi.OpenHandle()
if err != nil {
return nil, err
}
interfaces, err := winwlanapi.EnumInterfaces(handle)
if err != nil {
winwlanapi.CloseHandle(handle)
return nil, err
}
if len(interfaces) == 0 {
winwlanapi.CloseHandle(handle)
return nil, fmt.Errorf("no wireless interfaces found")
}
return &windowsWIFIMonitor{
handle: handle,
callback: callback,
}, nil
}
func (m *windowsWIFIMonitor) ReadWIFIState() adapter.WIFIState {
interfaces, err := winwlanapi.EnumInterfaces(m.handle)
if err != nil || len(interfaces) == 0 {
return adapter.WIFIState{}
}
for _, iface := range interfaces {
if iface.InterfaceState != winwlanapi.InterfaceStateConnected {
continue
}
guid := iface.InterfaceGUID
attrs, err := winwlanapi.QueryCurrentConnection(m.handle, &guid)
if err != nil {
continue
}
ssidLength := attrs.AssociationAttributes.SSID.Length
if ssidLength == 0 || ssidLength > winwlanapi.Dot11SSIDMaxLength {
continue
}
ssid := string(attrs.AssociationAttributes.SSID.SSID[:ssidLength])
bssid := formatBSSID(attrs.AssociationAttributes.BSSID)
return adapter.WIFIState{
SSID: strings.TrimSpace(ssid),
BSSID: bssid,
}
}
return adapter.WIFIState{}
}
func formatBSSID(mac winwlanapi.Dot11MacAddress) string {
return fmt.Sprintf("%02X%02X%02X%02X%02X%02X",
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5])
}
func (m *windowsWIFIMonitor) Start() error {
if m.callback == nil {
return nil
}
ctx, cancel := context.WithCancel(context.Background())
m.cancel = cancel
m.lastState = m.ReadWIFIState()
callbackFunc := func(data *winwlanapi.NotificationData, callbackContext uintptr) uintptr {
if data.NotificationSource != winwlanapi.NotificationSourceACM {
return 0
}
switch data.NotificationCode {
case winwlanapi.NotificationACMConnectionComplete,
winwlanapi.NotificationACMDisconnected:
m.checkAndNotify()
}
return 0
}
callbackPointer := syscall.NewCallback(callbackFunc)
err := winwlanapi.RegisterNotification(m.handle, winwlanapi.NotificationSourceACM, callbackPointer, 0)
if err != nil {
cancel()
return err
}
go func() {
<-ctx.Done()
}()
m.callback(m.lastState)
return nil
}
func (m *windowsWIFIMonitor) checkAndNotify() {
m.mutex.Lock()
defer m.mutex.Unlock()
state := m.ReadWIFIState()
if state != m.lastState {
m.lastState = state
if m.callback != nil {
m.callback(state)
}
}
}
func (m *windowsWIFIMonitor) Close() error {
if m.cancel != nil {
m.cancel()
}
winwlanapi.UnregisterNotification(m.handle)
return winwlanapi.CloseHandle(m.handle)
}

View File

@@ -119,21 +119,19 @@ func (d *defaultDialer) dialContext(ctx context.Context, destination M.Socksaddr
if err != nil {
return nil, err
}
tlsConn, err := ClientHandshake(ctx, conn, d.config)
if err == nil {
return tlsConn, nil
}
conn.Close()
if echRetry {
tlsConn, err := aTLS.ClientHandshake(ctx, conn, d.config)
if err != nil {
conn.Close()
var echErr *tls.ECHRejectionError
if errors.As(err, &echErr) && len(echErr.RetryConfigList) > 0 {
if echRetry && errors.As(err, &echErr) && len(echErr.RetryConfigList) > 0 {
if echConfig, isECH := d.config.(ECHCapableConfig); isECH {
echConfig.SetECHConfigList(echErr.RetryConfigList)
return d.dialContext(ctx, destination, false)
}
}
return d.dialContext(ctx, destination, false)
return nil, err
}
return nil, err
return tlsConn, nil
}
func (d *defaultDialer) Upstream() any {

View File

@@ -16,7 +16,6 @@ import (
"github.com/sagernet/sing-box/common/tlsfragment"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing/common"
E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/sing/common/logger"
"github.com/sagernet/sing/common/ntp"
@@ -150,13 +149,8 @@ func NewSTDClient(ctx context.Context, logger logger.ContextLogger, serverAddres
return nil, E.New("unknown cipher_suite: ", cipherSuite)
}
}
if len(options.CurvePreferences) > 0 {
for _, curve := range options.CurvePreferences {
tlsConfig.CurvePreferences = append(tlsConfig.CurvePreferences, tls.CurveID(curve))
}
} else {
// DisableX25519MLKEM768 by default
tlsConfig.CurvePreferences = []tls.CurveID{tls.X25519, tls.CurveP256, tls.CurveP384, tls.CurveP521}
for _, curve := range options.CurvePreferences {
tlsConfig.CurvePreferences = append(tlsConfig.CurvePreferences, tls.CurveID(curve))
}
var certificate []byte
if len(options.Certificate) > 0 {
@@ -175,6 +169,35 @@ func NewSTDClient(ctx context.Context, logger logger.ContextLogger, serverAddres
}
tlsConfig.RootCAs = certPool
}
var clientCertificate []byte
if len(options.ClientCertificate) > 0 {
clientCertificate = []byte(strings.Join(options.ClientCertificate, "\n"))
} else if options.ClientCertificatePath != "" {
content, err := os.ReadFile(options.ClientCertificatePath)
if err != nil {
return nil, E.Cause(err, "read client certificate")
}
clientCertificate = content
}
var clientKey []byte
if len(options.ClientKey) > 0 {
clientKey = []byte(strings.Join(options.ClientKey, "\n"))
} else if options.ClientKeyPath != "" {
content, err := os.ReadFile(options.ClientKeyPath)
if err != nil {
return nil, E.Cause(err, "read client key")
}
clientKey = content
}
if len(clientCertificate) > 0 && len(clientKey) > 0 {
keyPair, err := tls.X509KeyPair(clientCertificate, clientKey)
if err != nil {
return nil, E.Cause(err, "parse client x509 key pair")
}
tlsConfig.Certificates = []tls.Certificate{keyPair}
} else if len(clientCertificate) > 0 || len(clientKey) > 0 {
return nil, E.New("client certificate and client key must be provided together")
}
var config Config = &STDClientConfig{ctx, &tlsConfig, options.Fragment, time.Duration(options.FragmentFallbackDelay), options.RecordFragment}
if options.ECH != nil && options.ECH.Enabled {
var err error
@@ -198,62 +221,20 @@ func NewSTDClient(ctx context.Context, logger logger.ContextLogger, serverAddres
}
func verifyPublicKeySHA256(knownHashValues [][]byte, rawCerts [][]byte, timeFunc func() time.Time) error {
for i, rawCert := range rawCerts {
certificate, err := x509.ParseCertificate(rawCert)
if err != nil {
continue
}
leafCertificate, err := x509.ParseCertificate(rawCerts[0])
if err != nil {
return E.Cause(err, "failed to parse leaf certificate")
}
// Extract public key and hash it
pubKeyBytes, err := x509.MarshalPKIXPublicKey(certificate.PublicKey)
if err != nil {
continue
}
hash := sha256.Sum256(pubKeyBytes)
var matched bool
for _, value := range knownHashValues {
if bytes.Equal(value, hash[:]) {
matched = true
break
}
}
if !matched {
continue
}
if i == 0 {
pubKeyBytes, err := x509.MarshalPKIXPublicKey(leafCertificate.PublicKey)
if err != nil {
return E.Cause(err, "failed to marshal public key")
}
hashValue := sha256.Sum256(pubKeyBytes)
for _, value := range knownHashValues {
if bytes.Equal(value, hashValue[:]) {
return nil
}
certificates := make([]*x509.Certificate, i+1)
for j := range certificates {
certificate, err := x509.ParseCertificate(rawCerts[j])
if err != nil {
return err
}
certificates[j] = certificate
}
verifyOptions := x509.VerifyOptions{
Roots: x509.NewCertPool(),
Intermediates: x509.NewCertPool(),
}
if timeFunc != nil {
verifyOptions.CurrentTime = timeFunc()
}
verifyOptions.Roots.AddCert(certificates[i])
for _, certificate := range certificates[1:] {
verifyOptions.Intermediates.AddCert(certificate)
}
return common.Error(certificates[0].Verify(verifyOptions))
}
// Generate error message with first certificate's public key hash
if len(rawCerts) > 0 {
if certificate, err := x509.ParseCertificate(rawCerts[0]); err == nil {
if pubKeyBytes, err := x509.MarshalPKIXPublicKey(certificate.PublicKey); err == nil {
hash := sha256.Sum256(pubKeyBytes)
return E.New("unrecognized public key: ", base64.StdEncoding.EncodeToString(hash[:]))
}
}
}
return E.New("unrecognized certificate")
return E.New("unrecognized remote public key: ", base64.StdEncoding.EncodeToString(hashValue[:]))
}

View File

@@ -222,6 +222,35 @@ func NewUTLSClient(ctx context.Context, logger logger.ContextLogger, serverAddre
}
tlsConfig.RootCAs = certPool
}
var clientCertificate []byte
if len(options.ClientCertificate) > 0 {
clientCertificate = []byte(strings.Join(options.ClientCertificate, "\n"))
} else if options.ClientCertificatePath != "" {
content, err := os.ReadFile(options.ClientCertificatePath)
if err != nil {
return nil, E.Cause(err, "read client certificate")
}
clientCertificate = content
}
var clientKey []byte
if len(options.ClientKey) > 0 {
clientKey = []byte(strings.Join(options.ClientKey, "\n"))
} else if options.ClientKeyPath != "" {
content, err := os.ReadFile(options.ClientKeyPath)
if err != nil {
return nil, E.Cause(err, "read client key")
}
clientKey = content
}
if len(clientCertificate) > 0 && len(clientKey) > 0 {
keyPair, err := utls.X509KeyPair(clientCertificate, clientKey)
if err != nil {
return nil, E.Cause(err, "parse client x509 key pair")
}
tlsConfig.Certificates = []utls.Certificate{keyPair}
} else if len(clientCertificate) > 0 || len(clientKey) > 0 {
return nil, E.New("client certificate and client key must be provided together")
}
id, err := uTLSClientHelloID(options.UTLS.Fingerprint)
if err != nil {
return nil, err

View File

@@ -28,6 +28,7 @@ const (
TypeDERP = "derp"
TypeResolved = "resolved"
TypeSSMAPI = "ssm-api"
TypeCCM = "ccm"
)
const (

View File

@@ -3,7 +3,7 @@ package constant
import "time"
const (
TCPKeepAliveInitial = 10 * time.Minute
TCPKeepAliveInitial = 5 * time.Minute
TCPKeepAliveInterval = 75 * time.Second
TCPConnectTimeout = 5 * time.Second
TCPTimeout = 15 * time.Second

View File

@@ -95,6 +95,20 @@ func (c *Client) Start() {
}
}
func extractNegativeTTL(response *dns.Msg) (uint32, bool) {
for _, record := range response.Ns {
if soa, isSOA := record.(*dns.SOA); isSOA {
soaTTL := soa.Header().Ttl
soaMinimum := soa.Minttl
if soaTTL < soaMinimum {
return soaTTL, true
}
return soaMinimum, true
}
}
return 0, false
}
func (c *Client) Exchange(ctx context.Context, transport adapter.DNSTransport, message *dns.Msg, options adapter.DNSQueryOptions, responseChecker func(responseAddrs []netip.Addr) bool) (*dns.Msg, error) {
if len(message.Question) == 0 {
if c.logger != nil {
@@ -214,7 +228,7 @@ func (c *Client) Exchange(ctx context.Context, transport adapter.DNSTransport, m
response.Answer = append(response.Answer, validResponse.Answer...)
}
}*/
disableCache = disableCache || response.Rcode != dns.RcodeSuccess || len(response.Answer) == 0
disableCache = disableCache || (response.Rcode != dns.RcodeSuccess && response.Rcode != dns.RcodeNameError)
if responseChecker != nil {
var rejected bool
// TODO: add accept_any rule and support to check response instead of addresses
@@ -251,10 +265,17 @@ func (c *Client) Exchange(ctx context.Context, transport adapter.DNSTransport, m
}
}
var timeToLive uint32
for _, recordList := range [][]dns.RR{response.Answer, response.Ns, response.Extra} {
for _, record := range recordList {
if timeToLive == 0 || record.Header().Ttl > 0 && record.Header().Ttl < timeToLive {
timeToLive = record.Header().Ttl
if len(response.Answer) == 0 {
if soaTTL, hasSOA := extractNegativeTTL(response); hasSOA {
timeToLive = soaTTL
}
}
if timeToLive == 0 {
for _, recordList := range [][]dns.RR{response.Answer, response.Ns, response.Extra} {
for _, record := range recordList {
if timeToLive == 0 || record.Header().Ttl > 0 && record.Header().Ttl < timeToLive {
timeToLive = record.Header().Ttl
}
}
}
}
@@ -364,14 +385,18 @@ func (c *Client) LookupCache(domain string, strategy C.DomainStrategy) ([]netip.
Qtype: dns.TypeA,
Qclass: dns.ClassINET,
}, nil)
if response4 == nil {
return nil, false
}
response6, _ := c.loadResponse(dns.Question{
Name: dnsName,
Qtype: dns.TypeAAAA,
Qclass: dns.ClassINET,
}, nil)
if response4 != nil || response6 != nil {
return sortAddresses(MessageToAddresses(response4), MessageToAddresses(response6), strategy), true
if response6 == nil {
return nil, false
}
return sortAddresses(MessageToAddresses(response4), MessageToAddresses(response6), strategy), true
}
return nil, false
}

View File

@@ -15,8 +15,7 @@ func TruncateDNSMessage(request *dns.Msg, response *dns.Msg, headroom int) (*buf
}
responseLen := response.Len()
if responseLen > maxLen {
copyResponse := *response
response = &copyResponse
response = response.Copy()
response.Truncate(maxLen)
}
buffer := buf.NewSize(headroom*2 + 1 + responseLen)

View File

@@ -386,12 +386,7 @@ func (r *Router) Lookup(ctx context.Context, domain string, options adapter.DNSQ
if rule != nil {
switch action := rule.Action().(type) {
case *R.RuleActionReject:
switch action.Method {
case C.RuleActionRejectMethodDefault:
return nil, nil
case C.RuleActionRejectMethodDrop:
return nil, tun.ErrDrop
}
return nil, &R.RejectedError{Cause: action.Error(ctx)}
case *R.RuleActionPredefined:
if action.Rcode != mDNS.RcodeSuccess {
err = RcodeError(action.Rcode)

View File

@@ -25,7 +25,6 @@ import (
"github.com/sagernet/sing/common/logger"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
aTLS "github.com/sagernet/sing/common/tls"
sHTTP "github.com/sagernet/sing/protocol/http"
mDNS "github.com/miekg/dns"
@@ -47,7 +46,7 @@ type HTTPSTransport struct {
destination *url.URL
headers http.Header
transportAccess sync.Mutex
transport *http.Transport
transport *HTTPSTransportWrapper
transportResetAt time.Time
}
@@ -62,11 +61,8 @@ func NewHTTPS(ctx context.Context, logger log.ContextLogger, tag string, options
if err != nil {
return nil, err
}
if common.Error(tlsConfig.STDConfig()) == nil && !common.Contains(tlsConfig.NextProtos(), http2.NextProtoTLS) {
tlsConfig.SetNextProtos(append(tlsConfig.NextProtos(), http2.NextProtoTLS))
}
if !common.Contains(tlsConfig.NextProtos(), "http/1.1") {
tlsConfig.SetNextProtos(append(tlsConfig.NextProtos(), "http/1.1"))
if len(tlsConfig.NextProtos()) == 0 {
tlsConfig.SetNextProtos([]string{http2.NextProtoTLS, "http/1.1"})
}
headers := options.Headers.Build()
host := headers.Get("Host")
@@ -124,37 +120,13 @@ func NewHTTPSRaw(
serverAddr M.Socksaddr,
tlsConfig tls.Config,
) *HTTPSTransport {
var transport *http.Transport
if tlsConfig != nil {
transport = &http.Transport{
ForceAttemptHTTP2: true,
DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
tcpConn, hErr := dialer.DialContext(ctx, network, serverAddr)
if hErr != nil {
return nil, hErr
}
tlsConn, hErr := aTLS.ClientHandshake(ctx, tcpConn, tlsConfig)
if hErr != nil {
tcpConn.Close()
return nil, hErr
}
return tlsConn, nil
},
}
} else {
transport = &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialer.DialContext(ctx, network, serverAddr)
},
}
}
return &HTTPSTransport{
TransportAdapter: adapter,
logger: logger,
dialer: dialer,
destination: destination,
headers: headers,
transport: transport,
transport: NewHTTPSTransportWrapper(tls.NewDialer(dialer, tlsConfig), serverAddr),
}
}

View File

@@ -0,0 +1,80 @@
package transport
import (
"context"
"errors"
"net"
"net/http"
"sync/atomic"
"github.com/sagernet/sing-box/common/tls"
E "github.com/sagernet/sing/common/exceptions"
M "github.com/sagernet/sing/common/metadata"
"golang.org/x/net/http2"
)
var errFallback = E.New("fallback to HTTP/1.1")
type HTTPSTransportWrapper struct {
http2Transport *http2.Transport
httpTransport *http.Transport
fallback *atomic.Bool
}
func NewHTTPSTransportWrapper(dialer tls.Dialer, serverAddr M.Socksaddr) *HTTPSTransportWrapper {
var fallback atomic.Bool
return &HTTPSTransportWrapper{
http2Transport: &http2.Transport{
DialTLSContext: func(ctx context.Context, _, _ string, _ *tls.STDConfig) (net.Conn, error) {
tlsConn, err := dialer.DialTLSContext(ctx, serverAddr)
if err != nil {
return nil, err
}
state := tlsConn.ConnectionState()
if state.NegotiatedProtocol == http2.NextProtoTLS {
return tlsConn, nil
}
tlsConn.Close()
fallback.Store(true)
return nil, errFallback
},
},
httpTransport: &http.Transport{
DialTLSContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return dialer.DialTLSContext(ctx, serverAddr)
},
},
fallback: &fallback,
}
}
func (h *HTTPSTransportWrapper) RoundTrip(request *http.Request) (*http.Response, error) {
if h.fallback.Load() {
return h.httpTransport.RoundTrip(request)
} else {
response, err := h.http2Transport.RoundTrip(request)
if err != nil {
if errors.Is(err, errFallback) {
return h.httpTransport.RoundTrip(request)
}
return nil, err
}
return response, nil
}
}
func (h *HTTPSTransportWrapper) CloseIdleConnections() {
h.http2Transport.CloseIdleConnections()
h.httpTransport.CloseIdleConnections()
}
func (h *HTTPSTransportWrapper) Clone() *HTTPSTransportWrapper {
return &HTTPSTransportWrapper{
httpTransport: h.httpTransport,
http2Transport: &http2.Transport{
DialTLSContext: h.http2Transport.DialTLSContext,
},
fallback: h.fallback,
}
}

View File

@@ -53,13 +53,15 @@ func (t *Transport) Start(stage adapter.StartStage) error {
switch stage {
case adapter.StartStateInitialize:
if !t.preferGo {
resolvedResolver, err := NewResolvedResolver(t.ctx, t.logger)
if err == nil {
err = resolvedResolver.Start()
if isSystemdResolvedManaged() {
resolvedResolver, err := NewResolvedResolver(t.ctx, t.logger)
if err == nil {
t.resolved = resolvedResolver
} else {
t.logger.Warn(E.Cause(err, "initialize resolved resolver"))
err = resolvedResolver.Start()
if err == nil {
t.resolved = resolvedResolver
} else {
t.logger.Warn(E.Cause(err, "initialize resolved resolver"))
}
}
}
}
@@ -82,12 +84,11 @@ func (t *Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg,
}
}
question := message.Question[0]
domain := dns.FqdnToDomain(question.Name)
if question.Qtype == mDNS.TypeA || question.Qtype == mDNS.TypeAAAA {
addresses := t.hosts.Lookup(domain)
addresses := t.hosts.Lookup(dns.FqdnToDomain(question.Name))
if len(addresses) > 0 {
return dns.FixedResponse(message.Id, question, addresses, C.DefaultDNSTTL), nil
}
}
return t.exchange(ctx, message, domain)
return t.exchange(ctx, message, question.Name)
}

View File

@@ -96,15 +96,14 @@ func (t *Transport) Close() error {
func (t *Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) {
question := message.Question[0]
domain := dns.FqdnToDomain(question.Name)
if question.Qtype == mDNS.TypeA || question.Qtype == mDNS.TypeAAAA {
addresses := t.hosts.Lookup(domain)
addresses := t.hosts.Lookup(dns.FqdnToDomain(question.Name))
if len(addresses) > 0 {
return dns.FixedResponse(message.Id, question, addresses, C.DefaultDNSTTL), nil
}
}
if !t.fallback {
return t.exchange(ctx, message, domain)
return t.exchange(ctx, message, question.Name)
}
if !C.IsIos {
if t.dhcpTransport != nil {
@@ -116,7 +115,7 @@ func (t *Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg,
}
if t.preferGo {
// Assuming the user knows what they are doing, we still execute the query which will fail.
return t.exchange(ctx, message, domain)
return t.exchange(ctx, message, question.Name)
}
if question.Qtype == mDNS.TypeA || question.Qtype == mDNS.TypeAAAA {
var network string
@@ -125,7 +124,7 @@ func (t *Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg,
} else {
network = "ip6"
}
addresses, err := t.resolver.LookupNetIP(ctx, network, domain)
addresses, err := t.resolver.LookupNetIP(ctx, network, question.Name)
if err != nil {
var dnsError *net.DNSError
if errors.As(err, &dnsError) && dnsError.IsNotFound {

View File

@@ -1,9 +1,11 @@
package local
import (
"bufio"
"context"
"errors"
"os"
"strings"
"sync"
"sync/atomic"
@@ -22,6 +24,25 @@ import (
mDNS "github.com/miekg/dns"
)
func isSystemdResolvedManaged() bool {
resolvContent, err := os.Open("/etc/resolv.conf")
if err != nil {
return false
}
defer resolvContent.Close()
scanner := bufio.NewScanner(resolvContent)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || line[0] != '#' {
return false
}
if strings.Contains(line, "systemd-resolved") {
return true
}
}
return false
}
type DBusResolvedResolver struct {
ctx context.Context
logger logger.ContextLogger
@@ -188,7 +209,7 @@ func (t *DBusResolvedResolver) checkResolved(ctx context.Context) (*ResolvedObje
int32(defaultInterface.Index),
)
if call.Err != nil {
return nil, err
return nil, call.Err
}
var linkPath dbus.ObjectPath
err = call.Store(&linkPath)
@@ -214,15 +235,12 @@ func (t *DBusResolvedResolver) checkResolved(ctx context.Context) (*ResolvedObje
return nil, E.New("No appropriate name servers or networks for name found")
}
}
return &ResolvedObject{
BusObject: dbusObject,
}, nil
} else {
return &ResolvedObject{
BusObject: dbusObject,
InterfaceIndex: int32(defaultInterface.Index),
}, nil
return nil, E.New("link has no DNS servers configured")
}
return &ResolvedObject{
BusObject: dbusObject,
InterfaceIndex: int32(defaultInterface.Index),
}, nil
}
func (t *DBusResolvedResolver) updateDefaultInterface(defaultInterface *control.Interface, flags int) {

View File

@@ -9,6 +9,10 @@ import (
"github.com/sagernet/sing/common/logger"
)
func isSystemdResolvedManaged() bool {
return false
}
func NewResolvedResolver(ctx context.Context, logger logger.ContextLogger) (ResolvedResolver, error) {
return nil, os.ErrInvalid
}

View File

@@ -2,6 +2,101 @@
icon: material/alert-decagram
---
#### 1.13.0-alpha.28
* Update quic-go to v0.57.1
* Add `disable_tcp_keep_alive`, `tcp_keep_alive` and `tcp_keep_alive_interval` options for dial fields **1**
* Update default TCP keep-alive initial period from 10 minutes to 5 minutes
* Fixes and improvements
**1**:
See [Dial Fields](/configuration/shared/dial/#tcp_keep_alive).
__Unfortunately, for non-technical reasons, we are currently unable to notarize the standalone version of the macOS client:
because system extensions require signatures to function, we have had to temporarily halt its release.__
__We plan to fix the App Store release issue and launch a new standalone desktop client, but until then,
only clients on TestFlight will be available (unless you have an Apple Developer Program and compile from source code).__
#### 1.12.13
* Fixes and improvements
__Unfortunately, for non-technical reasons, we are currently unable to notarize the standalone version of the macOS client:
because system extensions require signatures to function, we have had to temporarily halt its release.__
__We plan to fix the App Store release issue and launch a new standalone desktop client, but until then,
only clients on TestFlight will be available (unless you have an Apple Developer Program and compile from source code).__
#### 1.12.12
* Fixes and improvements
#### 1.13.0-alpha.26
* Update quic-go to v0.55.0
* Fix memory leak in hysteria2
* Fixes and improvements
#### 1.12.11
* Fixes and improvements
#### 1.13.0-alpha.24
* Add Claude Code Multiplexer service **1**
* Fixes and improvements
**1**:
CCM (Claude Code Multiplexer) service allows you to access your local Claude Code subscription remotely through custom tokens, eliminating the need for OAuth authentication on remote clients.
See [CCM](/configuration/service/ccm).
#### 1.13.0-alpha.23
* Fix compatibility with MPTCP **1**
* Fixes and improvements
**1**:
`auto_redirect` now rejects MPTCP connections by default to fix compatibility issues,
but you can change it to bypass the sing-box via the new `exclude_mptcp` option.
See [TUN](/configuration/inbound/tun/#exclude_mptcp).
#### 1.13.0-alpha.22
* Update uTLS to v1.8.1 **1**
* Fixes and improvements
**1**:
This update fixes an critical issue that could cause simulated Chrome fingerprints to be detected,
see https://github.com/refraction-networking/utls/pull/375.
#### 1.12.10
* Update uTLS to v1.8.1 **1**
* Fixes and improvements
**1**:
This update fixes an critical issue that could cause simulated Chrome fingerprints to be detected,
see https://github.com/refraction-networking/utls/pull/375.
#### 1.13.0-alpha.21
* Fix missing mTLS support in client options **1**
* Fixes and improvements
See [TLS](/configuration/shared/tls/).
#### 1.12.9
* Fixes and improvements
#### 1.13.0-alpha.16
* Add curve preferences, pinned public key SHA256 and mTLS for TLS options **1**
@@ -119,7 +214,8 @@ See [Tailscale](/configuration/endpoint/tailscale/).
Due to maintenance difficulties, sing-box 1.12.0 requires at least Go 1.23 to compile.
For Windows 7 users, legacy binaries now continue to compile with Go 1.23 and patches from [MetaCubeX/go](https://github.com/MetaCubeX/go).
For Windows 7 users, legacy binaries now continue to compile with Go 1.23 and patches
from [MetaCubeX/go](https://github.com/MetaCubeX/go).
**7**:
@@ -181,7 +277,8 @@ See [Tun](/configuration/inbound/tun/#loopback_address).
We have significantly improved the performance of tun inbound on Apple platforms, especially in the gVisor stack.
The following data was tested using [tun_bench](https://github.com/SagerNet/sing-box/blob/dev-next/cmd/internal/tun_bench/main.go) on M4 MacBook pro.
The following data was tested
using [tun_bench](https://github.com/SagerNet/sing-box/blob/dev-next/cmd/internal/tun_bench/main.go) on M4 MacBook pro.
| Version | Stack | MTU | Upload | Download |
|-------------|--------|-------|--------|----------|
@@ -200,8 +297,8 @@ The following data was tested using [tun_bench](https://github.com/SagerNet/sing
**18**:
We continue to experience issues updating our sing-box apps on the App Store and Play Store.
Until we rewrite and resubmit the apps, they are considered irrecoverable.
We continue to experience issues updating our sing-box apps on the App Store and Play Store.
Until we rewrite and resubmit the apps, they are considered irrecoverable.
Therefore, after this release, we will not be repeating this notice unless there is new information.
### 1.11.15
@@ -482,7 +579,8 @@ See [AnyTLS Inbound](/configuration/inbound/anytls/) and [AnyTLS Outbound](/conf
**2**:
`resolve` route action now accepts `disable_cache` and other options like in DNS route actions, see [Route Action](/configuration/route/rule_action).
`resolve` route action now accepts `disable_cache` and other options like in DNS route actions,
see [Route Action](/configuration/route/rule_action).
**3**:
@@ -513,7 +611,8 @@ See [Tailscale](/configuration/endpoint/tailscale/).
Due to maintenance difficulties, sing-box 1.12.0 requires at least Go 1.23 to compile.
For Windows 7 users, legacy binaries now continue to compile with Go 1.23 and patches from [MetaCubeX/go](https://github.com/MetaCubeX/go).
For Windows 7 users, legacy binaries now continue to compile with Go 1.23 and patches
from [MetaCubeX/go](https://github.com/MetaCubeX/go).
### 1.11.3

View File

@@ -9,7 +9,7 @@ platform-specific function implementation, such as TUN transparent proxy impleme
!!! failure ""
We are temporarily unable to update sing-box apps on the App Store because the reviewer mistakenly found that we violated the rules (TestFlight users are not affected).
Due to non-technical reasons, we are temporarily unable to update the sing-box app on the App Store and release the standalone version of the macOS client (TestFlight users are not affected)
## :material-graph: Requirements
@@ -18,7 +18,7 @@ platform-specific function implementation, such as TUN transparent proxy impleme
## :material-download: Download
* [App Store](https://apps.apple.com/app/sing-box-vt/id6673731168)
* ~~[App Store](https://apps.apple.com/app/sing-box-vt/id6673731168)~~
* TestFlight (Beta)
TestFlight quota is only available to [sponsors](https://github.com/sponsors/nekohasekai)
@@ -26,15 +26,15 @@ TestFlight quota is only available to [sponsors](https://github.com/sponsors/nek
Once you donate, you can get an invitation by join our Telegram group for sponsors from [@yet_another_sponsor_bot](https://t.me/yet_another_sponsor_bot)
or sending us your Apple ID [via email](mailto:contact@sagernet.org).
## :material-file-download: Download (macOS standalone version)
## ~~:material-file-download: Download (macOS standalone version)~~
* [Homebrew Cask](https://formulae.brew.sh/cask/sfm)
* ~~[Homebrew Cask](https://formulae.brew.sh/cask/sfm)~~
```bash
brew install sfm
# brew install sfm
```
* [GitHub Releases](https://github.com/SagerNet/sing-box/releases)
* ~~[GitHub Releases](https://github.com/SagerNet/sing-box/releases)~~
## :material-source-repository: Source code

View File

@@ -412,7 +412,7 @@ Match default interface address.
!!! quote ""
Only supported in graphical clients on Android and Apple platforms.
Only supported in graphical clients on Android and Apple platforms, or on Linux.
Match WiFi SSID.
@@ -420,7 +420,7 @@ Match WiFi SSID.
!!! quote ""
Only supported in graphical clients on Android and Apple platforms.
Only supported in graphical clients on Android and Apple platforms, or on Linux.
Match WiFi BSSID.

View File

@@ -411,7 +411,7 @@ Available values: `wifi`, `cellular`, `ethernet` and `other`.
!!! quote ""
仅在 Android 与 Apple 平台图形客户端中支持。
仅在 Android 与 Apple 平台图形客户端和 Linux 中支持。
匹配 WiFi SSID。
@@ -419,7 +419,7 @@ Available values: `wifi`, `cellular`, `ethernet` and `other`.
!!! quote ""
仅在 Android 与 Apple 平台图形客户端中支持。
仅在 Android 与 Apple 平台图形客户端和 Linux 中支持。
匹配 WiFi BSSID。

View File

@@ -2,6 +2,10 @@
icon: material/new-box
---
!!! quote "Changes in sing-box 1.13.0"
:material-plus: [exclude_mptcp](#exclude_mptcp)
!!! quote "Changes in sing-box 1.12.0"
:material-plus: [loopback_address](#loopback_address)
@@ -63,6 +67,7 @@ icon: material/new-box
"auto_redirect": true,
"auto_redirect_input_mark": "0x2023",
"auto_redirect_output_mark": "0x2024",
"exclude_mptcp": false,
"loopback_address": [
"10.7.0.1"
],
@@ -278,6 +283,20 @@ Connection output mark used by `auto_redirect`.
`0x2024` is used by default.
#### exclude_mptcp
!!! question "Since sing-box 1.13.0"
!!! quote ""
Only supported on Linux with nftables and requires `auto_route` and `auto_redirect` enabled.
MPTCP cannot be transparently proxied due to protocol limitations.
Such traffic is usually created by Apple systems.
When enabled, MPTCP connections will bypass sing-box and connect directly, otherwise, will be rejected to avoid errors by default.
#### loopback_address
!!! question "Since sing-box 1.12.0"

View File

@@ -2,6 +2,10 @@
icon: material/new-box
---
!!! quote "sing-box 1.13.0 中的更改"
:material-plus: [exclude_mptcp](#exclude_mptcp)
!!! quote "sing-box 1.12.0 中的更改"
:material-plus: [loopback_address](#loopback_address)
@@ -63,6 +67,7 @@ icon: material/new-box
"auto_redirect": true,
"auto_redirect_input_mark": "0x2023",
"auto_redirect_output_mark": "0x2024",
"exclude_mptcp": false,
"loopback_address": [
"10.7.0.1"
],
@@ -277,6 +282,20 @@ tun 接口的 IPv6 前缀。
默认使用 `0x2024`
#### exclude_mptcp
!!! question "自 sing-box 1.13.0 起"
!!! quote ""
仅支持 Linux且需要 nftables`auto_route``auto_redirect` 已启用。
由于协议限制MPTCP 无法被透明代理。
此类流量通常由 Apple 系统创建。
启用时MPTCP 连接将绕过 sing-box 直接连接,否则,将被拒绝以避免错误。
#### loopback_address
!!! question "自 sing-box 1.12.0 起"

View File

@@ -430,7 +430,7 @@ Match default interface address.
!!! quote ""
Only supported in graphical clients on Android and Apple platforms.
Only supported in graphical clients on Android and Apple platforms, or on Linux.
Match WiFi SSID.
@@ -438,7 +438,7 @@ Match WiFi SSID.
!!! quote ""
Only supported in graphical clients on Android and Apple platforms.
Only supported in graphical clients on Android and Apple platforms, or on Linux.
Match WiFi BSSID.

View File

@@ -427,7 +427,7 @@ icon: material/new-box
!!! quote ""
仅在 Android 与 Apple 平台图形客户端中支持。
仅在 Android 与 Apple 平台图形客户端和 Linux 中支持。
匹配 WiFi SSID。
@@ -435,7 +435,7 @@ icon: material/new-box
!!! quote ""
仅在 Android 与 Apple 平台图形客户端中支持。
仅在 Android 与 Apple 平台图形客户端和 Linux 中支持。
匹配 WiFi BSSID。

View File

@@ -0,0 +1,106 @@
---
icon: material/new-box
---
!!! question "Since sing-box 1.13.0"
# CCM
CCM (Claude Code Multiplexer) service is a multiplexing service that allows you to access your local Claude Code subscription remotely through custom tokens.
It handles OAuth authentication with Claude's API on your local machine while allowing remote Claude Code to authenticate using Auth Tokens via the `ANTHROPIC_AUTH_TOKEN` environment variable.
### Structure
```json
{
"type": "ccm",
... // Listen Fields
"credential_path": "",
"usages_path": "",
"users": [],
"headers": {},
"detour": "",
"tls": {}
}
```
### Listen Fields
See [Listen Fields](/configuration/shared/listen/) for details.
### Fields
#### credential_path
Path to the Claude Code OAuth credentials file.
If not specified, defaults to:
- `$CLAUDE_CONFIG_DIR/.credentials.json` if `CLAUDE_CONFIG_DIR` environment variable is set
- `~/.claude/.credentials.json` otherwise
On macOS, credentials are read from the system keychain first, then fall back to the file if unavailable.
Refreshed tokens are automatically written back to the same location.
#### usages_path
Path to the file for storing aggregated API usage statistics.
Usage tracking is disabled if not specified.
When enabled, the service tracks and saves comprehensive statistics including:
- Request counts
- Token usage (input, output, cache read, cache creation)
- Calculated costs in USD based on Claude API pricing
Statistics are organized by model, context window (200k standard vs 1M premium), and optionally by user when authentication is enabled.
The statistics file is automatically saved every minute and upon service shutdown.
#### users
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.
#### headers
Custom HTTP headers to send to the Claude API.
These headers will override any existing headers with the same name.
#### detour
Outbound tag for connecting to the Claude API.
#### tls
TLS configuration, see [TLS](/configuration/shared/tls/#inbound).
### Example
```json
{
"services": [
{
"type": "ccm",
"listen": "127.0.0.1",
"listen_port": 8080
}
]
}
```
Connect to the CCM service:
```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"
claude
```

View File

@@ -0,0 +1,106 @@
---
icon: material/new-box
---
!!! question "自 sing-box 1.13.0 起"
# CCM
CCMClaude Code 多路复用器)服务是一个多路复用服务,允许您通过自定义令牌远程访问本地的 Claude Code 订阅。
它在本地机器上处理与 Claude API 的 OAuth 身份验证,同时允许远程 Claude Code 通过 `ANTHROPIC_AUTH_TOKEN` 环境变量使用认证令牌进行身份验证。
### 结构
```json
{
"type": "ccm",
... // 监听字段
"credential_path": "",
"usages_path": "",
"users": [],
"headers": {},
"detour": "",
"tls": {}
}
```
### 监听字段
参阅 [监听字段](/zh/configuration/shared/listen/) 了解详情。
### 字段
#### credential_path
Claude Code OAuth 凭据文件的路径。
如果未指定,默认值为:
- 如果设置了 `CLAUDE_CONFIG_DIR` 环境变量,则使用 `$CLAUDE_CONFIG_DIR/.credentials.json`
- 否则使用 `~/.claude/.credentials.json`
在 macOS 上,首先从系统钥匙串读取凭据,如果不可用则回退到文件。
刷新的令牌会自动写回相同位置。
#### usages_path
用于存储聚合 API 使用统计信息的文件路径。
如果未指定,使用跟踪将被禁用。
启用后,服务会跟踪并保存全面的统计信息,包括:
- 请求计数
- 令牌使用量(输入、输出、缓存读取、缓存创建)
- 基于 Claude API 定价计算的美元成本
统计信息按模型、上下文窗口200k 标准版 vs 1M 高级版)以及可选的用户(启用身份验证时)进行组织。
统计文件每分钟自动保存一次,并在服务关闭时保存。
#### users
用于令牌身份验证的授权用户列表。
如果为空,则不需要身份验证。
Claude Code 通过设置 `ANTHROPIC_AUTH_TOKEN` 环境变量为其令牌值进行身份验证。
#### headers
发送到 Claude API 的自定义 HTTP 头。
这些头会覆盖同名的现有头。
#### detour
用于连接 Claude API 的出站标签。
#### tls
TLS 配置,参阅 [TLS](/zh/configuration/shared/tls/#inbound)。
### 示例
```json
{
"services": [
{
"type": "ccm",
"listen": "127.0.0.1",
"listen_port": 8080
}
]
}
```
连接到 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"
claude
```

View File

@@ -23,6 +23,7 @@ icon: material/new-box
| Type | Format |
|------------|------------------------|
| `ccm` | [CCM](./ccm) |
| `derp` | [DERP](./derp) |
| `resolved` | [Resolved](./resolved) |
| `ssm-api` | [SSM API](./ssm-api) |

View File

@@ -23,6 +23,7 @@ icon: material/new-box
| 类型 | 格式 |
|-----------|------------------------|
| `ccm` | [CCM](./ccm) |
| `derp` | [DERP](./derp) |
| `resolved`| [Resolved](./resolved) |
| `ssm-api` | [SSM API](./ssm-api) |

View File

@@ -2,6 +2,12 @@
icon: material/new-box
---
!!! quote "Changes in sing-box 1.13.0"
:material-plus: [disable_tcp_keep_alive](#disable_tcp_keep_alive)
:material-plus: [tcp_keep_alive](#tcp_keep_alive)
:material-plus: [tcp_keep_alive_interval](#tcp_keep_alive_interval)
!!! quote "Changes in sing-box 1.12.0"
:material-plus: [domain_resolver](#domain_resolver)
@@ -29,8 +35,11 @@ icon: material/new-box
"connect_timeout": "",
"tcp_fast_open": false,
"tcp_multi_path": false,
"disable_tcp_keep_alive": false,
"tcp_keep_alive": "",
"tcp_keep_alive_interval": "",
"udp_fragment": false,
"domain_resolver": "", // or {}
"network_strategy": "",
"network_type": [],
@@ -112,6 +121,30 @@ Enable TCP Fast Open.
Enable TCP Multi Path.
#### disable_tcp_keep_alive
!!! question "Since sing-box 1.13.0"
Disable TCP keep alive.
#### tcp_keep_alive
!!! question "Since sing-box 1.13.0"
Default value changed from `10m` to `5m`.
TCP keep-alive initial period.
`5m` will be used by default.
#### tcp_keep_alive_interval
!!! question "Since sing-box 1.13.0"
TCP keep-alive interval.
`75s` will be used by default.
#### udp_fragment
Enable UDP fragmentation.

View File

@@ -2,6 +2,12 @@
icon: material/new-box
---
!!! quote "sing-box 1.13.0 中的更改"
:material-plus: [disable_tcp_keep_alive](#disable_tcp_keep_alive)
:material-plus: [tcp_keep_alive](#tcp_keep_alive)
:material-plus: [tcp_keep_alive_interval](#tcp_keep_alive_interval)
!!! quote "sing-box 1.12.0 中的更改"
:material-plus: [domain_resolver](#domain_resolver)
@@ -29,7 +35,11 @@ icon: material/new-box
"connect_timeout": "",
"tcp_fast_open": false,
"tcp_multi_path": false,
"disable_tcp_keep_alive": false,
"tcp_keep_alive": "",
"tcp_keep_alive_interval": "",
"udp_fragment": false,
"domain_resolver": "", // 或 {}
"network_strategy": "",
"network_type": [],
@@ -109,6 +119,30 @@ icon: material/new-box
启用 TCP Multi Path。
#### disable_tcp_keep_alive
!!! question "自 sing-box 1.13.0 起"
禁用 TCP keep alive。
#### tcp_keep_alive
!!! question "自 sing-box 1.13.0 起"
默认值从 `10m` 更改为 `5m`
TCP keep-alive 初始周期。
默认使用 `5m`
#### tcp_keep_alive_interval
!!! question "自 sing-box 1.13.0 起"
TCP keep-alive 间隔。
默认使用 `75s`
#### udp_fragment
启用 UDP 分段。

View File

@@ -2,6 +2,11 @@
icon: material/new-box
---
!!! quote "Changes in sing-box 1.13.0"
:material-plus: [disable_tcp_keep_alive](#disable_tcp_keep_alive)
:material-alert: [tcp_keep_alive](#tcp_keep_alive)
!!! quote "Changes in sing-box 1.12.0"
:material-plus: [netns](#netns)
@@ -29,6 +34,9 @@ icon: material/new-box
"netns": "",
"tcp_fast_open": false,
"tcp_multi_path": false,
"disable_tcp_keep_alive": false,
"tcp_keep_alive": "",
"tcp_keep_alive_interval": "",
"udp_fragment": false,
"udp_timeout": "",
"detour": "",
@@ -101,6 +109,28 @@ Enable TCP Fast Open.
Enable TCP Multi Path.
#### disable_tcp_keep_alive
!!! question "Since sing-box 1.13.0"
Disable TCP keep alive.
#### tcp_keep_alive
!!! question "Since sing-box 1.13.0"
Default value changed from `10m` to `5m`.
TCP keep alive initial period.
`5m` will be used by default.
#### tcp_keep_alive_interval
TCP keep-alive interval.
`75s` will be used by default.
#### udp_fragment
Enable UDP fragmentation.

View File

@@ -2,6 +2,11 @@
icon: material/new-box
---
!!! quote "sing-box 1.13.0 中的更改"
:material-plus: [disable_tcp_keep_alive](#disable_tcp_keep_alive)
:material-alert: [tcp_keep_alive](#tcp_keep_alive)
!!! quote "Changes in sing-box 1.12.0"
:material-plus: [netns](#netns)
@@ -29,6 +34,9 @@ icon: material/new-box
"netns": "",
"tcp_fast_open": false,
"tcp_multi_path": false,
"disable_tcp_keep_alive": false,
"tcp_keep_alive": "",
"tcp_keep_alive_interval": "",
"udp_fragment": false,
"udp_timeout": "",
"detour": "",
@@ -101,6 +109,28 @@ icon: material/new-box
启用 TCP Multi Path。
#### disable_tcp_keep_alive
!!! question "自 sing-box 1.13.0 起"
禁用 TCP keep alive。
#### tcp_keep_alive
!!! question "自 sing-box 1.13.0 起"
默认值从 `10m` 更改为 `5m`
TCP keep alive 初始周期。
默认使用 `5m`
#### tcp_keep_alive_interval
TCP keep-alive 间隔。
默认使用 `75s`
#### udp_fragment
启用 UDP 分段。

View File

@@ -4,13 +4,15 @@ icon: material/new-box
!!! quote "Changes in sing-box 1.13.0"
:material-plus: [kernel_tx](#kernel_tx)
:material-plus: [kernel_rx](#kernel_rx)
:material-plus: [curve_preferences](#curve_preferences)
:material-plus: [certificate_public_key_sha256](#certificate_public_key_sha256)
:material-plus: [client_authentication](#client_authentication)
:material-plus: [client_certificate](#client_certificate)
:material-plus: [client_certificate_path](#client_certificate_path)
:material-plus: [kernel_tx](#kernel_tx)
:material-plus: [kernel_rx](#kernel_rx)
:material-plus: [curve_preferences](#curve_preferences)
:material-plus: [certificate_public_key_sha256](#certificate_public_key_sha256)
:material-plus: [client_certificate](#client_certificate)
:material-plus: [client_certificate_path](#client_certificate_path)
:material-plus: [client_key](#client_key)
:material-plus: [client_key_path](#client_key_path)
:material-plus: [client_authentication](#client_authentication)
:material-plus: [client_certificate_public_key_sha256](#client_certificate_public_key_sha256)
!!! quote "Changes in sing-box 1.12.0"
@@ -101,9 +103,14 @@ icon: material/new-box
"min_version": "",
"max_version": "",
"cipher_suites": [],
"curve_preferences": [],
"certificate": "",
"certificate_path": "",
"certificate_public_key_sha256": [],
"client_certificate": [],
"client_certificate_path": "",
"client_key": [],
"client_key_path": "",
"fragment": false,
"fragment_fallback_delay": "",
"record_fragment": false,
@@ -258,6 +265,38 @@ openssl x509 -in certificate.pem -pubkey -noout | openssl pkey -pubin -outform d
echo | openssl s_client -servername example.com -connect example.com:443 2>/dev/null | openssl x509 -pubkey -noout | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl enc -base64
```
#### client_certificate
!!! question "Since sing-box 1.13.0"
==Client only==
Client certificate chain line array, in PEM format.
#### client_certificate_path
!!! question "Since sing-box 1.13.0"
==Client only==
The path to client certificate chain, in PEM format.
#### client_key
!!! question "Since sing-box 1.13.0"
==Client only==
Client private key line array, in PEM format.
#### client_key_path
!!! question "Since sing-box 1.13.0"
==Client only==
The path to client private key, in PEM format.
#### key
==Server only==

View File

@@ -8,9 +8,11 @@ icon: material/new-box
:material-plus: [kernel_rx](#kernel_rx)
:material-plus: [curve_preferences](#curve_preferences)
:material-plus: [certificate_public_key_sha256](#certificate_public_key_sha256)
:material-plus: [client_authentication](#client_authentication)
:material-plus: [client_certificate](#client_certificate)
:material-plus: [client_certificate_path](#client_certificate_path)
:material-plus: [client_key](#client_key)
:material-plus: [client_key_path](#client_key_path)
:material-plus: [client_authentication](#client_authentication)
:material-plus: [client_certificate_public_key_sha256](#client_certificate_public_key_sha256)
!!! quote "sing-box 1.12.0 中的更改"
@@ -101,9 +103,14 @@ icon: material/new-box
"min_version": "",
"max_version": "",
"cipher_suites": [],
"curve_preferences": [],
"certificate": "",
"certificate_path": "",
"certificate_public_key_sha256": [],
"client_certificate": [],
"client_certificate_path": "",
"client_key": [],
"client_key_path": "",
"fragment": false,
"fragment_fallback_delay": "",
"record_fragment": false,
@@ -253,6 +260,38 @@ openssl x509 -in certificate.pem -pubkey -noout | openssl pkey -pubin -outform d
echo | openssl s_client -servername example.com -connect example.com:443 2>/dev/null | openssl x509 -pubkey -noout | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl enc -base64
```
#### client_certificate
!!! question "自 sing-box 1.13.0 起"
==仅客户端==
客户端证书链行数组PEM 格式。
#### client_certificate_path
!!! question "自 sing-box 1.13.0 起"
==仅客户端==
客户端证书链路径PEM 格式。
#### client_key
!!! question "自 sing-box 1.13.0 起"
==仅客户端==
客户端私钥行数组PEM 格式。
#### client_key_path
!!! question "自 sing-box 1.13.0 起"
==仅客户端==
客户端私钥路径PEM 格式。
#### key
==仅服务器==

View File

@@ -11,16 +11,22 @@ the project maintainer via [GitHub Sponsors](https://github.com/sponsors/nekohas
![](https://nekohasekai.github.io/sponsor-images/sponsors.svg)
### Special Sponsors
## Commercial Sponsors
**Viral Tech, Inc.**
> [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.
Helping us re-list sing-box apps on the Apple Store.
---
[![JetBrains logo](https://resources.jetbrains.com/storage/products/company/brand/logos/jetbrains.svg)](https://www.jetbrains.com)
> [JetBrains](https://www.jetbrains.com)
Free license for the amazing IDEs.
---
[![JetBrains logo](https://resources.jetbrains.com/storage/products/company/brand/logos/jetbrains.svg)](https://www.jetbrains.com)

View File

@@ -107,6 +107,10 @@ func (s *platformInterfaceStub) IncludeAllNetworks() bool {
func (s *platformInterfaceStub) ClearDNSCache() {
}
func (s *platformInterfaceStub) UsePlatformWIFIMonitor() bool {
return false
}
func (s *platformInterfaceStub) ReadWIFIState() adapter.WIFIState {
return adapter.WIFIState{}
}

View File

@@ -18,6 +18,7 @@ type Interface interface {
UnderNetworkExtension() bool
IncludeAllNetworks() bool
ClearDNSCache()
UsePlatformWIFIMonitor() bool
ReadWIFIState() adapter.WIFIState
SystemCertificates() []string
process.Searcher

View File

@@ -111,7 +111,7 @@ func (s *BoxService) Close() error {
}
func (s *BoxService) NeedWIFIState() bool {
return s.instance.Router().NeedWIFIState()
return s.instance.Network().NeedWIFIState()
}
var (
@@ -224,6 +224,10 @@ func (w *platformInterfaceWrapper) ClearDNSCache() {
w.iif.ClearDNSCache()
}
func (w *platformInterfaceWrapper) UsePlatformWIFIMonitor() bool {
return true
}
func (w *platformInterfaceWrapper) ReadWIFIState() adapter.WIFIState {
wifiState := w.iif.ReadWIFIState()
if wifiState == nil {

View File

@@ -12,9 +12,7 @@ type iOSPauseFields struct {
func (s *BoxService) Pause() {
s.pauseManager.DevicePause()
if !C.IsIos {
s.instance.Router().ResetNetwork()
} else {
if C.IsIos {
if s.endPauseTimer == nil {
s.endPauseTimer = time.AfterFunc(time.Minute, s.pauseManager.DeviceWake)
} else {
@@ -26,7 +24,6 @@ func (s *BoxService) Pause() {
func (s *BoxService) Wake() {
if !C.IsIos {
s.pauseManager.DeviceWake()
s.instance.Router().ResetNetwork()
}
}

64
go.mod
View File

@@ -3,40 +3,44 @@ module github.com/sagernet/sing-box
go 1.24.7
require (
github.com/anytls/sing-anytls v0.0.8
github.com/anthropics/anthropic-sdk-go v1.14.0
github.com/anytls/sing-anytls v0.0.11
github.com/caddyserver/certmagic v0.23.0
github.com/coder/websocket v1.8.13
github.com/cretz/bine v0.2.0
github.com/database64128/tfo-go/v2 v2.2.2
github.com/database64128/tfo-go/v2 v2.3.1
github.com/go-chi/chi/v5 v5.2.2
github.com/go-chi/render v1.0.3
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466
github.com/gofrs/uuid/v5 v5.3.2
github.com/insomniacslk/dhcp v0.0.0-20250417080101-5f8cf70e8c5f
github.com/keybase/go-keychain v0.0.1
github.com/libdns/alidns v1.0.5-libdns.v1.beta1
github.com/libdns/cloudflare v0.2.2-0.20250708034226-c574dccb31a6
github.com/logrusorgru/aurora v2.0.3+incompatible
github.com/metacubex/utls v1.8.0
github.com/metacubex/utls v1.8.3
github.com/mholt/acmez/v3 v3.1.2
github.com/miekg/dns v1.1.67
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-20251209141152-67502c396ef4
github.com/sagernet/cronet-go/all v0.0.0-20251209141601-d8f29fa5b269
github.com/sagernet/fswatch v0.1.1
github.com/sagernet/gomobile v0.1.8
github.com/sagernet/gvisor v0.0.0-20250811.0-sing-box-mod.1
github.com/sagernet/quic-go v0.54.0-sing-box-mod.2
github.com/sagernet/sing v0.8.0-beta.2
github.com/sagernet/quic-go v0.57.1-sing-box-mod.1
github.com/sagernet/sing v0.8.0-beta.6.0.20251207063731-56fd482ce1c6
github.com/sagernet/sing-mux v0.3.3
github.com/sagernet/sing-quic v0.6.0-beta.2
github.com/sagernet/sing-quic v0.6.0-beta.5
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.0-beta.10
github.com/sagernet/sing-tun v0.8.0-beta.11
github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1
github.com/sagernet/smux v1.5.34-mod.2
github.com/sagernet/tailscale v1.86.5-sing-box-1.13-mod.3
github.com/sagernet/tailscale v1.86.5-sing-box-1.13-mod.4
github.com/sagernet/wireguard-go v0.0.2-beta.1.0.20250917110311-16510ac47288
github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854
github.com/spf13/cobra v1.9.1
@@ -44,11 +48,11 @@ require (
github.com/vishvananda/netns v0.0.5
go.uber.org/zap v1.27.0
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba
golang.org/x/crypto v0.41.0
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6
golang.org/x/mod v0.27.0
golang.org/x/net v0.43.0
golang.org/x/sys v0.35.0
golang.org/x/crypto v0.42.0
golang.org/x/exp v0.0.0-20250911091902-df9299821621
golang.org/x/mod v0.28.0
golang.org/x/net v0.44.0
golang.org/x/sys v0.36.0
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
google.golang.org/grpc v1.73.0
google.golang.org/protobuf v1.36.6
@@ -66,7 +70,7 @@ require (
github.com/caddyserver/zerossl v0.1.3 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 // indirect
github.com/database64128/netx-go v0.0.0-20240905055117-62795b8b054a // indirect
github.com/database64128/netx-go v0.1.1 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect
github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1 // indirect
@@ -100,8 +104,26 @@ require (
github.com/pierrec/lz4/v4 v4.1.21 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus-community/pro-bing v0.4.0 // indirect
github.com/quic-go/qpack v0.5.1 // 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-20251209141152-67502c396ef4 // indirect
github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20251209141152-67502c396ef4 // indirect
github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20251209141152-67502c396ef4 // indirect
github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20251209141152-67502c396ef4 // indirect
github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20251209141152-67502c396ef4 // indirect
github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20251209141152-67502c396ef4 // indirect
github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20251209141152-67502c396ef4 // indirect
github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20251209141152-67502c396ef4 // indirect
github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20251209141152-67502c396ef4 // indirect
github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20251209141152-67502c396ef4 // indirect
github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20251209141152-67502c396ef4 // indirect
github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20251209141152-67502c396ef4 // indirect
github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20251209141152-67502c396ef4 // indirect
github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20251209141152-67502c396ef4 // indirect
github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20251209141152-67502c396ef4 // indirect
github.com/sagernet/cronet-go/lib/windows_386 v0.0.0-20251209141152-67502c396ef4 // indirect
github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20251209141152-67502c396ef4 // indirect
github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20251209141152-67502c396ef4 // indirect
github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a // indirect
github.com/sagernet/nftables v0.3.0-beta.4 // indirect
github.com/spf13/pflag v1.0.6 // indirect
@@ -113,17 +135,21 @@ require (
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/zeebo/blake3 v0.2.4 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap/exp v0.3.0 // indirect
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/term v0.34.0 // indirect
golang.org/x/text v0.28.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/term v0.35.0 // indirect
golang.org/x/text v0.29.0 // indirect
golang.org/x/time v0.11.0 // indirect
golang.org/x/tools v0.36.0 // indirect
golang.org/x/tools v0.37.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect

130
go.sum
View File

@@ -8,8 +8,10 @@ github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7V
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/anytls/sing-anytls v0.0.8 h1:1u/fnH1HoeeMV5mX7/eUOjLBvPdkd1UJRmXiRi6Vymc=
github.com/anytls/sing-anytls v0.0.8/go.mod h1:7rjN6IukwysmdusYsrV51Fgu1uW6vsrdd6ctjnEAln8=
github.com/anthropics/anthropic-sdk-go v1.14.0 h1:EzNQvnZlaDHe2UPkoUySDz3ixRgNbwKdH8KtFpv7pi4=
github.com/anthropics/anthropic-sdk-go v1.14.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
github.com/anytls/sing-anytls v0.0.11 h1:w8e9Uj1oP3m4zxkyZDewPk0EcQbvVxb7Nn+rapEx4fc=
github.com/anytls/sing-anytls v0.0.11/go.mod h1:7rjN6IukwysmdusYsrV51Fgu1uW6vsrdd6ctjnEAln8=
github.com/caddyserver/certmagic v0.23.0 h1:CfpZ/50jMfG4+1J/u2LV6piJq4HOfO6ppOnOf7DkFEU=
github.com/caddyserver/certmagic v0.23.0/go.mod h1:9mEZIWqqWoI+Gf+4Trh04MOVPD0tGSxtqsxg87hAIH4=
github.com/caddyserver/zerossl v0.1.3 h1:onS+pxp3M8HnHpN5MMbOMyNjmTheJyWRaZYwn+YTAyA=
@@ -25,10 +27,10 @@ github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/cretz/bine v0.2.0 h1:8GiDRGlTgz+o8H9DSnsl+5MeBK4HsExxgl6WgzOCuZo=
github.com/cretz/bine v0.2.0/go.mod h1:WU4o9QR9wWp8AVKtTM1XD5vUHkEqnf2vVSo6dBqbetI=
github.com/database64128/netx-go v0.0.0-20240905055117-62795b8b054a h1:t4SDi0pmNkryzKdM4QF3o5vqSP4GRjeZD/6j3nyxNP0=
github.com/database64128/netx-go v0.0.0-20240905055117-62795b8b054a/go.mod h1:7K2NQKbabB5mBl41vF6YayYl5g7YpDwc4dQ5iMpP3Lg=
github.com/database64128/tfo-go/v2 v2.2.2 h1:BxynF4qGF5ct3DpPLEG62uyJZ3LQhqaf0Ken+kyy7PM=
github.com/database64128/tfo-go/v2 v2.2.2/go.mod h1:2IW8jppdBwdVMjA08uEyMNnqiAHKUlqAA+J8NrsfktY=
github.com/database64128/netx-go v0.1.1 h1:dT5LG7Gs7zFZBthFBbzWE6K8wAHjSNAaK7wCYZT7NzM=
github.com/database64128/netx-go v0.1.1/go.mod h1:LNlYVipaYkQArRFDNNJ02VkNV+My9A5XR/IGS7sIBQc=
github.com/database64128/tfo-go/v2 v2.3.1 h1:EGE+ELd5/AQ0X6YBlQ9RgKs8+kciNhgN3d8lRvfEJQw=
github.com/database64128/tfo-go/v2 v2.3.1/go.mod h1:k9wcpg/8i5zenspBkc9jUEYehpZZccBnCElzOJB++bU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
@@ -95,6 +97,8 @@ github.com/insomniacslk/dhcp v0.0.0-20250417080101-5f8cf70e8c5f/go.mod h1:zhFlBe
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I=
github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E=
github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
@@ -116,8 +120,8 @@ github.com/mdlayher/sdnotify v1.0.0 h1:Ma9XeLVN/l0qpyx1tNeMSeTjCPH6NtuD6/N9XdTlQ
github.com/mdlayher/sdnotify v1.0.0/go.mod h1:HQUmpM4XgYkhDLtd+Uad8ZFK1T9D5+pNxnXQjCeJlGE=
github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos=
github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ=
github.com/metacubex/utls v1.8.0 h1:mSYi6FMnmc5riARl5UZDmWVy710z+P5b7xuGW0lV9ac=
github.com/metacubex/utls v1.8.0/go.mod h1:FdjYzVfCtgtna19hX0ER1Xsa5uJInwdQ4IcaaI98lEQ=
github.com/metacubex/utls v1.8.3 h1:0m/yCxm3SK6kWve2lKiFb1pue1wHitJ8sQQD4Ikqde4=
github.com/metacubex/utls v1.8.3/go.mod h1:kncGGVhFaoGn5M3pFe3SXhZCzsbCJayNOH4UEqTKTko=
github.com/mholt/acmez/v3 v3.1.2 h1:auob8J/0FhmdClQicvJvuDavgd5ezwLBfKuYmynhYzc=
github.com/mholt/acmez/v3 v3.1.2/go.mod h1:L1wOU06KKvq7tswuMDwKdcHeKpFFgkppZy/y0DFxagQ=
github.com/miekg/dns v1.1.67 h1:kg0EHj0G4bfT5/oOys6HhZw4vmMlnoZ+gDu8tJ/AlI0=
@@ -135,8 +139,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyfyrrjEaAchdy3R4=
github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0=
github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs=
@@ -146,6 +150,46 @@ 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-20251209141152-67502c396ef4 h1:BfitgSppBdvn5gqwRLNA5Nhu67YvZxQKM9n3b1j1dgI=
github.com/sagernet/cronet-go v0.0.0-20251209141152-67502c396ef4/go.mod h1:l5IZJLEWpDGJbrF0qBHgxAVBPsAxKOLa1BYDh6B2sdI=
github.com/sagernet/cronet-go/all v0.0.0-20251209141601-d8f29fa5b269 h1:dA79nNuqhUIGcw7DP3ifRXtJq39rE/UWZPfmQ6QS40w=
github.com/sagernet/cronet-go/all v0.0.0-20251209141601-d8f29fa5b269/go.mod h1:iLjzAv2hALBTxeC10i99ludp7jU6U3dw/yXbn0x3Ek8=
github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20251209141152-67502c396ef4 h1:eN1EtBxDDOvWW7Q0+a1UzBcnYs6u2EqgHgLYOPFwHSE=
github.com/sagernet/cronet-go/lib/android_386 v0.0.0-20251209141152-67502c396ef4/go.mod h1:XXDwdjX/T8xftoeJxQmbBoYXZp8MAPFR2CwbFuTpEtw=
github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20251209141152-67502c396ef4 h1:mQSHDat1i4Q5/+CvvRGTAAvT2vaYtci671/v5nC4FIM=
github.com/sagernet/cronet-go/lib/android_amd64 v0.0.0-20251209141152-67502c396ef4/go.mod h1:iNiUGoLtnr8/JTuVNj7XJbmpOAp2C6+B81KDrPxwaZM=
github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20251209141152-67502c396ef4 h1:TLVfFFNvGEPBZzFUecr1r32A0hsS6oeiEQWVQlDys+g=
github.com/sagernet/cronet-go/lib/android_arm v0.0.0-20251209141152-67502c396ef4/go.mod h1:19ILNUOGIzRdOqa2mq+iY0JoHxuieB7/lnjYeaA2vEc=
github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20251209141152-67502c396ef4 h1:fCk6J6Shm+47s21JIpZuKLA0GD29HsGmUOCB3QkM7wc=
github.com/sagernet/cronet-go/lib/android_arm64 v0.0.0-20251209141152-67502c396ef4/go.mod h1:JxzGyQf94Cr6sBShKqODGDyRUlESfJK/Njcz9Lz6qMQ=
github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20251209141152-67502c396ef4 h1:lFaYkrltdVGtHoTtcTGCNP0lwGnwcCvZxJOCOyMtVcg=
github.com/sagernet/cronet-go/lib/darwin_amd64 v0.0.0-20251209141152-67502c396ef4/go.mod h1:KN+9T9TBycGOLzmKU4QdcHAJEj6Nlx48ifnlTvvHMvs=
github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20251209141152-67502c396ef4 h1:AdaoXuHTt5PnXUyDR/jcz2lBoA0osvOvORH9zUkTYtw=
github.com/sagernet/cronet-go/lib/darwin_arm64 v0.0.0-20251209141152-67502c396ef4/go.mod h1:kojvtUc29KKnk8hs2QIANynVR59921SnGWA9kXohHc0=
github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20251209141152-67502c396ef4 h1:d5qnUEz2E3GBiZTf0FcUx6zH70rxqHN6rKifhv3ww0g=
github.com/sagernet/cronet-go/lib/ios_arm64 v0.0.0-20251209141152-67502c396ef4/go.mod h1:tzVJFTOm66UxLxy6K0ZN5Ic2PC79e+sKKnt+V9puEa4=
github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20251209141152-67502c396ef4 h1:SBgtdbs/VPOlKZmh+ieSvg1FAhqZgah27u9U1TNqzLk=
github.com/sagernet/cronet-go/lib/linux_386 v0.0.0-20251209141152-67502c396ef4/go.mod h1:cGh5hO6eljCo6KMQ/Cel8Xgq4+etL0awZLRBDVG1EZQ=
github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20251209141152-67502c396ef4 h1:N4hzsQK1RMT/1cZdlVXgWJLnThM1SqSd8xKqMGVe2BM=
github.com/sagernet/cronet-go/lib/linux_386_musl v0.0.0-20251209141152-67502c396ef4/go.mod h1:JFE0/cxaKkx0wqPMZU7MgaplQlU0zudv82dROJjClKU=
github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20251209141152-67502c396ef4 h1:nnms0N+jFr78znmeTXNEZT3oa9M7QNJ3BZyKlh6xxPU=
github.com/sagernet/cronet-go/lib/linux_amd64 v0.0.0-20251209141152-67502c396ef4/go.mod h1:vU8VftFeSt7fURCa3JXD6+k6ss1YAX+idQjPvHmJ2tI=
github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20251209141152-67502c396ef4 h1:tQsiwZZO13yXPVG10FRKRvABMzVMta3HTMEyrKGVitg=
github.com/sagernet/cronet-go/lib/linux_amd64_musl v0.0.0-20251209141152-67502c396ef4/go.mod h1:vCe4OUuL+XOUge9v3MyTD45BnuAXiH+DkjN9quDXJzQ=
github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20251209141152-67502c396ef4 h1:/FAmfFm+22TIYAabODI6INOF6XgVoKiJrvo4JJk1QHI=
github.com/sagernet/cronet-go/lib/linux_arm v0.0.0-20251209141152-67502c396ef4/go.mod h1:w9amBWrvjtohQzBGCKJ7LCh22LhTIJs4sE7cYaKQzM0=
github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20251209141152-67502c396ef4 h1:c/mtA2g+ScCBGBfa26hakxAVXurD4nhFW/RI0yb4KVM=
github.com/sagernet/cronet-go/lib/linux_arm64 v0.0.0-20251209141152-67502c396ef4/go.mod h1:TqlsFtcYS/etTeck46kHBeT8Le0Igw1Q/AV88UnMS3s=
github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20251209141152-67502c396ef4 h1:iRq0SWRDfCf1sKN9R4+5EHWYak0Zfla5li0jhRwBCBA=
github.com/sagernet/cronet-go/lib/linux_arm64_musl v0.0.0-20251209141152-67502c396ef4/go.mod h1:B6Qd0vys8sv9OKVRN6J9RqDzYRGE938Fb2zrYdBDyTQ=
github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20251209141152-67502c396ef4 h1:GAQrA1S/cSNxmEeIcQU3jLVbPAU0GlJAM2iDcsKpphA=
github.com/sagernet/cronet-go/lib/linux_arm_musl v0.0.0-20251209141152-67502c396ef4/go.mod h1:3tXMMFY7AHugOVBZ5Al7cL7JKsnFOe5bMVr0hZPk3ow=
github.com/sagernet/cronet-go/lib/windows_386 v0.0.0-20251209141152-67502c396ef4 h1:Wix35ah7sy4oLTp06kjIqIX0CvtL2o05LXgaZLdDmzg=
github.com/sagernet/cronet-go/lib/windows_386 v0.0.0-20251209141152-67502c396ef4/go.mod h1:rnS7D+ULJX2PrP0Cy+05GS0mRZ2PP6+gVSroZKt8fjk=
github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20251209141152-67502c396ef4 h1:RA5r4IkF/Zvlq+4CwuWqZkb7xt3Fpj2HYUkzcSae1fQ=
github.com/sagernet/cronet-go/lib/windows_amd64 v0.0.0-20251209141152-67502c396ef4/go.mod h1:lm9w/oCCRyBiUa3G8lDQTT8x/ONUvgVR2iV9fVzUZB8=
github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20251209141152-67502c396ef4 h1:7lD3/vGXsmyfOuZN17yZe2u/UFVmOJpb9J7vz/cD0b8=
github.com/sagernet/cronet-go/lib/windows_arm64 v0.0.0-20251209141152-67502c396ef4/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/gomobile v0.1.8 h1:vXgoN0pjsMONAaYCTdsKBX2T1kxuS7sbT/mZ7PElGoo=
@@ -156,29 +200,29 @@ github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a h1:ObwtHN2VpqE0ZN
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/quic-go v0.54.0-sing-box-mod.2 h1:g3pJ8R8cMEt1le5YslP0x3MbT1ZAmAaE+wT095eLb8k=
github.com/sagernet/quic-go v0.54.0-sing-box-mod.2/go.mod h1:OV+V5kEBb8kJS7k29MzDu6oj9GyMc7HA07sE1tedxz4=
github.com/sagernet/quic-go v0.57.1-sing-box-mod.1 h1:6fhKbfA0b7L1CVekayV1g87uJFtMXFE0rFXR48SRrWI=
github.com/sagernet/quic-go v0.57.1-sing-box-mod.1/go.mod h1:OqILvS182CyOol5zNNo6bguvOGgXzV459+chpRaUC+4=
github.com/sagernet/sing v0.6.9/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak=
github.com/sagernet/sing v0.8.0-beta.2 h1:3khO2eE5LMylD/v47+pnVMtFzl6lBY2v/b/V+79qpsE=
github.com/sagernet/sing v0.8.0-beta.2/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak=
github.com/sagernet/sing v0.8.0-beta.6.0.20251207063731-56fd482ce1c6 h1:EYaDzllFzNYnzQ9xH/ieSAXct4wQ8pD45kgNMo7RPZc=
github.com/sagernet/sing v0.8.0-beta.6.0.20251207063731-56fd482ce1c6/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak=
github.com/sagernet/sing-mux v0.3.3 h1:YFgt9plMWzH994BMZLmyKL37PdIVaIilwP0Jg+EcLfw=
github.com/sagernet/sing-mux v0.3.3/go.mod h1:pht8iFY4c9Xltj7rhVd208npkNaeCxzyXCgulDPLUDA=
github.com/sagernet/sing-quic v0.6.0-beta.2 h1:RHBespnerWt/PIPKpZZ3v+k4jKrQmb2MTxwTmjrBLm8=
github.com/sagernet/sing-quic v0.6.0-beta.2/go.mod h1:lYeGF8RALGBlY006hdn2vsCJ5AzbYStaeNLs1C8wASg=
github.com/sagernet/sing-quic v0.6.0-beta.5 h1:kZfRLmsPxAgl0usZUgomDurLn7ZZ26lJWIpGow9ZWR4=
github.com/sagernet/sing-quic v0.6.0-beta.5/go.mod h1:9D9GANrK33NjWCe1VkU5L5+8MxU39WrduBSmHuHz8GA=
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.0-beta.10 h1:sHqSXTvzKPDF67AwZdoBV5FA91tFdWGfA1AbenIbpA4=
github.com/sagernet/sing-tun v0.8.0-beta.10/go.mod h1:eWETzl4AwaxGKiZTpDIDVJLTBz9cfIdoZwaZY1jlSjg=
github.com/sagernet/sing-tun v0.8.0-beta.11 h1:xVi8VcVkvz2o+3v1PLv5MOkFpiVCwjLjucVlmigDi5c=
github.com/sagernet/sing-tun v0.8.0-beta.11/go.mod h1:eWETzl4AwaxGKiZTpDIDVJLTBz9cfIdoZwaZY1jlSjg=
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.34-mod.2 h1:gkmBjIjlJ2zQKpLigOkFur5kBKdV6bNRoFu2WkltRQ4=
github.com/sagernet/smux v1.5.34-mod.2/go.mod h1:0KW0+R+ycvA2INW4gbsd7BNyg+HEfLIAxa5N02/28Zc=
github.com/sagernet/tailscale v1.86.5-sing-box-1.13-mod.3 h1:OGoHEw76F3F4keIGcOwB/5U+P1N3i+hLlgC7rvSnub0=
github.com/sagernet/tailscale v1.86.5-sing-box-1.13-mod.3/go.mod h1:YdN/avjce8sqPFLT9E1uEh8gPewNSnC41U4ZhBJ+ACw=
github.com/sagernet/tailscale v1.86.5-sing-box-1.13-mod.4 h1:Ceg+9Ug+qAFgEchGodlHmMOY2h7KktQQDAyuoIsPbos=
github.com/sagernet/tailscale v1.86.5-sing-box-1.13-mod.4/go.mod h1:YdN/avjce8sqPFLT9E1uEh8gPewNSnC41U4ZhBJ+ACw=
github.com/sagernet/wireguard-go v0.0.2-beta.1.0.20250917110311-16510ac47288 h1:E2tZFeg9mGYGQ7E7BbxMv1cU35HxwgRm6tPKI2Pp7DA=
github.com/sagernet/wireguard-go v0.0.2-beta.1.0.20250917110311-16510ac47288/go.mod h1:WUxgxUDZoCF2sxVmW+STSxatP02Qn3FcafTiI2BLtE0=
github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 h1:6uUiZcDRnZSAegryaUGwPC/Fj13JSHwiTftrXhMmYOc=
@@ -216,6 +260,16 @@ github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da h1:jVRUZPRs
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA=
github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM=
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA=
github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0=
@@ -254,21 +308,21 @@ go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wus
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/exp v0.0.0-20250911091902-df9299821621 h1:2id6c1/gto0kaHYyrixvknJ8tUK/Qs5IsmBtrc+FtgU=
golang.org/x/exp v0.0.0-20250911091902-df9299821621/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk=
golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -277,20 +331,20 @@ golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

12
include/ccm.go Normal file
View File

@@ -0,0 +1,12 @@
//go:build with_ccm && (!darwin || cgo)
package include
import (
"github.com/sagernet/sing-box/adapter/service"
"github.com/sagernet/sing-box/service/ccm"
)
func registerCCMService(registry *service.Registry) {
ccm.RegisterService(registry)
}

20
include/ccm_stub.go Normal file
View File

@@ -0,0 +1,20 @@
//go:build !with_ccm
package include
import (
"context"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/adapter/service"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
E "github.com/sagernet/sing/common/exceptions"
)
func registerCCMService(registry *service.Registry) {
service.Register[option.CCMServiceOptions](registry, C.TypeCCM, func(ctx context.Context, logger log.ContextLogger, tag string, options option.CCMServiceOptions) (adapter.Service, error) {
return nil, E.New(`CCM is not included in this build, rebuild with -tags with_CCM`)
})
}

View File

@@ -0,0 +1,20 @@
//go:build with_ccm && darwin && !cgo
package include
import (
"context"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/adapter/service"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
E "github.com/sagernet/sing/common/exceptions"
)
func registerCCMService(registry *service.Registry) {
service.Register[option.CCMServiceOptions](registry, C.TypeCCM, func(ctx context.Context, logger log.ContextLogger, tag string, options option.CCMServiceOptions) (adapter.Service, error) {
return nil, E.New(`CCM requires CGO on darwin, rebuild with CGO_ENABLED=1`)
})
}

12
include/naive_outbound.go Normal file
View File

@@ -0,0 +1,12 @@
//go:build with_naive_outbound
package include
import (
"github.com/sagernet/sing-box/adapter/outbound"
"github.com/sagernet/sing-box/protocol/naive"
)
func registerNaiveOutbound(registry *outbound.Registry) {
naive.RegisterOutbound(registry)
}

View File

@@ -0,0 +1,20 @@
//go:build !with_naive_outbound
package include
import (
"context"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/adapter/outbound"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
E "github.com/sagernet/sing/common/exceptions"
)
func registerNaiveOutbound(registry *outbound.Registry) {
outbound.Register[option.NaiveOutboundOptions](registry, C.TypeNaive, func(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.NaiveOutboundOptions) (adapter.Outbound, error) {
return nil, E.New(`naive outbound is not included in this build, rebuild with -tags with_naive_outbound`)
})
}

View File

@@ -86,6 +86,7 @@ func OutboundRegistry() *outbound.Registry {
shadowsocks.RegisterOutbound(registry)
vmess.RegisterOutbound(registry)
trojan.RegisterOutbound(registry)
registerNaiveOutbound(registry)
tor.RegisterOutbound(registry)
ssh.RegisterOutbound(registry)
shadowtls.RegisterOutbound(registry)
@@ -134,6 +135,7 @@ func ServiceRegistry() *service.Registry {
ssmapi.RegisterService(registry)
registerDERPService(registry)
registerCCMService(registry)
return registry
}

20
option/ccm.go Normal file
View File

@@ -0,0 +1,20 @@
package option
import (
"github.com/sagernet/sing/common/json/badoption"
)
type CCMServiceOptions struct {
ListenOptions
InboundTLSOptionsContainer
CredentialPath string `json:"credential_path,omitempty"`
Users []CCMUser `json:"users,omitempty"`
Headers badoption.HTTPHeader `json:"headers,omitempty"`
Detour string `json:"detour,omitempty"`
UsagesPath string `json:"usages_path,omitempty"`
}
type CCMUser struct {
Name string `json:"name,omitempty"`
Token string `json:"token,omitempty"`
}

View File

@@ -65,6 +65,7 @@ type ListenOptions struct {
RoutingMark FwMark `json:"routing_mark,omitempty"`
ReuseAddr bool `json:"reuse_addr,omitempty"`
NetNs string `json:"netns,omitempty"`
DisableTCPKeepAlive bool `json:"disable_tcp_keep_alive,omitempty"`
TCPKeepAlive badoption.Duration `json:"tcp_keep_alive,omitempty"`
TCPKeepAliveInterval badoption.Duration `json:"tcp_keep_alive_interval,omitempty"`
TCPFastOpen bool `json:"tcp_fast_open,omitempty"`

View File

@@ -1,6 +1,9 @@
package option
import "github.com/sagernet/sing/common/auth"
import (
"github.com/sagernet/sing/common/auth"
"github.com/sagernet/sing/common/json/badoption"
)
type NaiveInboundOptions struct {
ListenOptions
@@ -8,3 +11,13 @@ type NaiveInboundOptions struct {
Network NetworkList `json:"network,omitempty"`
InboundTLSOptionsContainer
}
type NaiveOutboundOptions struct {
DialerOptions
ServerOptions
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
InsecureConcurrency int `json:"insecure_concurrency,omitempty"`
ExtraHeaders badoption.HTTPHeader `json:"extra_headers,omitempty"`
OutboundTLSOptionsContainer
}

View File

@@ -65,24 +65,27 @@ type DialerOptionsWrapper interface {
}
type DialerOptions struct {
Detour string `json:"detour,omitempty"`
BindInterface string `json:"bind_interface,omitempty"`
Inet4BindAddress *badoption.Addr `json:"inet4_bind_address,omitempty"`
Inet6BindAddress *badoption.Addr `json:"inet6_bind_address,omitempty"`
ProtectPath string `json:"protect_path,omitempty"`
RoutingMark FwMark `json:"routing_mark,omitempty"`
ReuseAddr bool `json:"reuse_addr,omitempty"`
NetNs string `json:"netns,omitempty"`
ConnectTimeout badoption.Duration `json:"connect_timeout,omitempty"`
TCPFastOpen bool `json:"tcp_fast_open,omitempty"`
TCPMultiPath bool `json:"tcp_multi_path,omitempty"`
UDPFragment *bool `json:"udp_fragment,omitempty"`
UDPFragmentDefault bool `json:"-"`
DomainResolver *DomainResolveOptions `json:"domain_resolver,omitempty"`
NetworkStrategy *NetworkStrategy `json:"network_strategy,omitempty"`
NetworkType badoption.Listable[InterfaceType] `json:"network_type,omitempty"`
FallbackNetworkType badoption.Listable[InterfaceType] `json:"fallback_network_type,omitempty"`
FallbackDelay badoption.Duration `json:"fallback_delay,omitempty"`
Detour string `json:"detour,omitempty"`
BindInterface string `json:"bind_interface,omitempty"`
Inet4BindAddress *badoption.Addr `json:"inet4_bind_address,omitempty"`
Inet6BindAddress *badoption.Addr `json:"inet6_bind_address,omitempty"`
ProtectPath string `json:"protect_path,omitempty"`
RoutingMark FwMark `json:"routing_mark,omitempty"`
ReuseAddr bool `json:"reuse_addr,omitempty"`
NetNs string `json:"netns,omitempty"`
ConnectTimeout badoption.Duration `json:"connect_timeout,omitempty"`
TCPFastOpen bool `json:"tcp_fast_open,omitempty"`
TCPMultiPath bool `json:"tcp_multi_path,omitempty"`
DisableTCPKeepAlive bool `json:"disable_tcp_keep_alive,omitempty"`
TCPKeepAlive badoption.Duration `json:"tcp_keep_alive,omitempty"`
TCPKeepAliveInterval badoption.Duration `json:"tcp_keep_alive_interval,omitempty"`
UDPFragment *bool `json:"udp_fragment,omitempty"`
UDPFragmentDefault bool `json:"-"`
DomainResolver *DomainResolveOptions `json:"domain_resolver,omitempty"`
NetworkStrategy *NetworkStrategy `json:"network_strategy,omitempty"`
NetworkType badoption.Listable[InterfaceType] `json:"network_type,omitempty"`
FallbackNetworkType badoption.Listable[InterfaceType] `json:"fallback_network_type,omitempty"`
FallbackDelay badoption.Duration `json:"fallback_delay,omitempty"`
// Deprecated: migrated to domain resolver
DomainStrategy DomainStrategy `json:"domain_strategy,omitempty"`

View File

@@ -107,6 +107,10 @@ type OutboundTLSOptions struct {
Certificate badoption.Listable[string] `json:"certificate,omitempty"`
CertificatePath string `json:"certificate_path,omitempty"`
CertificatePublicKeySHA256 badoption.Listable[[]byte] `json:"certificate_public_key_sha256,omitempty"`
ClientCertificate badoption.Listable[string] `json:"client_certificate,omitempty"`
ClientCertificatePath string `json:"client_certificate_path,omitempty"`
ClientKey badoption.Listable[string] `json:"client_key,omitempty"`
ClientKeyPath string `json:"client_key_path,omitempty"`
Fragment bool `json:"fragment,omitempty"`
FragmentFallbackDelay badoption.Duration `json:"fragment_fallback_delay,omitempty"`
RecordFragment bool `json:"record_fragment,omitempty"`

View File

@@ -20,6 +20,7 @@ type TunInboundOptions struct {
AutoRedirect bool `json:"auto_redirect,omitempty"`
AutoRedirectInputMark FwMark `json:"auto_redirect_input_mark,omitempty"`
AutoRedirectOutputMark FwMark `json:"auto_redirect_output_mark,omitempty"`
ExcludeMPTCP bool `json:"exclude_mptcp,omitempty"`
LoopbackAddress badoption.Listable[netip.Addr] `json:"loopback_address,omitempty"`
StrictRoute bool `json:"strict_route,omitempty"`
RouteAddress badoption.Listable[netip.Prefix] `json:"route_address,omitempty"`

View File

@@ -13,6 +13,7 @@ import (
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing/common"
E "github.com/sagernet/sing/common/exceptions"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
"github.com/sagernet/sing/common/uot"
@@ -43,6 +44,13 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL
if options.TLS == nil || !options.TLS.Enabled {
return nil, C.ErrTLSRequired
}
// TCP Fast Open is incompatible with anytls because TFO creates a lazy connection
// that only establishes on first write. The lazy connection returns an empty address
// before establishment, but anytls SOCKS wrapper tries to access the remote address
// during handshake, causing a null pointer dereference crash.
if options.DialerOptions.TCPFastOpen {
return nil, E.New("tcp_fast_open is not supported with anytls outbound")
}
tlsConfig, err := tls.NewClient(ctx, logger, options.Server, common.PtrValueOrDefault(options.TLS))
if err != nil {

View File

@@ -2,8 +2,8 @@ package naive
import (
"context"
"errors"
"io"
"math/rand"
"net"
"net/http"
@@ -22,7 +22,11 @@ import (
"github.com/sagernet/sing/common/logger"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
aTLS "github.com/sagernet/sing/common/tls"
sHttp "github.com/sagernet/sing/protocol/http"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
)
var ConfigureHTTP3ListenerFunc func(listener *listener.Listener, handler http.Handler, tlsConfig tls.ServerConfig, logger logger.Logger) (io.Closer, error)
@@ -82,16 +86,11 @@ func (n *Inbound) Start(stage adapter.StartStage) error {
if stage != adapter.StartStateStart {
return nil
}
var tlsConfig *tls.STDConfig
if n.tlsConfig != nil {
err := n.tlsConfig.Start()
if err != nil {
return E.Cause(err, "create TLS config")
}
tlsConfig, err = n.tlsConfig.STDConfig()
if err != nil {
return err
}
}
if common.Contains(n.network, N.NetworkTCP) {
tcpListener, err := n.listener.ListenTCP()
@@ -99,20 +98,23 @@ func (n *Inbound) Start(stage adapter.StartStage) error {
return err
}
n.httpServer = &http.Server{
Handler: n,
TLSConfig: tlsConfig,
Handler: h2c.NewHandler(n, &http2.Server{}),
BaseContext: func(listener net.Listener) context.Context {
return n.ctx
},
}
go func() {
var sErr error
if tlsConfig != nil {
sErr = n.httpServer.ServeTLS(tcpListener, "", "")
} else {
sErr = n.httpServer.Serve(tcpListener)
var listener net.Listener = tcpListener
if n.tlsConfig != nil {
if len(n.tlsConfig.NextProtos()) == 0 {
n.tlsConfig.SetNextProtos([]string{http2.NextProtoTLS, "http/1.1"})
} else if !common.Contains(n.tlsConfig.NextProtos(), http2.NextProtoTLS) {
n.tlsConfig.SetNextProtos(append([]string{http2.NextProtoTLS}, n.tlsConfig.NextProtos()...))
}
listener = aTLS.NewListener(tcpListener, n.tlsConfig)
}
if sErr != nil && !E.IsClosedOrCanceled(sErr) {
sErr := n.httpServer.Serve(listener)
if sErr != nil && !errors.Is(sErr, http.ErrServerClosed) {
n.logger.Error("http server serve error: ", sErr)
}
}()
@@ -161,13 +163,16 @@ func (n *Inbound) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
n.badRequest(ctx, request, E.New("authorization failed"))
return
}
writer.Header().Set("Padding", generateNaivePaddingHeader())
writer.Header().Set("Padding", generatePaddingHeader())
writer.WriteHeader(http.StatusOK)
writer.(http.Flusher).Flush()
hostPort := request.URL.Host
hostPort := request.Header.Get("-connect-authority")
if hostPort == "" {
hostPort = request.Host
hostPort = request.URL.Host
if hostPort == "" {
hostPort = request.Host
}
}
source := sHttp.SourceAddress(request)
destination := M.ParseSocksaddr(hostPort).Unwrap()
@@ -178,9 +183,14 @@ func (n *Inbound) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
n.badRequest(ctx, request, E.New("hijack failed"))
return
}
n.newConnection(ctx, false, &naiveH1Conn{Conn: conn}, userName, source, destination)
n.newConnection(ctx, false, &naiveConn{Conn: conn}, userName, source, destination)
} else {
n.newConnection(ctx, true, &naiveH2Conn{reader: request.Body, writer: writer, flusher: writer.(http.Flusher)}, userName, source, destination)
n.newConnection(ctx, true, &naiveH2Conn{
reader: request.Body,
writer: writer,
flusher: writer.(http.Flusher),
remoteAddress: source,
}, userName, source, destination)
}
}
@@ -236,18 +246,3 @@ func rejectHTTP(writer http.ResponseWriter, statusCode int) {
}
conn.Close()
}
func generateNaivePaddingHeader() string {
paddingLen := rand.Intn(32) + 30
padding := make([]byte, paddingLen)
bits := rand.Uint64()
for i := 0; i < 16; i++ {
// Codes that won't be Huffman coded.
padding[i] = "!#$()+<>?@[]^`{}"[bits&15]
bits >>= 4
}
for i := 16; i < paddingLen; i++ {
padding[i] = '~'
}
return string(padding)
}

View File

@@ -7,417 +7,242 @@ import (
"net"
"net/http"
"os"
"strings"
"time"
"github.com/sagernet/sing/common"
"github.com/sagernet/sing/common/baderror"
"github.com/sagernet/sing/common/buf"
M "github.com/sagernet/sing/common/metadata"
"github.com/sagernet/sing/common/rw"
)
const kFirstPaddings = 8
const paddingCount = 8
type naiveH1Conn struct {
net.Conn
func generatePaddingHeader() string {
paddingLen := rand.Intn(32) + 30
padding := make([]byte, paddingLen)
bits := rand.Uint64()
for i := 0; i < 16; i++ {
padding[i] = "!#$()+<>?@[]^`{}"[bits&15]
bits >>= 4
}
for i := 16; i < paddingLen; i++ {
padding[i] = '~'
}
return string(padding)
}
type paddingConn struct {
readPadding int
writePadding int
readRemaining int
paddingRemaining int
}
func (c *naiveH1Conn) Read(p []byte) (n int, err error) {
n, err = c.read(p)
return n, wrapHttpError(err)
}
func (c *naiveH1Conn) read(p []byte) (n int, err error) {
if c.readRemaining > 0 {
if len(p) > c.readRemaining {
p = p[:c.readRemaining]
func (p *paddingConn) readWithPadding(reader io.Reader, buffer []byte) (n int, err error) {
if p.readRemaining > 0 {
if len(buffer) > p.readRemaining {
buffer = buffer[:p.readRemaining]
}
n, err = c.Conn.Read(p)
n, err = reader.Read(buffer)
if err != nil {
return
}
c.readRemaining -= n
p.readRemaining -= n
return
}
if c.paddingRemaining > 0 {
err = rw.SkipN(c.Conn, c.paddingRemaining)
if p.paddingRemaining > 0 {
err = rw.SkipN(reader, p.paddingRemaining)
if err != nil {
return
}
c.paddingRemaining = 0
p.paddingRemaining = 0
}
if c.readPadding < kFirstPaddings {
var paddingHdr []byte
if len(p) >= 3 {
paddingHdr = p[:3]
if p.readPadding < paddingCount {
var paddingHeader []byte
if len(buffer) >= 3 {
paddingHeader = buffer[:3]
} else {
paddingHdr = make([]byte, 3)
paddingHeader = make([]byte, 3)
}
_, err = io.ReadFull(c.Conn, paddingHdr)
_, err = io.ReadFull(reader, paddingHeader)
if err != nil {
return
}
originalDataSize := int(binary.BigEndian.Uint16(paddingHdr[:2]))
paddingSize := int(paddingHdr[2])
if len(p) > originalDataSize {
p = p[:originalDataSize]
originalDataSize := int(binary.BigEndian.Uint16(paddingHeader[:2]))
paddingSize := int(paddingHeader[2])
if len(buffer) > originalDataSize {
buffer = buffer[:originalDataSize]
}
n, err = c.Conn.Read(p)
n, err = reader.Read(buffer)
if err != nil {
return
}
c.readPadding++
c.readRemaining = originalDataSize - n
c.paddingRemaining = paddingSize
p.readPadding++
p.readRemaining = originalDataSize - n
p.paddingRemaining = paddingSize
return
}
return c.Conn.Read(p)
return reader.Read(buffer)
}
func (c *naiveH1Conn) Write(p []byte) (n int, err error) {
for pLen := len(p); pLen > 0; {
var data []byte
if pLen > 65535 {
data = p[:65535]
p = p[65535:]
pLen -= 65535
} else {
data = p
pLen = 0
}
var writeN int
writeN, err = c.write(data)
n += writeN
if err != nil {
break
}
}
return n, wrapHttpError(err)
}
func (c *naiveH1Conn) write(p []byte) (n int, err error) {
if c.writePadding < kFirstPaddings {
func (p *paddingConn) writeWithPadding(writer io.Writer, data []byte) (n int, err error) {
if p.writePadding < paddingCount {
paddingSize := rand.Intn(256)
buffer := buf.NewSize(3 + len(p) + paddingSize)
buffer := buf.NewSize(3 + len(data) + paddingSize)
defer buffer.Release()
header := buffer.Extend(3)
binary.BigEndian.PutUint16(header, uint16(len(p)))
binary.BigEndian.PutUint16(header, uint16(len(data)))
header[2] = byte(paddingSize)
common.Must1(buffer.Write(p))
_, err = c.Conn.Write(buffer.Bytes())
common.Must1(buffer.Write(data))
_, err = writer.Write(buffer.Bytes())
if err == nil {
n = len(p)
n = len(data)
}
c.writePadding++
p.writePadding++
return
}
return c.Conn.Write(p)
return writer.Write(data)
}
func (c *naiveH1Conn) FrontHeadroom() int {
if c.writePadding < kFirstPaddings {
return 3
}
return 0
}
func (c *naiveH1Conn) RearHeadroom() int {
if c.writePadding < kFirstPaddings {
return 255
}
return 0
}
func (c *naiveH1Conn) WriterMTU() int {
if c.writePadding < kFirstPaddings {
return 65535
}
return 0
}
func (c *naiveH1Conn) WriteBuffer(buffer *buf.Buffer) error {
defer buffer.Release()
if c.writePadding < kFirstPaddings {
func (p *paddingConn) writeBufferWithPadding(writer io.Writer, buffer *buf.Buffer) error {
if p.writePadding < paddingCount {
bufferLen := buffer.Len()
if bufferLen > 65535 {
return common.Error(c.Write(buffer.Bytes()))
_, err := p.writeChunked(writer, buffer.Bytes())
return err
}
paddingSize := rand.Intn(256)
header := buffer.ExtendHeader(3)
binary.BigEndian.PutUint16(header, uint16(bufferLen))
header[2] = byte(paddingSize)
buffer.Extend(paddingSize)
c.writePadding++
p.writePadding++
}
return wrapHttpError(common.Error(c.Conn.Write(buffer.Bytes())))
return common.Error(writer.Write(buffer.Bytes()))
}
// FIXME
/*func (c *naiveH1Conn) WriteTo(w io.Writer) (n int64, err error) {
if c.readPadding < kFirstPaddings {
n, err = bufio.WriteToN(c, w, kFirstPaddings-c.readPadding)
} else {
n, err = bufio.Copy(w, c.Conn)
}
return n, wrapHttpError(err)
}
func (c *naiveH1Conn) ReadFrom(r io.Reader) (n int64, err error) {
if c.writePadding < kFirstPaddings {
n, err = bufio.ReadFromN(c, r, kFirstPaddings-c.writePadding)
} else {
n, err = bufio.Copy(c.Conn, r)
}
return n, wrapHttpError(err)
}
*/
func (c *naiveH1Conn) Upstream() any {
return c.Conn
}
func (c *naiveH1Conn) ReaderReplaceable() bool {
return c.readPadding == kFirstPaddings
}
func (c *naiveH1Conn) WriterReplaceable() bool {
return c.writePadding == kFirstPaddings
}
type naiveH2Conn struct {
reader io.Reader
writer io.Writer
flusher http.Flusher
rAddr net.Addr
readPadding int
writePadding int
readRemaining int
paddingRemaining int
}
func (c *naiveH2Conn) Read(p []byte) (n int, err error) {
n, err = c.read(p)
return n, wrapHttpError(err)
}
func (c *naiveH2Conn) read(p []byte) (n int, err error) {
if c.readRemaining > 0 {
if len(p) > c.readRemaining {
p = p[:c.readRemaining]
}
n, err = c.reader.Read(p)
if err != nil {
return
}
c.readRemaining -= n
return
}
if c.paddingRemaining > 0 {
err = rw.SkipN(c.reader, c.paddingRemaining)
if err != nil {
return
}
c.paddingRemaining = 0
}
if c.readPadding < kFirstPaddings {
var paddingHdr []byte
if len(p) >= 3 {
paddingHdr = p[:3]
func (p *paddingConn) writeChunked(writer io.Writer, data []byte) (n int, err error) {
for len(data) > 0 {
var chunk []byte
if len(data) > 65535 {
chunk = data[:65535]
data = data[65535:]
} else {
paddingHdr = make([]byte, 3)
chunk = data
data = nil
}
_, err = io.ReadFull(c.reader, paddingHdr)
var written int
written, err = p.writeWithPadding(writer, chunk)
n += written
if err != nil {
return
}
originalDataSize := int(binary.BigEndian.Uint16(paddingHdr[:2]))
paddingSize := int(paddingHdr[2])
if len(p) > originalDataSize {
p = p[:originalDataSize]
}
n, err = c.reader.Read(p)
if err != nil {
return
}
c.readPadding++
c.readRemaining = originalDataSize - n
c.paddingRemaining = paddingSize
return
}
return c.reader.Read(p)
return
}
func (c *naiveH2Conn) Write(p []byte) (n int, err error) {
for pLen := len(p); pLen > 0; {
var data []byte
if pLen > 65535 {
data = p[:65535]
p = p[65535:]
pLen -= 65535
} else {
data = p
pLen = 0
}
var writeN int
writeN, err = c.write(data)
n += writeN
if err != nil {
break
}
}
if err == nil {
c.flusher.Flush()
}
return n, wrapHttpError(err)
}
func (c *naiveH2Conn) write(p []byte) (n int, err error) {
if c.writePadding < kFirstPaddings {
paddingSize := rand.Intn(256)
buffer := buf.NewSize(3 + len(p) + paddingSize)
defer buffer.Release()
header := buffer.Extend(3)
binary.BigEndian.PutUint16(header, uint16(len(p)))
header[2] = byte(paddingSize)
common.Must1(buffer.Write(p))
_, err = c.writer.Write(buffer.Bytes())
if err == nil {
n = len(p)
}
c.writePadding++
return
}
return c.writer.Write(p)
}
func (c *naiveH2Conn) FrontHeadroom() int {
if c.writePadding < kFirstPaddings {
func (p *paddingConn) frontHeadroom() int {
if p.writePadding < paddingCount {
return 3
}
return 0
}
func (c *naiveH2Conn) RearHeadroom() int {
if c.writePadding < kFirstPaddings {
func (p *paddingConn) rearHeadroom() int {
if p.writePadding < paddingCount {
return 255
}
return 0
}
func (c *naiveH2Conn) WriterMTU() int {
if c.writePadding < kFirstPaddings {
func (p *paddingConn) writerMTU() int {
if p.writePadding < paddingCount {
return 65535
}
return 0
}
func (p *paddingConn) readerReplaceable() bool {
return p.readPadding == paddingCount
}
func (p *paddingConn) writerReplaceable() bool {
return p.writePadding == paddingCount
}
type naiveConn struct {
net.Conn
paddingConn
}
func (c *naiveConn) Read(p []byte) (n int, err error) {
n, err = c.readWithPadding(c.Conn, p)
return n, baderror.WrapH2(err)
}
func (c *naiveConn) Write(p []byte) (n int, err error) {
n, err = c.writeChunked(c.Conn, p)
return n, baderror.WrapH2(err)
}
func (c *naiveConn) WriteBuffer(buffer *buf.Buffer) error {
defer buffer.Release()
err := c.writeBufferWithPadding(c.Conn, buffer)
return baderror.WrapH2(err)
}
func (c *naiveConn) FrontHeadroom() int { return c.frontHeadroom() }
func (c *naiveConn) RearHeadroom() int { return c.rearHeadroom() }
func (c *naiveConn) WriterMTU() int { return c.writerMTU() }
func (c *naiveConn) Upstream() any { return c.Conn }
func (c *naiveConn) ReaderReplaceable() bool { return c.readerReplaceable() }
func (c *naiveConn) WriterReplaceable() bool { return c.writerReplaceable() }
type naiveH2Conn struct {
reader io.Reader
writer io.Writer
flusher http.Flusher
remoteAddress net.Addr
paddingConn
}
func (c *naiveH2Conn) Read(p []byte) (n int, err error) {
n, err = c.readWithPadding(c.reader, p)
return n, baderror.WrapH2(err)
}
func (c *naiveH2Conn) Write(p []byte) (n int, err error) {
n, err = c.writeChunked(c.writer, p)
if err == nil {
c.flusher.Flush()
}
return n, baderror.WrapH2(err)
}
func (c *naiveH2Conn) WriteBuffer(buffer *buf.Buffer) error {
defer buffer.Release()
if c.writePadding < kFirstPaddings {
bufferLen := buffer.Len()
if bufferLen > 65535 {
return common.Error(c.Write(buffer.Bytes()))
}
paddingSize := rand.Intn(256)
header := buffer.ExtendHeader(3)
binary.BigEndian.PutUint16(header, uint16(bufferLen))
header[2] = byte(paddingSize)
buffer.Extend(paddingSize)
c.writePadding++
}
err := common.Error(c.writer.Write(buffer.Bytes()))
err := c.writeBufferWithPadding(c.writer, buffer)
if err == nil {
c.flusher.Flush()
}
return wrapHttpError(err)
return baderror.WrapH2(err)
}
// FIXME
/*func (c *naiveH2Conn) WriteTo(w io.Writer) (n int64, err error) {
if c.readPadding < kFirstPaddings {
n, err = bufio.WriteToN(c, w, kFirstPaddings-c.readPadding)
} else {
n, err = bufio.Copy(w, c.reader)
}
return n, wrapHttpError(err)
}
func (c *naiveH2Conn) ReadFrom(r io.Reader) (n int64, err error) {
if c.writePadding < kFirstPaddings {
n, err = bufio.ReadFromN(c, r, kFirstPaddings-c.writePadding)
} else {
n, err = bufio.Copy(c.writer, r)
}
return n, wrapHttpError(err)
}*/
func (c *naiveH2Conn) Close() error {
return common.Close(
c.reader,
c.writer,
)
return common.Close(c.reader, c.writer)
}
func (c *naiveH2Conn) LocalAddr() net.Addr {
return M.Socksaddr{}
}
func (c *naiveH2Conn) RemoteAddr() net.Addr {
return c.rAddr
}
func (c *naiveH2Conn) SetDeadline(t time.Time) error {
return os.ErrInvalid
}
func (c *naiveH2Conn) SetReadDeadline(t time.Time) error {
return os.ErrInvalid
}
func (c *naiveH2Conn) SetWriteDeadline(t time.Time) error {
return os.ErrInvalid
}
func (c *naiveH2Conn) NeedAdditionalReadDeadline() bool {
return true
}
func (c *naiveH2Conn) UpstreamReader() any {
return c.reader
}
func (c *naiveH2Conn) UpstreamWriter() any {
return c.writer
}
func (c *naiveH2Conn) ReaderReplaceable() bool {
return c.readPadding == kFirstPaddings
}
func (c *naiveH2Conn) WriterReplaceable() bool {
return c.writePadding == kFirstPaddings
}
func wrapHttpError(err error) error {
if err == nil {
return err
}
if strings.Contains(err.Error(), "client disconnected") {
return net.ErrClosed
}
if strings.Contains(err.Error(), "body closed by handler") {
return net.ErrClosed
}
if strings.Contains(err.Error(), "canceled with error code 268") {
return io.EOF
}
return err
}
func (c *naiveH2Conn) LocalAddr() net.Addr { return M.Socksaddr{} }
func (c *naiveH2Conn) RemoteAddr() net.Addr { return c.remoteAddress }
func (c *naiveH2Conn) SetDeadline(t time.Time) error { return os.ErrInvalid }
func (c *naiveH2Conn) SetReadDeadline(t time.Time) error { return os.ErrInvalid }
func (c *naiveH2Conn) SetWriteDeadline(t time.Time) error { return os.ErrInvalid }
func (c *naiveH2Conn) NeedAdditionalReadDeadline() bool { return true }
func (c *naiveH2Conn) UpstreamReader() any { return c.reader }
func (c *naiveH2Conn) UpstreamWriter() any { return c.writer }
func (c *naiveH2Conn) FrontHeadroom() int { return c.frontHeadroom() }
func (c *naiveH2Conn) RearHeadroom() int { return c.rearHeadroom() }
func (c *naiveH2Conn) WriterMTU() int { return c.writerMTU() }
func (c *naiveH2Conn) ReaderReplaceable() bool { return c.readerReplaceable() }
func (c *naiveH2Conn) WriterReplaceable() bool { return c.writerReplaceable() }

179
protocol/naive/outbound.go Normal file
View File

@@ -0,0 +1,179 @@
//go:build with_naive_outbound
package naive
import (
"context"
"net"
"os"
"strings"
"github.com/sagernet/cronet-go"
_ "github.com/sagernet/cronet-go/all"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/adapter/outbound"
"github.com/sagernet/sing-box/common/dialer"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
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"
)
func RegisterOutbound(registry *outbound.Registry) {
outbound.Register[option.NaiveOutboundOptions](registry, C.TypeNaive, NewOutbound)
}
type Outbound struct {
outbound.Adapter
ctx context.Context
logger logger.ContextLogger
client *cronet.NaiveClient
}
func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.NaiveOutboundOptions) (adapter.Outbound, error) {
if options.TLS == nil || !options.TLS.Enabled {
return nil, C.ErrTLSRequired
}
if options.TLS.DisableSNI {
return nil, E.New("disable_sni is not supported on naive outbound")
}
if options.TLS.Insecure {
return nil, E.New("insecure is not supported on naive outbound")
}
if len(options.TLS.ALPN) > 0 {
return nil, E.New("alpn is not supported on naive outbound")
}
if options.TLS.MinVersion != "" {
return nil, E.New("min_version is not supported on naive outbound")
}
if options.TLS.MaxVersion != "" {
return nil, E.New("max_version is not supported on naive outbound")
}
if len(options.TLS.CipherSuites) > 0 {
return nil, E.New("cipher_suites is not supported on naive outbound")
}
if len(options.TLS.CurvePreferences) > 0 {
return nil, E.New("curve_preferences is not supported on naive outbound")
}
if len(options.TLS.ClientCertificate) > 0 || options.TLS.ClientCertificatePath != "" {
return nil, E.New("client_certificate is not supported on naive outbound")
}
if len(options.TLS.ClientKey) > 0 || options.TLS.ClientKeyPath != "" {
return nil, E.New("client_key is not supported on naive outbound")
}
if options.TLS.Fragment || options.TLS.RecordFragment {
return nil, E.New("fragment is not supported on naive outbound")
}
if options.TLS.KernelTx || options.TLS.KernelRx {
return nil, E.New("kernel TLS is not supported on naive outbound")
}
if options.TLS.ECH != nil && options.TLS.ECH.Enabled {
return nil, E.New("ECH is not currently supported on naive outbound")
}
if options.TLS.UTLS != nil && options.TLS.UTLS.Enabled {
return nil, E.New("uTLS is not supported on naive outbound")
}
if options.TLS.Reality != nil && options.TLS.Reality.Enabled {
return nil, E.New("reality is not supported on naive outbound")
}
serverAddress := options.ServerOptions.Build()
var serverName string
if options.TLS.ServerName != "" {
serverName = options.TLS.ServerName
} else {
serverName = serverAddress.AddrString()
}
outboundDialer, err := dialer.NewWithOptions(dialer.Options{
Context: ctx,
Options: options.DialerOptions,
RemoteIsDomain: true,
ResolverOnDetour: true,
NewDialer: true,
})
if err != nil {
return nil, err
}
var trustedRootCertificates string
if len(options.TLS.Certificate) > 0 {
trustedRootCertificates = strings.Join(options.TLS.Certificate, "\n")
} else if options.TLS.CertificatePath != "" {
content, err := os.ReadFile(options.TLS.CertificatePath)
if err != nil {
return nil, E.Cause(err, "read certificate")
}
trustedRootCertificates = string(content)
}
extraHeaders := make(map[string]string)
for key, values := range options.ExtraHeaders.Build() {
if len(values) > 0 {
extraHeaders[key] = values[0]
}
}
client, err := cronet.NewNaiveClient(cronet.NaiveClientConfig{
Context: ctx,
ServerAddress: serverAddress,
ServerName: serverName,
Username: options.Username,
Password: options.Password,
Concurrency: options.InsecureConcurrency,
ExtraHeaders: extraHeaders,
TrustedRootCertificates: trustedRootCertificates,
CertificatePublicKeySHA256: options.TLS.CertificatePublicKeySHA256,
Dialer: outboundDialer,
})
if err != nil {
return nil, err
}
return &Outbound{
Adapter: outbound.NewAdapterWithDialerOptions(C.TypeNaive, tag, []string{N.NetworkTCP}, options.DialerOptions),
ctx: ctx,
logger: logger,
client: client,
}, nil
}
func (o *Outbound) Start(stage adapter.StartStage) error {
if stage != adapter.StartStateStart {
return nil
}
err := o.client.Start()
if err != nil {
return err
}
o.logger.Info("NaiveProxy started, version: ", o.client.Engine().Version())
return nil
}
func (o *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
ctx, metadata := adapter.ExtendContext(ctx)
metadata.Outbound = o.Tag()
metadata.Destination = destination
o.logger.InfoContext(ctx, "outbound connection to ", destination)
return o.client.DialContext(ctx, destination)
}
func (o *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
return nil, os.ErrInvalid
}
func (o *Outbound) Close() error {
return o.client.Close()
}
func (o *Outbound) StartNetLogToFile(fileName string, logAll bool) bool {
return o.client.Engine().StartNetLogToFile(fileName, logAll)
}
func (o *Outbound) StopNetLog() {
o.client.Engine().StopNetLog()
}

View File

@@ -494,20 +494,20 @@ func (t *Endpoint) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn,
metadata.Inbound = t.Tag()
metadata.InboundType = t.Type()
metadata.Source = source
metadata.Destination = destination
addr4, addr6 := t.server.TailscaleIPs()
switch destination.Addr {
case addr4:
metadata.OriginDestination = destination
destination.Addr = netip.AddrFrom4([4]uint8{127, 0, 0, 1})
conn = bufio.NewNATPacketConn(bufio.NewNetPacketConn(conn), metadata.OriginDestination, metadata.Destination)
conn = bufio.NewNATPacketConn(bufio.NewNetPacketConn(conn), metadata.OriginDestination, destination)
case addr6:
metadata.OriginDestination = destination
destination.Addr = netip.IPv6Loopback()
conn = bufio.NewNATPacketConn(bufio.NewNetPacketConn(conn), metadata.OriginDestination, metadata.Destination)
conn = bufio.NewNATPacketConn(bufio.NewNetPacketConn(conn), metadata.OriginDestination, destination)
}
metadata.Destination = destination
t.logger.InfoContext(ctx, "inbound packet connection from ", source)
t.logger.InfoContext(ctx, "inbound packet connection to ", destination)
t.logger.InfoContext(ctx, "inbound packet connection to ", metadata.Destination)
t.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose)
}

View File

@@ -203,6 +203,7 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo
IPRoute2RuleIndex: ruleIndex,
AutoRedirectInputMark: inputMark,
AutoRedirectOutputMark: outputMark,
ExcludeMPTCP: options.ExcludeMPTCP,
Inet4LoopbackAddress: common.Filter(options.LoopbackAddress, netip.Addr.Is4),
Inet6LoopbackAddress: common.Filter(options.LoopbackAddress, netip.Addr.Is6),
StrictRoute: options.StrictRoute,

100
release/local/common.sh Executable file
View File

@@ -0,0 +1,100 @@
#!/usr/bin/env bash
set -e -o pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
BINARY_NAME="sing-box"
INSTALL_BIN_PATH="/usr/local/bin"
INSTALL_CONFIG_PATH="/usr/local/etc/sing-box"
INSTALL_DATA_PATH="/var/lib/sing-box"
SYSTEMD_SERVICE_PATH="/etc/systemd/system"
DEFAULT_BUILD_TAGS="with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,badlinkname,tfogo_checklinkname0"
setup_environment() {
if [ -d /usr/local/go ]; then
export PATH="$PATH:/usr/local/go/bin"
fi
if ! command -v go &> /dev/null; then
echo "Error: Go is not installed or not in PATH"
echo "Run install_go.sh to install Go"
exit 1
fi
}
get_build_tags() {
local extra_tags="$1"
if [ -n "$extra_tags" ]; then
echo "${DEFAULT_BUILD_TAGS},${extra_tags}"
else
echo "${DEFAULT_BUILD_TAGS}"
fi
}
get_version() {
cd "$PROJECT_DIR"
GOHOSTOS=$(go env GOHOSTOS)
GOHOSTARCH=$(go env GOHOSTARCH)
CGO_ENABLED=0 GOOS=$GOHOSTOS GOARCH=$GOHOSTARCH go run github.com/sagernet/sing-box/cmd/internal/read_tag@latest
}
get_ldflags() {
local version
version=$(get_version)
echo "-X 'github.com/sagernet/sing-box/constant.Version=${version}' -s -w -buildid= -checklinkname=0"
}
build_sing_box() {
local tags="$1"
local ldflags
ldflags=$(get_ldflags)
echo "Building sing-box with tags: $tags"
cd "$PROJECT_DIR"
export GOTOOLCHAIN=local
go install -v -trimpath -ldflags "$ldflags" -tags "$tags" ./cmd/sing-box
}
install_binary() {
local gopath
gopath=$(go env GOPATH)
echo "Installing binary to $INSTALL_BIN_PATH/$BINARY_NAME"
sudo cp "${gopath}/bin/${BINARY_NAME}" "${INSTALL_BIN_PATH}/"
}
setup_config() {
echo "Setting up configuration"
sudo mkdir -p "$INSTALL_CONFIG_PATH"
if [ ! -f "$INSTALL_CONFIG_PATH/config.json" ]; then
sudo cp "$PROJECT_DIR/release/config/config.json" "$INSTALL_CONFIG_PATH/config.json"
echo "Default config installed to $INSTALL_CONFIG_PATH/config.json"
else
echo "Config already exists at $INSTALL_CONFIG_PATH/config.json (not overwriting)"
fi
}
setup_systemd() {
echo "Setting up systemd service"
sudo cp "$SCRIPT_DIR/sing-box.service" "$SYSTEMD_SERVICE_PATH/"
sudo systemctl daemon-reload
}
stop_service() {
if systemctl is-active --quiet sing-box; then
echo "Stopping sing-box service"
sudo systemctl stop sing-box
fi
}
start_service() {
echo "Starting sing-box service"
sudo systemctl start sing-box
}
restart_service() {
echo "Restarting sing-box service"
sudo systemctl restart sing-box
}

View File

@@ -2,21 +2,25 @@
set -e -o pipefail
if [ -d /usr/local/go ]; then
export PATH="$PATH:/usr/local/go/bin"
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "$SCRIPT_DIR/common.sh"
DIR=$(dirname "$0")
PROJECT=$DIR/../..
setup_environment
pushd $PROJECT
echo "Updating sing-box from git repository..."
cd "$PROJECT_DIR"
git fetch
git reset FETCH_HEAD --hard
git clean -fdx
go install -v -trimpath -ldflags "-s -w -buildid=" -tags with_quic,with_acme,debug ./cmd/sing-box
popd
sudo systemctl stop sing-box
sudo cp $(go env GOPATH)/bin/sing-box /usr/local/bin/
sudo systemctl start sing-box
BUILD_TAGS=$(get_build_tags "debug")
build_sing_box "$BUILD_TAGS"
stop_service
install_binary
start_service
echo ""
echo "Following service logs (Ctrl+C to exit)..."
sudo journalctl -u sing-box --output cat -f

View File

@@ -2,19 +2,18 @@
set -e -o pipefail
if [ -d /usr/local/go ]; then
export PATH="$PATH:/usr/local/go/bin"
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "$SCRIPT_DIR/common.sh"
DIR=$(dirname "$0")
PROJECT=$DIR/../..
setup_environment
pushd $PROJECT
go install -v -trimpath -ldflags "-s -w -buildid=" -tags with_quic,with_wireguard,with_acme ./cmd/sing-box
popd
BUILD_TAGS=$(get_build_tags)
sudo cp $(go env GOPATH)/bin/sing-box /usr/local/bin/
sudo mkdir -p /usr/local/etc/sing-box
sudo cp $PROJECT/release/config/config.json /usr/local/etc/sing-box/config.json
sudo cp $DIR/sing-box.service /etc/systemd/system
sudo systemctl daemon-reload
build_sing_box "$BUILD_TAGS"
install_binary
setup_config
setup_systemd
echo ""
echo "Installation complete!"
echo "To enable and start the service, run: $SCRIPT_DIR/enable.sh"

View File

@@ -2,17 +2,18 @@
set -e -o pipefail
if [ -d /usr/local/go ]; then
export PATH="$PATH:/usr/local/go/bin"
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "$SCRIPT_DIR/common.sh"
DIR=$(dirname "$0")
PROJECT=$DIR/../..
setup_environment
pushd $PROJECT
go install -v -trimpath -ldflags "-s -w -buildid=" -tags with_quic,with_wireguard,with_acme ./cmd/sing-box
popd
BUILD_TAGS=$(get_build_tags)
sudo systemctl stop sing-box
sudo cp $(go env GOPATH)/bin/sing-box /usr/local/bin/
sudo systemctl start sing-box
build_sing_box "$BUILD_TAGS"
stop_service
install_binary
start_service
echo ""
echo "Reinstallation complete!"

View File

@@ -1,8 +1,30 @@
#!/usr/bin/env bash
sudo systemctl stop sing-box
sudo rm -rf /var/lib/sing-box
sudo rm -rf /usr/local/bin/sing-box
sudo rm -rf /usr/local/etc/sing-box
sudo rm -rf /etc/systemd/system/sing-box.service
set -e -o pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "$SCRIPT_DIR/common.sh"
echo "Uninstalling sing-box..."
if systemctl is-active --quiet sing-box 2>/dev/null; then
echo "Stopping sing-box service..."
sudo systemctl stop sing-box
fi
if systemctl is-enabled --quiet sing-box 2>/dev/null; then
echo "Disabling sing-box service..."
sudo systemctl disable sing-box
fi
echo "Removing files..."
sudo rm -rf "$INSTALL_DATA_PATH"
sudo rm -rf "$INSTALL_BIN_PATH/$BINARY_NAME"
sudo rm -rf "$INSTALL_CONFIG_PATH"
sudo rm -rf "$SYSTEMD_SERVICE_PATH/sing-box.service"
echo "Reloading systemd..."
sudo systemctl daemon-reload
echo ""
echo "Uninstallation complete!"

View File

@@ -2,13 +2,15 @@
set -e -o pipefail
DIR=$(dirname "$0")
PROJECT=$DIR/../..
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "$SCRIPT_DIR/common.sh"
pushd $PROJECT
echo "Updating sing-box from git repository..."
cd "$PROJECT_DIR"
git fetch
git reset FETCH_HEAD --hard
git clean -fdx
popd
$DIR/reinstall.sh
echo ""
echo "Running reinstall..."
exec "$SCRIPT_DIR/reinstall.sh"

View File

@@ -8,11 +8,13 @@ import (
"os"
"runtime"
"strings"
"sync"
"syscall"
"time"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/common/conntrack"
"github.com/sagernet/sing-box/common/settings"
"github.com/sagernet/sing-box/common/taskmonitor"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/experimental/libbox/platform"
@@ -50,11 +52,14 @@ type NetworkManager struct {
endpoint adapter.EndpointManager
inbound adapter.InboundManager
outbound adapter.OutboundManager
needWIFIState bool
wifiMonitor settings.WIFIMonitor
wifiState adapter.WIFIState
wifiStateMutex sync.RWMutex
started bool
}
func NewNetworkManager(ctx context.Context, logger logger.ContextLogger, routeOptions option.RouteOptions) (*NetworkManager, error) {
func NewNetworkManager(ctx context.Context, logger logger.ContextLogger, routeOptions option.RouteOptions, dnsOptions option.DNSOptions) (*NetworkManager, error) {
defaultDomainResolver := common.PtrValueOrDefault(routeOptions.DefaultDomainResolver)
if routeOptions.AutoDetectInterface && !(C.IsLinux || C.IsDarwin || C.IsWindows) {
return nil, E.New("`auto_detect_interface` is only supported on Linux, Windows and macOS")
@@ -89,6 +94,7 @@ func NewNetworkManager(ctx context.Context, logger logger.ContextLogger, routeOp
endpoint: service.FromContext[adapter.EndpointManager](ctx),
inbound: service.FromContext[adapter.InboundManager](ctx),
outbound: service.FromContext[adapter.OutboundManager](ctx),
needWIFIState: hasRule(routeOptions.Rules, isWIFIRule) || hasDNSRule(dnsOptions.Rules, isWIFIDNSRule),
}
if routeOptions.DefaultNetworkStrategy != nil {
if routeOptions.DefaultInterface != "" {
@@ -183,11 +189,35 @@ func (r *NetworkManager) Start(stage adapter.StartStage) error {
}
}
case adapter.StartStatePostStart:
if r.needWIFIState && !(r.platformInterface != nil && r.platformInterface.UsePlatformWIFIMonitor()) {
wifiMonitor, err := settings.NewWIFIMonitor(r.onWIFIStateChanged)
if err != nil {
if err != os.ErrInvalid {
r.logger.Warn(E.Cause(err, "create WIFI monitor"))
}
} else {
r.wifiMonitor = wifiMonitor
err = r.wifiMonitor.Start()
if err != nil {
r.logger.Warn(E.Cause(err, "start WIFI monitor"))
}
}
}
r.started = true
}
return nil
}
func (r *NetworkManager) Initialize(ruleSets []adapter.RuleSet) {
for _, ruleSet := range ruleSets {
metadata := ruleSet.Metadata()
if metadata.ContainsWIFIRule {
r.needWIFIState = true
break
}
}
}
func (r *NetworkManager) Close() error {
monitor := taskmonitor.New(r.logger, C.StopTimeout)
var err error
@@ -219,6 +249,13 @@ func (r *NetworkManager) Close() error {
})
monitor.Finish()
}
if r.wifiMonitor != nil {
monitor.Start("close WIFI monitor")
err = E.Append(err, r.wifiMonitor.Close(), func(err error) error {
return E.Cause(err, "close WIFI monitor")
})
monitor.Finish()
}
return err
}
@@ -376,20 +413,39 @@ func (r *NetworkManager) PackageManager() tun.PackageManager {
return r.packageManager
}
func (r *NetworkManager) NeedWIFIState() bool {
return r.needWIFIState
}
func (r *NetworkManager) WIFIState() adapter.WIFIState {
r.wifiStateMutex.RLock()
defer r.wifiStateMutex.RUnlock()
return r.wifiState
}
func (r *NetworkManager) UpdateWIFIState() {
if r.platformInterface != nil {
state := r.platformInterface.ReadWIFIState()
if state != r.wifiState {
r.wifiState = state
if state.SSID != "" {
r.logger.Info("updated WIFI state: SSID=", state.SSID, ", BSSID=", state.BSSID)
}
}
func (r *NetworkManager) onWIFIStateChanged(state adapter.WIFIState) {
r.wifiStateMutex.Lock()
if state == r.wifiState {
r.wifiStateMutex.Unlock()
return
}
r.wifiState = state
r.wifiStateMutex.Unlock()
if state.SSID != "" {
r.logger.Info("updated WIFI state: SSID=", state.SSID, ", BSSID=", state.BSSID)
}
}
func (r *NetworkManager) UpdateWIFIState() {
var state adapter.WIFIState
if r.wifiMonitor != nil {
state = r.wifiMonitor.ReadWIFIState()
} else if r.platformInterface != nil && r.platformInterface.UsePlatformWIFIMonitor() {
state = r.platformInterface.ReadWIFIState()
} else {
return
}
r.onWIFIStateChanged(state)
}
func (r *NetworkManager) ResetNetwork() {

View File

@@ -38,7 +38,6 @@ type Router struct {
pauseManager pause.Manager
trackers []adapter.ConnectionTracker
platformInterface platform.Interface
needWIFIState bool
started bool
}
@@ -57,7 +56,6 @@ func NewRouter(ctx context.Context, logFactory log.Factory, options option.Route
needFindProcess: hasRule(options.Rules, isProcessRule) || hasDNSRule(dnsOptions.Rules, isProcessDNSRule) || options.FindProcess,
pauseManager: service.FromContext[pause.Manager](ctx),
platformInterface: service.FromContext[platform.Interface](ctx),
needWIFIState: hasRule(options.Rules, isWIFIRule) || hasDNSRule(dnsOptions.Rules, isWIFIDNSRule),
}
}
@@ -113,15 +111,13 @@ func (r *Router) Start(stage adapter.StartStage) error {
if cacheContext != nil {
cacheContext.Close()
}
r.network.Initialize(r.ruleSets)
needFindProcess := r.needFindProcess
for _, ruleSet := range r.ruleSets {
metadata := ruleSet.Metadata()
if metadata.ContainsProcessRule {
needFindProcess = true
}
if metadata.ContainsWIFIRule {
r.needWIFIState = true
}
}
if needFindProcess {
if r.platformInterface != nil {
@@ -195,10 +191,6 @@ func (r *Router) RuleSet(tag string) (adapter.RuleSet, bool) {
return ruleSet, loaded
}
func (r *Router) NeedWIFIState() bool {
return r.needWIFIState
}
func (r *Router) Rules() []adapter.Rule {
return r.rules
}

139
service/ccm/credential.go Normal file
View File

@@ -0,0 +1,139 @@
package ccm
import (
"bytes"
"encoding/json"
"io"
"net/http"
"os"
"os/user"
"path/filepath"
"time"
E "github.com/sagernet/sing/common/exceptions"
)
const (
oauth2ClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
oauth2TokenURL = "https://console.anthropic.com/v1/oauth/token"
claudeAPIBaseURL = "https://api.anthropic.com"
tokenRefreshBufferMs = 60000
anthropicBetaOAuthValue = "oauth-2025-04-20"
)
func getRealUser() (*user.User, error) {
if sudoUser := os.Getenv("SUDO_USER"); sudoUser != "" {
sudoUserInfo, err := user.Lookup(sudoUser)
if err == nil {
return sudoUserInfo, nil
}
}
return user.Current()
}
func getDefaultCredentialsPath() (string, error) {
if configDir := os.Getenv("CLAUDE_CONFIG_DIR"); configDir != "" {
return filepath.Join(configDir, ".credentials.json"), nil
}
userInfo, err := getRealUser()
if err != nil {
return "", err
}
return filepath.Join(userInfo.HomeDir, ".claude", ".credentials.json"), nil
}
func readCredentialsFromFile(path string) (*oauthCredentials, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var credentialsContainer struct {
ClaudeAIAuth *oauthCredentials `json:"claudeAiOauth,omitempty"`
}
err = json.Unmarshal(data, &credentialsContainer)
if err != nil {
return nil, err
}
if credentialsContainer.ClaudeAIAuth == nil {
return nil, E.New("claudeAiOauth field not found in credentials")
}
return credentialsContainer.ClaudeAIAuth, nil
}
func writeCredentialsToFile(oauthCredentials *oauthCredentials, path string) error {
data, err := json.MarshalIndent(map[string]any{
"claudeAiOauth": oauthCredentials,
}, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0o600)
}
type oauthCredentials struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
ExpiresAt int64 `json:"expiresAt"`
Scopes []string `json:"scopes,omitempty"`
SubscriptionType string `json:"subscriptionType,omitempty"`
IsMax bool `json:"isMax,omitempty"`
}
func (c *oauthCredentials) needsRefresh() bool {
if c.ExpiresAt == 0 {
return false
}
return time.Now().UnixMilli() >= c.ExpiresAt-tokenRefreshBufferMs
}
func refreshToken(httpClient *http.Client, credentials *oauthCredentials) (*oauthCredentials, error) {
if credentials.RefreshToken == "" {
return nil, E.New("refresh token is empty")
}
requestBody, err := json.Marshal(map[string]string{
"grant_type": "refresh_token",
"refresh_token": credentials.RefreshToken,
"client_id": oauth2ClientID,
})
if err != nil {
return nil, E.Cause(err, "marshal request")
}
request, err := http.NewRequest("POST", oauth2TokenURL, bytes.NewReader(requestBody))
if err != nil {
return nil, err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json")
response, err := httpClient.Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
body, _ := io.ReadAll(response.Body)
return nil, E.New("refresh failed: ", response.Status, " ", string(body))
}
var tokenResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
}
err = json.NewDecoder(response.Body).Decode(&tokenResponse)
if err != nil {
return nil, E.Cause(err, "decode response")
}
newCredentials := *credentials
newCredentials.AccessToken = tokenResponse.AccessToken
if tokenResponse.RefreshToken != "" {
newCredentials.RefreshToken = tokenResponse.RefreshToken
}
newCredentials.ExpiresAt = time.Now().UnixMilli() + int64(tokenResponse.ExpiresIn)*1000
return &newCredentials, nil
}

View File

@@ -0,0 +1,116 @@
//go:build darwin && cgo
package ccm
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"os"
"path/filepath"
E "github.com/sagernet/sing/common/exceptions"
"github.com/keybase/go-keychain"
)
func getKeychainServiceName() string {
configDirectory := os.Getenv("CLAUDE_CONFIG_DIR")
if configDirectory == "" {
return "Claude Code-credentials"
}
userInfo, err := getRealUser()
if err != nil {
return "Claude Code-credentials"
}
defaultConfigDirectory := filepath.Join(userInfo.HomeDir, ".claude")
if configDirectory == defaultConfigDirectory {
return "Claude Code-credentials"
}
hash := sha256.Sum256([]byte(configDirectory))
return "Claude Code-credentials-" + hex.EncodeToString(hash[:])[:8]
}
func platformReadCredentials(customPath string) (*oauthCredentials, error) {
if customPath != "" {
return readCredentialsFromFile(customPath)
}
userInfo, err := getRealUser()
if err == nil {
query := keychain.NewItem()
query.SetSecClass(keychain.SecClassGenericPassword)
query.SetService(getKeychainServiceName())
query.SetAccount(userInfo.Username)
query.SetMatchLimit(keychain.MatchLimitOne)
query.SetReturnData(true)
results, err := keychain.QueryItem(query)
if err == nil && len(results) == 1 {
var container struct {
ClaudeAIAuth *oauthCredentials `json:"claudeAiOauth,omitempty"`
}
unmarshalErr := json.Unmarshal(results[0].Data, &container)
if unmarshalErr == nil && container.ClaudeAIAuth != nil {
return container.ClaudeAIAuth, nil
}
}
if err != nil && err != keychain.ErrorItemNotFound {
return nil, E.Cause(err, "query keychain")
}
}
defaultPath, err := getDefaultCredentialsPath()
if err != nil {
return nil, err
}
return readCredentialsFromFile(defaultPath)
}
func platformWriteCredentials(oauthCredentials *oauthCredentials, customPath string) error {
if customPath != "" {
return writeCredentialsToFile(oauthCredentials, customPath)
}
userInfo, err := getRealUser()
if err == nil {
data, err := json.Marshal(map[string]any{"claudeAiOauth": oauthCredentials})
if err == nil {
serviceName := getKeychainServiceName()
item := keychain.NewItem()
item.SetSecClass(keychain.SecClassGenericPassword)
item.SetService(serviceName)
item.SetAccount(userInfo.Username)
item.SetData(data)
item.SetAccessible(keychain.AccessibleWhenUnlocked)
err = keychain.AddItem(item)
if err == nil {
return nil
}
if err == keychain.ErrorDuplicateItem {
query := keychain.NewItem()
query.SetSecClass(keychain.SecClassGenericPassword)
query.SetService(serviceName)
query.SetAccount(userInfo.Username)
updateItem := keychain.NewItem()
updateItem.SetData(data)
updateErr := keychain.UpdateItem(query, updateItem)
if updateErr == nil {
return nil
}
}
}
}
defaultPath, err := getDefaultCredentialsPath()
if err != nil {
return err
}
return writeCredentialsToFile(oauthCredentials, defaultPath)
}

View File

@@ -0,0 +1,25 @@
//go:build !darwin
package ccm
func platformReadCredentials(customPath string) (*oauthCredentials, error) {
if customPath == "" {
var err error
customPath, err = getDefaultCredentialsPath()
if err != nil {
return nil, err
}
}
return readCredentialsFromFile(customPath)
}
func platformWriteCredentials(oauthCredentials *oauthCredentials, customPath string) error {
if customPath == "" {
var err error
customPath, err = getDefaultCredentialsPath()
if err != nil {
return err
}
}
return writeCredentialsToFile(oauthCredentials, customPath)
}

541
service/ccm/service.go Normal file
View File

@@ -0,0 +1,541 @@
package ccm
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"mime"
"net"
"net/http"
"strings"
"sync"
"time"
"github.com/sagernet/sing-box/adapter"
boxService "github.com/sagernet/sing-box/adapter/service"
"github.com/sagernet/sing-box/common/dialer"
"github.com/sagernet/sing-box/common/listener"
"github.com/sagernet/sing-box/common/tls"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing/common"
"github.com/sagernet/sing/common/buf"
E "github.com/sagernet/sing/common/exceptions"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
aTLS "github.com/sagernet/sing/common/tls"
"github.com/anthropics/anthropic-sdk-go"
"github.com/go-chi/chi/v5"
"golang.org/x/net/http2"
)
const (
contextWindowStandard = 200000
contextWindowPremium = 1000000
premiumContextThreshold = 200000
)
func RegisterService(registry *boxService.Registry) {
boxService.Register[option.CCMServiceOptions](registry, C.TypeCCM, NewService)
}
type errorResponse struct {
Type string `json:"type"`
Error errorDetails `json:"error"`
RequestID string `json:"request_id,omitempty"`
}
type errorDetails struct {
Type string `json:"type"`
Message string `json:"message"`
}
func writeJSONError(w http.ResponseWriter, r *http.Request, statusCode int, errorType string, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
json.NewEncoder(w).Encode(errorResponse{
Type: "error",
Error: errorDetails{
Type: errorType,
Message: message,
},
RequestID: r.Header.Get("Request-Id"),
})
}
func isHopByHopHeader(header string) bool {
switch strings.ToLower(header) {
case "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailers", "transfer-encoding", "upgrade", "host":
return true
default:
return false
}
}
type Service struct {
boxService.Adapter
ctx context.Context
logger log.ContextLogger
credentialPath string
credentials *oauthCredentials
users []option.CCMUser
httpClient *http.Client
httpHeaders http.Header
listener *listener.Listener
tlsConfig tls.ServerConfig
httpServer *http.Server
userManager *UserManager
accessMutex sync.RWMutex
usageTracker *AggregatedUsage
trackingGroup sync.WaitGroup
shuttingDown bool
}
func NewService(ctx context.Context, logger log.ContextLogger, tag string, options option.CCMServiceOptions) (adapter.Service, error) {
serviceDialer, err := dialer.NewWithOptions(dialer.Options{
Context: ctx,
Options: option.DialerOptions{
Detour: options.Detour,
},
RemoteIsDomain: true,
})
if err != nil {
return nil, E.Cause(err, "create dialer")
}
httpClient := &http.Client{
Transport: &http.Transport{
ForceAttemptHTTP2: true,
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return serviceDialer.DialContext(ctx, network, M.ParseSocksaddr(addr))
},
},
}
userManager := &UserManager{
tokenMap: make(map[string]string),
}
var usageTracker *AggregatedUsage
if options.UsagesPath != "" {
usageTracker = &AggregatedUsage{
LastUpdated: time.Now(),
Combinations: make([]CostCombination, 0),
filePath: options.UsagesPath,
logger: logger,
}
}
service := &Service{
Adapter: boxService.NewAdapter(C.TypeCCM, tag),
ctx: ctx,
logger: logger,
credentialPath: options.CredentialPath,
users: options.Users,
httpClient: httpClient,
httpHeaders: options.Headers.Build(),
listener: listener.New(listener.Options{
Context: ctx,
Logger: logger,
Network: []string{N.NetworkTCP},
Listen: options.ListenOptions,
}),
userManager: userManager,
usageTracker: usageTracker,
}
if options.TLS != nil {
tlsConfig, err := tls.NewServer(ctx, logger, common.PtrValueOrDefault(options.TLS))
if err != nil {
return nil, err
}
service.tlsConfig = tlsConfig
}
return service, nil
}
func (s *Service) Start(stage adapter.StartStage) error {
if stage != adapter.StartStateStart {
return nil
}
s.userManager.UpdateUsers(s.users)
credentials, err := platformReadCredentials(s.credentialPath)
if err != nil {
return E.Cause(err, "read credentials")
}
s.credentials = credentials
if s.usageTracker != nil {
err = s.usageTracker.Load()
if err != nil {
s.logger.Warn("load usage statistics: ", err)
}
}
router := chi.NewRouter()
router.Mount("/", s)
s.httpServer = &http.Server{Handler: router}
if s.tlsConfig != nil {
err = s.tlsConfig.Start()
if err != nil {
return E.Cause(err, "create TLS config")
}
}
tcpListener, err := s.listener.ListenTCP()
if err != nil {
return err
}
if s.tlsConfig != nil {
if !common.Contains(s.tlsConfig.NextProtos(), http2.NextProtoTLS) {
s.tlsConfig.SetNextProtos(append([]string{"h2"}, s.tlsConfig.NextProtos()...))
}
tcpListener = aTLS.NewListener(tcpListener, s.tlsConfig)
}
go func() {
serveErr := s.httpServer.Serve(tcpListener)
if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
s.logger.Error("serve error: ", serveErr)
}
}()
return nil
}
func (s *Service) getAccessToken() (string, error) {
s.accessMutex.RLock()
if !s.credentials.needsRefresh() {
token := s.credentials.AccessToken
s.accessMutex.RUnlock()
return token, nil
}
s.accessMutex.RUnlock()
s.accessMutex.Lock()
defer s.accessMutex.Unlock()
if !s.credentials.needsRefresh() {
return s.credentials.AccessToken, nil
}
newCredentials, err := refreshToken(s.httpClient, s.credentials)
if err != nil {
return "", err
}
s.credentials = newCredentials
err = platformWriteCredentials(newCredentials, s.credentialPath)
if err != nil {
s.logger.Warn("persist refreshed token: ", err)
}
return newCredentials.AccessToken, nil
}
func detectContextWindow(betaHeader string, inputTokens int64) int {
if inputTokens > premiumContextThreshold {
features := strings.Split(betaHeader, ",")
for _, feature := range features {
if strings.TrimSpace(feature) == "context-1m" {
return contextWindowPremium
}
}
}
return contextWindowStandard
}
func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/v1/") {
writeJSONError(w, r, http.StatusNotFound, "not_found_error", "Not found")
return
}
var username string
if len(s.users) > 0 {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
s.logger.Warn("authentication failed for request from ", r.RemoteAddr, ": missing Authorization header")
writeJSONError(w, r, http.StatusUnauthorized, "authentication_error", "missing api key")
return
}
clientToken := strings.TrimPrefix(authHeader, "Bearer ")
if clientToken == authHeader {
s.logger.Warn("authentication failed for request from ", r.RemoteAddr, ": invalid Authorization format")
writeJSONError(w, r, http.StatusUnauthorized, "authentication_error", "invalid api key format")
return
}
var ok bool
username, ok = s.userManager.Authenticate(clientToken)
if !ok {
s.logger.Warn("authentication failed for request from ", r.RemoteAddr, ": unknown key: ", clientToken)
writeJSONError(w, r, http.StatusUnauthorized, "authentication_error", "invalid api key")
return
}
}
var requestModel string
var messagesCount int
if s.usageTracker != nil && r.Body != nil {
bodyBytes, err := io.ReadAll(r.Body)
if err == nil {
var request struct {
Model string `json:"model"`
Messages []anthropic.MessageParam `json:"messages"`
}
err := json.Unmarshal(bodyBytes, &request)
if err == nil {
requestModel = request.Model
messagesCount = len(request.Messages)
}
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
}
}
accessToken, err := s.getAccessToken()
if err != nil {
s.logger.Error("get access token: ", err)
writeJSONError(w, r, http.StatusUnauthorized, "authentication_error", "Authentication failed")
return
}
proxyURL := claudeAPIBaseURL + r.URL.RequestURI()
proxyRequest, err := http.NewRequestWithContext(r.Context(), r.Method, proxyURL, r.Body)
if err != nil {
s.logger.Error("create proxy request: ", err)
writeJSONError(w, r, http.StatusInternalServerError, "api_error", "Internal server error")
return
}
for key, values := range r.Header {
if !isHopByHopHeader(key) && key != "Authorization" {
proxyRequest.Header[key] = values
}
}
anthropicBetaHeader := proxyRequest.Header.Get("anthropic-beta")
if anthropicBetaHeader != "" {
proxyRequest.Header.Set("anthropic-beta", anthropicBetaOAuthValue+","+anthropicBetaHeader)
} else {
proxyRequest.Header.Set("anthropic-beta", anthropicBetaOAuthValue)
}
for key, values := range s.httpHeaders {
proxyRequest.Header.Del(key)
proxyRequest.Header[key] = values
}
proxyRequest.Header.Set("Authorization", "Bearer "+accessToken)
response, err := s.httpClient.Do(proxyRequest)
if err != nil {
writeJSONError(w, r, http.StatusBadGateway, "api_error", err.Error())
return
}
defer response.Body.Close()
for key, values := range response.Header {
if !isHopByHopHeader(key) {
w.Header()[key] = values
}
}
w.WriteHeader(response.StatusCode)
if s.usageTracker != nil && response.StatusCode == http.StatusOK {
s.handleResponseWithTracking(w, response, requestModel, anthropicBetaHeader, messagesCount, username)
} else {
mediaType, _, err := mime.ParseMediaType(response.Header.Get("Content-Type"))
if err == nil && mediaType != "text/event-stream" {
_, _ = io.Copy(w, response.Body)
return
}
flusher, ok := w.(http.Flusher)
if !ok {
s.logger.Error("streaming not supported")
return
}
buffer := make([]byte, buf.BufferSize)
for {
n, err := response.Body.Read(buffer)
if n > 0 {
_, writeError := w.Write(buffer[:n])
if writeError != nil {
s.logger.Error("write streaming response: ", writeError)
return
}
flusher.Flush()
}
if err != nil {
return
}
}
}
}
func (s *Service) handleResponseWithTracking(writer http.ResponseWriter, response *http.Response, requestModel string, anthropicBetaHeader string, messagesCount int, username string) {
mediaType, _, err := mime.ParseMediaType(response.Header.Get("Content-Type"))
isStreaming := err == nil && mediaType == "text/event-stream"
if !isStreaming {
bodyBytes, err := io.ReadAll(response.Body)
if err != nil {
s.logger.Error("read response body: ", err)
return
}
var message anthropic.Message
var usage anthropic.Usage
var responseModel string
err = json.Unmarshal(bodyBytes, &message)
if err == nil {
responseModel = string(message.Model)
usage = message.Usage
}
if responseModel == "" {
responseModel = requestModel
}
if usage.InputTokens > 0 || usage.OutputTokens > 0 {
if responseModel != "" {
contextWindow := detectContextWindow(anthropicBetaHeader, usage.InputTokens)
s.usageTracker.AddUsage(
responseModel,
contextWindow,
messagesCount,
usage.InputTokens,
usage.OutputTokens,
usage.CacheReadInputTokens,
usage.CacheCreationInputTokens,
username,
)
}
}
_, _ = writer.Write(bodyBytes)
return
}
flusher, ok := writer.(http.Flusher)
if !ok {
s.logger.Error("streaming not supported")
return
}
var accumulatedUsage anthropic.Usage
var responseModel string
buffer := make([]byte, buf.BufferSize)
var leftover []byte
for {
n, err := response.Body.Read(buffer)
if n > 0 {
data := append(leftover, buffer[:n]...)
lines := bytes.Split(data, []byte("\n"))
if err == nil {
leftover = lines[len(lines)-1]
lines = lines[:len(lines)-1]
} else {
leftover = nil
}
for _, line := range lines {
line = bytes.TrimSpace(line)
if len(line) == 0 {
continue
}
if bytes.HasPrefix(line, []byte("data: ")) {
eventData := bytes.TrimPrefix(line, []byte("data: "))
if bytes.Equal(eventData, []byte("[DONE]")) {
continue
}
var event anthropic.MessageStreamEventUnion
err := json.Unmarshal(eventData, &event)
if err != nil {
continue
}
switch event.Type {
case "message_start":
messageStart := event.AsMessageStart()
if messageStart.Message.Model != "" {
responseModel = string(messageStart.Message.Model)
}
if messageStart.Message.Usage.InputTokens > 0 {
accumulatedUsage.InputTokens = messageStart.Message.Usage.InputTokens
accumulatedUsage.CacheReadInputTokens = messageStart.Message.Usage.CacheReadInputTokens
accumulatedUsage.CacheCreationInputTokens = messageStart.Message.Usage.CacheCreationInputTokens
}
case "message_delta":
messageDelta := event.AsMessageDelta()
if messageDelta.Usage.OutputTokens > 0 {
accumulatedUsage.OutputTokens = messageDelta.Usage.OutputTokens
}
}
}
}
_, writeError := writer.Write(buffer[:n])
if writeError != nil {
s.logger.Error("write streaming response: ", writeError)
return
}
flusher.Flush()
}
if err != nil {
if responseModel == "" {
responseModel = requestModel
}
if accumulatedUsage.InputTokens > 0 || accumulatedUsage.OutputTokens > 0 {
if responseModel != "" {
contextWindow := detectContextWindow(anthropicBetaHeader, accumulatedUsage.InputTokens)
s.usageTracker.AddUsage(
responseModel,
contextWindow,
messagesCount,
accumulatedUsage.InputTokens,
accumulatedUsage.OutputTokens,
accumulatedUsage.CacheReadInputTokens,
accumulatedUsage.CacheCreationInputTokens,
username,
)
}
}
return
}
}
}
func (s *Service) Close() error {
err := common.Close(
common.PtrOrNil(s.httpServer),
common.PtrOrNil(s.listener),
s.tlsConfig,
)
if s.usageTracker != nil {
s.usageTracker.cancelPendingSave()
saveErr := s.usageTracker.Save()
if saveErr != nil {
s.logger.Error("save usage statistics: ", saveErr)
}
}
return err
}

View File

@@ -0,0 +1,407 @@
package ccm
import (
"encoding/json"
"math"
"os"
"regexp"
"sync"
"time"
"github.com/sagernet/sing-box/log"
E "github.com/sagernet/sing/common/exceptions"
)
type UsageStats struct {
RequestCount int `json:"request_count"`
MessagesCount int `json:"messages_count"`
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
CacheReadInputTokens int64 `json:"cache_read_input_tokens"`
CacheCreationInputTokens int64 `json:"cache_creation_input_tokens"`
}
type CostCombination struct {
Model string `json:"model"`
ContextWindow int `json:"context_window"`
Total UsageStats `json:"total"`
ByUser map[string]UsageStats `json:"by_user"`
}
type AggregatedUsage struct {
LastUpdated time.Time `json:"last_updated"`
Combinations []CostCombination `json:"combinations"`
mutex sync.Mutex
filePath string
logger log.ContextLogger
lastSaveTime time.Time
pendingSave bool
saveTimer *time.Timer
saveMutex sync.Mutex
}
type UsageStatsJSON struct {
RequestCount int `json:"request_count"`
MessagesCount int `json:"messages_count"`
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
CacheReadInputTokens int64 `json:"cache_read_input_tokens"`
CacheCreationInputTokens int64 `json:"cache_creation_input_tokens"`
CostUSD float64 `json:"cost_usd"`
}
type CostCombinationJSON struct {
Model string `json:"model"`
ContextWindow int `json:"context_window"`
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"`
}
type AggregatedUsageJSON struct {
LastUpdated time.Time `json:"last_updated"`
Costs CostsSummaryJSON `json:"costs"`
Combinations []CostCombinationJSON `json:"combinations"`
}
type ModelPricing struct {
InputPrice float64
OutputPrice float64
CacheReadPrice float64
CacheWritePrice float64
}
type modelFamily struct {
pattern *regexp.Regexp
standardPricing ModelPricing
premiumPricing *ModelPricing
}
var (
opus4Pricing = ModelPricing{
InputPrice: 15.0,
OutputPrice: 75.0,
CacheReadPrice: 1.5,
CacheWritePrice: 18.75,
}
sonnet4StandardPricing = ModelPricing{
InputPrice: 3.0,
OutputPrice: 15.0,
CacheReadPrice: 0.3,
CacheWritePrice: 3.75,
}
sonnet4PremiumPricing = ModelPricing{
InputPrice: 6.0,
OutputPrice: 22.5,
CacheReadPrice: 0.6,
CacheWritePrice: 7.5,
}
haiku4Pricing = ModelPricing{
InputPrice: 1.0,
OutputPrice: 5.0,
CacheReadPrice: 0.1,
CacheWritePrice: 1.25,
}
haiku35Pricing = ModelPricing{
InputPrice: 0.8,
OutputPrice: 4.0,
CacheReadPrice: 0.08,
CacheWritePrice: 1.0,
}
sonnet35Pricing = ModelPricing{
InputPrice: 3.0,
OutputPrice: 15.0,
CacheReadPrice: 0.3,
CacheWritePrice: 3.75,
}
modelFamilies = []modelFamily{
{
pattern: regexp.MustCompile(`^claude-(?:opus-4-|4-opus-|opus-4-1-)`),
standardPricing: opus4Pricing,
premiumPricing: nil,
},
{
pattern: regexp.MustCompile(`^claude-3-7-sonnet-`),
standardPricing: sonnet4StandardPricing,
premiumPricing: &sonnet4PremiumPricing,
},
{
pattern: regexp.MustCompile(`^claude-(?:sonnet-4-|4-sonnet-)`),
standardPricing: sonnet4StandardPricing,
premiumPricing: &sonnet4PremiumPricing,
},
{
pattern: regexp.MustCompile(`^claude-haiku-4-`),
standardPricing: haiku4Pricing,
premiumPricing: nil,
},
{
pattern: regexp.MustCompile(`^claude-3-5-haiku-`),
standardPricing: haiku35Pricing,
premiumPricing: nil,
},
{
pattern: regexp.MustCompile(`^claude-3-5-sonnet-`),
standardPricing: sonnet35Pricing,
premiumPricing: nil,
},
}
)
func getPricing(model string, contextWindow int) ModelPricing {
isPremium := contextWindow >= contextWindowPremium
for _, family := range modelFamilies {
if family.pattern.MatchString(model) {
if isPremium && family.premiumPricing != nil {
return *family.premiumPricing
}
return family.standardPricing
}
}
return sonnet4StandardPricing
}
func calculateCost(stats UsageStats, model string, contextWindow int) float64 {
pricing := getPricing(model, contextWindow)
cost := (float64(stats.InputTokens)*pricing.InputPrice +
float64(stats.OutputTokens)*pricing.OutputPrice +
float64(stats.CacheReadInputTokens)*pricing.CacheReadPrice +
float64(stats.CacheCreationInputTokens)*pricing.CacheWritePrice) / 1_000_000
return math.Round(cost*100) / 100
}
func (u *AggregatedUsage) ToJSON() *AggregatedUsageJSON {
u.mutex.Lock()
defer u.mutex.Unlock()
result := &AggregatedUsageJSON{
LastUpdated: u.LastUpdated,
Combinations: make([]CostCombinationJSON, len(u.Combinations)),
Costs: CostsSummaryJSON{
TotalUSD: 0,
ByUser: make(map[string]float64),
},
}
for i, combo := range u.Combinations {
totalCost := calculateCost(combo.Total, combo.Model, combo.ContextWindow)
result.Costs.TotalUSD += totalCost
comboJSON := CostCombinationJSON{
Model: combo.Model,
ContextWindow: combo.ContextWindow,
Total: UsageStatsJSON{
RequestCount: combo.Total.RequestCount,
MessagesCount: combo.Total.MessagesCount,
InputTokens: combo.Total.InputTokens,
OutputTokens: combo.Total.OutputTokens,
CacheReadInputTokens: combo.Total.CacheReadInputTokens,
CacheCreationInputTokens: combo.Total.CacheCreationInputTokens,
CostUSD: totalCost,
},
ByUser: make(map[string]UsageStatsJSON),
}
for user, userStats := range combo.ByUser {
userCost := calculateCost(userStats, combo.Model, combo.ContextWindow)
result.Costs.ByUser[user] += userCost
comboJSON.ByUser[user] = UsageStatsJSON{
RequestCount: userStats.RequestCount,
MessagesCount: userStats.MessagesCount,
InputTokens: userStats.InputTokens,
OutputTokens: userStats.OutputTokens,
CacheReadInputTokens: userStats.CacheReadInputTokens,
CacheCreationInputTokens: userStats.CacheCreationInputTokens,
CostUSD: userCost,
}
}
result.Combinations[i] = comboJSON
}
result.Costs.TotalUSD = math.Round(result.Costs.TotalUSD*100) / 100
for user, cost := range result.Costs.ByUser {
result.Costs.ByUser[user] = math.Round(cost*100) / 100
}
return result
}
func (u *AggregatedUsage) Load() error {
u.mutex.Lock()
defer u.mutex.Unlock()
data, err := os.ReadFile(u.filePath)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
var temp struct {
LastUpdated time.Time `json:"last_updated"`
Combinations []CostCombination `json:"combinations"`
}
err = json.Unmarshal(data, &temp)
if err != nil {
return err
}
u.LastUpdated = temp.LastUpdated
u.Combinations = temp.Combinations
for i := range u.Combinations {
if u.Combinations[i].ByUser == nil {
u.Combinations[i].ByUser = make(map[string]UsageStats)
}
}
return nil
}
func (u *AggregatedUsage) Save() error {
jsonData := u.ToJSON()
data, err := json.MarshalIndent(jsonData, "", " ")
if err != nil {
return err
}
tmpFile := u.filePath + ".tmp"
err = os.WriteFile(tmpFile, data, 0o644)
if err != nil {
return err
}
defer os.Remove(tmpFile)
err = os.Rename(tmpFile, u.filePath)
if err == nil {
u.saveMutex.Lock()
u.lastSaveTime = time.Now()
u.saveMutex.Unlock()
}
return err
}
func (u *AggregatedUsage) AddUsage(model string, contextWindow int, messagesCount int, inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens int64, user string) error {
if model == "" {
return E.New("model cannot be empty")
}
if contextWindow <= 0 {
return E.New("contextWindow must be positive")
}
u.mutex.Lock()
defer u.mutex.Unlock()
u.LastUpdated = time.Now()
// Find or create combination
var combo *CostCombination
for i := range u.Combinations {
if u.Combinations[i].Model == model && u.Combinations[i].ContextWindow == contextWindow {
combo = &u.Combinations[i]
break
}
}
if combo == nil {
newCombo := CostCombination{
Model: model,
ContextWindow: contextWindow,
Total: UsageStats{},
ByUser: make(map[string]UsageStats),
}
u.Combinations = append(u.Combinations, newCombo)
combo = &u.Combinations[len(u.Combinations)-1]
}
// Update total stats
combo.Total.RequestCount++
combo.Total.MessagesCount += messagesCount
combo.Total.InputTokens += inputTokens
combo.Total.OutputTokens += outputTokens
combo.Total.CacheReadInputTokens += cacheReadTokens
combo.Total.CacheCreationInputTokens += cacheCreationTokens
// Update per-user stats if user is specified
if user != "" {
userStats := combo.ByUser[user]
userStats.RequestCount++
userStats.MessagesCount += messagesCount
userStats.InputTokens += inputTokens
userStats.OutputTokens += outputTokens
userStats.CacheReadInputTokens += cacheReadTokens
userStats.CacheCreationInputTokens += cacheCreationTokens
combo.ByUser[user] = userStats
}
go u.scheduleSave()
return nil
}
func (u *AggregatedUsage) scheduleSave() {
const saveInterval = time.Minute
u.saveMutex.Lock()
defer u.saveMutex.Unlock()
timeSinceLastSave := time.Since(u.lastSaveTime)
if timeSinceLastSave >= saveInterval {
go u.saveAsync()
return
}
if u.pendingSave {
return
}
u.pendingSave = true
remainingTime := saveInterval - timeSinceLastSave
u.saveTimer = time.AfterFunc(remainingTime, func() {
u.saveMutex.Lock()
u.pendingSave = false
u.saveMutex.Unlock()
u.saveAsync()
})
}
func (u *AggregatedUsage) saveAsync() {
err := u.Save()
if err != nil {
if u.logger != nil {
u.logger.Error("save usage statistics: ", err)
}
}
}
func (u *AggregatedUsage) cancelPendingSave() {
u.saveMutex.Lock()
defer u.saveMutex.Unlock()
if u.saveTimer != nil {
u.saveTimer.Stop()
u.saveTimer = nil
}
u.pendingSave = false
}

View File

@@ -0,0 +1,29 @@
package ccm
import (
"sync"
"github.com/sagernet/sing-box/option"
)
type UserManager struct {
accessMutex sync.RWMutex
tokenMap map[string]string
}
func (m *UserManager) UpdateUsers(users []option.CCMUser) {
m.accessMutex.Lock()
defer m.accessMutex.Unlock()
tokenMap := make(map[string]string, len(users))
for _, user := range users {
tokenMap[user.Token] = user.Name
}
m.tokenMap = tokenMap
}
func (m *UserManager) Authenticate(token string) (string, bool) {
m.accessMutex.RLock()
username, found := m.tokenMap[token]
m.accessMutex.RUnlock()
return username, found
}

Some files were not shown because too many files have changed in this diff Show More