Merge tag 'v1.14.0'

This commit is contained in:
Shtorm
2026-09-04 01:08:30 +03:00
987 changed files with 141481 additions and 11302 deletions
+1 -1
View File
@@ -1 +1 @@
ec9a39c5ba3b4a8d625ede04deaf3c9020afb916
45832ab074849607406baa3e3a2c4660274602ed
+2
View File
@@ -31,6 +31,8 @@ body:
- sing-box for macOS Graphical Client
- sing-box for Apple tvOS Graphical Client
- sing-box for Android Graphical Client
- sing-box for Windows Graphical Client
- sing-box for Linux Graphical Client
- Third-party graphical clients that advertise themselves as using sing-box (Windows)
- Third-party graphical clients that advertise themselves as using sing-box (Android)
- Others
+2
View File
@@ -31,6 +31,8 @@ body:
- sing-box for macOS 图形客户端程序
- sing-box for Apple tvOS 图形客户端程序
- sing-box for Android 图形客户端程序
- sing-box for Windows 图形客户端程序
- sing-box for Linux 图形客户端程序
- 宣传使用 sing-box 的第三方图形客户端程序 (Windows)
- 宣传使用 sing-box 的第三方图形客户端程序 (Android)
- 其他
+8 -6
View File
@@ -2,10 +2,11 @@
set -euo pipefail
VERSION="1.25.12"
VERSION="1.26.7"
PATCH_COMMITS=(
"afe69d3cec1c6dcf0f1797b20546795730850070"
"1ed289b0cf87dc5aae9c6fe1aa5f200a83412938"
"f080b0c6346eb690c0dc82497b35925f385b35ac"
"2d9c12887c342fb9051d231aa5388743cb7e9cb6"
"367663849c656612fc3c8875ef6e7f6b0c93dbd0"
)
CURL_ARGS=(
-fL
@@ -25,12 +26,13 @@ tar -xzf "go${VERSION}.darwin-arm64.tar.gz"
mv go go_osx
cd go_osx
# these patch URLs only work on golang1.25.x
# that means after golang1.26 release it must be changed
# see: https://github.com/SagerNet/go/commits/release-branch.go1.25/
# these patch URLs only work on golang1.26.x
# that means after golang1.27 release it must be changed
# see: https://github.com/SagerNet/go/commits/release-branch.go1.26/
# revert:
# 33d3f603c1: "cmd/link/internal/ld: use 12.0.0 OS/SDK versions for macOS linking"
# 937368f84e: "crypto/x509: change how we retrieve chains on darwin"
# d90a57ffe8: "cmd/link/internal/ld: unify OS/SDK versions for macOS linking"
for patch_commit in "${PATCH_COMMITS[@]}"; do
curl "${CURL_ARGS[@]}" "https://github.com/SagerNet/go/commit/${patch_commit}.diff" | patch --verbose -p 1
+10 -10
View File
@@ -2,14 +2,14 @@
set -euo pipefail
VERSION="1.25.12"
VERSION="1.26.7"
PATCH_COMMITS=(
"da4094da73b3b419e3f347594d805e2831f65667"
"824aa60e77f06dbae86c20a164c78df722eb7047"
"a3b6ba31c8cc67b6d899b978bba7b53e95afc46b"
"edfa8de63435a409a59f60731b66ab5940d6d3a4"
"284f9b24d6284984966a8431e30fdc2583938f96"
"9864798dee8dd47b55d1d5100d2f1b909a2a6e6c"
"a4ae550aa148b04c9d4890e98bee63aede5c4b53"
"95b851f661584711faa8115b3234a461044f4510"
"b0bf0a863cf218b9bb0d6c013903ab160ae0c39b"
"7c7a2a0d68920f8764d9a3aeae7ad1067a17b3fa"
"ba34356e82f3c12a7303d2231e6ea0e42bbbb3bf"
"472a88edbc2a42feb53fa31d29ab54d33840dca6"
)
CURL_ARGS=(
-fL
@@ -29,9 +29,9 @@ mv go go_win7
cd go_win7
# modify from https://github.com/restic/restic/issues/4636#issuecomment-1896455557
# these patch URLs only work on golang1.25.x
# that means after golang1.26 release it must be changed
# see: https://github.com/MetaCubeX/go/commits/release-branch.go1.25/
# these patch URLs only work on golang1.26.x
# that means after golang1.27 release it must be changed
# see: https://github.com/MetaCubeX/go/commits/release-branch.go1.26/
# revert:
# 693def151adff1af707d82d28f55dba81ceb08e1: "crypto/rand,runtime: switch RtlGenRandom for ProcessPrng"
# 7c1157f9544922e96945196b47b95664b1e39108: "net: remove sysSocket fallback for Windows 7"
+2 -1
View File
@@ -4,7 +4,7 @@ PROJECTS=$(dirname "$0")/../..
function updateClient() {
pushd clients/$1
git fetch
git fetch origin main
git reset FETCH_HEAD --hard
popd
git add clients/$1
@@ -12,3 +12,4 @@ function updateClient() {
updateClient "apple"
updateClient "android"
updateClient "desktop"
+369 -46
View File
@@ -16,12 +16,15 @@ on:
- All
- Binary
- Android
- Windows
- Linux
- Apple
- Apple-release
- iOS
- macOS
- tvOS
- macOS-standalone
- iOS-jailbreak
- publish-android
test_message:
description: "TestFlight test message"
@@ -51,7 +54,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: 1.25.12
go-version: 1.26.7
cache: false
- name: Cache Go modules
uses: actions/cache/restore@v4
@@ -142,7 +145,7 @@ jobs:
if: ${{ ! matrix.legacy_win7 }}
uses: actions/setup-go@v5
with:
go-version: 1.25.12
go-version: 1.26.7
cache: false
- name: Cache Go modules
uses: actions/cache/restore@v4
@@ -157,7 +160,7 @@ jobs:
with:
path: |
~/go/go_win7
key: go_win7_12512
key: go_win7_1267
- name: Setup Go for Windows 7
if: matrix.legacy_win7 && steps.cache-go-for-windows7.outputs.cache-hit != 'true'
env:
@@ -485,7 +488,7 @@ jobs:
if: ${{ ! matrix.legacy_osx }}
uses: actions/setup-go@v5
with:
go-version: ^1.25.3
go-version: 1.26.7
cache: false
- name: Cache Go modules
uses: actions/cache/restore@v4
@@ -500,7 +503,7 @@ jobs:
with:
path: |
~/go/go_osx
key: go_osx_1258
key: go_osx_1267
- name: Setup Go for macOS 10.13
if: matrix.legacy_osx && steps.cache-go-for-macos1013.outputs.cache-hit != 'true'
env:
@@ -584,7 +587,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ^1.25.4
go-version: 1.26.7
cache: false
- name: Cache Go modules
uses: actions/cache@v4
@@ -687,7 +690,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: 1.25.12
go-version: 1.26.7
cache: false
- name: Cache Go modules
uses: actions/cache@v4
@@ -751,7 +754,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: 1.25.12
go-version: 1.26.7
cache: false
- name: Cache Go modules
uses: actions/cache/restore@v4
@@ -859,7 +862,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: 1.25.12
go-version: 1.26.7
cache: false
- name: Cache Go modules
uses: actions/cache/restore@v4
@@ -941,10 +944,278 @@ jobs:
env:
ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }}
LOCAL_PROPERTIES: ${{ secrets.LOCAL_PROPERTIES }}
build_windows_client:
name: Build Windows client (${{ matrix.desktop_arch }})
if: (github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Windows') && github.ref != 'refs/heads/oldstable'
runs-on: windows-latest
needs:
- calculate_version
strategy:
fail-fast: false
matrix:
include:
- { desktop_arch: x64, rust_target: x86_64-pc-windows-msvc }
- { desktop_arch: x86, rust_target: i686-pc-windows-msvc }
- { desktop_arch: arm64, rust_target: aarch64-pc-windows-msvc }
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
fetch-depth: 1
- name: Checkout desktop client
run: git submodule update --init clients/desktop
- name: Checkout main branch
if: github.ref == 'refs/heads/stable' && github.event_name != 'workflow_dispatch'
run: git -C clients/desktop checkout main
- name: Checkout dev branch
if: github.ref == 'refs/heads/testing'
run: git -C clients/desktop checkout dev
- name: Checkout desktop submodules
run: git -C clients/desktop submodule update --init --recursive
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: 1.26.7
cache: false
- name: Cache Go modules
uses: actions/cache@v4
with:
path: ~/go/pkg/mod
key: go-mod-desktop-windows-${{ hashFiles('go.sum') }}
restore-keys: go-mod-desktop-windows-
- name: Setup Rust
uses: dtolnay/rust-toolchain@1.88.0
with:
targets: ${{ matrix.rust_target }}
- name: Cache Windows sharing module
uses: actions/cache@v4
with:
path: |
~/.cargo/git
~/.cargo/registry
clients/desktop/bin/windows-share-toolchain/cargo-target
key: windows-share-${{ matrix.rust_target }}-${{ hashFiles('clients/desktop/native/windows-share/Cargo.lock') }}
restore-keys: windows-share-${{ matrix.rust_target }}-
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
package_json_file: clients/desktop/package.json
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: clients/desktop/package.json
cache: pnpm
cache-dependency-path: |
clients/desktop/pnpm-lock.yaml
clients/desktop/dashboard/pnpm-lock.yaml
- name: Set tag
run: |-
git ls-remote --exit-code --tags origin v${{ needs.calculate_version.outputs.version }}
if ($LASTEXITCODE -ne 0) {
"PUBLISHED=false" >> $env:GITHUB_ENV
}
git tag v${{ needs.calculate_version.outputs.version }} -f
- name: Update version
if: github.event_name == 'workflow_dispatch'
run: |-
go run -v ./cmd/internal/update_desktop_version --ci
- name: Update nightly version
if: github.event_name != 'workflow_dispatch'
run: |-
go run -v ./cmd/internal/update_desktop_version --ci --nightly
- name: Setup signing
run: |-
$certificatePath = Join-Path $env:RUNNER_TEMP "windows-signing.p12"
[IO.File]::WriteAllBytes($certificatePath, [Convert]::FromBase64String($env:WINDOWS_CERTIFICATES_P12))
@{
windows = @{
certificateFile = $certificatePath
certificatePassword = $env:WINDOWS_P12_PASSWORD
}
} | ConvertTo-Json -Depth 3 | Set-Content -Encoding utf8NoBOM clients/desktop/signing.local.json
env:
WINDOWS_CERTIFICATES_P12: ${{ secrets.WINDOWS_CERTIFICATES_P12 }}
WINDOWS_P12_PASSWORD: ${{ secrets.WINDOWS_P12_PASSWORD }}
- name: Install dependencies
run: |-
pnpm -C clients/desktop install
- name: Build
run: |-
pnpm -C clients/desktop package:win -- ${{ matrix.desktop_arch }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Prepare upload
run: |-
$artifacts = @(Get-ChildItem clients/desktop/release/SFW-*.exe -File)
if ($artifacts.Count -ne 1) {
throw "expected 1 Windows installer for ${{ matrix.desktop_arch }}, found $($artifacts.Count)"
}
New-Item -ItemType Directory -Force dist | Out-Null
Copy-Item $artifacts.FullName dist
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: binary-windows-client-${{ matrix.desktop_arch }}
path: 'dist'
build_linux_client:
name: Build Linux client (${{ matrix.desktop_arch }})
if: (github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Linux') && github.ref != 'refs/heads/oldstable'
runs-on: ubuntu-26.04
needs:
- calculate_version
strategy:
matrix:
include:
- { desktop_arch: x64, go_arch: amd64 }
- { desktop_arch: arm64, go_arch: arm64 }
- { desktop_arch: armv7l, go_arch: arm, go_arm: "7" }
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
fetch-depth: 1
- name: Checkout desktop client
run: git submodule update --init clients/desktop
- name: Checkout main branch
if: github.ref == 'refs/heads/stable' && github.event_name != 'workflow_dispatch'
run: git -C clients/desktop checkout main
- name: Checkout dev branch
if: github.ref == 'refs/heads/testing'
run: git -C clients/desktop checkout dev
- name: Checkout desktop submodules
run: git -C clients/desktop submodule update --init --recursive
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: 1.26.7
cache: false
- name: Cache Go modules
uses: actions/cache@v4
with:
path: ~/go/pkg/mod
key: go-mod-desktop-linux-${{ hashFiles('go.sum') }}
restore-keys: go-mod-desktop-linux-
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
package_json_file: clients/desktop/package.json
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: clients/desktop/package.json
cache: pnpm
cache-dependency-path: |
clients/desktop/pnpm-lock.yaml
clients/desktop/dashboard/pnpm-lock.yaml
- name: Set tag
run: |-
git ls-remote --exit-code --tags origin v${{ needs.calculate_version.outputs.version }} || echo "PUBLISHED=false" >> "$GITHUB_ENV"
git tag v${{ needs.calculate_version.outputs.version }} -f
- name: Update version
if: github.event_name == 'workflow_dispatch'
run: |-
go run -v ./cmd/internal/update_desktop_version --ci
- name: Update nightly version
if: github.event_name != 'workflow_dispatch'
run: |-
go run -v ./cmd/internal/update_desktop_version --ci --nightly
- name: Clone cronet-go
run: |-
set -xeuo pipefail
CRONET_GO_VERSION=$(cat .github/CRONET_GO_VERSION)
git init ~/cronet-go
git -C ~/cronet-go remote add origin https://github.com/sagernet/cronet-go.git
git -C ~/cronet-go fetch --depth=1 origin "$CRONET_GO_VERSION"
git -C ~/cronet-go checkout FETCH_HEAD
git -C ~/cronet-go submodule update --init --recursive --depth=1
- name: Regenerate Debian keyring
run: |-
set -xeuo pipefail
rm -f ~/cronet-go/naiveproxy/src/build/linux/sysroot_scripts/keyring.gpg
cd ~/cronet-go
GPG_TTY=/dev/null ./naiveproxy/src/build/linux/sysroot_scripts/generate_keyring.sh
- name: Download Chromium toolchain
run: |-
set -xeuo pipefail
cd ~/cronet-go
go run ./cmd/build-naive --target=linux/${{ matrix.go_arch }} download-toolchain
- name: Set Chromium toolchain environment
run: |-
set -xeuo pipefail
cd ~/cronet-go
go run ./cmd/build-naive --target=linux/${{ matrix.go_arch }} env >> "$GITHUB_ENV"
- name: Install package tools
run: |-
set -euo pipefail
sudo apt-get update || true
sudo apt-get install -y debsigs libarchive-tools rpm zstd
echo '%_rpmformat 4' > "$HOME/.rpmmacros"
- name: Install dependencies
run: |-
pnpm -C clients/desktop install
- name: Build
run: |-
pnpm -C clients/desktop package:linux -- deb rpm pacman ${{ matrix.desktop_arch }}
env:
CGO_ENABLED: "0"
GOARM: ${{ matrix.go_arm }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Setup package signing
run: |-
set -euo pipefail
SIGNING_GNUPG_HOME="$RUNNER_TEMP/package-signing-gnupg"
SIGNING_PASSPHRASE_FILE="$RUNNER_TEMP/package-signing-passphrase"
mkdir -m 700 "$SIGNING_GNUPG_HOME"
printf '%s' "$GPG_PASSPHRASE" > "$SIGNING_PASSPHRASE_FILE"
chmod 600 "$SIGNING_PASSPHRASE_FILE"
{
echo "GNUPGHOME=$SIGNING_GNUPG_HOME"
echo "SIGNING_PASSPHRASE_FILE=$SIGNING_PASSPHRASE_FILE"
} >> "$GITHUB_ENV"
printf '%s' "$GPG_KEY" | gpg \
--homedir "$SIGNING_GNUPG_HOME" \
--batch \
--pinentry-mode loopback \
--passphrase-file "$SIGNING_PASSPHRASE_FILE" \
--import
cat > "$HOME/.rpmmacros" <<EOF
%_gpg_name $GPG_KEY_ID
%_gpg_sign_cmd_extra_args --pinentry-mode loopback --passphrase-file $SIGNING_PASSPHRASE_FILE
EOF
env:
GPG_KEY: ${{ secrets.GPG_KEY }}
GPG_KEY_ID: ${{ secrets.GPG_KEY_ID }}
GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
- name: Sign Linux packages
run: |-
set -euo pipefail
debsigs \
--sign=origin \
-k "$GPG_KEY_ID" \
--gpgopts "--batch --pinentry-mode loopback --passphrase-file $SIGNING_PASSPHRASE_FILE" \
clients/desktop/release/SFL-*.deb
rpmsign --addsign clients/desktop/release/SFL-*.rpm
env:
GPG_KEY_ID: ${{ secrets.GPG_KEY_ID }}
- name: Prepare upload
run: |-
shopt -s nullglob
artifacts=(clients/desktop/release/SFL-*.deb clients/desktop/release/SFL-*.rpm clients/desktop/release/SFL-*.pkg.tar.zst)
if (( ${#artifacts[@]} != 3 )); then
echo "expected 3 Linux packages, found ${#artifacts[@]}" >&2
exit 1
fi
mkdir -p dist
cp "${artifacts[@]}" dist
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: binary-linux-client-${{ matrix.desktop_arch }}
path: 'dist'
build_apple_library:
name: Build Apple library (${{ matrix.artifact }})
runs-on: ${{ matrix.build && 'macos-26' || 'ubuntu-latest' }}
if: github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'Apple-release' || inputs.build == 'iOS' || inputs.build == 'macOS' || inputs.build == 'tvOS' || inputs.build == 'macOS-standalone'
if: github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'Apple-release' || inputs.build == 'iOS' || inputs.build == 'macOS' || inputs.build == 'tvOS' || inputs.build == 'macOS-standalone' || inputs.build == 'iOS-jailbreak'
needs:
- calculate_version
strategy:
@@ -953,7 +1224,7 @@ jobs:
include:
- artifact: ios-arm64
platform: ios/arm64
build: ${{ github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'Apple-release' || inputs.build == 'iOS' }}
build: ${{ github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'Apple-release' || inputs.build == 'iOS' || inputs.build == 'iOS-jailbreak' }}
- artifact: tvos-arm64
platform: tvos/arm64
build: ${{ github.event_name == 'workflow_dispatch' && (inputs.build == 'All' || inputs.build == 'Apple' || inputs.build == 'tvOS') }}
@@ -973,7 +1244,7 @@ jobs:
if: matrix.build
uses: actions/setup-go@v5
with:
go-version: 1.25.12
go-version: 1.26.7
cache: false
- name: Cache Go modules
if: matrix.build
@@ -987,8 +1258,8 @@ jobs:
uses: actions/cache@v4
with:
path: ~/Library/Caches/go-build
key: libbox-go-build-${{ matrix.artifact }}-1.25.12-${{ hashFiles('go.sum') }}
restore-keys: libbox-go-build-${{ matrix.artifact }}-1.25.12-
key: libbox-go-build-${{ matrix.artifact }}-1.26.7-${{ hashFiles('go.sum') }}
restore-keys: libbox-go-build-${{ matrix.artifact }}-1.26.7-
- name: Set tag
if: matrix.build
run: git tag v${{ needs.calculate_version.outputs.version }} -f
@@ -1010,7 +1281,7 @@ jobs:
build_apple:
name: Build Apple release clients
runs-on: ${{ matrix.if && 'macos-26' || 'ubuntu-latest' }}
if: github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Apple-release' || inputs.build == 'macOS-standalone'
if: github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Apple-release' || inputs.build == 'macOS-standalone' || inputs.build == 'iOS-jailbreak'
needs:
- calculate_version
- build_apple_library
@@ -1028,6 +1299,20 @@ jobs:
destination: ''
archive: ''
upload: ''
upload_extension: ''
altool_platform: ''
- name: iOS-jailbreak
if: ${{ github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Apple-release' || inputs.build == 'iOS-jailbreak' }}
type: jailbreak
platform: ios
variant: ''
library_arch: ''
scheme: ''
destination: ''
archive: ''
upload: ''
upload_extension: ''
altool_platform: ''
steps: &build_apple_steps
- name: Checkout
if: matrix.if
@@ -1041,7 +1326,7 @@ jobs:
if: matrix.if
uses: actions/setup-go@v5
with:
go-version: 1.25.12
go-version: 1.26.7
cache: false
- name: Cache Go modules
if: matrix.if
@@ -1092,29 +1377,25 @@ jobs:
- name: Setup App Store Connect key
if: matrix.if && matrix.type != 'jailbreak' && github.event_name == 'workflow_dispatch'
run: |-
ASC_KEY_PATH=$RUNNER_TEMP/AuthKey.p8
# altool selects individual key authentication by the ApiKey_ file name prefix;
# an AuthKey_ prefixed file is signed as a team key and rejected.
ASC_PRIVATE_KEYS_DIR=$RUNNER_TEMP/private_keys
mkdir -p $ASC_PRIVATE_KEYS_DIR
ASC_KEY_PATH=$ASC_PRIVATE_KEYS_DIR/ApiKey_$ASC_KEY_ID.p8
echo -n "$ASC_KEY" | base64 --decode -o $ASC_KEY_PATH
echo "API_PRIVATE_KEYS_DIR=$ASC_PRIVATE_KEYS_DIR" >> "$GITHUB_ENV"
echo "ASC_KEY_PATH=$ASC_KEY_PATH" >> "$GITHUB_ENV"
echo "ASC_KEY_ID=$ASC_KEY_ID" >> "$GITHUB_ENV"
echo "ASC_KEY_ISSUER_ID=$ASC_KEY_ISSUER_ID" >> "$GITHUB_ENV"
env:
ASC_KEY: ${{ secrets.ASC_KEY }}
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_KEY_ISSUER_ID: ${{ secrets.ASC_KEY_ISSUER_ID }}
- name: Install Apple provisioning profiles
if: matrix.if && matrix.type != 'jailbreak'
run: |-
PROFILES_ZIP_PATH=$RUNNER_TEMP/AppleProvisioningProfiles.zip
printf '%s%s%s%s%s%s%s%s' \
"$PROVISIONING_PROFILES_1" \
"$PROVISIONING_PROFILES_2" \
"$PROVISIONING_PROFILES_3" \
"$PROVISIONING_PROFILES_4" \
"$PROVISIONING_PROFILES_5" \
"$PROVISIONING_PROFILES_6" \
"$PROVISIONING_PROFILES_7" \
"$PROVISIONING_PROFILES_8" \
| base64 --decode -o $PROFILES_ZIP_PATH
for variable in $(compgen -v | grep -E '^PROVISIONING_PROFILES_[0-9]+$' | sort -t_ -k3 -n); do
printf '%s' "${!variable}"
done | base64 --decode -o $PROFILES_ZIP_PATH
PROFILES_PATH="$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles"
mkdir -p "$PROFILES_PATH"
unzip -q $PROFILES_ZIP_PATH -d "$PROFILES_PATH"
@@ -1127,6 +1408,14 @@ jobs:
PROVISIONING_PROFILES_6: ${{ secrets.APPLE_PROVISIONING_PROFILES_6 }}
PROVISIONING_PROFILES_7: ${{ secrets.APPLE_PROVISIONING_PROFILES_7 }}
PROVISIONING_PROFILES_8: ${{ secrets.APPLE_PROVISIONING_PROFILES_8 }}
PROVISIONING_PROFILES_9: ${{ secrets.APPLE_PROVISIONING_PROFILES_9 }}
PROVISIONING_PROFILES_10: ${{ secrets.APPLE_PROVISIONING_PROFILES_10 }}
PROVISIONING_PROFILES_11: ${{ secrets.APPLE_PROVISIONING_PROFILES_11 }}
PROVISIONING_PROFILES_12: ${{ secrets.APPLE_PROVISIONING_PROFILES_12 }}
PROVISIONING_PROFILES_13: ${{ secrets.APPLE_PROVISIONING_PROFILES_13 }}
PROVISIONING_PROFILES_14: ${{ secrets.APPLE_PROVISIONING_PROFILES_14 }}
PROVISIONING_PROFILES_15: ${{ secrets.APPLE_PROVISIONING_PROFILES_15 }}
PROVISIONING_PROFILES_16: ${{ secrets.APPLE_PROVISIONING_PROFILES_16 }}
- name: Setup Apple Development certificate
if: matrix.if && matrix.type != 'jailbreak'
run: |-
@@ -1165,14 +1454,33 @@ jobs:
DEVELOPER_ID_CERTIFICATES_P12: ${{ secrets.DEVELOPER_ID_CERTIFICATES_P12 }}
P12_PASSWORD: ${{ secrets.DEVELOPER_ID_P12_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.APPLE_KEYCHAIN_PASSWORD }}
- name: Setup Apple Distribution certificate
if: matrix.if && matrix.type == 'app-store'
run: |-
CERTIFICATE_PATH=$RUNNER_TEMP/AppleDistributionCertificates.p12
KEYCHAIN_PATH=$RUNNER_TEMP/apple-distribution.keychain-db
echo -n "$APPLE_DISTRIBUTION_CERTIFICATES_P12" | base64 --decode -o $CERTIFICATE_PATH
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security list-keychains -d user -s $KEYCHAIN_PATH $APPLE_DEVELOPMENT_KEYCHAIN_PATH
security default-keychain -d user -s $KEYCHAIN_PATH
security find-identity -v -p codesigning $KEYCHAIN_PATH
env:
APPLE_DISTRIBUTION_CERTIFICATES_P12: ${{ secrets.APPLE_DISTRIBUTION_CERTIFICATES_P12 }}
P12_PASSWORD: ${{ secrets.APPLE_DISTRIBUTION_P12_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.APPLE_KEYCHAIN_PASSWORD }}
- name: Setup notarization credentials
if: matrix.if && matrix.type == 'standalone' && github.event_name == 'workflow_dispatch'
run: |-
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $SIGNING_KEYCHAIN_PATH
xcrun notarytool store-credentials "notarytool-password" \
# store-credentials prompts for the issuer ID even when --issuer is absent;
# an empty answer selects individual key authentication.
printf '\n' | xcrun notarytool store-credentials "notarytool-password" \
--key $ASC_KEY_PATH \
--key-id $ASC_KEY_ID \
--issuer $ASC_KEY_ISSUER_ID \
--keychain $SIGNING_KEYCHAIN_PATH
env:
KEYCHAIN_PASSWORD: ${{ secrets.APPLE_KEYCHAIN_PASSWORD }}
@@ -1206,16 +1514,17 @@ jobs:
go run ./cmd/internal/merge_apple_xcframework \
-output clients/apple/Libbox.xcframework \
"${library_inputs[@]}"
- name: Update macOS version
if: matrix.if && matrix.name == 'macOS' && github.event_name == 'workflow_dispatch'
- name: Update project version
if: matrix.if && matrix.type == 'app-store' && github.event_name == 'workflow_dispatch'
run: |-
MACOS_PROJECT_VERSION=$(go run -v ./cmd/internal/app_store_connect next_macos_project_version)
echo "MACOS_PROJECT_VERSION=$MACOS_PROJECT_VERSION"
echo "MACOS_PROJECT_VERSION=$MACOS_PROJECT_VERSION" >> "$GITHUB_ENV"
PROJECT_VERSION_VARIABLE=$(echo "${{ matrix.platform }}" | tr '[:lower:]' '[:upper:]')_PROJECT_VERSION
PROJECT_VERSION=$(go run -v ./cmd/internal/app_store_connect next_project_version ${{ matrix.platform }})
echo "$PROJECT_VERSION_VARIABLE=$PROJECT_VERSION"
echo "$PROJECT_VERSION_VARIABLE=$PROJECT_VERSION" >> "$GITHUB_ENV"
- name: Update version
if: matrix.if && matrix.name != 'iOS'
if: matrix.if
run: |-
go run -v ./cmd/internal/update_apple_version --ci
go run -v ./cmd/internal/update_apple_version --ci ${{ matrix.type == 'app-store' && '--testflight' || '' }}
- name: Archive App Store build
if: matrix.if && matrix.type == 'app-store'
run: |-
@@ -1236,10 +1545,14 @@ jobs:
cd clients/apple
xcodebuild -exportArchive \
-archivePath "${{ matrix.archive }}" \
-exportOptionsPlist ${{ matrix.upload }} \
-authenticationKeyPath $ASC_KEY_PATH \
-authenticationKeyID $ASC_KEY_ID \
-authenticationKeyIssuerID $ASC_KEY_ISSUER_ID
-exportPath "$RUNNER_TEMP/export" \
-exportOptionsPlist ${{ matrix.upload }}
# altool requires --api-issuer even for an individual key, and ignores its value.
xcrun altool --upload-app \
-f "$RUNNER_TEMP"/export/*.${{ matrix.upload_extension }} \
-t ${{ matrix.altool_platform }} \
--api-key $ASC_KEY_ID \
--api-issuer 00000000-0000-0000-0000-000000000000
- name: Publish to TestFlight
if: matrix.if && matrix.type == 'app-store' && github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/testing'
run: |-
@@ -1314,7 +1627,9 @@ jobs:
scheme: SFI
destination: 'generic/platform=iOS'
archive: build/SFI.xcarchive
upload: SFI/Upload.plist
upload: SFI/AppStoreExport.plist
upload_extension: ipa
altool_platform: ios
variant: ''
library_arch: ''
- name: macOS
@@ -1324,7 +1639,9 @@ jobs:
scheme: SFM
destination: 'generic/platform=macOS'
archive: build/SFM.xcarchive
upload: SFI/Upload.plist
upload: SFI/AppStoreExport.plist
upload_extension: pkg
altool_platform: macos
variant: ''
library_arch: ''
- name: tvOS
@@ -1334,13 +1651,15 @@ jobs:
scheme: SFT
destination: 'generic/platform=tvOS'
archive: build/SFT.xcarchive
upload: SFI/Upload.plist
upload: SFI/AppStoreExport.plist
upload_extension: ipa
altool_platform: appletvos
variant: ''
library_arch: ''
steps: *build_apple_steps
upload:
name: Upload builds
if: "!failure() && github.event_name == 'workflow_dispatch' && (inputs.build == 'All' || inputs.build == 'Binary' || inputs.build == 'Android' || inputs.build == 'Apple-release' || inputs.build == 'macOS-standalone')"
if: "!failure() && github.event_name == 'workflow_dispatch' && (inputs.build == 'All' || inputs.build == 'Binary' || inputs.build == 'Android' || inputs.build == 'Windows' || inputs.build == 'Linux' || inputs.build == 'Apple-release' || inputs.build == 'macOS-standalone' || inputs.build == 'iOS-jailbreak')"
runs-on: ubuntu-latest
permissions:
contents: write
@@ -1354,6 +1673,8 @@ jobs:
- build_windows
- build_android_library
- build_android
- build_windows_client
- build_linux_client
- build_apple_library
- build_apple
steps:
@@ -1416,6 +1737,8 @@ jobs:
- build_windows
- build_android_library
- build_android
- build_windows_client
- build_linux_client
- build_apple_library
- build_apple
- build_apple_app_store
+1 -1
View File
@@ -55,7 +55,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: 1.25.12
go-version: 1.26.7
- name: Clone cronet-go
if: matrix.naive
run: |
+1 -1
View File
@@ -60,7 +60,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ^1.25
go-version: ^1.26
- name: Cache go module
uses: actions/cache@v4
with:
+3 -5
View File
@@ -29,7 +29,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: 1.25.12
go-version: 1.26.7
- name: Check input version
if: github.event_name == 'workflow_dispatch'
run: |-
@@ -45,7 +45,7 @@ jobs:
echo "version=$version" >> "$GITHUB_OUTPUT"
build:
name: Build binary
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
needs:
- calculate_version
strategy:
@@ -72,7 +72,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: 1.25.12
go-version: 1.26.7
- name: Clone cronet-go
if: matrix.naive
run: |
@@ -183,8 +183,6 @@ jobs:
-p "dist/${NAME}_${{ needs.calculate_version.outputs.version }}_linux_${{ matrix.debian }}.deb" \
--architecture ${{ matrix.debian }} \
dist/sing-box=/usr/bin/sing-box
curl -Lo '/tmp/debsigs.diff' 'https://gitlab.com/debsigs/debsigs/-/commit/160138f5de1ec110376d3c807b60a37388bc7c90.diff'
sudo patch /usr/bin/debsigs < '/tmp/debsigs.diff'
rm -rf $HOME/.gnupg
gpg --pinentry-mode loopback --passphrase "${{ secrets.GPG_PASSPHRASE }}" --import <<EOF
${{ secrets.GPG_KEY }}
+57
View File
@@ -0,0 +1,57 @@
name: Test
on:
push:
branches:
- oldstable
- stable
- testing
- unstable
paths-ignore:
- '**.md'
- '.github/**'
- '!.github/workflows/test.yml'
pull_request:
branches:
- oldstable
- stable
- testing
- unstable
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}-${{ inputs.build }}
cancel-in-progress: true
jobs:
test:
name: Test
strategy:
fail-fast: false
matrix:
os:
- ubuntu-latest
- windows-latest
- macos-latest
go:
- ~1.25
- ~1.26
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go }}
- name: Set build tags and ldflags
shell: bash
run: |
echo "BUILD_TAGS=$(cat release/DEFAULT_BUILD_TAGS_OTHERS)" >> "$GITHUB_ENV"
echo "LDFLAGS_SHARED=$(cat release/LDFLAGS)" >> "$GITHUB_ENV"
- name: Test (unix)
if: matrix.os != 'windows-latest'
run: go test -v -exec sudo -tags "$BUILD_TAGS" -ldflags "$LDFLAGS_SHARED" ./...
- name: Test (windows)
if: matrix.os == 'windows-latest'
shell: bash
run: go test -v -tags "$BUILD_TAGS" -ldflags "$LDFLAGS_SHARED" ./...
+1 -1
View File
@@ -18,7 +18,6 @@ linters:
default: none
enable:
- ineffassign
- paralleltest
- staticcheck
- unused
- modernize
@@ -32,6 +31,7 @@ linters:
- -QF1008 # could remove embedded field "<interface>" from selector
- -ST1003 # should not use ALL_CAPS in Go names; use CamelCase instead
- -QF1001 # could apply De Morgan's law
- -SA4023 # impossible nil comparison, false positive on platform-specific implementations
exclusions:
generated: lax
presets:
+20 -17
View File
@@ -24,7 +24,7 @@ DOCKER_PLATFORMS ?= linux/amd64,linux/arm64
CRONET_GO_PATH ?= $(shell pwd)/cronet-go
.PHONY: test release docs build
.PHONY: test release docs build schema
build:
go build $(MAIN_PARAMS) $(MAIN)
@@ -57,6 +57,9 @@ ci_build:
generate_completions:
go run -v --tags "$(TAGS),generate,generate_completions" $(MAIN)
schema:
go run -ldflags "$(LDFLAGS_SHARED)" --tags "$(TAGS)" $(MAIN) schema -o docs/schema.json
install:
go build -o $(PREFIX)/bin/$(NAME) $(MAIN_PARAMS) $(MAIN)
@@ -78,8 +81,7 @@ lint_install:
proto:
@go run ./cmd/internal/protogen
@gofumpt -l -w .
@gofumpt -l -w .
@golangci-lint fmt
proto_install:
go install -v google.golang.org/protobuf/cmd/protoc-gen-go@latest
@@ -118,6 +120,9 @@ release_docker:
update_android_version:
go run ./cmd/internal/update_android_version
update_desktop_version:
go run ./cmd/internal/update_desktop_version
build_android:
cd ../sing-box-for-android && ./gradlew :app:clean :app:assembleOtherRelease && ./gradlew --stop
@@ -141,29 +146,27 @@ publish_android:
build_ios:
cd ../sing-box-for-apple && \
rm -rf build/SFI.xcarchive && \
xcodebuild clean -scheme SFI && \
xcodebuild archive -scheme SFI -configuration Release -destination 'generic/platform=iOS' -archivePath build/SFI.xcarchive -allowProvisioningUpdates | xcbeautify | grep -A 10 -e "Archive Succeeded" -e "ARCHIVE FAILED" -e "❌"
xcodebuild clean -scheme SFI -derivedDataPath build/SFI.dd && \
xcodebuild archive -scheme SFI -configuration Release -destination 'generic/platform=iOS' -archivePath build/SFI.xcarchive -derivedDataPath build/SFI.dd -allowProvisioningUpdates | xcbeautify | grep -A 10 -e "Archive Succeeded" -e "ARCHIVE FAILED" -e "❌"
upload_ios_app_store:
cd ../sing-box-for-apple && \
xcodebuild -exportArchive -archivePath build/SFI.xcarchive -exportOptionsPlist SFI/Upload.plist -allowProvisioningUpdates
export_ios_ipa:
cd ../sing-box-for-apple && \
xcodebuild -exportArchive -archivePath build/SFI.xcarchive -exportOptionsPlist SFI/Export.plist -allowProvisioningUpdates -exportPath build/SFI && \
cp build/SFI/sing-box.ipa dist/SFI.ipa
build_ios_deb:
$(MAKE) -C ../sing-box-for-apple build_ios_deb
upload_ios_ipa:
cd dist && \
cp SFI.ipa "SFI-${VERSION}.ipa" && \
ghr --replace --draft --prerelease "v${VERSION}" "SFI-${VERSION}.ipa"
upload_ios_deb:
ghr --replace --draft --prerelease "v${VERSION}" ../sing-box-for-apple/build/jailbreak/"SFI-${VERSION}-iphoneos-arm64.deb"
release_ios: build_ios upload_ios_app_store
release_ios_deb: build_ios_deb upload_ios_deb
build_macos:
cd ../sing-box-for-apple && \
rm -rf build/SFM.xcarchive && \
xcodebuild archive -scheme SFM -configuration Release -archivePath build/SFM.xcarchive -allowProvisioningUpdates | xcbeautify | grep -A 10 -e "Archive Succeeded" -e "ARCHIVE FAILED" -e "❌"
xcodebuild archive -scheme SFM -configuration Release -archivePath build/SFM.xcarchive -derivedDataPath build/SFM.dd -allowProvisioningUpdates | xcbeautify | grep -A 10 -e "Archive Succeeded" -e "ARCHIVE FAILED" -e "❌"
upload_macos_app_store:
cd ../sing-box-for-apple && \
@@ -232,7 +235,7 @@ replace_macos_standalone: build_macos_pkg notarize_macos_pkg upload_macos_pkg up
build_tvos:
cd ../sing-box-for-apple && \
rm -rf build/SFT.xcarchive && \
xcodebuild archive -scheme SFT -configuration Release -archivePath build/SFT.xcarchive -allowProvisioningUpdates | xcbeautify | grep -A 10 -e "Archive Succeeded" -e "ARCHIVE FAILED" -e "❌"
xcodebuild archive -scheme SFT -configuration Release -archivePath build/SFT.xcarchive -derivedDataPath build/SFT.dd -allowProvisioningUpdates | xcbeautify | grep -A 10 -e "Archive Succeeded" -e "ARCHIVE FAILED" -e "❌"
upload_tvos_app_store:
cd ../sing-box-for-apple && \
@@ -297,8 +300,8 @@ lib_apple_new:
$(SING_FFI) generate --config $(LIBBOX_FFI_CONFIG) --platform-type apple
lib_install:
go install -v github.com/sagernet/gomobile/cmd/gomobile@v0.1.12
go install -v github.com/sagernet/gomobile/cmd/gobind@v0.1.12
go install -v github.com/sagernet/gomobile/cmd/gomobile@v0.1.13
go install -v github.com/sagernet/gomobile/cmd/gobind@v0.1.13
docs:
venv/bin/mkdocs serve
+1
View File
@@ -10,6 +10,7 @@ import (
type CertificateStore interface {
LifecycleService
Pool() *x509.CertPool
ExclusiveAnchors() bool
}
func RootPoolFromContext(ctx context.Context) *x509.CertPool {
+21
View File
@@ -0,0 +1,21 @@
package certificate
type Adapter struct {
providerType string
providerTag string
}
func NewAdapter(providerType string, providerTag string) Adapter {
return Adapter{
providerType: providerType,
providerTag: providerTag,
}
}
func (a *Adapter) Type() string {
return a.providerType
}
func (a *Adapter) Tag() string {
return a.providerTag
}
+158
View File
@@ -0,0 +1,158 @@
package certificate
import (
"context"
"os"
"sync"
"time"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/common/taskmonitor"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing/common"
E "github.com/sagernet/sing/common/exceptions"
F "github.com/sagernet/sing/common/format"
)
var _ adapter.CertificateProviderManager = (*Manager)(nil)
type Manager struct {
logger log.ContextLogger
registry adapter.CertificateProviderRegistry
access sync.Mutex
started bool
stage adapter.StartStage
providers []adapter.CertificateProviderService
providerByTag map[string]adapter.CertificateProviderService
}
func NewManager(logger log.ContextLogger, registry adapter.CertificateProviderRegistry) *Manager {
return &Manager{
logger: logger,
registry: registry,
providerByTag: make(map[string]adapter.CertificateProviderService),
}
}
func (m *Manager) Start(stage adapter.StartStage) error {
m.access.Lock()
if m.started && m.stage >= stage {
panic("already started")
}
m.started = true
m.stage = stage
providers := m.providers
m.access.Unlock()
for _, provider := range providers {
name := "certificate-provider/" + provider.Type() + "[" + provider.Tag() + "]"
m.logger.Trace(stage, " ", name)
startTime := time.Now()
err := adapter.LegacyStart(provider, stage)
if err != nil {
return E.Cause(err, stage, " ", name)
}
m.logger.Trace(stage, " ", name, " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)")
}
return nil
}
func (m *Manager) Close() error {
m.access.Lock()
defer m.access.Unlock()
if !m.started {
return nil
}
m.started = false
providers := m.providers
m.providers = nil
monitor := taskmonitor.New(m.logger, C.StopTimeout)
var err error
for _, provider := range providers {
name := "certificate-provider/" + provider.Type() + "[" + provider.Tag() + "]"
m.logger.Trace("close ", name)
startTime := time.Now()
monitor.Start("close ", name)
err = E.Append(err, provider.Close(), func(err error) error {
return E.Cause(err, "close ", name)
})
monitor.Finish()
m.logger.Trace("close ", name, " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)")
}
return err
}
func (m *Manager) CertificateProviders() []adapter.CertificateProviderService {
m.access.Lock()
defer m.access.Unlock()
return m.providers
}
func (m *Manager) Get(tag string) (adapter.CertificateProviderService, bool) {
m.access.Lock()
provider, found := m.providerByTag[tag]
m.access.Unlock()
return provider, found
}
func (m *Manager) Remove(tag string) error {
m.access.Lock()
provider, found := m.providerByTag[tag]
if !found {
m.access.Unlock()
return os.ErrInvalid
}
delete(m.providerByTag, tag)
index := common.Index(m.providers, func(it adapter.CertificateProviderService) bool {
return it == provider
})
if index == -1 {
panic("invalid certificate provider index")
}
m.providers = append(m.providers[:index], m.providers[index+1:]...)
started := m.started
m.access.Unlock()
if started {
return provider.Close()
}
return nil
}
func (m *Manager) Create(ctx context.Context, logger log.ContextLogger, tag string, providerType string, options any) error {
provider, err := m.registry.Create(ctx, logger, tag, providerType, options)
if err != nil {
return err
}
m.access.Lock()
defer m.access.Unlock()
if m.started {
name := "certificate-provider/" + provider.Type() + "[" + provider.Tag() + "]"
for _, stage := range adapter.ListStartStages {
m.logger.Trace(stage, " ", name)
startTime := time.Now()
err = adapter.LegacyStart(provider, stage)
if err != nil {
return E.Cause(err, stage, " ", name)
}
m.logger.Trace(stage, " ", name, " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)")
}
}
if existsProvider, loaded := m.providerByTag[tag]; loaded {
if m.started {
err = existsProvider.Close()
if err != nil {
return E.Cause(err, "close certificate-provider/", existsProvider.Type(), "[", existsProvider.Tag(), "]")
}
}
existsIndex := common.Index(m.providers, func(it adapter.CertificateProviderService) bool {
return it == existsProvider
})
if existsIndex == -1 {
panic("invalid certificate provider index")
}
m.providers = append(m.providers[:existsIndex], m.providers[existsIndex+1:]...)
}
m.providers = append(m.providers, provider)
m.providerByTag[tag] = provider
return nil
}
+80
View File
@@ -0,0 +1,80 @@
package certificate
import (
"context"
"maps"
"slices"
"sync"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing/common"
E "github.com/sagernet/sing/common/exceptions"
)
type ConstructorFunc[T any] func(ctx context.Context, logger log.ContextLogger, tag string, options T) (adapter.CertificateProviderService, error)
func Register[Options any](registry *Registry, providerType string, constructor ConstructorFunc[Options]) {
registry.register(providerType, func() any {
return new(Options)
}, func(ctx context.Context, logger log.ContextLogger, tag string, rawOptions any) (adapter.CertificateProviderService, error) {
var options *Options
if rawOptions != nil {
options = rawOptions.(*Options)
}
return constructor(ctx, logger, tag, common.PtrValueOrDefault(options))
})
}
var _ adapter.CertificateProviderRegistry = (*Registry)(nil)
type (
optionsConstructorFunc func() any
constructorFunc func(ctx context.Context, logger log.ContextLogger, tag string, options any) (adapter.CertificateProviderService, error)
)
type Registry struct {
access sync.Mutex
optionsType map[string]optionsConstructorFunc
constructor map[string]constructorFunc
}
func NewRegistry() *Registry {
return &Registry{
optionsType: make(map[string]optionsConstructorFunc),
constructor: make(map[string]constructorFunc),
}
}
func (m *Registry) OptionTypes() []string {
m.access.Lock()
defer m.access.Unlock()
return slices.Sorted(maps.Keys(m.optionsType))
}
func (m *Registry) CreateOptions(providerType string) (any, bool) {
m.access.Lock()
defer m.access.Unlock()
optionsConstructor, loaded := m.optionsType[providerType]
if !loaded {
return nil, false
}
return optionsConstructor(), true
}
func (m *Registry) Create(ctx context.Context, logger log.ContextLogger, tag string, providerType string, options any) (adapter.CertificateProviderService, error) {
m.access.Lock()
defer m.access.Unlock()
constructor, loaded := m.constructor[providerType]
if !loaded {
return nil, E.New("certificate provider type not found: " + providerType)
}
return constructor(ctx, logger, tag, options)
}
func (m *Registry) register(providerType string, optionsConstructor optionsConstructorFunc, constructor constructorFunc) {
m.access.Lock()
defer m.access.Unlock()
m.optionsType[providerType] = optionsConstructor
m.constructor[providerType] = constructor
}
+17
View File
@@ -0,0 +1,17 @@
//go:build darwin && cgo
package adapter
import "unsafe"
type AppleAnchors interface {
Retain() AppleAnchors
Release()
// Ref returns the underlying CFArrayRef, or nil if the anchor set is empty.
Ref() unsafe.Pointer
}
type AppleCertificateStore interface {
CertificateStore
AppleAnchors() AppleAnchors
}
+38
View File
@@ -0,0 +1,38 @@
package adapter
import (
"context"
"crypto/tls"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
)
type CertificateProvider interface {
GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error)
}
type ACMECertificateProvider interface {
CertificateProvider
GetACMENextProtos() []string
}
type CertificateProviderService interface {
Lifecycle
Type() string
Tag() string
CertificateProvider
}
type CertificateProviderRegistry interface {
option.CertificateProviderOptionsRegistry
Create(ctx context.Context, logger log.ContextLogger, tag string, providerType string, options any) (CertificateProviderService, error)
}
type CertificateProviderManager interface {
Lifecycle
CertificateProviders() []CertificateProviderService
Get(tag string) (CertificateProviderService, bool)
Remove(tag string) error
Create(ctx context.Context, logger log.ContextLogger, tag string, providerType string, options any) error
}
+42 -21
View File
@@ -3,6 +3,7 @@ package adapter
import (
"context"
"net/netip"
"time"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/log"
@@ -17,6 +18,7 @@ import (
type DNSRouter interface {
Lifecycle
Exchange(ctx context.Context, message *dns.Msg, options DNSQueryOptions) (*dns.Msg, error)
ExchangeAsync(ctx context.Context, message *dns.Msg, options DNSQueryOptions, callback func(response *dns.Msg, err error))
Lookup(ctx context.Context, domain string, options DNSQueryOptions) ([]netip.Addr, error)
ClearCache()
LookupReverseMapping(ip netip.Addr) (string, bool)
@@ -25,35 +27,41 @@ type DNSRouter interface {
type DNSClient interface {
Start()
Exchange(ctx context.Context, transport DNSTransport, message *dns.Msg, options DNSQueryOptions, responseChecker func(responseAddrs []netip.Addr) bool) (*dns.Msg, error)
Lookup(ctx context.Context, transport DNSTransport, domain string, options DNSQueryOptions, responseChecker func(responseAddrs []netip.Addr) bool) ([]netip.Addr, error)
Exchange(ctx context.Context, transport DNSTransport, message *dns.Msg, options DNSQueryOptions, responseChecker func(response *dns.Msg) bool) (*dns.Msg, error)
ExchangeAsync(ctx context.Context, transport DNSTransport, message *dns.Msg, options DNSQueryOptions, responseChecker func(response *dns.Msg) bool, callback func(response *dns.Msg, err error))
Lookup(ctx context.Context, transport DNSTransport, domain string, options DNSQueryOptions, responseChecker func(response *dns.Msg) bool) ([]netip.Addr, error)
ClearCache()
}
type DNSQueryOptions struct {
Transport DNSTransport
Strategy C.DomainStrategy
LookupStrategy C.DomainStrategy
DisableCache bool
RewriteTTL *uint32
ClientSubnet netip.Prefix
Transport DNSTransport
Strategy C.DomainStrategy
LookupStrategy C.DomainStrategy
DisableCache bool
DisableOptimisticCache bool
RewriteTTL *uint32
Timeout time.Duration
ClientSubnet netip.Prefix
RemoveClientSubnet bool
}
func DNSQueryOptionsFrom(ctx context.Context, options *option.DomainResolveOptions) (*DNSQueryOptions, error) {
if options == nil {
return &DNSQueryOptions{}, nil
func DNSQueryOptionsFrom(ctx context.Context, options *option.DomainResolveOptions) (DNSQueryOptions, error) {
if options == nil || options.Server == "" {
return DNSQueryOptions{}, nil
}
transportManager := service.FromContext[DNSTransportManager](ctx)
transport, loaded := transportManager.Transport(options.Server)
if !loaded {
return nil, E.New("domain resolver not found: " + options.Server)
return DNSQueryOptions{}, E.New("domain resolver not found: " + options.Server)
}
return &DNSQueryOptions{
Transport: transport,
Strategy: C.DomainStrategy(options.Strategy),
DisableCache: options.DisableCache,
RewriteTTL: options.RewriteTTL,
ClientSubnet: options.ClientSubnet.Build(netip.Prefix{}),
return DNSQueryOptions{
Transport: transport,
Strategy: C.DomainStrategy(options.Strategy),
DisableCache: options.DisableCache,
DisableOptimisticCache: options.DisableOptimisticCache,
RewriteTTL: options.RewriteTTL,
Timeout: time.Duration(options.Timeout),
ClientSubnet: options.ClientSubnet.Build(netip.Prefix{}),
}, nil
}
@@ -63,6 +71,13 @@ type RDRCStore interface {
SaveRDRCAsync(transportName string, qName string, qType uint16, logger logger.Logger)
}
type DNSCacheStore interface {
LoadDNSCache(transportName string, qName string, qType uint16) (rawMessage []byte, expireAt time.Time, loaded bool)
SaveDNSCache(transportName string, qName string, qType uint16, rawMessage []byte, expireAt time.Time) error
SaveDNSCacheAsync(transportName string, qName string, qType uint16, rawMessage []byte, expireAt time.Time, logger logger.Logger)
ClearDNSCache() error
}
type DNSTransport interface {
Lifecycle
Type() string
@@ -72,11 +87,17 @@ type DNSTransport interface {
// Exchanges that are currently using those connections may fail.
Reset()
Exchange(ctx context.Context, message *dns.Msg) (*dns.Msg, error)
ExchangeAsync(ctx context.Context, message *dns.Msg, callback func(response *dns.Msg, err error))
}
type LegacyDNSTransport interface {
LegacyStrategy() C.DomainStrategy
LegacyClientSubnet() netip.Prefix
type DNSTransportWithPreferredDomain interface {
DNSTransport
PreferredDomain(domain string) bool
}
type DNSTransportWithEnvironment interface {
DNSTransport
Environment() []string
}
type DNSTransportRegistry interface {
+8
View File
@@ -2,6 +2,8 @@ package endpoint
import (
"context"
"maps"
"slices"
"sync"
"github.com/sagernet/sing-box/adapter"
@@ -44,6 +46,12 @@ func NewRegistry() *Registry {
}
}
func (m *Registry) OptionTypes() []string {
m.access.Lock()
defer m.access.Unlock()
return slices.Sorted(maps.Keys(m.optionsType))
}
func (m *Registry) CreateOptions(outboundType string) (any, bool) {
m.access.Lock()
defer m.access.Unlock()
+43 -19
View File
@@ -7,17 +7,17 @@ import (
"io"
"time"
E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/sing/common/observable"
"github.com/sagernet/sing/common/varbin"
)
type ClashServer interface {
LifecycleService
ConnectionTracker
Mode() string
ModeList() []string
SetModeUpdateHook(hook *observable.Subscriber[struct{}])
HistoryStorage() URLTestHistoryStorage
SetMode(mode string)
AddModeUpdateHook(hook *observable.Subscriber[struct{}])
}
type URLTestHistory struct {
@@ -25,14 +25,6 @@ type URLTestHistory struct {
Delay uint16 `json:"delay"`
}
type URLTestHistoryStorage interface {
SetHook(hook *observable.Subscriber[struct{}])
LoadURLTestHistory(tag string) *URLTestHistory
DeleteURLTestHistory(tag string)
StoreURLTestHistory(tag string, history *URLTestHistory)
Close() error
}
type V2RayServer interface {
LifecycleService
StatsService() ConnectionTracker
@@ -41,6 +33,8 @@ type V2RayServer interface {
type CacheFile interface {
LifecycleService
CacheID() string
StoreFakeIP() bool
FakeIPStorage
@@ -50,6 +44,12 @@ type CacheFile interface {
StoreWARPConfig() bool
StoreMASQUEConfig() bool
StoreDNS() bool
DNSCacheStore
SetDisableExpire(disableExpire bool)
SetOptimisticTimeout(timeout time.Duration)
LoadMode() string
StoreMode(mode string) error
LoadSelected(group string) string
@@ -70,11 +70,12 @@ type SavedBinary struct {
Content []byte
LastUpdated time.Time
LastEtag string
URLHash []byte
}
func (s *SavedBinary) MarshalBinary() ([]byte, error) {
var buffer bytes.Buffer
err := binary.Write(&buffer, binary.BigEndian, uint8(1))
err := binary.Write(&buffer, binary.BigEndian, uint8(2))
if err != nil {
return nil, err
}
@@ -98,6 +99,14 @@ func (s *SavedBinary) MarshalBinary() ([]byte, error) {
if err != nil {
return nil, err
}
_, err = varbin.WriteUvarint(&buffer, uint64(len(s.URLHash)))
if err != nil {
return nil, err
}
_, err = buffer.Write(s.URLHash)
if err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
@@ -112,6 +121,9 @@ func (s *SavedBinary) UnmarshalBinary(data []byte) error {
if err != nil {
return err
}
if contentLength > uint64(reader.Len()) {
return E.New("invalid content length: ", contentLength)
}
s.Content = make([]byte, contentLength)
_, err = io.ReadFull(reader, s.Content)
if err != nil {
@@ -127,12 +139,30 @@ func (s *SavedBinary) UnmarshalBinary(data []byte) error {
if err != nil {
return err
}
if etagLength > uint64(reader.Len()) {
return E.New("invalid etag length: ", etagLength)
}
etagBytes := make([]byte, etagLength)
_, err = io.ReadFull(reader, etagBytes)
if err != nil {
return err
}
s.LastEtag = string(etagBytes)
if version < 2 {
return nil
}
urlHashLength, err := binary.ReadUvarint(reader)
if err != nil {
return err
}
if urlHashLength > uint64(reader.Len()) {
return E.New("invalid url hash length: ", urlHashLength)
}
s.URLHash = make([]byte, urlHashLength)
_, err = io.ReadFull(reader, s.URLHash)
if err != nil {
return err
}
return nil
}
@@ -145,11 +175,5 @@ type OutboundGroup interface {
type URLTestGroup interface {
OutboundGroup
URLTest(ctx context.Context) (map[string]uint16, error)
}
func OutboundTag(detour Outbound) string {
if group, isGroup := detour.(OutboundGroup); isGroup {
return group.Now()
}
return detour.Tag()
PerformUpdateCheck()
}
+6 -32
View File
@@ -5,57 +5,31 @@ import (
"net"
"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"
)
// Deprecated
type ConnectionHandler interface {
NewConnection(ctx context.Context, conn net.Conn, metadata InboundContext) error
NewConnection(ctx context.Context, conn net.Conn, metadata InboundContext, onClose N.CloseHandlerFunc)
}
type ConnectionHandlerEx interface {
NewConnectionEx(ctx context.Context, conn net.Conn, metadata InboundContext, onClose N.CloseHandlerFunc)
}
// Deprecated: use PacketHandlerEx instead
type PacketHandler interface {
NewPacket(ctx context.Context, conn N.PacketConn, buffer *buf.Buffer, metadata InboundContext) error
NewPacket(buffer *buf.Buffer, source M.Socksaddr)
}
type PacketHandlerEx interface {
NewPacketEx(buffer *buf.Buffer, source M.Socksaddr)
type PacketBatchHandler interface {
NewPacketBatch(buffers []*buf.Buffer, sources []M.Socksaddr)
}
// Deprecated: use OOBPacketHandlerEx instead
type OOBPacketHandler interface {
NewPacket(ctx context.Context, conn N.PacketConn, buffer *buf.Buffer, oob []byte, metadata InboundContext) error
NewPacket(buffer *buf.Buffer, oob []byte, source M.Socksaddr)
}
type OOBPacketHandlerEx interface {
NewPacketEx(buffer *buf.Buffer, oob []byte, source M.Socksaddr)
}
// Deprecated
type PacketConnectionHandler interface {
NewPacketConnection(ctx context.Context, conn N.PacketConn, metadata InboundContext) error
NewPacketConnection(ctx context.Context, conn N.PacketConn, metadata InboundContext, onClose N.CloseHandlerFunc)
}
type PacketConnectionHandlerEx interface {
NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, metadata InboundContext, onClose N.CloseHandlerFunc)
}
// Deprecated: use TCPConnectionHandlerEx instead
//
//nolint:staticcheck
type UpstreamHandlerAdapter interface {
N.TCPConnectionHandler
N.UDPConnectionHandler
E.Handler
}
type UpstreamHandlerAdapterEx interface {
N.TCPConnectionHandlerEx
N.UDPConnectionHandlerEx
}
+43
View File
@@ -0,0 +1,43 @@
package adapter
import (
"context"
"net/http"
"sync"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing/common/logger"
)
type HTTPTransport interface {
http.RoundTripper
CloseIdleConnections()
Reset()
}
type HTTPClientManager interface {
ResolveTransport(ctx context.Context, logger logger.ContextLogger, options option.HTTPClientOptions) (HTTPTransport, error)
DefaultTransport() HTTPTransport
ResetNetwork()
}
type HTTPStartContext struct {
access sync.Mutex
transports []HTTPTransport
}
func NewHTTPStartContext() *HTTPStartContext {
return &HTTPStartContext{}
}
func (c *HTTPStartContext) Register(transport HTTPTransport) {
c.access.Lock()
defer c.access.Unlock()
c.transports = append(c.transports, transport)
}
func (c *HTTPStartContext) Close() {
for _, transport := range c.transports {
transport.CloseIdleConnections()
}
}
+85 -10
View File
@@ -2,13 +2,17 @@ package adapter
import (
"context"
"net"
"net/netip"
"time"
"github.com/sagernet/sing-box/common/tlsspoof"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
M "github.com/sagernet/sing/common/metadata"
"github.com/miekg/dns"
)
type Inbound interface {
@@ -19,12 +23,12 @@ type Inbound interface {
type TCPInjectableInbound interface {
Inbound
ConnectionHandlerEx
ConnectionHandler
}
type UDPInjectableInbound interface {
Inbound
PacketConnectionHandlerEx
PacketConnectionHandler
}
type InboundRegistry interface {
@@ -52,6 +56,11 @@ type InboundContext struct {
User string
Outbound string
// power report
RouteRule string
RouteOutbound string
// sniffer
Protocol string
@@ -74,18 +83,28 @@ type InboundContext struct {
TLSFragment bool
TLSFragmentFallbackDelay time.Duration
TLSRecordFragment bool
TLSSpoof string
TLSSpoofMethod tlsspoof.Method
NetworkStrategy *C.NetworkStrategy
NetworkType []C.InterfaceType
FallbackNetworkType []C.InterfaceType
FallbackDelay time.Duration
DestinationAddresses []netip.Addr
SourceGeoIPCode string
GeoIPCode string
ProcessInfo *ConnectionOwner
QueryType uint16
FakeIP bool
DestinationAddresses []netip.Addr
DNSResponse *dns.Msg
NamedDNSResponses map[string]*dns.Msg
DestinationAddressMatchFromResponse bool
SourceGeoIPCode string
GeoIPCode string
ProcessInfo *ConnectionOwner
SourceMACAddress net.HardwareAddr
SourceHostname string
QueryType uint16
QueryClientSubnet netip.Prefix
QueryDNSSEC bool
FakeIP bool
PreMatch bool
// rule cache
@@ -96,7 +115,7 @@ type InboundContext struct {
SourcePortMatch bool
DestinationAddressMatch bool
DestinationPortMatch bool
DidMatch bool
DeferredIPCIDRMatchGroups uint8
IgnoreDestinationIPCIDRMatch bool
}
@@ -111,11 +130,67 @@ func (c *InboundContext) ResetRuleMatchCache() {
c.SourcePortMatch = false
c.DestinationAddressMatch = false
c.DestinationPortMatch = false
c.DidMatch = false
c.DeferredIPCIDRMatchGroups = 0
}
func (c *InboundContext) DNSResponseAddressesForMatch() []netip.Addr {
return DNSResponseAddresses(c.DNSResponse)
}
func DNSResponseAddresses(response *dns.Msg) []netip.Addr {
if response == nil || response.Rcode != dns.RcodeSuccess {
return nil
}
addresses := make([]netip.Addr, 0, len(response.Answer))
for _, rawRecord := range response.Answer {
switch record := rawRecord.(type) {
case *dns.A:
addr := M.AddrFromIP(record.A)
if addr.IsValid() {
addresses = append(addresses, addr)
}
case *dns.AAAA:
addr := M.AddrFromIP(record.AAAA)
if addr.IsValid() {
addresses = append(addresses, addr)
}
case *dns.HTTPS:
for _, value := range record.SVCB.Value {
switch hint := value.(type) {
case *dns.SVCBIPv4Hint:
for _, ip := range hint.Hint {
addr := M.AddrFromIP(ip).Unmap()
if addr.IsValid() {
addresses = append(addresses, addr)
}
}
case *dns.SVCBIPv6Hint:
for _, ip := range hint.Hint {
addr := M.AddrFromIP(ip)
if addr.IsValid() {
addresses = append(addresses, addr)
}
}
}
}
}
}
return addresses
}
type inboundContextKey struct{}
type dnsTransportTagKey struct{}
func ContextWithDNSTransportTag(ctx context.Context, transportTag string) context.Context {
return context.WithValue(ctx, (*dnsTransportTagKey)(nil), transportTag)
}
func DNSTransportTagFromContext(ctx context.Context) (string, bool) {
transportTag, loaded := ctx.Value((*dnsTransportTagKey)(nil)).(string)
return transportTag, loaded
}
func WithContext(ctx context.Context, inboundContext *InboundContext) context.Context {
return context.WithValue(ctx, (*inboundContextKey)(nil), inboundContext)
}
+8
View File
@@ -2,6 +2,8 @@ package inbound
import (
"context"
"maps"
"slices"
"sync"
"github.com/sagernet/sing-box/adapter"
@@ -44,6 +46,12 @@ func NewRegistry() *Registry {
}
}
func (m *Registry) OptionTypes() []string {
m.access.Lock()
defer m.access.Unlock()
return slices.Sorted(maps.Keys(m.optionsType))
}
func (m *Registry) CreateOptions(outboundType string) (any, bool) {
m.access.Lock()
defer m.access.Unlock()
+45
View File
@@ -0,0 +1,45 @@
package adapter
import (
"net"
"net/netip"
"testing"
"github.com/miekg/dns"
"github.com/stretchr/testify/require"
)
func TestDNSResponseAddressesUnmapsHTTPSIPv4Hints(t *testing.T) {
t.Parallel()
ipv4Hint := net.ParseIP("1.1.1.1")
require.NotNil(t, ipv4Hint)
response := &dns.Msg{
MsgHdr: dns.MsgHdr{
Response: true,
Rcode: dns.RcodeSuccess,
},
Answer: []dns.RR{
&dns.HTTPS{
SVCB: dns.SVCB{
Hdr: dns.RR_Header{
Name: dns.Fqdn("example.com"),
Rrtype: dns.TypeHTTPS,
Class: dns.ClassINET,
Ttl: 60,
},
Priority: 1,
Target: ".",
Value: []dns.SVCBKeyValue{
&dns.SVCBIPv4Hint{Hint: []net.IP{ipv4Hint}},
},
},
},
},
}
addresses := DNSResponseAddresses(response)
require.Equal(t, []netip.Addr{netip.MustParseAddr("1.1.1.1")}, addresses)
require.True(t, addresses[0].Is4())
}
+13 -4
View File
@@ -1,6 +1,7 @@
package adapter
import (
"context"
"reflect"
"strings"
"time"
@@ -74,11 +75,15 @@ func getServiceName(service any) string {
return strings.ToLower(t.Name())
}
func Start(logger log.ContextLogger, stage StartStage, services ...Lifecycle) error {
func Start(ctx context.Context, logger log.ContextLogger, stage StartStage, services ...Lifecycle) error {
for _, service := range services {
err := ctx.Err()
if err != nil {
return err
}
name := getServiceName(service)
done := LogElapsed(logger, stage, " ", name)
err := service.Start(stage)
err = service.Start(stage)
done()
if err != nil {
return err
@@ -87,10 +92,14 @@ func Start(logger log.ContextLogger, stage StartStage, services ...Lifecycle) er
return nil
}
func StartNamed(logger log.ContextLogger, stage StartStage, services []LifecycleService) error {
func StartNamed(ctx context.Context, logger log.ContextLogger, stage StartStage, services []LifecycleService) error {
for _, service := range services {
err := ctx.Err()
if err != nil {
return err
}
done := LogElapsed(logger, stage, " ", service.Name())
err := service.Start(stage)
err = service.Start(stage)
done()
if err != nil {
return E.Cause(err, stage.String(), " ", service.Name())
+24
View File
@@ -0,0 +1,24 @@
package adapter
import (
"net"
"net/netip"
)
type NeighborEntry struct {
Address netip.Addr
MACAddress net.HardwareAddr
Hostname string
}
type NeighborResolver interface {
LookupMAC(address netip.Addr) (net.HardwareAddr, bool)
LookupHostname(address netip.Addr) (string, bool)
LookupAddresses(hostname string) []netip.Addr
Start() error
Close() error
}
type NeighborUpdateListener interface {
UpdateNeighborTable(entries []NeighborEntry)
}
+5
View File
@@ -0,0 +1,5 @@
package adapter
type NetworkNamespaceManager interface {
ResolvePath(nameOrPath string) string
}
+7 -3
View File
@@ -1,8 +1,10 @@
package adapter
import (
"context"
"encoding/hex"
"net"
"net/netip"
"strings"
"time"
@@ -18,6 +20,7 @@ type NetworkManager interface {
UpdateInterfaces() error
DefaultNetworkInterface() *NetworkInterface
NetworkInterfaces() []NetworkInterface
NetworkEnvironment() uint64
AutoDetectInterface() bool
AutoDetectInterfaceFunc() control.Func
ProtectFunc() control.Func
@@ -30,8 +33,8 @@ type NetworkManager interface {
PackageManager() tun.PackageManager
NeedWIFIState() bool
WIFIState() WIFIState
UpdateWIFIState()
ResetNetwork()
UpdateWIFIState(ctx context.Context)
ResetNetwork(ctx context.Context)
}
type NetworkOptions struct {
@@ -46,7 +49,7 @@ type NetworkOptions struct {
}
type InterfaceUpdateListener interface {
InterfaceUpdated()
InterfaceUpdated(ctx context.Context)
}
type WIFIState struct {
@@ -76,6 +79,7 @@ type NetworkInterface struct {
control.Interface
Type C.InterfaceType
DNSServers []string
Gateways []netip.Addr
Expensive bool
Constrained bool
}
+101
View File
@@ -0,0 +1,101 @@
package adapter
import (
"net/netip"
"time"
)
const (
OpenConnectStateConnecting = "connecting"
OpenConnectStateAuthPending = "auth-pending"
OpenConnectStateConnected = "connected"
OpenConnectStateError = "error"
)
type OpenConnectEndpoint interface {
Endpoint
OpenConnectStatus() OpenConnectStatus
StatusUpdated() <-chan struct{}
CompleteAuthChallenge(challengeID string, response OpenConnectAuthResponse) error
CancelAuthChallenge(challengeID string) error
}
type OpenConnectStatus struct {
State string
AuthChallenge *OpenConnectAuthChallenge
Error string
TunnelInfo *OpenConnectTunnelInfo
}
type OpenConnectTunnelInfo struct {
Server string
Flavor string
Transport string
IPv4 []netip.Prefix
IPv6 []netip.Prefix
DNS []netip.Addr
MTU uint32
ConnectedSince time.Time
}
type OpenConnectAuthChallenge struct {
ID string
Banner string
Message string
Error string
Form *OpenConnectAuthForm
Browser *OpenConnectBrowserRequest
}
type OpenConnectAuthForm struct {
Fields []OpenConnectAuthFormField
}
type OpenConnectBrowserRequest struct {
URL string
FinalURL string
CookieNames []string
EarlyCookieNames []string
HeaderNames []string
CallbackURLPrefixes []string
CacheID string
}
type OpenConnectBrowserCookie struct {
Name string
Value string
}
type OpenConnectBrowserHeader struct {
Name string
Values []string
}
type OpenConnectAuthResponse struct {
Form *OpenConnectAuthFormResponse
Browser *OpenConnectBrowserResult
}
type OpenConnectAuthFormResponse struct {
Values map[string]string
}
type OpenConnectBrowserResult struct {
FinalURL string
Cookies []OpenConnectBrowserCookie
Headers []OpenConnectBrowserHeader
}
type OpenConnectAuthFormField struct {
SubmissionKey string
Name string
Label string
Kind string
Value string
Options []OpenConnectAuthFormChoice
}
type OpenConnectAuthFormChoice struct {
Value string
Label string
}
+57
View File
@@ -0,0 +1,57 @@
package adapter
import (
"net/netip"
"time"
)
const (
OpenVPNStateConnecting = "connecting"
OpenVPNStateAuthPending = "auth-pending"
OpenVPNStateConnected = "connected"
OpenVPNStateError = "error"
)
type OpenVPNEndpoint interface {
Endpoint
OpenVPNStatus() OpenVPNStatus
StatusUpdated() <-chan struct{}
CompleteChallenge(challengeID string, response OpenVPNChallengeResponse) error
CancelChallenge(challengeID string) error
}
type OpenVPNStatus struct {
State string
Challenge *OpenVPNChallenge
Error string
TunnelInfo *OpenVPNTunnelInfo
}
type OpenVPNTunnelInfo struct {
Server string
Network string
Cipher string
IPv4 []netip.Prefix
IPv6 []netip.Prefix
DNS []netip.Addr
MTU uint32
ConnectedSince time.Time
}
type OpenVPNChallenge struct {
ID string
Kind string
Username string
Message string
URL string
SecretMessage string
Echo bool
PreviousError string
Deadline time.Time
}
type OpenVPNChallengeResponse struct {
Username string
Password string
Secret string
}
+5 -5
View File
@@ -3,7 +3,6 @@ package adapter
import (
"context"
"net/netip"
"time"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
@@ -23,8 +22,8 @@ type Outbound interface {
type OutboundWithPreferredRoutes interface {
Outbound
PreferredDomain(domain string) bool
PreferredAddress(address netip.Addr) bool
PreferredDomain(metadata *InboundContext, domain string) bool
PreferredAddress(metadata *InboundContext, address netip.Addr) bool
}
type OutboundWithMultiplex interface {
@@ -32,9 +31,10 @@ type OutboundWithMultiplex interface {
MultiplexEnabled() bool
}
type DirectRouteOutbound interface {
type FlowOutbound interface {
Outbound
NewDirectRouteConnection(metadata InboundContext, routeContext tun.DirectRouteContext, timeout time.Duration) (tun.DirectRouteDestination, error)
tun.Port
PreMatchFlow(network string, destination netip.Addr) PreMatchAction
}
type OutboundRegistry interface {
+8
View File
@@ -2,6 +2,8 @@ package outbound
import (
"context"
"maps"
"slices"
"sync"
"github.com/sagernet/sing-box/adapter"
@@ -44,6 +46,12 @@ func NewRegistry() *Registry {
}
}
func (r *Registry) OptionTypes() []string {
r.access.Lock()
defer r.access.Unlock()
return slices.Sorted(maps.Keys(r.optionsType))
}
func (r *Registry) CreateOptions(outboundType string) (any, bool) {
r.access.Lock()
defer r.access.Unlock()
+46 -2
View File
@@ -1,6 +1,7 @@
package adapter
import (
"context"
"net/netip"
"github.com/sagernet/sing-box/option"
@@ -17,6 +18,7 @@ type PlatformInterface interface {
UsePlatformInterface() bool
OpenInterface(options *tun.Options, platformOptions option.TunPlatformOptions) (tun.Tun, error)
ProcessPlatformOptions(options option.TunPlatformOptions) error
UsePlatformDefaultInterfaceMonitor() bool
CreateDefaultInterfaceMonitor(logger logger.Logger) tun.DefaultInterfaceMonitor
@@ -29,8 +31,7 @@ type PlatformInterface interface {
ClearDNSCache()
RequestPermissionForWIFIState() error
ReadWIFIState() WIFIState
SystemCertificates() []string
ReadWIFIState(ctx context.Context) WIFIState
UsePlatformConnectionOwnerFinder() bool
FindConnectionOwner(request *FindConnectionOwnerRequest) (*ConnectionOwner, error)
@@ -39,8 +40,51 @@ type PlatformInterface interface {
UsePlatformNotification() bool
SendNotification(notification *Notification) error
CancelNotification(identifier string, typeID int32) error
MyInterfaceAddress() []netip.Addr
UsePlatformNeighborResolver() bool
StartNeighborMonitor(listener NeighborUpdateListener) error
CloseNeighborMonitor(listener NeighborUpdateListener) error
UsePlatformShell() bool
CheckPlatformShell() error
OpenShellSession(user *PlatformUser, command string, env []string, term string, rows int32, cols int32) (ShellSession, error)
LookupUser(username string) (*PlatformUser, error)
LookupSFTPServer() (string, error)
ReadSystemSSHHostKey() ([]byte, error)
TailscaleHostname() string
UsePlatformBridge() bool
CreateBridge(options BridgeOptions) (BridgeSession, error)
}
type BridgeOptions struct {
BridgeName string
MTU uint32
Inet4Port netip.Addr
Inet6Port netip.Addr
Interface string
RuleIndex int
RouteTable int
}
type BridgeSession interface {
FileDescriptor() int
Name() string
Inet6Active() bool
SetEgress(interfaceName string) error
Close() error
}
type PlatformUser struct {
Username string
Uid int
Gid int
HomeDir string
Shell string
Groups []int
}
type FindConnectionOwnerRequest struct {
+3 -7
View File
@@ -32,7 +32,7 @@ type Adapter struct {
outboundsByTag map[string]adapter.Outbound
ticker *time.Ticker
checking atomic.Bool
history adapter.URLTestHistoryStorage
history *urltest.HistoryStorage
callbackAccess sync.Mutex
callbacks list.List[adapter.ProviderUpdateCallback]
@@ -76,13 +76,9 @@ func (a *Adapter) SetRemoveEmojis(remove bool) {
}
func (a *Adapter) Start() error {
a.history = service.FromContext[adapter.URLTestHistoryStorage](a.ctx)
a.history = service.PtrFromContext[urltest.HistoryStorage](a.ctx)
if a.history == nil {
if clashServer := service.FromContext[adapter.ClashServer](a.ctx); clashServer != nil {
a.history = clashServer.HistoryStorage()
} else {
a.history = urltest.NewHistoryStorage()
}
a.history = urltest.NewHistoryStorage()
}
go a.loopCheck()
return nil
+93 -53
View File
@@ -2,17 +2,14 @@ package adapter
import (
"context"
"crypto/tls"
"net"
"net/http"
"sync"
"net/netip"
"time"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-tun"
"github.com/sagernet/sing-tun/gtcpip/header"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
"github.com/sagernet/sing/common/ntp"
"github.com/sagernet/sing/common/x/list"
"go4.org/netipx"
@@ -21,18 +18,93 @@ import (
type Router interface {
Lifecycle
ConnectionRouter
PreMatch(metadata InboundContext, context tun.DirectRouteContext, timeout time.Duration, supportBypass bool) (tun.DirectRouteDestination, error)
PreMatch(metadata InboundContext, firstPacket []byte) PreMatchResult
HijackDNSPacket(ctx context.Context, payload []byte, writer N.PacketWriter, metadata InboundContext)
ConnectionRouterEx
RuleSet(tag string) (RuleSet, bool)
Rules() []Rule
NeedFindProcess() bool
NeedFindNeighbor() bool
NeighborResolver() NeighborResolver
AppendTracker(tracker ConnectionTracker)
ResetNetwork()
}
type PreMatchAction uint8
const (
PreMatchContinue PreMatchAction = iota
PreMatchFlow
PreMatchReject
PreMatchDrop
PreMatchBypass
PreMatchHijackDNS
)
type PreMatchResult struct {
Action PreMatchAction
Outbound Outbound
Destination netip.AddrPort
UDPTimeout time.Duration
NewTracker func() tun.FlowTracker
}
func JudgeFlow(router Router, inbound string, inboundType string, network uint8, source netip.AddrPort, destination netip.AddrPort, firstPacket []byte) tun.FlowVerdict {
var networkName string
switch network {
case uint8(header.TCPProtocolNumber):
networkName = N.NetworkTCP
case uint8(header.UDPProtocolNumber):
networkName = N.NetworkUDP
case uint8(header.ICMPv4ProtocolNumber), uint8(header.ICMPv6ProtocolNumber):
networkName = N.NetworkICMP
default:
return tun.FlowVerdict{Action: tun.ActionAccept}
}
metadata := InboundContext{
Inbound: inbound,
InboundType: inboundType,
Network: networkName,
Source: M.SocksaddrFromNetIP(source),
Destination: M.SocksaddrFromNetIP(destination),
}
if networkName == N.NetworkICMP {
metadata.Source.Port = 0
metadata.Destination.Port = 0
}
result := router.PreMatch(metadata, firstPacket)
switch result.Action {
case PreMatchFlow:
port, isPort := result.Outbound.(tun.Port)
if !isPort {
return tun.FlowVerdict{Action: tun.ActionAccept}
}
verdict := tun.FlowVerdict{Action: tun.ActionFlow, Port: port, UDPTimeout: result.UDPTimeout, NewTracker: result.NewTracker}
if result.Destination.IsValid() {
destinationPort := result.Destination.Port()
if networkName == N.NetworkICMP {
destinationPort = destination.Port()
}
verdict.Destination = netip.AddrPortFrom(result.Destination.Addr(), destinationPort)
}
return verdict
case PreMatchReject:
return tun.FlowVerdict{Action: tun.ActionReject}
case PreMatchDrop:
return tun.FlowVerdict{Action: tun.ActionDrop}
case PreMatchBypass:
return tun.FlowVerdict{Action: tun.ActionBypass}
case PreMatchHijackDNS:
return tun.FlowVerdict{Action: tun.ActionHijackDNS}
default:
return tun.FlowVerdict{Action: tun.ActionAccept}
}
}
type ConnectionTracker interface {
RoutedConnection(ctx context.Context, conn net.Conn, metadata InboundContext, matchedRule Rule, matchOutbound Outbound) net.Conn
RoutedPacketConnection(ctx context.Context, conn N.PacketConn, metadata InboundContext, matchedRule Rule, matchOutbound Outbound) N.PacketConn
RoutedFlow(ctx context.Context, metadata InboundContext, matchedRule Rule, matchOutbound Outbound) tun.FlowTracker
}
// Deprecated: Use ConnectionRouterEx instead.
@@ -50,7 +122,6 @@ type ConnectionRouterEx interface {
type RuleSet interface {
Name() string
StartContext(ctx context.Context, startContext *HTTPStartContext) error
PostStart() error
Metadata() RuleSetMetadata
ExtractIPSet() []*netipx.IPSet
IncRef()
@@ -64,51 +135,20 @@ type RuleSet interface {
type RuleSetUpdateCallback func(it RuleSet)
type DNSRuleSetUpdateValidator interface {
ValidateRuleSetMetadataUpdate(tag string, metadata RuleSetMetadata) error
}
// ip_version is not a headless-rule item, so ContainsIPVersionRule is intentionally absent.
type RuleSetMetadata struct {
ContainsProcessRule bool
ContainsWIFIRule bool
ContainsIPCIDRRule bool
}
type HTTPStartContext struct {
ctx context.Context
access sync.Mutex
httpClientCache map[string]*http.Client
}
func NewHTTPStartContext(ctx context.Context) *HTTPStartContext {
return &HTTPStartContext{
ctx: ctx,
httpClientCache: make(map[string]*http.Client),
}
}
func (c *HTTPStartContext) HTTPClient(detour string, dialer N.Dialer) *http.Client {
c.access.Lock()
defer c.access.Unlock()
if httpClient, loaded := c.httpClientCache[detour]; loaded {
return httpClient
}
httpClient := &http.Client{
Transport: &http.Transport{
ForceAttemptHTTP2: true,
TLSHandshakeTimeout: C.TCPTimeout,
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialer.DialContext(ctx, network, M.ParseSocksaddr(addr))
},
TLSClientConfig: &tls.Config{
Time: ntp.TimeFuncFromContext(c.ctx),
RootCAs: RootPoolFromContext(c.ctx),
},
},
}
c.httpClientCache[detour] = httpClient
return httpClient
}
func (c *HTTPStartContext) Close() {
c.access.Lock()
defer c.access.Unlock()
for _, client := range c.httpClientCache {
client.CloseIdleConnections()
}
ContainsProcessRule bool
ContainsWIFIRule bool
ContainsIPCIDRRule bool
ContainsDNSQueryTypeRule bool
// ContainsNonIPCIDRRule signals that the rule-set carries at least one sub-rule
// with a predicate other than destination ip_cidr / ip_set, so it can contribute
// to DNS pre-response matching. A rule-set where this is false and
// ContainsIPCIDRRule is true is "pure-IP" and matches nothing before a DNS
// response is available.
ContainsNonIPCIDRRule bool
}
+9 -2
View File
@@ -2,6 +2,8 @@ package adapter
import (
C "github.com/sagernet/sing-box/constant"
"github.com/miekg/dns"
)
type HeadlessRule interface {
@@ -18,8 +20,13 @@ type Rule interface {
type DNSRule interface {
Rule
LegacyPreMatch(metadata *InboundContext) bool
WithAddressLimit() bool
MatchAddressLimit(metadata *InboundContext) bool
MatchAddressLimit(metadata *InboundContext, response *dns.Msg) bool
MatchResponseTag() string
MatchResponseTags() []string
MatchResponseAnonymous() bool
Race() bool
}
type RuleAction interface {
@@ -29,7 +36,7 @@ type RuleAction interface {
func IsFinalAction(action RuleAction) bool {
switch action.Type() {
case C.RuleActionTypeSniff, C.RuleActionTypeResolve:
case C.RuleActionTypeSniff, C.RuleActionTypeResolve, C.RuleActionTypeEvaluate:
return false
default:
return true
+19
View File
@@ -0,0 +1,19 @@
package adapter
import (
"context"
"github.com/sagernet/sing/service"
)
type SecurityPolicy interface {
CheckFeature(ctx context.Context, feature string) error
}
func CheckSecurityFeature(ctx context.Context, feature string) error {
policy := service.FromContext[SecurityPolicy](ctx)
if policy == nil {
return nil
}
return policy.CheckFeature(ctx, feature)
}
+8
View File
@@ -2,6 +2,8 @@ package service
import (
"context"
"maps"
"slices"
"sync"
"github.com/sagernet/sing-box/adapter"
@@ -44,6 +46,12 @@ func NewRegistry() *Registry {
}
}
func (m *Registry) OptionTypes() []string {
m.access.Lock()
defer m.access.Unlock()
return slices.Sorted(maps.Keys(m.optionsType))
}
func (m *Registry) CreateOptions(outboundType string) (any, bool) {
m.access.Lock()
defer m.access.Unlock()
+104
View File
@@ -0,0 +1,104 @@
package adapter
import (
"context"
"io"
"time"
)
type TailscaleEndpoint interface {
SubscribeTailscaleStatus(ctx context.Context, fn func(*TailscaleEndpointStatus)) error
StartTailscalePing(ctx context.Context, peerIP string, fn func(*TailscalePingResult)) error
SetTailscaleExitNode(ctx context.Context, stableID string) error
Logout(ctx context.Context) error
GetTailscaleCertificate(ctx context.Context, domain string, minValidity time.Duration) (certificatePEM []byte, privateKeyPEM []byte, err error)
SubscribeTaildropInbox(ctx context.Context, fn func(*TaildropInbox)) error
MarkTaildropInboxRead() error
SendTaildropFile(ctx context.Context, peerStableID string, fileName string, size int64, content io.Reader, progress func(sentBytes int64)) error
OpenTaildropFile(fileName string) (io.ReadCloser, int64, error)
DeleteTaildropFile(fileName string) error
CancelTaildropReceiving(senderID string, fileName string) error
}
type TaildropInbox struct {
Files []*TaildropFile
Receiving []*TaildropReceivingFile
}
type TaildropFile struct {
Name string
Size int64
SenderName string
ModifiedAt int64
}
type TaildropReceivingFile struct {
Name string
Size int64
ReceivedBytes int64
SenderID string
SenderName string
}
type TailscalePingResult struct {
LatencyMs float64
IsDirect bool
Endpoint string
PeerRelay string
DERPRegionID int32
DERPRegionCode string
Error string
}
type TailscaleEndpointStatus struct {
BackendState string
AuthURL string
NetworkName string
MagicDNSSuffix string
Self *TailscalePeer
ExitNode *TailscalePeer
UserGroups []*TailscaleUserGroup
KeyAuth bool
CanShareFiles bool
WaitingFileCount int32
ReceivingFileCount int32
UnreadFileCount int32
CertDomains []string
}
type TailscaleUserGroup struct {
UserID int64
LoginName string
DisplayName string
ProfilePicURL string
Peers []*TailscalePeer
}
type TailscalePeer struct {
StableID string
HostName string
DNSName string
OS string
TailscaleIPs []string
SSHHostKeys []string
Online bool
ExitNode bool
ExitNodeOption bool
ShareeNode bool
Expired bool
Active bool
CanReceiveFiles bool
RxBytes int64
TxBytes int64
UserID int64
KeyExpiry int64
LastSeen int64
}
type ShellSession interface {
MasterFD() int32
Resize(rows int32, cols int32) error
Signal(signal int32) error
WaitExit() (int32, error)
Close() error
}
+38 -38
View File
@@ -9,31 +9,31 @@ import (
)
type (
ConnectionHandlerFuncEx = func(ctx context.Context, conn net.Conn, metadata InboundContext, onClose N.CloseHandlerFunc)
PacketConnectionHandlerFuncEx = func(ctx context.Context, conn N.PacketConn, metadata InboundContext, onClose N.CloseHandlerFunc)
ConnectionHandlerFunc = func(ctx context.Context, conn net.Conn, metadata InboundContext, onClose N.CloseHandlerFunc)
PacketConnectionHandlerFunc = func(ctx context.Context, conn N.PacketConn, metadata InboundContext, onClose N.CloseHandlerFunc)
)
func NewUpstreamHandlerEx(
func NewUpstreamHandler(
metadata InboundContext,
connectionHandler ConnectionHandlerFuncEx,
packetHandler PacketConnectionHandlerFuncEx,
) UpstreamHandlerAdapterEx {
return &myUpstreamHandlerWrapperEx{
connectionHandler ConnectionHandlerFunc,
packetHandler PacketConnectionHandlerFunc,
) UpstreamHandlerAdapter {
return &myUpstreamHandlerWrapper{
metadata: metadata,
connectionHandler: connectionHandler,
packetHandler: packetHandler,
}
}
var _ UpstreamHandlerAdapterEx = (*myUpstreamHandlerWrapperEx)(nil)
var _ UpstreamHandlerAdapter = (*myUpstreamHandlerWrapper)(nil)
type myUpstreamHandlerWrapperEx struct {
type myUpstreamHandlerWrapper struct {
metadata InboundContext
connectionHandler ConnectionHandlerFuncEx
packetHandler PacketConnectionHandlerFuncEx
connectionHandler ConnectionHandlerFunc
packetHandler PacketConnectionHandlerFunc
}
func (w *myUpstreamHandlerWrapperEx) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
func (w *myUpstreamHandlerWrapper) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
myMetadata := w.metadata
if source.IsValid() {
myMetadata.Source = source
@@ -44,7 +44,7 @@ func (w *myUpstreamHandlerWrapperEx) NewConnectionEx(ctx context.Context, conn n
w.connectionHandler(ctx, conn, myMetadata, onClose)
}
func (w *myUpstreamHandlerWrapperEx) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
func (w *myUpstreamHandlerWrapper) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
myMetadata := w.metadata
if source.IsValid() {
myMetadata.Source = source
@@ -55,24 +55,24 @@ func (w *myUpstreamHandlerWrapperEx) NewPacketConnectionEx(ctx context.Context,
w.packetHandler(ctx, conn, myMetadata, onClose)
}
var _ UpstreamHandlerAdapterEx = (*myUpstreamContextHandlerWrapperEx)(nil)
var _ UpstreamHandlerAdapter = (*myUpstreamContextHandlerWrapper)(nil)
type myUpstreamContextHandlerWrapperEx struct {
connectionHandler ConnectionHandlerFuncEx
packetHandler PacketConnectionHandlerFuncEx
type myUpstreamContextHandlerWrapper struct {
connectionHandler ConnectionHandlerFunc
packetHandler PacketConnectionHandlerFunc
}
func NewUpstreamContextHandlerEx(
connectionHandler ConnectionHandlerFuncEx,
packetHandler PacketConnectionHandlerFuncEx,
) UpstreamHandlerAdapterEx {
return &myUpstreamContextHandlerWrapperEx{
func NewUpstreamContextHandler(
connectionHandler ConnectionHandlerFunc,
packetHandler PacketConnectionHandlerFunc,
) UpstreamHandlerAdapter {
return &myUpstreamContextHandlerWrapper{
connectionHandler: connectionHandler,
packetHandler: packetHandler,
}
}
func (w *myUpstreamContextHandlerWrapperEx) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
func (w *myUpstreamContextHandlerWrapper) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
_, myMetadata := ExtendContext(ctx)
if source.IsValid() {
myMetadata.Source = source
@@ -83,7 +83,7 @@ func (w *myUpstreamContextHandlerWrapperEx) NewConnectionEx(ctx context.Context,
w.connectionHandler(ctx, conn, *myMetadata, onClose)
}
func (w *myUpstreamContextHandlerWrapperEx) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
func (w *myUpstreamContextHandlerWrapper) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
_, myMetadata := ExtendContext(ctx)
if source.IsValid() {
myMetadata.Source = source
@@ -94,24 +94,24 @@ func (w *myUpstreamContextHandlerWrapperEx) NewPacketConnectionEx(ctx context.Co
w.packetHandler(ctx, conn, *myMetadata, onClose)
}
func NewRouteHandlerEx(
func NewRouteHandler(
metadata InboundContext,
router ConnectionRouterEx,
) UpstreamHandlerAdapterEx {
return &routeHandlerWrapperEx{
) UpstreamHandlerAdapter {
return &routeHandlerWrapper{
metadata: metadata,
router: router,
}
}
var _ UpstreamHandlerAdapterEx = (*routeHandlerWrapperEx)(nil)
var _ UpstreamHandlerAdapter = (*routeHandlerWrapper)(nil)
type routeHandlerWrapperEx struct {
type routeHandlerWrapper struct {
metadata InboundContext
router ConnectionRouterEx
}
func (r *routeHandlerWrapperEx) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
func (r *routeHandlerWrapper) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
if source.IsValid() {
r.metadata.Source = source
}
@@ -121,7 +121,7 @@ func (r *routeHandlerWrapperEx) NewConnectionEx(ctx context.Context, conn net.Co
r.router.RouteConnectionEx(ctx, conn, r.metadata, onClose)
}
func (r *routeHandlerWrapperEx) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
func (r *routeHandlerWrapper) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
if source.IsValid() {
r.metadata.Source = source
}
@@ -131,21 +131,21 @@ func (r *routeHandlerWrapperEx) NewPacketConnectionEx(ctx context.Context, conn
r.router.RoutePacketConnectionEx(ctx, conn, r.metadata, onClose)
}
func NewRouteContextHandlerEx(
func NewRouteContextHandler(
router ConnectionRouterEx,
) UpstreamHandlerAdapterEx {
return &routeContextHandlerWrapperEx{
) UpstreamHandlerAdapter {
return &routeContextHandlerWrapper{
router: router,
}
}
var _ UpstreamHandlerAdapterEx = (*routeContextHandlerWrapperEx)(nil)
var _ UpstreamHandlerAdapter = (*routeContextHandlerWrapper)(nil)
type routeContextHandlerWrapperEx struct {
type routeContextHandlerWrapper struct {
router ConnectionRouterEx
}
func (r *routeContextHandlerWrapperEx) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
func (r *routeContextHandlerWrapper) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
_, metadata := ExtendContext(ctx)
if source.IsValid() {
metadata.Source = source
@@ -156,7 +156,7 @@ func (r *routeContextHandlerWrapperEx) NewConnectionEx(ctx context.Context, conn
r.router.RouteConnectionEx(ctx, conn, *metadata, onClose)
}
func (r *routeContextHandlerWrapperEx) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
func (r *routeContextHandlerWrapper) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
_, metadata := ExtendContext(ctx)
if source.IsValid() {
metadata.Source = source
+59 -50
View File
@@ -12,21 +12,30 @@ import (
type (
// Deprecated
ConnectionHandlerFunc = func(ctx context.Context, conn net.Conn, metadata InboundContext) error
LegacyConnectionHandlerFunc = func(ctx context.Context, conn net.Conn, metadata InboundContext) error
// Deprecated
PacketConnectionHandlerFunc = func(ctx context.Context, conn N.PacketConn, metadata InboundContext) error
LegacyPacketConnectionHandlerFunc = func(ctx context.Context, conn N.PacketConn, metadata InboundContext) error
)
// Deprecated
//
//nolint:staticcheck
func NewUpstreamHandler(
type LegacyUpstreamHandlerAdapter interface {
N.TCPConnectionHandler
N.UDPConnectionHandler
E.Handler
}
// Deprecated
//
//nolint:staticcheck
func NewLegacyUpstreamHandler(
metadata InboundContext,
connectionHandler ConnectionHandlerFunc,
packetHandler PacketConnectionHandlerFunc,
connectionHandler LegacyConnectionHandlerFunc,
packetHandler LegacyPacketConnectionHandlerFunc,
errorHandler E.Handler,
) UpstreamHandlerAdapter {
return &myUpstreamHandlerWrapper{
) LegacyUpstreamHandlerAdapter {
return &legacyUpstreamHandlerWrapper{
metadata: metadata,
connectionHandler: connectionHandler,
packetHandler: packetHandler,
@@ -34,20 +43,20 @@ func NewUpstreamHandler(
}
}
var _ UpstreamHandlerAdapter = (*myUpstreamHandlerWrapper)(nil)
var _ LegacyUpstreamHandlerAdapter = (*legacyUpstreamHandlerWrapper)(nil)
// Deprecated: use myUpstreamHandlerWrapperEx instead.
// Deprecated: use NewUpstreamHandler instead.
//
//nolint:staticcheck
type myUpstreamHandlerWrapper struct {
type legacyUpstreamHandlerWrapper struct {
metadata InboundContext
connectionHandler ConnectionHandlerFunc
packetHandler PacketConnectionHandlerFunc
connectionHandler LegacyConnectionHandlerFunc
packetHandler LegacyPacketConnectionHandlerFunc
errorHandler E.Handler
}
// Deprecated: use myUpstreamHandlerWrapperEx instead.
func (w *myUpstreamHandlerWrapper) NewConnection(ctx context.Context, conn net.Conn, metadata M.Metadata) error {
// Deprecated: use NewUpstreamHandler instead.
func (w *legacyUpstreamHandlerWrapper) NewConnection(ctx context.Context, conn net.Conn, metadata M.Metadata) error {
myMetadata := w.metadata
if metadata.Source.IsValid() {
myMetadata.Source = metadata.Source
@@ -58,8 +67,8 @@ func (w *myUpstreamHandlerWrapper) NewConnection(ctx context.Context, conn net.C
return w.connectionHandler(ctx, conn, myMetadata)
}
// Deprecated: use myUpstreamHandlerWrapperEx instead.
func (w *myUpstreamHandlerWrapper) NewPacketConnection(ctx context.Context, conn N.PacketConn, metadata M.Metadata) error {
// Deprecated: use NewUpstreamHandler instead.
func (w *legacyUpstreamHandlerWrapper) NewPacketConnection(ctx context.Context, conn N.PacketConn, metadata M.Metadata) error {
myMetadata := w.metadata
if metadata.Source.IsValid() {
myMetadata.Source = metadata.Source
@@ -70,8 +79,8 @@ func (w *myUpstreamHandlerWrapper) NewPacketConnection(ctx context.Context, conn
return w.packetHandler(ctx, conn, myMetadata)
}
// Deprecated: use myUpstreamHandlerWrapperEx instead.
func (w *myUpstreamHandlerWrapper) NewError(ctx context.Context, err error) {
// Deprecated: use NewUpstreamHandler instead.
func (w *legacyUpstreamHandlerWrapper) NewError(ctx context.Context, err error) {
w.errorHandler.NewError(ctx, err)
}
@@ -83,28 +92,28 @@ func UpstreamMetadata(metadata InboundContext) M.Metadata {
}
}
// Deprecated: Use NewUpstreamContextHandlerEx instead.
type myUpstreamContextHandlerWrapper struct {
connectionHandler ConnectionHandlerFunc
packetHandler PacketConnectionHandlerFunc
// Deprecated: Use NewUpstreamContextHandler instead.
type legacyUpstreamContextHandlerWrapper struct {
connectionHandler LegacyConnectionHandlerFunc
packetHandler LegacyPacketConnectionHandlerFunc
errorHandler E.Handler
}
// Deprecated: Use NewUpstreamContextHandlerEx instead.
func NewUpstreamContextHandler(
connectionHandler ConnectionHandlerFunc,
packetHandler PacketConnectionHandlerFunc,
// Deprecated: Use NewUpstreamContextHandler instead.
func NewLegacyUpstreamContextHandler(
connectionHandler LegacyConnectionHandlerFunc,
packetHandler LegacyPacketConnectionHandlerFunc,
errorHandler E.Handler,
) UpstreamHandlerAdapter {
return &myUpstreamContextHandlerWrapper{
) LegacyUpstreamHandlerAdapter {
return &legacyUpstreamContextHandlerWrapper{
connectionHandler: connectionHandler,
packetHandler: packetHandler,
errorHandler: errorHandler,
}
}
// Deprecated: Use NewUpstreamContextHandlerEx instead.
func (w *myUpstreamContextHandlerWrapper) NewConnection(ctx context.Context, conn net.Conn, metadata M.Metadata) error {
// Deprecated: Use NewUpstreamContextHandler instead.
func (w *legacyUpstreamContextHandlerWrapper) NewConnection(ctx context.Context, conn net.Conn, metadata M.Metadata) error {
myMetadata := ContextFrom(ctx)
if metadata.Source.IsValid() {
myMetadata.Source = metadata.Source
@@ -115,8 +124,8 @@ func (w *myUpstreamContextHandlerWrapper) NewConnection(ctx context.Context, con
return w.connectionHandler(ctx, conn, *myMetadata)
}
// Deprecated: Use NewUpstreamContextHandlerEx instead.
func (w *myUpstreamContextHandlerWrapper) NewPacketConnection(ctx context.Context, conn N.PacketConn, metadata M.Metadata) error {
// Deprecated: Use NewUpstreamContextHandler instead.
func (w *legacyUpstreamContextHandlerWrapper) NewPacketConnection(ctx context.Context, conn N.PacketConn, metadata M.Metadata) error {
myMetadata := ContextFrom(ctx)
if metadata.Source.IsValid() {
myMetadata.Source = metadata.Source
@@ -127,18 +136,18 @@ func (w *myUpstreamContextHandlerWrapper) NewPacketConnection(ctx context.Contex
return w.packetHandler(ctx, conn, *myMetadata)
}
// Deprecated: Use NewUpstreamContextHandlerEx instead.
func (w *myUpstreamContextHandlerWrapper) NewError(ctx context.Context, err error) {
// Deprecated: Use NewUpstreamContextHandler instead.
func (w *legacyUpstreamContextHandlerWrapper) NewError(ctx context.Context, err error) {
w.errorHandler.NewError(ctx, err)
}
// Deprecated: Use ConnectionRouterEx instead.
func NewRouteHandler(
func NewLegacyRouteHandler(
metadata InboundContext,
router ConnectionRouter,
logger logger.ContextLogger,
) UpstreamHandlerAdapter {
return &routeHandlerWrapper{
) LegacyUpstreamHandlerAdapter {
return &legacyRouteHandlerWrapper{
metadata: metadata,
router: router,
logger: logger,
@@ -146,29 +155,29 @@ func NewRouteHandler(
}
// Deprecated: Use ConnectionRouterEx instead.
func NewRouteContextHandler(
func NewLegacyRouteContextHandler(
router ConnectionRouter,
logger logger.ContextLogger,
) UpstreamHandlerAdapter {
return &routeContextHandlerWrapper{
) LegacyUpstreamHandlerAdapter {
return &legacyRouteContextHandlerWrapper{
router: router,
logger: logger,
}
}
var _ UpstreamHandlerAdapter = (*routeHandlerWrapper)(nil)
var _ LegacyUpstreamHandlerAdapter = (*legacyRouteHandlerWrapper)(nil)
// Deprecated: Use ConnectionRouterEx instead.
//
//nolint:staticcheck
type routeHandlerWrapper struct {
type legacyRouteHandlerWrapper struct {
metadata InboundContext
router ConnectionRouter
logger logger.ContextLogger
}
// Deprecated: Use ConnectionRouterEx instead.
func (w *routeHandlerWrapper) NewConnection(ctx context.Context, conn net.Conn, metadata M.Metadata) error {
func (w *legacyRouteHandlerWrapper) NewConnection(ctx context.Context, conn net.Conn, metadata M.Metadata) error {
myMetadata := w.metadata
if metadata.Source.IsValid() {
myMetadata.Source = metadata.Source
@@ -180,7 +189,7 @@ func (w *routeHandlerWrapper) NewConnection(ctx context.Context, conn net.Conn,
}
// Deprecated: Use ConnectionRouterEx instead.
func (w *routeHandlerWrapper) NewPacketConnection(ctx context.Context, conn N.PacketConn, metadata M.Metadata) error {
func (w *legacyRouteHandlerWrapper) NewPacketConnection(ctx context.Context, conn N.PacketConn, metadata M.Metadata) error {
myMetadata := w.metadata
if metadata.Source.IsValid() {
myMetadata.Source = metadata.Source
@@ -192,20 +201,20 @@ func (w *routeHandlerWrapper) NewPacketConnection(ctx context.Context, conn N.Pa
}
// Deprecated: Use ConnectionRouterEx instead.
func (w *routeHandlerWrapper) NewError(ctx context.Context, err error) {
func (w *legacyRouteHandlerWrapper) NewError(ctx context.Context, err error) {
w.logger.ErrorContext(ctx, err)
}
var _ UpstreamHandlerAdapter = (*routeContextHandlerWrapper)(nil)
var _ LegacyUpstreamHandlerAdapter = (*legacyRouteContextHandlerWrapper)(nil)
// Deprecated: Use ConnectionRouterEx instead.
type routeContextHandlerWrapper struct {
type legacyRouteContextHandlerWrapper struct {
router ConnectionRouter
logger logger.ContextLogger
}
// Deprecated: Use ConnectionRouterEx instead.
func (w *routeContextHandlerWrapper) NewConnection(ctx context.Context, conn net.Conn, metadata M.Metadata) error {
func (w *legacyRouteContextHandlerWrapper) NewConnection(ctx context.Context, conn net.Conn, metadata M.Metadata) error {
myMetadata := ContextFrom(ctx)
if metadata.Source.IsValid() {
myMetadata.Source = metadata.Source
@@ -217,7 +226,7 @@ func (w *routeContextHandlerWrapper) NewConnection(ctx context.Context, conn net
}
// Deprecated: Use ConnectionRouterEx instead.
func (w *routeContextHandlerWrapper) NewPacketConnection(ctx context.Context, conn N.PacketConn, metadata M.Metadata) error {
func (w *legacyRouteContextHandlerWrapper) NewPacketConnection(ctx context.Context, conn N.PacketConn, metadata M.Metadata) error {
myMetadata := ContextFrom(ctx)
if metadata.Source.IsValid() {
myMetadata.Source = metadata.Source
@@ -229,6 +238,6 @@ func (w *routeContextHandlerWrapper) NewPacketConnection(ctx context.Context, co
}
// Deprecated: Use ConnectionRouterEx instead.
func (w *routeContextHandlerWrapper) NewError(ctx context.Context, err error) {
func (w *legacyRouteContextHandlerWrapper) NewError(ctx context.Context, err error) {
w.logger.ErrorContext(ctx, err)
}
+15
View File
@@ -0,0 +1,15 @@
//go:build with_usbip && (linux || (darwin && cgo) || windows)
package adapter
import (
"context"
"github.com/sagernet/sing-usbip"
)
type USBIPDynamicServer interface {
AddDevice(info usbip.ProvidedDeviceInfo, transport usbip.DeviceTransport) (string, error)
RemoveDevice(busID string)
SubscribeDevices(ctx context.Context, listener func([]usbip.ControlDeviceInfo))
}
+7
View File
@@ -0,0 +1,7 @@
//go:build !with_usbip || !(linux || (darwin && cgo) || windows)
package adapter
type USBIPDynamicServer interface {
usbipNotIncluded()
}
+175 -57
View File
@@ -4,11 +4,13 @@ import (
"context"
"fmt"
"io"
"net/http"
"os"
"runtime/debug"
"time"
"github.com/sagernet/sing-box/adapter"
boxCertificate "github.com/sagernet/sing-box/adapter/certificate"
"github.com/sagernet/sing-box/adapter/endpoint"
"github.com/sagernet/sing-box/adapter/inbound"
"github.com/sagernet/sing-box/adapter/outbound"
@@ -16,13 +18,17 @@ import (
boxService "github.com/sagernet/sing-box/adapter/service"
"github.com/sagernet/sing-box/common/certificate"
"github.com/sagernet/sing-box/common/dialer"
"github.com/sagernet/sing-box/common/httpclient"
"github.com/sagernet/sing-box/common/netns"
"github.com/sagernet/sing-box/common/taskmonitor"
"github.com/sagernet/sing-box/common/tls"
"github.com/sagernet/sing-box/common/trafficcontrol"
"github.com/sagernet/sing-box/common/urltest"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/dns"
"github.com/sagernet/sing-box/experimental"
"github.com/sagernet/sing-box/experimental/cachefile"
"github.com/sagernet/sing-box/experimental/deprecated"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing-box/protocol/direct"
@@ -38,27 +44,33 @@ import (
var _ adapter.SimpleLifecycle = (*Box)(nil)
type Box struct {
createdAt time.Time
logFactory log.Factory
logger log.ContextLogger
network *route.NetworkManager
endpoint *endpoint.Manager
inbound *inbound.Manager
outbound *outbound.Manager
provider *provider.Manager
service *boxService.Manager
dnsTransport *dns.TransportManager
dnsRouter *dns.Router
connection *route.ConnectionManager
router *route.Router
internalService []adapter.LifecycleService
done chan struct{}
ctx context.Context
createdAt time.Time
debugOptions option.DebugOptions
debugHTTPServer *http.Server
logFactory log.Factory
logger log.ContextLogger
network *route.NetworkManager
endpoint *endpoint.Manager
inbound *inbound.Manager
outbound *outbound.Manager
provider *provider.Manager
service *boxService.Manager
certificateProvider *boxCertificate.Manager
dnsTransport *dns.TransportManager
dnsRouter *dns.Router
connection *route.ConnectionManager
router *route.Router
httpClientService adapter.LifecycleService
internalService []adapter.LifecycleService
done chan struct{}
}
type Options struct {
option.Options
Context context.Context
PlatformLogWriter log.PlatformWriter
Context context.Context
PlatformLogWriter log.PlatformWriter
NetworkNamespaceHolderArgs []string
}
func Context(
@@ -69,6 +81,7 @@ func Context(
providerRegistry adapter.ProviderRegistry,
dnsTransportRegistry adapter.DNSTransportRegistry,
serviceRegistry adapter.ServiceRegistry,
certificateProviderRegistry adapter.CertificateProviderRegistry,
) context.Context {
if service.FromContext[option.InboundOptionsRegistry](ctx) == nil ||
service.FromContext[adapter.InboundRegistry](ctx) == nil {
@@ -98,6 +111,10 @@ func Context(
ctx = service.ContextWith[option.ServiceOptionsRegistry](ctx, serviceRegistry)
ctx = service.ContextWith[adapter.ServiceRegistry](ctx, serviceRegistry)
}
if service.FromContext[adapter.CertificateProviderRegistry](ctx) == nil {
ctx = service.ContextWith[option.CertificateProviderOptionsRegistry](ctx, certificateProviderRegistry)
ctx = service.ContextWith[adapter.CertificateProviderRegistry](ctx, certificateProviderRegistry)
}
return ctx
}
@@ -115,6 +132,7 @@ func New(options Options) (*Box, error) {
providerRegistry := service.FromContext[adapter.ProviderRegistry](ctx)
dnsTransportRegistry := service.FromContext[adapter.DNSTransportRegistry](ctx)
serviceRegistry := service.FromContext[adapter.ServiceRegistry](ctx)
certificateProviderRegistry := service.FromContext[adapter.CertificateProviderRegistry](ctx)
if endpointRegistry == nil {
return nil, E.New("missing endpoint registry in context")
@@ -134,10 +152,14 @@ func New(options Options) (*Box, error) {
if serviceRegistry == nil {
return nil, E.New("missing service registry in context")
}
if certificateProviderRegistry == nil {
return nil, E.New("missing certificate provider registry in context")
}
ctx = pause.WithDefaultManager(ctx)
experimentalOptions := common.PtrValueOrDefault(options.Experimental)
err := applyDebugOptions(common.PtrValueOrDefault(experimentalOptions.Debug))
debugOptions := common.PtrValueOrDefault(experimentalOptions.Debug)
err := checkDebugOptions(debugOptions)
if err != nil {
return nil, err
}
@@ -156,6 +178,12 @@ func New(options Options) (*Box, error) {
if experimentalOptions.UnifiedDelay != nil && experimentalOptions.UnifiedDelay.Enabled {
ctx = urltest.ContextWithIsUnifiedDelay(ctx)
}
needAPIService := common.Any(options.Services, func(it option.Service) bool {
return it.Type == C.TypeAPI
})
if service.PtrFromContext[urltest.HistoryStorage](ctx) == nil {
ctx = service.ContextWithPtr(ctx, urltest.NewHistoryStorage())
}
platformInterface := service.FromContext[adapter.PlatformInterface](ctx)
var defaultLogWriter io.Writer
if platformInterface != nil {
@@ -164,7 +192,7 @@ func New(options Options) (*Box, error) {
logFactory, err := log.New(log.Options{
Context: ctx,
Options: common.PtrValueOrDefault(options.Log),
Observable: needClashAPI,
Observable: needClashAPI || needAPIService,
DefaultWriter: defaultLogWriter,
BaseTime: createdAt,
PlatformWriter: options.PlatformLogWriter,
@@ -175,6 +203,7 @@ func New(options Options) (*Box, error) {
service.MustRegister[log.Factory](ctx, logFactory)
var internalServices []adapter.LifecycleService
routeOptions := common.PtrValueOrDefault(options.Route)
certificateOptions := common.PtrValueOrDefault(options.Certificate)
if C.IsAndroid || certificateOptions.Store != "" && certificateOptions.Store != C.CertificateStoreSystem ||
len(certificateOptions.Certificate) > 0 ||
@@ -187,8 +216,12 @@ func New(options Options) (*Box, error) {
service.MustRegister[adapter.CertificateStore](ctx, certificateStore)
internalServices = append(internalServices, certificateStore)
}
routeOptions := common.PtrValueOrDefault(options.Route)
netnsManager, err := netns.NewManager(logFactory.NewLogger("netns"), options.NetworkNamespaces, options.NetworkNamespaceHolderArgs)
if err != nil {
return nil, err
}
service.MustRegister[adapter.NetworkNamespaceManager](ctx, netnsManager)
internalServices = append(internalServices, netnsManager)
dnsOptions := common.PtrValueOrDefault(options.DNS)
endpointManager := endpoint.NewManager(logFactory.NewLogger("endpoint"), endpointRegistry)
inboundManager := inbound.NewManager(logFactory.NewLogger("inbound"), inboundRegistry, endpointManager)
@@ -196,14 +229,20 @@ func New(options Options) (*Box, error) {
providerManager := provider.NewManager(logFactory.NewLogger("provider"), providerRegistry)
dnsTransportManager := dns.NewTransportManager(logFactory.NewLogger("dns/transport"), dnsTransportRegistry, outboundManager, dnsOptions.Final)
serviceManager := boxService.NewManager(logFactory.NewLogger("service"), serviceRegistry)
certificateProviderManager := boxCertificate.NewManager(logFactory.NewLogger("certificate-provider"), certificateProviderRegistry)
service.MustRegister[adapter.EndpointManager](ctx, endpointManager)
service.MustRegister[adapter.InboundManager](ctx, inboundManager)
service.MustRegister[adapter.OutboundManager](ctx, outboundManager)
service.MustRegister[adapter.ProviderManager](ctx, providerManager)
service.MustRegister[adapter.DNSTransportManager](ctx, dnsTransportManager)
service.MustRegister[adapter.ServiceManager](ctx, serviceManager)
dnsRouter := dns.NewRouter(ctx, logFactory, dnsOptions)
service.MustRegister[adapter.CertificateProviderManager](ctx, certificateProviderManager)
dnsRouter, err := dns.NewRouter(ctx, logFactory, dnsOptions)
if err != nil {
return nil, E.Cause(err, "initialize DNS router")
}
service.MustRegister[adapter.DNSRouter](ctx, dnsRouter)
service.MustRegister[adapter.DNSRuleSetUpdateValidator](ctx, dnsRouter)
networkManager, err := route.NewNetworkManager(ctx, logFactory.NewLogger("network"), routeOptions, dnsOptions)
if err != nil {
return nil, E.Cause(err, "initialize network manager")
@@ -211,12 +250,22 @@ func New(options Options) (*Box, error) {
service.MustRegister[adapter.NetworkManager](ctx, networkManager)
connectionManager := route.NewConnectionManager(logFactory.NewLogger("connection"))
service.MustRegister[adapter.ConnectionManager](ctx, connectionManager)
// Must register after ConnectionManager: the Apple HTTP engine's proxy bridge reads it from the context when Manager.Start resolves the default client.
httpClientManager := httpclient.NewManager(ctx, logFactory.NewLogger("httpclient"), options.HTTPClients, routeOptions.DefaultHTTPClient)
service.MustRegister[adapter.HTTPClientManager](ctx, httpClientManager)
httpClientService := adapter.LifecycleService(httpClientManager)
router := route.NewRouter(ctx, logFactory, routeOptions, dnsOptions)
service.MustRegister[adapter.Router](ctx, router)
err = router.Initialize(routeOptions.Rules, routeOptions.RuleSet)
if err != nil {
return nil, E.Cause(err, "initialize router")
}
if needClashAPI || needAPIService {
trafficManager := trafficcontrol.NewManager(outboundManager)
service.MustRegisterPtr(ctx, trafficManager)
router.AppendTracker(trafficManager)
internalServices = append(internalServices, trafficManager)
}
ntpOptions := common.PtrValueOrDefault(options.NTP)
var timeService *tls.TimeServiceWrapper
if ntpOptions.Enabled {
@@ -294,6 +343,24 @@ func New(options Options) (*Box, error) {
Tag: "Compatible",
Type: C.TypeDirect,
})
for i, serviceOptions := range options.Services {
var tag string
if serviceOptions.Tag != "" {
tag = serviceOptions.Tag
} else {
tag = F.ToString(i)
}
err = serviceManager.Create(
ctx,
logFactory.NewLogger(F.ToString("service/", serviceOptions.Type, "[", tag, "]")),
tag,
serviceOptions.Type,
serviceOptions.Options,
)
if err != nil {
return nil, E.Cause(err, "initialize service[", i, "]")
}
}
for i, outboundOptions := range options.Outbounds {
var tag string
if outboundOptions.Tag != "" {
@@ -339,22 +406,22 @@ func New(options Options) (*Box, error) {
return nil, E.Cause(err, "initialize provider[", i, "]")
}
}
for i, serviceOptions := range options.Services {
for i, certificateProviderOptions := range options.CertificateProviders {
var tag string
if serviceOptions.Tag != "" {
tag = serviceOptions.Tag
if certificateProviderOptions.Tag != "" {
tag = certificateProviderOptions.Tag
} else {
tag = F.ToString(i)
}
err = serviceManager.Create(
err = certificateProviderManager.Create(
ctx,
logFactory.NewLogger(F.ToString("service/", serviceOptions.Type, "[", tag, "]")),
logFactory.NewLogger(F.ToString("certificate-provider/", certificateProviderOptions.Type, "[", tag, "]")),
tag,
serviceOptions.Type,
serviceOptions.Options,
certificateProviderOptions.Type,
certificateProviderOptions.Options,
)
if err != nil {
return nil, E.Cause(err, "initialize service[", i, "]")
return nil, E.Cause(err, "initialize certificate provider[", i, "]")
}
}
outboundManager.Initialize(func() (adapter.Outbound, error) {
@@ -375,6 +442,12 @@ func New(options Options) (*Box, error) {
&option.LocalDNSServerOptions{},
)
})
httpClientManager.Initialize(func() (*httpclient.ManagedTransport, error) {
deprecated.Report(ctx, deprecated.OptionImplicitDefaultHTTPClient)
var httpClientOptions option.HTTPClientOptions
httpClientOptions.DefaultOutbound = true
return httpclient.NewTransport(ctx, logFactory.NewLogger("httpclient"), "", httpClientOptions)
})
if platformInterface != nil {
err = platformInterface.Initialize(networkManager)
if err != nil {
@@ -382,7 +455,7 @@ func New(options Options) (*Box, error) {
}
}
if needCacheFile {
cacheFile := cachefile.New(ctx, common.PtrValueOrDefault(experimentalOptions.CacheFile))
cacheFile := cachefile.New(ctx, logFactory.NewLogger("cache-file"), common.PtrValueOrDefault(experimentalOptions.CacheFile))
service.MustRegister[adapter.CacheFile](ctx, cacheFile)
internalServices = append(internalServices, cacheFile)
}
@@ -393,7 +466,6 @@ func New(options Options) (*Box, error) {
if err != nil {
return nil, E.Cause(err, "create clash-server")
}
router.AppendTracker(clashServer)
service.MustRegister[adapter.ClashServer](ctx, clashServer)
internalServices = append(internalServices, clashServer)
}
@@ -409,6 +481,12 @@ func New(options Options) (*Box, error) {
}
}
if ntpOptions.Enabled {
if ntpOptions.WriteToSystem {
err = adapter.CheckSecurityFeature(ctx, "NTP `write_to_system`")
if err != nil {
return nil, err
}
}
ntpDialer, err := dialer.New(ctx, ntpOptions.DialerOptions, ntpOptions.ServerIsDomain())
if err != nil {
return nil, E.Cause(err, "create NTP service")
@@ -425,21 +503,25 @@ func New(options Options) (*Box, error) {
internalServices = append(internalServices, adapter.NewLifecycleService(ntpService, "ntp service"))
}
return &Box{
network: networkManager,
endpoint: endpointManager,
inbound: inboundManager,
outbound: outboundManager,
provider: providerManager,
dnsTransport: dnsTransportManager,
service: serviceManager,
dnsRouter: dnsRouter,
connection: connectionManager,
router: router,
createdAt: createdAt,
logFactory: logFactory,
logger: logFactory.Logger(),
internalService: internalServices,
done: make(chan struct{}),
ctx: ctx,
network: networkManager,
endpoint: endpointManager,
inbound: inboundManager,
outbound: outboundManager,
provider: providerManager,
dnsTransport: dnsTransportManager,
service: serviceManager,
certificateProvider: certificateProviderManager,
dnsRouter: dnsRouter,
connection: connectionManager,
router: router,
httpClientService: httpClientService,
createdAt: createdAt,
debugOptions: debugOptions,
logFactory: logFactory,
logger: logFactory.Logger(),
internalService: internalServices,
done: make(chan struct{}),
}, nil
}
@@ -489,15 +571,28 @@ func (s *Box) preStart() error {
if err != nil {
return E.Cause(err, "start logger")
}
err = adapter.StartNamed(s.logger, adapter.StartStateInitialize, s.internalService) // cache-file clash-api v2ray-api
applyDebugOptions(s.debugOptions)
s.debugHTTPServer, err = startDebugHTTPServer(s.debugOptions)
if err != nil {
return err
}
err = adapter.Start(s.logger, adapter.StartStateInitialize, s.network, s.dnsTransport, s.dnsRouter, s.connection, s.router, s.outbound, s.inbound, s.endpoint, s.provider, s.service)
err = adapter.StartNamed(s.ctx, s.logger, adapter.StartStateInitialize, s.internalService) // cache-file clash-api v2ray-api
if err != nil {
return err
}
err = adapter.Start(s.logger, adapter.StartStateStart, s.outbound, s.provider, s.dnsTransport, s.dnsRouter, s.network, s.connection, s.router)
err = adapter.Start(s.ctx, s.logger, adapter.StartStateInitialize, s.network, s.dnsTransport, s.dnsRouter, s.connection, s.router, s.outbound, s.provider, s.inbound, s.endpoint, s.service, s.certificateProvider)
if err != nil {
return err
}
err = adapter.Start(s.ctx, s.logger, adapter.StartStateStart, s.outbound, s.provider, s.dnsTransport, s.network, s.connection)
if err != nil {
return err
}
err = adapter.StartNamed(s.ctx, s.logger, adapter.StartStateStart, []adapter.LifecycleService{s.httpClientService})
if err != nil {
return err
}
err = adapter.Start(s.ctx, s.logger, adapter.StartStateStart, s.router, s.dnsRouter)
if err != nil {
return err
}
@@ -509,27 +604,35 @@ func (s *Box) start() error {
if err != nil {
return err
}
err = adapter.StartNamed(s.logger, adapter.StartStateStart, s.internalService)
err = adapter.StartNamed(s.ctx, s.logger, adapter.StartStateStart, s.internalService)
if err != nil {
return err
}
err = adapter.Start(s.logger, adapter.StartStateStart, s.inbound, s.endpoint, s.service)
err = adapter.Start(s.ctx, s.logger, adapter.StartStateStart, s.endpoint)
if err != nil {
return err
}
err = adapter.Start(s.logger, adapter.StartStatePostStart, s.outbound, s.network, s.dnsTransport, s.dnsRouter, s.connection, s.router, s.inbound, s.endpoint, s.provider, s.service)
err = adapter.Start(s.ctx, s.logger, adapter.StartStateStart, s.certificateProvider)
if err != nil {
return err
}
err = adapter.StartNamed(s.logger, adapter.StartStatePostStart, s.internalService)
err = adapter.Start(s.ctx, s.logger, adapter.StartStateStart, s.inbound, s.service)
if err != nil {
return err
}
err = adapter.Start(s.logger, adapter.StartStateStarted, s.network, s.dnsTransport, s.dnsRouter, s.connection, s.router, s.outbound, s.inbound, s.endpoint, s.provider, s.service)
err = adapter.Start(s.ctx, s.logger, adapter.StartStatePostStart, s.outbound, s.provider, s.network, s.dnsTransport, s.dnsRouter, s.connection, s.router, s.endpoint, s.certificateProvider, s.inbound, s.service)
if err != nil {
return err
}
err = adapter.StartNamed(s.logger, adapter.StartStateStarted, s.internalService)
err = adapter.StartNamed(s.ctx, s.logger, adapter.StartStatePostStart, s.internalService)
if err != nil {
return err
}
err = adapter.Start(s.ctx, s.logger, adapter.StartStateStarted, s.network, s.dnsTransport, s.dnsRouter, s.connection, s.router, s.outbound, s.provider, s.endpoint, s.certificateProvider, s.inbound, s.service)
if err != nil {
return err
}
err = adapter.StartNamed(s.ctx, s.logger, adapter.StartStateStarted, s.internalService)
if err != nil {
return err
}
@@ -544,14 +647,21 @@ func (s *Box) Close() error {
close(s.done)
}
var err error
if s.debugHTTPServer != nil {
err = E.Append(err, s.debugHTTPServer.Close(), func(err error) error {
return E.Cause(err, "close debug HTTP server")
})
s.debugHTTPServer = nil
}
for _, closeItem := range []struct {
name string
service adapter.Lifecycle
}{
{"service", s.service},
{"endpoint", s.endpoint},
{"inbound", s.inbound},
{"certificate-provider", s.certificateProvider},
{"provider", s.provider},
{"endpoint", s.endpoint},
{"outbound", s.outbound},
{"router", s.router},
{"connection", s.connection},
@@ -565,6 +675,14 @@ func (s *Box) Close() error {
})
done()
}
if s.httpClientService != nil {
s.logger.Trace("close ", s.httpClientService.Name())
startTime := time.Now()
err = E.Append(err, s.httpClientService.Close(), func(err error) error {
return E.Cause(err, "close ", s.httpClientService.Name())
})
s.logger.Trace("close ", s.httpClientService.Name(), " completed (", F.Seconds(time.Since(startTime).Seconds()), "s)")
}
for _, lifecycleService := range s.internalService {
done := adapter.LogElapsed(s.logger, "close ", lifecycleService.Name())
err = E.Append(err, lifecycleService.Close(), func(err error) error {
+1
Submodule clients/desktop added at cebee0d527
+44 -28
View File
@@ -19,8 +19,11 @@ import (
func main() {
ctx := context.Background()
switch os.Args[1] {
case "next_macos_project_version":
err := fetchMacOSVersion(ctx)
case "next_project_version":
if len(os.Args) < 3 {
log.Fatal("platform required: ios, macos, or tvos")
}
err := fetchNextProjectVersion(ctx, os.Args[2])
if err != nil {
log.Fatal(err)
}
@@ -50,8 +53,8 @@ func main() {
}
const (
appID = "6673731168"
groupID = "5c5f3b78-b7a0-40c0-bcad-e6ef87bbefda"
appID = "6785326793"
groupID = "39f9ebdc-05d4-421f-9595-dae71df227c4"
)
func createClient(expireDuration time.Duration) *asc.Client {
@@ -66,36 +69,49 @@ func createClient(expireDuration time.Duration) *asc.Client {
return asc.NewClient(tokenConfig.Client())
}
func fetchMacOSVersion(ctx context.Context) error {
func fetchNextProjectVersion(ctx context.Context, platformName string) error {
var platform asc.Platform
switch platformName {
case "ios":
platform = asc.PlatformIOS
case "macos":
platform = asc.PlatformMACOS
case "tvos":
platform = asc.PlatformTVOS
default:
return E.New("unknown platform: ", platformName)
}
query := &asc.ListBuildsQuery{
FilterApp: []string{appID},
FilterPreReleaseVersionPlatform: []string{string(platform)},
Limit: 200,
}
if platform != asc.PlatformMACOS {
tagVersion, err := build_shared.ReadTagVersion()
if err != nil {
return err
}
query.FilterPreReleaseVersionVersion = []string{build_shared.TestFlightVersion(tagVersion)}
}
client := createClient(time.Minute)
versions, _, err := client.Apps.ListAppStoreVersionsForApp(ctx, appID, &asc.ListAppStoreVersionsQuery{
FilterPlatform: []string{"MAC_OS"},
})
builds, _, err := client.Builds.ListBuilds(ctx, query)
if err != nil {
return err
}
var versionID string
findVersion:
for _, version := range versions.Data {
switch *version.Attributes.AppStoreState {
case asc.AppStoreVersionStateReadyForSale,
asc.AppStoreVersionStatePendingDeveloperRelease:
versionID = version.ID
break findVersion
nextProjectVersion := 1
var projectVersion int
for _, build := range builds.Data {
projectVersion, err = strconv.Atoi(*build.Attributes.Version)
if err != nil {
return E.Cause(err, "parse version code")
}
if projectVersion >= nextProjectVersion {
nextProjectVersion = projectVersion + 1
}
}
if versionID == "" {
return E.New("no version found")
}
latestBuild, _, err := client.Builds.GetBuildForAppStoreVersion(ctx, versionID, &asc.GetBuildForAppStoreVersionQuery{})
if err != nil {
return err
}
versionInt, err := strconv.Atoi(*latestBuild.Data.Attributes.Version)
if err != nil {
return E.Cause(err, "parse version code")
}
os.Stdout.WriteString(F.ToString(versionInt+1, "\n"))
os.Stdout.WriteString(F.ToString(nextProjectVersion, "\n"))
return nil
}
+236
View File
@@ -0,0 +1,236 @@
package main
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"flag"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"strings"
"github.com/sagernet/sing-box/cmd/internal/build_shared"
"github.com/sagernet/sing-box/common/windivert"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-usbip/driverassets"
E "github.com/sagernet/sing/common/exceptions"
)
var (
debugEnabled bool
outputPath string
target string
)
func init() {
flag.BoolVar(&debugEnabled, "debug", false, "enable debug")
flag.StringVar(&outputPath, "output", "", "output path")
flag.StringVar(&target, "target", runtime.GOOS+"/"+runtime.GOARCH, "target platform")
}
func main() {
flag.Parse()
err := build()
if err != nil {
log.Fatal(err)
}
}
func build() error {
targetParts := strings.Split(target, "/")
if len(targetParts) != 2 || targetParts[0] == "" || targetParts[1] == "" {
return E.New("invalid target: ", target)
}
operatingSystem := targetParts[0]
architecture := targetParts[1]
if outputPath == "" {
outputPath = "sing-box-daemon"
if operatingSystem == "windows" {
outputPath += ".exe"
}
}
absoluteOutputPath, err := filepath.Abs(outputPath)
if err != nil {
return E.Cause(err, "resolve output path")
}
err = os.MkdirAll(filepath.Dir(absoluteOutputPath), 0o755)
if err != nil {
return E.Cause(err, "create output directory")
}
version, err := build_shared.ReadTag()
if err != nil {
return E.Cause(err, "read version")
}
cgoEnabled := operatingSystem != "windows" && os.Getenv("CC") != ""
tags, err := buildTags(operatingSystem, architecture, cgoEnabled)
if err != nil {
return err
}
arguments := []string{
"build",
"-v",
"-trimpath",
"-buildvcs=false",
"-tags", strings.Join(tags, ","),
"-ldflags", build_shared.LinkerFlags(version, debugEnabled),
"-o", absoluteOutputPath,
}
if operatingSystem == "windows" && architecture == "386" {
arguments = append(arguments, "-gcflags=net=-l")
}
arguments = append(arguments, "./experimental/boxdd")
command := exec.Command("go", arguments...)
cgoEnabledValue := "0"
if cgoEnabled {
cgoEnabledValue = "1"
}
command.Env = append(os.Environ(),
"CGO_ENABLED="+cgoEnabledValue,
"GOOS="+operatingSystem,
"GOARCH="+architecture,
"GOTOOLCHAIN=local",
)
command.Stdout = os.Stdout
command.Stderr = os.Stderr
err = command.Run()
if err != nil {
return E.Cause(err, "build sing-box daemon")
}
if operatingSystem == "windows" {
err = stageWinDivertDriver(architecture, filepath.Dir(absoluteOutputPath))
if err != nil {
return err
}
err = stageUSBIPDrivers(architecture, filepath.Dir(absoluteOutputPath))
if err != nil {
return err
}
}
return nil
}
func stageUSBIPDrivers(architecture string, outputDirectory string) error {
driverPackages := []struct {
assets map[string]driverassets.Package
assetDir string
}{
{driverassets.VBoxUSB, filepath.Join("internal", "vboxusb", "assets")},
{driverassets.VHCI, filepath.Join("internal", "usbipvhci", "assets")},
}
var moduleDirectory string
for _, driverPackage := range driverPackages {
staged := driverPackage.assets[architecture]
for _, architecturePackage := range driverPackage.assets {
for _, file := range architecturePackage.Files {
if slices.ContainsFunc(staged.Files, func(stagedFile driverassets.File) bool {
return stagedFile.Name == file.Name
}) {
continue
}
err := os.Remove(filepath.Join(outputDirectory, file.Name))
if err != nil && !os.IsNotExist(err) {
return E.Cause(err, "remove stale ", file.Name)
}
}
}
if len(staged.Files) == 0 {
continue
}
if moduleDirectory == "" {
listOutput, err := exec.Command("go", "list", "-m", "-f", "{{.Dir}}", "github.com/sagernet/sing-usbip").Output()
if err != nil {
return E.Cause(err, "locate sing-usbip module directory")
}
moduleDirectory = strings.TrimSpace(string(listOutput))
}
for _, file := range staged.Files {
content, err := os.ReadFile(filepath.Join(moduleDirectory, driverPackage.assetDir, architecture, file.Name))
if err != nil {
return E.Cause(err, "read ", file.Name)
}
checksum := sha256.Sum256(content)
if hex.EncodeToString(checksum[:]) != file.SHA256 {
return E.New(file.Name, " does not match the digest declared in sing-usbip/driverassets")
}
targetPath := filepath.Join(outputDirectory, file.Name)
stagedContent, err := os.ReadFile(targetPath)
if err == nil && bytes.Equal(stagedContent, content) {
continue
}
err = os.WriteFile(targetPath, content, 0o644)
if err != nil {
return E.Cause(err, "write ", file.Name)
}
}
}
return nil
}
func stageWinDivertDriver(architecture string, outputDirectory string) error {
var assetName, assetDigest string
switch architecture {
case "amd64":
assetName, assetDigest = windivert.Asset64Name, windivert.Asset64SHA256
case "386":
assetName, assetDigest = windivert.Asset32Name, windivert.Asset32SHA256
}
for _, name := range []string{windivert.Asset64Name, windivert.Asset32Name} {
if name == assetName {
continue
}
err := os.Remove(filepath.Join(outputDirectory, name))
if err != nil && !os.IsNotExist(err) {
return E.Cause(err, "remove stale ", name)
}
}
if assetName == "" {
return nil
}
assetDirectory := filepath.Join("common", "windivert", "assets")
content, err := os.ReadFile(filepath.Join(assetDirectory, assetName))
if err != nil {
return E.Cause(err, "read ", assetName)
}
checksum := sha256.Sum256(content)
if hex.EncodeToString(checksum[:]) != assetDigest {
return E.New(assetName, " does not match the digest declared in common/windivert")
}
targetPath := filepath.Join(outputDirectory, assetName)
staged, err := os.ReadFile(targetPath)
if err == nil && bytes.Equal(staged, content) {
return nil
}
err = os.WriteFile(targetPath, content, 0o644)
if err != nil {
return E.Cause(err, "write ", assetName)
}
return nil
}
func buildTags(operatingSystem string, architecture string, cgoEnabled bool) ([]string, error) {
tagsFile := "release/DEFAULT_BUILD_TAGS"
if operatingSystem == "windows" {
if architecture == "386" {
tagsFile = "release/DEFAULT_BUILD_TAGS_OTHERS"
} else {
tagsFile = "release/DEFAULT_BUILD_TAGS_WINDOWS"
}
} else if !cgoEnabled {
tagsFile = "release/DEFAULT_BUILD_TAGS_OTHERS"
}
content, err := os.ReadFile(tagsFile)
if err != nil {
return nil, E.Cause(err, "read build tags")
}
tags := strings.Split(strings.TrimSpace(string(content)), ",")
if operatingSystem == "windows" {
tags = append(tags, "with_external_windivert", "with_external_usbip_drivers")
}
if debugEnabled {
tags = append(tags, "debug")
}
return tags, nil
}
+8 -5
View File
@@ -60,10 +60,10 @@ func init() {
if err != nil {
currentTag = "unknown"
}
sharedFlags = append(sharedFlags, "-ldflags", "-X github.com/sagernet/sing-box/constant.Version="+currentTag+" -X internal/godebug.defaultGODEBUG=multipathtcp=0 -s -w -buildid= -checklinkname=0")
debugFlags = append(debugFlags, "-ldflags", "-X github.com/sagernet/sing-box/constant.Version="+currentTag+" -X internal/godebug.defaultGODEBUG=multipathtcp=0 -checklinkname=0")
sharedFlags = append(sharedFlags, "-ldflags", build_shared.LinkerFlags(currentTag, false))
debugFlags = append(debugFlags, "-ldflags", build_shared.LinkerFlags(currentTag, true))
sharedTags = append(sharedTags, "with_gvisor", "with_quic", "with_wireguard", "with_masque", "with_mtproxy", "with_trusttunnel", "with_call", "with_sudoku", "with_utls", "with_naive_outbound", "with_clash_api", "badlinkname", "tfogo_checklinkname0")
sharedTags = append(sharedTags, "with_gvisor", "with_quic", "with_wireguard", "with_masque", "with_mtproxy", "with_trusttunnel", "with_call", "with_sudoku", "with_utls", "with_naive_outbound", "with_clash_api", "with_usbip", "with_openvpn", "with_openconnect", "badlinkname", "tfogo_checklinkname0")
darwinTags = append(darwinTags, "with_dhcp", "grpcnotrace")
// memcTags = append(memcTags, "with_tailscale")
sharedTags = append(sharedTags, "with_tailscale", "ts_omit_logtail", "ts_omit_ssh", "ts_omit_drive", "ts_omit_taildrop", "ts_omit_webclient", "ts_omit_doctor", "ts_omit_capture", "ts_omit_kube", "ts_omit_aws", "ts_omit_synology", "ts_omit_bird")
@@ -163,14 +163,14 @@ func buildAndroid() {
bindTarget := getAndroidBindTarget()
// Build main variant (SDK 23)
// Build main variant (SDK 24)
mainTags := append([]string{}, sharedTags...)
// mainTags = append(mainTags, memcTags...)
if debugEnabled {
mainTags = append(mainTags, debugTags...)
}
buildAndroidVariant(AndroidBuildConfig{
AndroidAPI: 23,
AndroidAPI: 24,
OutputName: "libbox.aar",
Tags: mainTags,
}, bindTarget)
@@ -204,6 +204,9 @@ func buildApple() {
"-target", bindTarget,
"-libname=box",
"-tags-not-macos=with_low_memory",
"-iosversion=15.0",
"-macosversion=13.0",
"-tvosversion=17.0",
}
//if !withTailscale {
// args = append(args, "-tags-macos="+strings.Join(memcTags, ","))
+15
View File
@@ -0,0 +1,15 @@
package build_shared
import "strings"
func LinkerFlags(version string, debug bool) string {
flags := []string{
"-X github.com/sagernet/sing-box/constant.Version=" + version,
"-X runtime.godebugDefault=multipathtcp=0,tlssha1=1",
"-checklinkname=0",
}
if !debug {
flags = append(flags, "-s", "-w", "-buildid=")
}
return strings.Join(flags, " ")
}
+5
View File
@@ -3,6 +3,7 @@ package build_shared
import (
"github.com/sagernet/sing-box/common/badversion"
"github.com/sagernet/sing/common"
F "github.com/sagernet/sing/common/format"
"github.com/sagernet/sing/common/shell"
)
@@ -36,3 +37,7 @@ func ReadTagVersion() (badversion.Version, error) {
}
return version, nil
}
func TestFlightVersion(version badversion.Version) string {
return F.ToString(version.Major, ".", version.Minor, ".10")
}
+47 -13
View File
@@ -8,16 +8,21 @@ import (
"strings"
"github.com/sagernet/sing-box/cmd/internal/build_shared"
"github.com/sagernet/sing-box/common/badversion"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing/common"
"howett.net/plist"
)
var flagRunInCI bool
var (
flagRunInCI bool
flagTestFlight bool
)
func init() {
flag.BoolVar(&flagRunInCI, "ci", false, "Run in CI")
flag.BoolVar(&flagTestFlight, "testflight", false, "Override the App Store marketing version with the version reserved for TestFlight")
}
func main() {
@@ -40,19 +45,39 @@ func main() {
common.Must(decoder.Decode(&project))
objectsMap := project["objects"].(map[string]any)
projectContent := string(common.Must1(os.ReadFile("sing-box.xcodeproj/project.pbxproj")))
newContent, updated0 := findAndReplace(objectsMap, projectContent, []string{"io.nekohasekai.sfavt"}, newVersion.VersionString())
newContent, updated1 := findAndReplace(objectsMap, newContent, []string{"io.nekohasekai.sfavt.standalone", "io.nekohasekai.sfavt.system"}, newVersion.String())
if updated0 || updated1 {
log.Info("updated version to ", newVersion.VersionString(), " (", newVersion.String(), ")")
}
var updated2 bool
if macProjectVersion := os.Getenv("MACOS_PROJECT_VERSION"); macProjectVersion != "" {
newContent, updated2 = findAndReplaceProjectVersion(objectsMap, newContent, []string{"SFM"}, macProjectVersion)
if updated2 {
log.Info("updated macos project version to ", macProjectVersion)
newContent := projectContent
var marketingVersionUpdated bool
if flagTestFlight {
testFlightVersion := build_shared.TestFlightVersion(newVersion)
newContent, marketingVersionUpdated = findAndReplace(objectsMap, newContent, []string{"io.nekohasekai.sfamt"}, testFlightVersion)
if marketingVersionUpdated {
log.Info("updated App Store version to ", testFlightVersion)
}
}
if updated0 || updated1 || updated2 {
var standaloneVersionUpdated bool
newContent, standaloneVersionUpdated = findAndReplace(objectsMap, newContent, []string{"io.nekohasekai.sfamt.standalone", "io.nekohasekai.sfamt.system"}, newVersion.String())
if standaloneVersionUpdated {
marketingVersionUpdated = true
log.Info("updated version to ", newVersion.String())
}
var projectVersionUpdated bool
for environmentName, directory := range map[string]string{
"IOS_PROJECT_VERSION": "SFI",
"MACOS_PROJECT_VERSION": "SFM",
"TVOS_PROJECT_VERSION": "SFT",
} {
projectVersion := os.Getenv(environmentName)
if projectVersion == "" {
continue
}
var updated bool
newContent, updated = findAndReplaceProjectVersion(objectsMap, newContent, []string{directory}, projectVersion)
if updated {
projectVersionUpdated = true
log.Info("updated ", directory, " project version to ", projectVersion)
}
}
if marketingVersionUpdated || projectVersionUpdated {
common.Must(os.WriteFile("sing-box.xcodeproj/project.pbxproj", []byte(newContent), 0o644))
}
}
@@ -76,11 +101,20 @@ func findAndReplace(objectsMap map[string]any, projectContent string, bundleIDLi
continue
}
updated = true
projectContent = projectContent[:versionStart] + "\"" + newVersion + "\"" + projectContent[versionEnd:]
projectContent = projectContent[:versionStart] + formatProjectVersion(newVersion) + projectContent[versionEnd:]
}
return projectContent, updated
}
// Xcode serializes a version without quotes unless it contains a pre-release
// part; always quoting makes Xcode rewrite the value on the next save.
func formatProjectVersion(version string) string {
if badversion.Parse(version).PreReleaseIdentifier == "" {
return version
}
return "\"" + version + "\""
}
func findAndReplaceProjectVersion(objectsMap map[string]any, projectContent string, directoryList []string, newVersion string) (string, bool) {
objectKeyList := findObjectKeyByDirectory(objectsMap, directoryList)
var updated bool
+12 -26
View File
@@ -43,11 +43,8 @@ func updateMozillaIncludedRootCAs() error {
package certificate
import "crypto/x509"
func newMozillaIncluded() *x509.CertPool {
pool := x509.NewCertPool()
`)
func mozillaIncludedPEM() string {
return ` + "`")
for {
record, err := reader.Read()
if err == io.EOF {
@@ -58,17 +55,14 @@ func newMozillaIncluded() *x509.CertPool {
if record[geoIndex] == "China" {
continue
}
generated.WriteString("\n // ")
cert := strings.Trim(record[certIndex], "'")
generated.WriteString("\n// ")
generated.WriteString(record[nameIndex])
generated.WriteString("\n")
generated.WriteString(" pool.AppendCertsFromPEM([]byte(`")
cert := record[certIndex]
// Remove single quotes
cert = cert[1 : len(cert)-1]
generated.WriteString(cert)
generated.WriteString("`))\n")
generated.WriteString("\n")
}
generated.WriteString("\treturn pool\n}\n")
generated.WriteString("`\n}\n")
return os.WriteFile("common/certificate/mozilla.go", []byte(generated.String()), 0o644)
}
@@ -127,11 +121,8 @@ func updateChromeIncludedRootCAs() error {
package certificate
import "crypto/x509"
func newChromeIncluded() *x509.CertPool {
pool := x509.NewCertPool()
`)
func chromeIncludedPEM() string {
return ` + "`")
for {
record, err := reader.Read()
if err == io.EOF {
@@ -145,18 +136,13 @@ func newChromeIncluded() *x509.CertPool {
if chinaFingerprints[record[fingerprintIndex]] {
continue
}
generated.WriteString("\n // ")
cert := strings.Trim(record[certIndex], "'")
generated.WriteString("\n// ")
generated.WriteString(record[subjectIndex])
generated.WriteString("\n")
generated.WriteString(" pool.AppendCertsFromPEM([]byte(`")
cert := record[certIndex]
// Remove single quotes if present
if len(cert) > 0 && cert[0] == '\'' {
cert = cert[1 : len(cert)-1]
}
generated.WriteString(cert)
generated.WriteString("`))\n")
generated.WriteString("\n")
}
generated.WriteString("\treturn pool\n}\n")
generated.WriteString("`\n}\n")
return os.WriteFile("common/certificate/chrome.go", []byte(generated.String()), 0o644)
}
@@ -0,0 +1,66 @@
package main
import (
"encoding/json"
"flag"
"os"
"path/filepath"
"runtime"
"github.com/sagernet/sing-box/cmd/internal/build_shared"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing/common"
)
var (
flagRunInCI bool
flagRunNightly bool
)
type versionMetadata struct {
Version string `json:"version"`
GoVersion string `json:"go_version"`
}
func init() {
flag.BoolVar(&flagRunInCI, "ci", false, "Run in CI")
flag.BoolVar(&flagRunNightly, "nightly", false, "Run nightly")
}
func main() {
flag.Parse()
newVersion := common.Must1(build_shared.ReadTag())
desktopPath := "../sing-box-for-desktop"
if flagRunInCI {
desktopPath = "clients/desktop"
}
desktopPath = common.Must1(filepath.Abs(desktopPath))
versionPath := filepath.Join(desktopPath, "version.json")
versionFile := common.Must1(os.Open(versionPath))
var metadata versionMetadata
common.Must(json.NewDecoder(versionFile).Decode(&metadata))
common.Must(versionFile.Close())
newGoVersion := runtime.Version()
versionUpdated := metadata.Version != newVersion
goVersionUpdated := metadata.GoVersion != newGoVersion
if !(versionUpdated || goVersionUpdated) {
log.Info("version not changed")
return
}
if versionUpdated {
log.Info("updated version from ", metadata.Version, " to ", newVersion)
}
if goVersionUpdated {
log.Info("updated Go version from ", metadata.GoVersion, " to ", newGoVersion)
}
if flagRunInCI && !flagRunNightly {
log.Fatal("version changed, commit changes first.")
}
metadata.Version = newVersion
metadata.GoVersion = newGoVersion
outputFile := common.Must1(os.Create(versionPath))
encoder := json.NewEncoder(outputFile)
encoder.SetIndent("", " ")
common.Must(encoder.Encode(metadata))
common.Must(outputFile.Close())
}
+4 -1
View File
@@ -10,6 +10,7 @@ import (
"github.com/sagernet/sing-box/experimental/deprecated"
"github.com/sagernet/sing-box/include"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing/common"
"github.com/sagernet/sing/service"
"github.com/sagernet/sing/service/filemanager"
@@ -52,7 +53,9 @@ func preRun(cmd *cobra.Command, args []string) {
globalCtx = filemanager.WithDefault(globalCtx, "", "", sudoUID, sudoGID)
}
if disableColor {
log.SetStdLogger(log.NewDefaultFactory(context.Background(), log.Formatter{BaseTime: time.Now(), DisableColors: true}, os.Stderr, "", nil, false).Logger())
logFactory := log.NewDefaultFactory(context.Background(), log.Formatter{BaseTime: time.Now(), DisableColors: true}, os.Stderr, "", nil, false)
common.Must(logFactory.Start())
log.SetStdLogger(logFactory.Logger())
}
if workingDir != "" {
_, err := os.Stat(workingDir)
+107
View File
@@ -0,0 +1,107 @@
package main
import (
"os"
"strings"
"github.com/sagernet/sing-box/daemon"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing/common"
E "github.com/sagernet/sing/common/exceptions"
"github.com/spf13/cobra"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
var (
commandAPIFlagURL string
commandAPIFlagSecret string
commandAPIServerURL string
)
var commandAPI = &cobra.Command{
Use: "api <command>",
Short: "API service client",
DisableFlagParsing: true,
Run: func(cmd *cobra.Command, args []string) {
err := runAPI(args)
if err != nil {
log.Fatal(err)
}
},
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
targetCommand, remainingArgs, err := commandAPIRoot.Find(args)
if err != nil || len(remainingArgs) > 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
return common.Map(common.Filter(targetCommand.Commands(), func(it *cobra.Command) bool {
return it.IsAvailableCommand() && strings.HasPrefix(it.Name(), toComplete)
}), func(it *cobra.Command) string {
return it.Name() + "\t" + it.Short
}), cobra.ShellCompDirectiveNoFileComp
},
}
var commandAPIRoot = &cobra.Command{
Use: "api",
Short: "API service client",
SilenceUsage: true,
SilenceErrors: true,
CompletionOptions: cobra.CompletionOptions{DisableDefaultCmd: true},
}
func init() {
commandAPIRoot.PersistentFlags().StringVar(&commandAPIFlagURL, "url", "", "API service URL (default: $BOX_API_URL)")
commandAPIRoot.PersistentFlags().StringVar(&commandAPIFlagSecret, "secret", "", "API service secret (default: $BOX_API_SECRET)")
mainCommand.AddCommand(commandAPI)
}
func runAPI(args []string) error {
commandAPIRoot.SetArgs(append([]string{}, args...))
err := commandAPIRoot.Execute()
if err == nil {
return nil
}
grpcStatus, isStatus := status.FromError(err)
if !isStatus {
return err
}
switch grpcStatus.Code() {
case codes.Unavailable:
return E.New("failed to connect to API service at ", commandAPIServerURL, ": ", grpcStatus.Message())
case codes.Unknown:
return E.New(grpcStatus.Message())
case codes.Unimplemented:
return E.New(grpcStatus.Code().String(), ": ", grpcStatus.Message(), " (client API version ", daemon.APIVersion, ")")
default:
return E.New(grpcStatus.Code().String(), ": ", grpcStatus.Message())
}
}
func createAPIClient() (*grpc.ClientConn, daemon.StartedServiceClient, error) {
serverURL := commandAPIFlagURL
if serverURL == "" {
serverURL = os.Getenv("BOX_API_URL")
}
if serverURL == "" {
return nil, nil, E.New("missing API service URL, set --url or BOX_API_URL")
}
if !strings.Contains(serverURL, "://") {
serverURL = "http://" + serverURL
}
commandAPIServerURL = serverURL
secret := commandAPIFlagSecret
if secret == "" {
secret = os.Getenv("BOX_API_SECRET")
}
clientConn, err := daemon.NewRemoteClient(daemon.RemoteClientOptions{
ServerURL: serverURL,
Secret: secret,
})
if err != nil {
return nil, nil, err
}
return clientConn, daemon.NewStartedServiceClient(clientConn), nil
}
+37
View File
@@ -0,0 +1,37 @@
package main
import (
"time"
"github.com/sagernet/sing-box/daemon"
"github.com/sagernet/sing/common"
"github.com/spf13/cobra"
)
var commandAPIConnection = &cobra.Command{
Use: "connection",
Short: "Manage connections",
}
func init() {
commandAPIRoot.AddCommand(commandAPIConnection)
}
func fetchConnections(client daemon.StartedServiceClient) ([]*daemon.Connection, error) {
stream, err := client.SubscribeConnections(globalCtx, &daemon.SubscribeConnectionsRequest{Interval: int64(time.Second)})
if err != nil {
return nil, err
}
events, err := stream.Recv()
if err != nil {
return nil, err
}
connections := common.FilterNotNil(common.Map(events.GetEvents(), func(it *daemon.ConnectionEvent) *daemon.Connection {
return it.GetConnection()
}))
common.SortBy(connections, func(it *daemon.Connection) int64 {
return it.GetCreatedAt()
})
return connections, nil
}
+47
View File
@@ -0,0 +1,47 @@
package main
import (
"github.com/sagernet/sing-box/daemon"
E "github.com/sagernet/sing/common/exceptions"
"github.com/spf13/cobra"
"google.golang.org/protobuf/types/known/emptypb"
)
var commandAPIConnectionCloseFlagAll bool
var commandAPIConnectionClose = &cobra.Command{
Use: "close <id>",
Short: "Close connections",
Long: "Close connections.\n\nThe id must be a full UUID; the service reports success for an unknown or already closed connection.",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runAPIConnectionClose(args)
},
}
func init() {
commandAPIConnectionClose.Flags().BoolVar(&commandAPIConnectionCloseFlagAll, "all", false, "Close all connections")
commandAPIConnection.AddCommand(commandAPIConnectionClose)
}
func runAPIConnectionClose(args []string) error {
if commandAPIConnectionCloseFlagAll {
if len(args) > 0 {
return E.New("--all takes no connection id")
}
} else if len(args) == 0 {
return E.New("missing connection id")
}
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
if commandAPIConnectionCloseFlagAll {
_, err = client.CloseAllConnections(globalCtx, &emptypb.Empty{})
} else {
_, err = client.CloseConnection(globalCtx, &daemon.CloseConnectionRequest{Id: args[0]})
}
return err
}
+201
View File
@@ -0,0 +1,201 @@
package main
import (
"slices"
"strings"
"time"
"github.com/sagernet/sing-box/daemon"
"github.com/sagernet/sing/common"
"github.com/sagernet/sing/common/byteformats"
E "github.com/sagernet/sing/common/exceptions"
"github.com/spf13/cobra"
)
var commandAPIConnectionListFlagColumns []string
type connectionRate struct {
uplink int64
downlink int64
}
type connectionColumn struct {
header string
value func(connection *daemon.Connection, rates map[string]connectionRate) string
}
var connectionColumnNames = []string{
"id", "network", "source", "destination", "inbound", "outbound",
"chain", "rule", "protocol", "user", "process", "created", "rate", "total",
}
var connectionColumns = map[string]connectionColumn{
"id": {"ID", func(connection *daemon.Connection, _ map[string]connectionRate) string {
return connection.GetId()
}},
"network": {"NETWORK", func(connection *daemon.Connection, _ map[string]connectionRate) string {
return connection.GetNetwork()
}},
"source": {"SOURCE", func(connection *daemon.Connection, _ map[string]connectionRate) string {
return connection.GetSource()
}},
"destination": {"DESTINATION", func(connection *daemon.Connection, _ map[string]connectionRate) string {
return connectionDestination(connection)
}},
"inbound": {"INBOUND", func(connection *daemon.Connection, _ map[string]connectionRate) string {
if connection.GetInbound() == "" {
return connection.GetInboundType()
}
return connection.GetInboundType() + "/" + connection.GetInbound()
}},
"outbound": {"OUTBOUND", func(connection *daemon.Connection, _ map[string]connectionRate) string {
return connection.GetOutbound()
}},
"chain": {"CHAIN", func(connection *daemon.Connection, _ map[string]connectionRate) string {
chain := slices.Clone(connection.GetChainList())
slices.Reverse(chain)
return strings.Join(chain, "/")
}},
"rule": {"RULE", func(connection *daemon.Connection, _ map[string]connectionRate) string {
return connection.GetRule()
}},
"protocol": {"PROTOCOL", func(connection *daemon.Connection, _ map[string]connectionRate) string {
return connection.GetProtocol()
}},
"user": {"USER", func(connection *daemon.Connection, _ map[string]connectionRate) string {
return connection.GetUser()
}},
"process": {"PROCESS", func(connection *daemon.Connection, _ map[string]connectionRate) string {
processInfo := connection.GetProcessInfo()
if processInfo.GetProcessPath() != "" {
return processInfo.GetProcessPath()
}
if len(processInfo.GetPackageNames()) > 0 {
return processInfo.GetPackageNames()[0]
}
return ""
}},
"created": {"CREATED", func(connection *daemon.Connection, _ map[string]connectionRate) string {
return formatConnectionTime(connection.GetCreatedAt())
}},
"rate": {"RATE", func(connection *daemon.Connection, rates map[string]connectionRate) string {
rate, found := rates[connection.GetId()]
if !found || (rate.uplink == 0 && rate.downlink == 0) {
return ""
}
return "↑" + byteformats.FormatBytes(uint64(rate.uplink)) + "/s ↓" + byteformats.FormatBytes(uint64(rate.downlink)) + "/s"
}},
"total": {"TOTAL", func(connection *daemon.Connection, _ map[string]connectionRate) string {
return "↑" + byteformats.FormatBytes(uint64(connection.GetUplinkTotal())) + " ↓" + byteformats.FormatBytes(uint64(connection.GetDownlinkTotal()))
}},
}
var commandAPIConnectionList = &cobra.Command{
Use: "list",
Short: "List open connections",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runAPIConnectionList()
},
}
func init() {
commandAPIConnectionList.Flags().StringSliceVar(
&commandAPIConnectionListFlagColumns,
"columns",
[]string{"id", "network", "destination", "inbound", "outbound", "total"},
"Columns to display (available: "+strings.Join(connectionColumnNames, ", ")+")",
)
commandAPIConnection.AddCommand(commandAPIConnectionList)
}
func runAPIConnectionList() error {
columns := make([]connectionColumn, 0, len(commandAPIConnectionListFlagColumns))
sampleRates := false
for _, name := range commandAPIConnectionListFlagColumns {
column, found := connectionColumns[name]
if !found {
return E.New("unknown column: ", name, ", available: ", strings.Join(connectionColumnNames, ", "))
}
if name == "rate" {
sampleRates = true
}
columns = append(columns, column)
}
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
var (
connections []*daemon.Connection
rates map[string]connectionRate
)
if sampleRates {
connections, rates, err = fetchConnectionsAndRates(client)
} else {
connections, err = fetchConnections(client)
}
if err != nil {
return err
}
table := tableWriter{
header: common.Map(columns, func(it connectionColumn) string {
return it.header
}),
emptyMessage: "no connections",
}
for _, connection := range connections {
if connection.GetClosedAt() != 0 {
continue
}
table.addRow(common.Map(columns, func(it connectionColumn) string {
return it.value(connection, rates)
})...)
}
table.flush()
return nil
}
func connectionDestination(connection *daemon.Connection) string {
destination := connection.GetDestination()
domain := connection.GetDomain()
if domain == "" {
return destination
}
portIndex := strings.LastIndex(destination, ":")
if portIndex == -1 {
return domain
}
return domain + destination[portIndex:]
}
func fetchConnectionsAndRates(client daemon.StartedServiceClient) ([]*daemon.Connection, map[string]connectionRate, error) {
stream, err := client.SubscribeConnections(globalCtx, &daemon.SubscribeConnectionsRequest{Interval: int64(time.Second)})
if err != nil {
return nil, nil, err
}
initialEvents, err := stream.Recv()
if err != nil {
return nil, nil, err
}
connections := common.FilterNotNil(common.Map(initialEvents.GetEvents(), func(it *daemon.ConnectionEvent) *daemon.Connection {
return it.GetConnection()
}))
common.SortBy(connections, func(it *daemon.Connection) int64 {
return it.GetCreatedAt()
})
updateEvents, err := stream.Recv()
if err != nil {
return nil, nil, err
}
rates := make(map[string]connectionRate, len(updateEvents.GetEvents()))
for _, event := range updateEvents.GetEvents() {
rates[event.GetId()] = connectionRate{
uplink: event.GetUplinkDelta(),
downlink: event.GetDownlinkDelta(),
}
}
return connections, rates, nil
}
+112
View File
@@ -0,0 +1,112 @@
package main
import (
"strings"
"time"
"github.com/sagernet/sing-box/daemon"
"github.com/sagernet/sing/common"
"github.com/sagernet/sing/common/byteformats"
E "github.com/sagernet/sing/common/exceptions"
F "github.com/sagernet/sing/common/format"
"github.com/spf13/cobra"
)
var commandAPIConnectionShow = &cobra.Command{
Use: "show <id>",
Short: "Print connection details",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runAPIConnectionShow(args[0])
},
}
func init() {
commandAPIConnection.AddCommand(commandAPIConnectionShow)
}
func runAPIConnectionShow(connectionID string) error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
connections, err := fetchConnections(client)
if err != nil {
return err
}
connection := common.Find(connections, func(it *daemon.Connection) bool {
return it.GetId() == connectionID
})
if connection == nil {
return E.New("connection not found: ", connectionID)
}
state := "open"
if connection.GetClosedAt() != 0 {
state = "closed"
}
var ipVersion string
if connection.GetIpVersion() != 0 {
ipVersion = F.ToString(connection.GetIpVersion())
}
inbound := connection.GetInboundType()
if connection.GetInbound() != "" {
inbound = connection.GetInboundType() + "/" + connection.GetInbound()
}
outbound := connection.GetOutbound()
if outbound != "" && connection.GetOutboundType() != "" {
outbound = F.ToString(outbound, " (", connection.GetOutboundType(), ")")
}
var block blockWriter
block.addLine("ID", connection.GetId())
block.addLine("State", state)
block.addLine("Created", formatConnectionTime(connection.GetCreatedAt()))
block.addLine("Closed", formatConnectionTime(connection.GetClosedAt()))
block.addLine("Network", connection.GetNetwork())
block.addLine("IP version", ipVersion)
block.addLine("Protocol", connection.GetProtocol())
block.addLine("Inbound", inbound)
block.addLine("Source", connection.GetSource())
block.addLine("Destination", connection.GetDestination())
block.addLine("Domain", connection.GetDomain())
block.addLine("User", connection.GetUser())
block.addLine("Process", formatProcessInfo(connection.GetProcessInfo()))
block.addLine("Rule", connection.GetRule())
block.addLine("Outbound", outbound)
block.addLine("Chain", strings.Join(connection.GetChainList(), " <- "))
block.addLine("From outbound", connection.GetFromOutbound())
block.addLine("Uplink", byteformats.FormatBytes(uint64(connection.GetUplinkTotal())))
block.addLine("Downlink", byteformats.FormatBytes(uint64(connection.GetDownlinkTotal())))
block.flush()
return nil
}
func formatConnectionTime(timestamp int64) string {
if timestamp == 0 {
return ""
}
return time.UnixMilli(timestamp).Local().Format(time.RFC3339)
}
func formatProcessInfo(processInfo *daemon.ProcessInfo) string {
if processInfo == nil {
return ""
}
var process string
if processInfo.GetProcessPath() != "" {
process = processInfo.GetProcessPath()
} else if len(processInfo.GetPackageNames()) > 0 {
process = processInfo.GetPackageNames()[0]
}
if process == "" {
if processInfo.GetUserId() != -1 {
process = F.ToString(processInfo.GetUserId())
}
} else if processInfo.GetUserName() != "" {
process = F.ToString(process, " (", processInfo.GetUserName(), ")")
} else if processInfo.GetUserId() != -1 {
process = F.ToString(process, " (", processInfo.GetUserId(), ")")
}
return process
}
+29
View File
@@ -0,0 +1,29 @@
package main
import (
"github.com/sagernet/sing-box/daemon"
"github.com/spf13/cobra"
"google.golang.org/protobuf/types/known/emptypb"
)
var commandAPIGroup = &cobra.Command{
Use: "group",
Short: "Manage outbound groups",
}
func init() {
commandAPIRoot.AddCommand(commandAPIGroup)
}
func fetchGroups(client daemon.StartedServiceClient) ([]*daemon.Group, error) {
stream, err := client.SubscribeGroups(globalCtx, &emptypb.Empty{})
if err != nil {
return nil, err
}
groups, err := stream.Recv()
if err != nil {
return nil, err
}
return groups.GetGroup(), nil
}
+39
View File
@@ -0,0 +1,39 @@
package main
import (
"github.com/spf13/cobra"
)
var commandAPIGroupList = &cobra.Command{
Use: "list",
Short: "List outbound groups",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runAPIGroupList()
},
}
func init() {
commandAPIGroup.AddCommand(commandAPIGroupList)
}
func runAPIGroupList() error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
groups, err := fetchGroups(client)
if err != nil {
return err
}
table := tableWriter{
header: []string{"TAG", "TYPE", "SELECTED"},
emptyMessage: "no groups",
}
for _, group := range groups {
table.addRow(group.GetTag(), group.GetType(), group.GetSelected())
}
table.flush()
return nil
}
+33
View File
@@ -0,0 +1,33 @@
package main
import (
"github.com/sagernet/sing-box/daemon"
"github.com/spf13/cobra"
)
var commandAPIGroupSelect = &cobra.Command{
Use: "select <group> <outbound>",
Short: "Select an outbound in a group",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
return runAPIGroupSelect(args[0], args[1])
},
}
func init() {
commandAPIGroup.AddCommand(commandAPIGroupSelect)
}
func runAPIGroupSelect(groupTag string, outboundTag string) error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
_, err = client.SelectOutbound(globalCtx, &daemon.SelectOutboundRequest{
GroupTag: groupTag,
OutboundTag: outboundTag,
})
return err
}
+51
View File
@@ -0,0 +1,51 @@
package main
import (
E "github.com/sagernet/sing/common/exceptions"
"github.com/spf13/cobra"
)
var commandAPIGroupShow = &cobra.Command{
Use: "show <group>",
Short: "Show an outbound group",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runAPIGroupShow(args[0])
},
}
func init() {
commandAPIGroup.AddCommand(commandAPIGroupShow)
}
func runAPIGroupShow(groupTag string) error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
groups, err := fetchGroups(client)
if err != nil {
return err
}
for _, group := range groups {
if group.GetTag() != groupTag {
continue
}
block := blockWriter{}
block.addLine("Tag", group.GetTag())
block.addLine("Type", group.GetType())
block.addLine("Selected", group.GetSelected())
block.flush()
table := tableWriter{
header: []string{"TAG", "TYPE", "DELAY"},
}
for _, item := range group.GetItems() {
table.addRow(item.GetTag(), item.GetType(), formatDelay(item.GetUrlTestDelay()))
}
table.flush()
return nil
}
return E.New("group not found: ", groupTag)
}
+31
View File
@@ -0,0 +1,31 @@
package main
import (
"github.com/sagernet/sing-box/daemon"
"github.com/spf13/cobra"
)
var commandAPIGroupURLTest = &cobra.Command{
Use: "urltest <group>",
Short: "Start a URL test",
Long: "Start a URL test.\n\nThe tests are only spawned: results appear in `outbounds --group <group>` a few seconds later.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runAPIGroupURLTest(args[0])
},
}
func init() {
commandAPIGroup.AddCommand(commandAPIGroupURLTest)
}
func runAPIGroupURLTest(groupTag string) error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
_, err = client.URLTest(globalCtx, &daemon.URLTestRequest{OutboundTag: groupTag})
return err
}
+96
View File
@@ -0,0 +1,96 @@
package main
import (
"os"
"os/signal"
"strings"
"syscall"
"github.com/sagernet/sing-box/log"
"github.com/spf13/cobra"
"google.golang.org/protobuf/types/known/emptypb"
)
var (
commandAPILogsFlagFollow bool
commandAPILogsFlagLevel string
commandAPILogsFlagSearch string
)
var commandAPILogs = &cobra.Command{
Use: "logs",
Short: "Print the service logs",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runAPILogs()
},
}
func init() {
commandAPILogs.Flags().BoolVarP(&commandAPILogsFlagFollow, "follow", "f", false, "Keep printing new log entries until interrupted")
commandAPILogs.Flags().StringVar(&commandAPILogsFlagLevel, "level", "", "Print entries at this level or more severe (default: the service log level)")
commandAPILogs.Flags().StringVar(&commandAPILogsFlagSearch, "search", "", "Print entries containing this text, case-insensitive")
commandAPIRoot.AddCommand(commandAPILogs)
}
func runAPILogs() error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
ctx, cancel := signal.NotifyContext(globalCtx, os.Interrupt, syscall.SIGTERM)
defer cancel()
var level log.Level
if commandAPILogsFlagLevel != "" {
level, err = log.ParseLevel(commandAPILogsFlagLevel)
if err != nil {
return err
}
} else {
defaultLevel, levelErr := client.GetDefaultLogLevel(ctx, &emptypb.Empty{})
if levelErr != nil {
return levelErr
}
level = log.Level(defaultLevel.GetLevel())
}
stream, err := client.SubscribeLog(ctx, &emptypb.Empty{})
if err != nil {
return err
}
searchQuery := strings.ToLower(strings.TrimSpace(commandAPILogsFlagSearch))
for backlog := true; ; backlog = false {
message, recvErr := stream.Recv()
if recvErr != nil {
if ctx.Err() != nil {
return nil
}
return recvErr
}
if message.GetReset_() && len(message.GetMessages()) == 0 && !backlog {
writeStderrLine("log buffer cleared")
continue
}
var output strings.Builder
for _, entry := range message.GetMessages() {
if log.Level(entry.GetLevel()) > level {
continue
}
plainMessage := stripColors(entry.GetMessage())
if searchQuery != "" && !strings.Contains(strings.ToLower(plainMessage), searchQuery) {
continue
}
if stdoutIsTerminal {
output.WriteString(entry.GetMessage())
} else {
output.WriteString(plainMessage)
}
output.WriteString("\n")
}
os.Stdout.WriteString(output.String())
if backlog && !commandAPILogsFlagFollow {
return nil
}
}
}
+39
View File
@@ -0,0 +1,39 @@
package main
import (
"os"
"github.com/spf13/cobra"
"google.golang.org/protobuf/types/known/emptypb"
)
var commandAPIMode = &cobra.Command{
Use: "mode",
Short: "Print the current clash mode",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runAPIMode()
},
}
func init() {
commandAPIRoot.AddCommand(commandAPIMode)
}
func runAPIMode() error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
modeStatus, err := client.GetClashModeStatus(globalCtx, &emptypb.Empty{})
if err != nil {
return err
}
currentMode := modeStatus.GetCurrentMode()
if currentMode == "" {
currentMode = "-"
}
os.Stdout.WriteString(currentMode + "\n")
return nil
}
+45
View File
@@ -0,0 +1,45 @@
package main
import (
"os"
"strings"
"github.com/spf13/cobra"
"google.golang.org/protobuf/types/known/emptypb"
)
var commandAPIModeList = &cobra.Command{
Use: "list",
Short: "List clash modes",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runAPIModeList()
},
}
func init() {
commandAPIMode.AddCommand(commandAPIModeList)
}
func runAPIModeList() error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
modeStatus, err := client.GetClashModeStatus(globalCtx, &emptypb.Empty{})
if err != nil {
return err
}
if len(modeStatus.GetModeList()) == 0 {
writeStderrLine("no clash modes")
return nil
}
var output strings.Builder
for _, mode := range modeStatus.GetModeList() {
output.WriteString(mode)
output.WriteString("\n")
}
os.Stdout.WriteString(output.String())
return nil
}
+31
View File
@@ -0,0 +1,31 @@
package main
import (
"github.com/sagernet/sing-box/daemon"
"github.com/spf13/cobra"
)
var commandAPIModeSet = &cobra.Command{
Use: "set <mode>",
Short: "Set the clash mode",
Long: "Set the clash mode.\n\nThe value is not validated against the mode list: setting an unknown mode reports success.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runAPIModeSet(args[0])
},
}
func init() {
commandAPIMode.AddCommand(commandAPIModeSet)
}
func runAPIModeSet(mode string) error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
_, err = client.SetClashMode(globalCtx, &daemon.ClashMode{Mode: mode})
return err
}
+131
View File
@@ -0,0 +1,131 @@
package main
import (
"fmt"
"os"
"strings"
"time"
"github.com/sagernet/sing-box/common/networkquality"
"github.com/sagernet/sing-box/daemon"
E "github.com/sagernet/sing/common/exceptions"
"github.com/spf13/cobra"
)
var (
commandAPINetworkQualityFlagConfigURL string
commandAPINetworkQualityFlagSerial bool
commandAPINetworkQualityFlagMaxRuntime int
commandAPINetworkQualityFlagHTTP3 bool
commandAPINetworkQualityFlagOutbound string
)
var commandAPINetworkQuality = &cobra.Command{
Use: "networkquality",
Short: "Run a network quality test",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runAPINetworkQuality()
},
}
func init() {
commandAPINetworkQuality.Flags().StringVar(
&commandAPINetworkQualityFlagConfigURL,
"config-url", "",
"Network quality test config URL (default: Apple mensura)",
)
commandAPINetworkQuality.Flags().BoolVar(
&commandAPINetworkQualityFlagSerial,
"serial", false,
"Run download and upload tests sequentially instead of in parallel",
)
commandAPINetworkQuality.Flags().IntVar(
&commandAPINetworkQualityFlagMaxRuntime,
"max-runtime", int(networkquality.DefaultMaxRuntime/time.Second),
"Network quality maximum runtime in seconds",
)
commandAPINetworkQuality.Flags().BoolVar(
&commandAPINetworkQualityFlagHTTP3,
"http3", false,
"Use HTTP/3 (QUIC) for measurement traffic",
)
commandAPINetworkQuality.Flags().StringVarP(
&commandAPINetworkQualityFlagOutbound,
"outbound", "o", "",
"Use specified tag instead of default outbound",
)
commandAPIRoot.AddCommand(commandAPINetworkQuality)
}
func runAPINetworkQuality() error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
stream, err := client.StartNetworkQualityTest(globalCtx, &daemon.NetworkQualityTestRequest{
ConfigURL: commandAPINetworkQualityFlagConfigURL,
OutboundTag: commandAPINetworkQualityFlagOutbound,
Serial: commandAPINetworkQualityFlagSerial,
MaxRuntimeSeconds: int32(commandAPINetworkQualityFlagMaxRuntime),
Http3: commandAPINetworkQualityFlagHTTP3,
})
if err != nil {
return err
}
writeStderrLine("==== NETWORK QUALITY TEST ====")
for {
progress, recvErr := stream.Recv()
if recvErr != nil {
return recvErr
}
if !progress.GetIsFinal() {
writeNetworkQualityProgress(progress)
continue
}
writeStderrLine("")
if progress.GetError() != "" {
return E.New(progress.GetError())
}
writeStderrLine(strings.Repeat("-", 40))
fmt.Fprintf(os.Stdout, "Idle Latency: %d ms\n", progress.GetIdleLatencyMs())
fmt.Fprintf(os.Stdout, "Download Capacity: %-20s Accuracy: %s\n",
networkquality.FormatBitrate(progress.GetDownloadCapacity()),
networkquality.Accuracy(progress.GetDownloadCapacityAccuracy()))
fmt.Fprintf(os.Stdout, "Upload Capacity: %-20s Accuracy: %s\n",
networkquality.FormatBitrate(progress.GetUploadCapacity()),
networkquality.Accuracy(progress.GetUploadCapacityAccuracy()))
fmt.Fprintf(os.Stdout, "Download Responsiveness: %-20s Accuracy: %s\n",
fmt.Sprintf("%d RPM", progress.GetDownloadRPM()),
networkquality.Accuracy(progress.GetDownloadRPMAccuracy()))
fmt.Fprintf(os.Stdout, "Upload Responsiveness: %-20s Accuracy: %s\n",
fmt.Sprintf("%d RPM", progress.GetUploadRPM()),
networkquality.Accuracy(progress.GetUploadRPMAccuracy()))
return nil
}
}
func writeNetworkQualityProgress(progress *daemon.NetworkQualityTestProgress) {
if !commandAPINetworkQualityFlagSerial && networkquality.Phase(progress.GetPhase()) != networkquality.PhaseIdle {
writeProgress(fmt.Sprintf("Download: %s RPM: %d Upload: %s RPM: %d",
networkquality.FormatBitrate(progress.GetDownloadCapacity()), progress.GetDownloadRPM(),
networkquality.FormatBitrate(progress.GetUploadCapacity()), progress.GetUploadRPM()))
return
}
switch networkquality.Phase(progress.GetPhase()) {
case networkquality.PhaseIdle:
if progress.GetIdleLatencyMs() > 0 {
writeProgress(fmt.Sprintf("Idle Latency: %d ms", progress.GetIdleLatencyMs()))
} else {
writeProgress("Measuring idle latency...")
}
case networkquality.PhaseDownload:
writeProgress(fmt.Sprintf("Download: %s RPM: %d",
networkquality.FormatBitrate(progress.GetDownloadCapacity()), progress.GetDownloadRPM()))
case networkquality.PhaseUpload:
writeProgress(fmt.Sprintf("Upload: %s RPM: %d",
networkquality.FormatBitrate(progress.GetUploadCapacity()), progress.GetUploadRPM()))
}
}
+63
View File
@@ -0,0 +1,63 @@
package main
import (
"context"
"errors"
"io"
"github.com/sagernet/sing-box/daemon"
E "github.com/sagernet/sing/common/exceptions"
F "github.com/sagernet/sing/common/format"
"github.com/spf13/cobra"
"google.golang.org/grpc"
"google.golang.org/protobuf/types/known/emptypb"
)
var commandAPIOpenConnect = &cobra.Command{
Use: "openconnect",
Short: "Manage OpenConnect authentication",
}
func init() {
commandAPIRoot.AddCommand(commandAPIOpenConnect)
}
func subscribeOpenConnectStatus(ctx context.Context, client daemon.StartedServiceClient) (grpc.ServerStreamingClient[daemon.OpenConnectStatusUpdate], []*daemon.OpenConnectEndpointStatus, error) {
stream, err := client.SubscribeOpenConnectStatus(ctx, &emptypb.Empty{})
if err != nil {
return nil, nil, err
}
endpoints, err := recvOpenConnectStatus(stream)
if err != nil {
return nil, nil, err
}
return stream, endpoints, nil
}
func recvOpenConnectStatus(stream grpc.ServerStreamingClient[daemon.OpenConnectStatusUpdate]) ([]*daemon.OpenConnectEndpointStatus, error) {
update, err := stream.Recv()
if err != nil {
if errors.Is(err, io.EOF) {
return nil, E.New("api service closed the status stream")
}
return nil, err
}
return update.GetEndpoints(), nil
}
func openConnectChallengeSummary(challenge *daemon.OpenConnectAuthChallenge) string {
form := challenge.GetForm()
if form != nil {
return F.ToString("form (", len(form.GetFields()), " fields)")
}
browser := challenge.GetBrowser()
if browser == nil {
return "unknown"
}
mode, err := deriveOpenConnectBrowserMode(browser)
if err != nil {
return "browser (invalid)"
}
return "browser (" + mode + ")"
}
+297
View File
@@ -0,0 +1,297 @@
package main
import (
"context"
"errors"
"os"
"os/signal"
"slices"
"strconv"
"strings"
"syscall"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/daemon"
E "github.com/sagernet/sing/common/exceptions"
"github.com/spf13/cobra"
)
var (
commandAPIOpenConnectAuthFlagEndpoint string
commandAPIOpenConnectAuthFlagCallbackPort uint16
)
var commandAPIOpenConnectAuth = &cobra.Command{
Use: "auth",
Short: "Answer OpenConnect authentication challenges",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
err := runAPIOpenConnectAuth()
if errors.Is(err, errAuthInterrupted) {
writeAuthLine(`interrupted; the challenge is still pending — run "sing-box api openconnect auth" again, or "sing-box api openconnect cancel" to restart authentication`)
os.Exit(130)
}
return wrapAuthError("openconnect", err)
},
}
func init() {
commandAPIOpenConnectAuth.Flags().StringVar(&commandAPIOpenConnectAuthFlagEndpoint, "endpoint", "", "OpenConnect endpoint tag (default: the only configured endpoint)")
commandAPIOpenConnectAuth.Flags().Uint16Var(&commandAPIOpenConnectAuthFlagCallbackPort, "callback-port", 8020, "Local port for the browser single sign-on callback listener")
commandAPIOpenConnect.AddCommand(commandAPIOpenConnectAuth)
}
func runAPIOpenConnectAuth() error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
ctx, cancel := signal.NotifyContext(globalCtx, os.Interrupt, syscall.SIGTERM)
defer cancel()
stream, endpoints, err := subscribeOpenConnectStatus(ctx, client)
if err != nil {
return err
}
endpointStatus, err := resolveVPNEndpoint(endpoints, commandAPIOpenConnectAuthFlagEndpoint, "openconnect")
if err != nil {
return err
}
endpointTag := endpointStatus.GetEndpointTag()
if endpointStatus.GetAuthChallenge() == nil {
switch endpointStatus.GetState() {
case adapter.OpenConnectStateConnected:
return E.New("endpoint ", endpointTag, " is already connected")
case adapter.OpenConnectStateError:
return E.New("endpoint ", endpointTag, " failed: ", endpointStatus.GetError())
}
}
watcher := newVPNStatusWatcher(endpoints, func() ([]*daemon.OpenConnectEndpointStatus, error) {
return recvOpenConnectStatus(stream)
})
err = openConnectAuthLoop(ctx, client, watcher, newInteractiveInput(), endpointTag)
if err != nil && ctx.Err() != nil {
return errAuthInterrupted
}
return err
}
func openConnectAuthLoop(
ctx context.Context,
client daemon.StartedServiceClient,
watcher *vpnStatusWatcher[*daemon.OpenConnectEndpointStatus],
input *interactiveInput,
endpointTag string,
) error {
var (
renderedID string
waitingPrinted bool
)
for {
endpoints, updated, streamErr := watcher.current()
if streamErr != nil {
return streamErr
}
index := slices.IndexFunc(endpoints, func(it *daemon.OpenConnectEndpointStatus) bool {
return it.GetEndpointTag() == endpointTag
})
if index == -1 {
return E.New("endpoint not found: ", endpointTag)
}
endpointStatus := endpoints[index]
challenge := endpointStatus.GetAuthChallenge()
switch {
case challenge == nil && endpointStatus.GetState() == adapter.OpenConnectStateConnected:
os.Stdout.WriteString(endpointTag + ": connected\n")
return nil
case challenge == nil && endpointStatus.GetState() == adapter.OpenConnectStateError:
return E.New("endpoint ", endpointTag, " failed: ", endpointStatus.GetError())
case challenge != nil && challenge.GetId() != renderedID:
renderedID = challenge.GetId()
waitingPrinted = false
handleErr := handleOpenConnectChallenge(ctx, client, watcher, input, endpointTag, challenge)
switch {
case handleErr == nil:
case errors.Is(handleErr, errAuthChallengeWithdrawn):
writeAuthLine(errAuthChallengeWithdrawn.Error())
default:
return handleErr
}
continue
case challenge == nil && !waitingPrinted:
waitingPrinted = true
writeAuthLine("waiting for an authentication challenge on " + endpointTag + "...")
}
select {
case <-updated:
case <-ctx.Done():
return errAuthInterrupted
}
}
}
func handleOpenConnectChallenge(
ctx context.Context,
client daemon.StartedServiceClient,
watcher *vpnStatusWatcher[*daemon.OpenConnectEndpointStatus],
input *interactiveInput,
endpointTag string,
challenge *daemon.OpenConnectAuthChallenge,
) error {
prompter := &authPrompter{ctx: ctx, input: input, aborted: make(chan struct{})}
watchCtx, cancelWatch := context.WithCancel(ctx)
defer cancelWatch()
go watchOpenConnectChallenge(watchCtx, watcher, endpointTag, challenge.GetId(), prompter)
form := challenge.GetForm()
browser := challenge.GetBrowser()
switch {
case form != nil:
return submitOpenConnectForm(ctx, client, prompter, endpointTag, challenge, form)
case browser != nil:
return submitOpenConnectBrowser(ctx, client, prompter, endpointTag, challenge, browser)
default:
return E.New("unsupported authentication challenge")
}
}
func watchOpenConnectChallenge(
ctx context.Context,
watcher *vpnStatusWatcher[*daemon.OpenConnectEndpointStatus],
endpointTag string,
challengeID string,
prompter *authPrompter,
) {
for {
endpoints, updated, streamErr := watcher.current()
if streamErr != nil {
prompter.abort(streamErr)
return
}
index := slices.IndexFunc(endpoints, func(it *daemon.OpenConnectEndpointStatus) bool {
return it.GetEndpointTag() == endpointTag
})
if index == -1 || endpoints[index].GetAuthChallenge().GetId() != challengeID {
prompter.abort(errAuthChallengeWithdrawn)
return
}
select {
case <-updated:
case <-ctx.Done():
return
}
}
}
func submitOpenConnectForm(
ctx context.Context,
client daemon.StartedServiceClient,
prompter *authPrompter,
endpointTag string,
challenge *daemon.OpenConnectAuthChallenge,
form *daemon.OpenConnectAuthForm,
) error {
if !authInputIsTerminal {
return errAuthNotInteractive
}
writeAuthHeader(endpointTag, "authentication")
preambleWritten := false
if challenge.GetBanner() != "" {
writeAuthBanner(challenge.GetBanner())
preambleWritten = true
}
if challenge.GetError() != "" {
writeAuthLine("previous attempt failed: " + challenge.GetError())
preambleWritten = true
}
if challenge.GetMessage() != "" {
writeAuthLine(challenge.GetMessage())
preambleWritten = true
}
if preambleWritten {
writeAuthLine("")
}
for {
values := make(map[string]string, len(form.GetFields()))
for _, field := range form.GetFields() {
value, err := promptOpenConnectField(prompter, field)
if err != nil {
return err
}
values[field.GetSubmissionKey()] = value
}
_, err := client.SubmitOpenConnectAuthResponse(ctx, &daemon.OpenConnectAuthResponseSubmission{
EndpointTag: endpointTag,
ChallengeID: challenge.GetId(),
Response: &daemon.OpenConnectAuthResponseSubmission_Form{
Form: &daemon.OpenConnectAuthFormResponse{Values: values},
},
})
if err == nil {
return nil
}
outcome, message := classifySubmitError(err)
switch outcome {
case submitStale:
return errAuthChallengeWithdrawn
case submitFatal:
return err
}
writeAuthError("openconnect", "submit rejected: "+message)
}
}
func promptOpenConnectField(prompter *authPrompter, field *daemon.OpenConnectAuthFormField) (string, error) {
label := field.GetLabel()
if label == "" {
label = field.GetName()
}
switch field.GetKind() {
case "text":
return prompter.promptText(label, field.GetValue())
case "password":
return prompter.promptPassword(label, field.GetValue())
case "select":
return promptOpenConnectSelect(prompter, label, field.GetOptions(), field.GetValue())
default:
return "", E.New("unsupported authentication field kind: ", field.GetKind())
}
}
func promptOpenConnectSelect(prompter *authPrompter, label string, options []*daemon.OpenConnectAuthFormChoice, defaultValue string) (string, error) {
prompt := strings.TrimSuffix(label, ":")
var menu strings.Builder
menu.WriteString(prompt + ":\n")
for index, option := range options {
optionLabel := option.GetLabel()
if optionLabel == "" {
optionLabel = option.GetValue()
}
menu.WriteString(" " + strconv.Itoa(index+1) + ") " + optionLabel)
if option.GetValue() == defaultValue {
menu.WriteString(" [default]")
}
menu.WriteString("\n")
}
os.Stderr.WriteString(menu.String())
for {
line, err := prompter.read(prompt+": ", false)
if err != nil {
return "", err
}
line = strings.TrimSpace(line)
if line == "" && defaultValue != "" {
return defaultValue, nil
}
selected, parseErr := strconv.Atoi(line)
if parseErr == nil && selected >= 1 && selected <= len(options) {
return options[selected-1].GetValue(), nil
}
if slices.ContainsFunc(options, func(it *daemon.OpenConnectAuthFormChoice) bool {
return it.GetValue() == line
}) {
return line, nil
}
writeAuthLine("select a number between 1 and " + strconv.Itoa(len(options)))
}
}
@@ -0,0 +1,290 @@
package main
import (
"context"
"net"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"github.com/sagernet/sing-box/daemon"
"github.com/sagernet/sing/common"
E "github.com/sagernet/sing/common/exceptions"
F "github.com/sagernet/sing/common/format"
)
const (
openConnectBrowserModeCallback = "callback"
openConnectBrowserModeCookies = "cookies"
openConnectBrowserModeHeaders = "headers"
)
const openConnectCallbackPage = `<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>single sign-on</title></head>
<body style="font-family:system-ui,sans-serif;text-align:center;margin-top:4em">
<h3>Single sign-on completed</h3>
<p>You may close this tab and return to the terminal.</p>
</body>
</html>
`
func deriveOpenConnectBrowserMode(request *daemon.OpenConnectBrowserRequest) (string, error) {
callbackMode := len(request.GetCallbackURLPrefixes()) > 0
cookieMode := request.GetFinalURL() != "" || len(request.GetCookieNames()) > 0 || len(request.GetEarlyCookieNames()) > 0
headerMode := len(request.GetHeaderNames()) > 0
selectedModes := common.Filter([]bool{callbackMode, cookieMode, headerMode}, func(it bool) bool {
return it
})
invalidRequest := E.New("openconnect browser request must select exactly one completion mode")
if len(selectedModes) != 1 {
return "", invalidRequest
}
switch {
case callbackMode:
if len(common.Uniq(request.GetCallbackURLPrefixes())) != len(request.GetCallbackURLPrefixes()) {
return "", invalidRequest
}
return openConnectBrowserModeCallback, nil
case cookieMode:
cookieNames := append(slices.Clone(request.GetCookieNames()), request.GetEarlyCookieNames()...)
if len(common.Uniq(cookieNames)) != len(cookieNames) {
return "", invalidRequest
}
return openConnectBrowserModeCookies, nil
default:
headerNames := common.Map(request.GetHeaderNames(), strings.ToLower)
if len(common.Uniq(headerNames)) != len(headerNames) {
return "", invalidRequest
}
return openConnectBrowserModeHeaders, nil
}
}
func submitOpenConnectBrowser(
ctx context.Context,
client daemon.StartedServiceClient,
prompter *authPrompter,
endpointTag string,
challenge *daemon.OpenConnectAuthChallenge,
request *daemon.OpenConnectBrowserRequest,
) error {
mode, err := deriveOpenConnectBrowserMode(request)
if err != nil {
return err
}
writeAuthHeader(endpointTag, "browser authentication")
if challenge.GetError() != "" {
writeAuthLine("previous attempt failed: " + challenge.GetError())
}
if challenge.GetMessage() != "" {
writeAuthLine(challenge.GetMessage())
}
for {
result, earlyFailure, collectErr := collectOpenConnectBrowserResult(ctx, prompter, mode, request)
if collectErr != nil {
return collectErr
}
warnPlaintextAPIConnection()
_, submitErr := client.SubmitOpenConnectAuthResponse(ctx, &daemon.OpenConnectAuthResponseSubmission{
EndpointTag: endpointTag,
ChallengeID: challenge.GetId(),
Response: &daemon.OpenConnectAuthResponseSubmission_Browser{Browser: result},
})
if submitErr == nil {
if earlyFailure {
writeAuthLine("single sign-on failed; the client will retry authentication")
}
return nil
}
outcome, message := classifySubmitError(submitErr)
switch outcome {
case submitStale:
return errAuthChallengeWithdrawn
case submitFatal:
return submitErr
}
writeAuthError("openconnect", "browser authentication rejected: "+message)
}
}
func collectOpenConnectBrowserResult(
ctx context.Context,
prompter *authPrompter,
mode string,
request *daemon.OpenConnectBrowserRequest,
) (*daemon.OpenConnectBrowserResult, bool, error) {
switch {
case mode == openConnectBrowserModeCallback:
target, err := parseOpenConnectCallbackTarget(request.GetCallbackURLPrefixes())
if err != nil {
return nil, false, err
}
if !authInputIsTerminal {
return nil, false, errAuthNotInteractive
}
finalURL, err := runOpenConnectCallbackListener(ctx, prompter, target, request.GetUrl())
if err != nil {
return nil, false, err
}
return &daemon.OpenConnectBrowserResult{FinalURL: finalURL}, false, nil
case mode == openConnectBrowserModeCookies && len(request.GetCookieNames()) > 0:
if !authInputIsTerminal {
return nil, false, errAuthNotInteractive
}
return promptOpenConnectBrowserCookies(prompter, request)
case mode == openConnectBrowserModeHeaders:
return nil, false, E.New("this single sign-on requires reading HTTP response headers, which cannot be done manually; use the sing-box desktop application")
default:
return nil, false, E.New("this single sign-on cannot be completed manually; use the sing-box desktop application")
}
}
type openConnectCallbackTarget struct {
scheme string
host string
port string
}
func (t openConnectCallbackTarget) resolve(requestURI string) string {
return t.scheme + "://" + net.JoinHostPort(t.host, t.port) + requestURI
}
func parseOpenConnectCallbackTarget(prefixes []string) (openConnectCallbackTarget, error) {
var target openConnectCallbackTarget
for _, prefix := range prefixes {
parsed, err := url.Parse(prefix)
if err != nil || !isLoopbackHost(parsed.Hostname()) {
return target, E.New("callback URL prefix is not on loopback: ", prefix)
}
if target.scheme == "" {
target.scheme = parsed.Scheme
target.host = parsed.Hostname()
}
if target.port == "" {
target.port = parsed.Port()
}
}
if target.port == "" {
target.port = strconv.Itoa(int(commandAPIOpenConnectAuthFlagCallbackPort))
}
return target, nil
}
func runOpenConnectCallbackListener(ctx context.Context, prompter *authPrompter, target openConnectCallbackTarget, loginURL string) (string, error) {
listener, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", target.port))
if err != nil {
return "", E.New("cannot listen on 127.0.0.1:", target.port, ": ", err.Error(), "; pass --callback-port")
}
defer listener.Close()
writeAuthLine("Complete single sign-on in your browser; this command finishes automatically.")
writeAuthLine("")
writeAuthLine(" listening on " + target.resolve("/"))
writeAuthLine(" url " + loginURL)
writeAuthLine("")
confirmed, err := prompter.promptConfirm("Open it now? [Y/n] ")
if err != nil {
return "", err
}
if !confirmed {
writeAuthLine("waiting for the callback...")
} else {
openErr := openURLInBrowser(loginURL)
if openErr != nil {
writeAuthLine("failed to open the default browser: " + openErr.Error())
writeAuthLine("waiting for the callback...")
} else {
writeAuthLine("opened in the default browser; waiting for the callback...")
}
}
requestURIs := make(chan string, 1)
server := &http.Server{Handler: http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
select {
case requestURIs <- request.RequestURI:
default:
}
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
writer.Header().Set("Connection", "close")
writer.WriteHeader(http.StatusOK)
writer.Write([]byte(openConnectCallbackPage))
})}
go func() {
_ = server.Serve(listener)
}()
defer server.Close()
select {
case requestURI := <-requestURIs:
writeAuthLine("received callback")
return target.resolve(requestURI), nil
case <-prompter.aborted:
return "", prompter.abortErr
case <-ctx.Done():
return "", errAuthInterrupted
}
}
func promptOpenConnectBrowserCookies(prompter *authPrompter, request *daemon.OpenConnectBrowserRequest) (*daemon.OpenConnectBrowserResult, bool, error) {
writeAuthLine("This single sign-on must be completed manually.")
writeAuthLine("")
step := 1
writeAuthLine(" " + strconv.Itoa(step) + ". Open this URL in any browser:")
writeAuthLine(" " + request.GetUrl())
if request.GetFinalURL() != "" {
step++
writeAuthLine(" " + strconv.Itoa(step) + ". Sign in until the browser lands on:")
writeAuthLine(" " + request.GetFinalURL())
}
step++
writeAuthLine(" " + strconv.Itoa(step) + ". Open the developer tools (F12) > Application > Cookies, and read the")
writeAuthLine(" value of the cookie listed below for that page.")
writeAuthLine("")
earlyCookieNames := request.GetEarlyCookieNames()
var cookies []*daemon.OpenConnectBrowserCookie
for index, name := range request.GetCookieNames() {
prompt := `Cookie "` + name + `": `
if index == 0 && len(earlyCookieNames) > 0 {
prompt = `Cookie "` + name + `" (or "!" if the page reported an error): `
}
for {
value, err := prompter.read(prompt, true)
if err != nil {
return nil, false, err
}
if index == 0 && len(earlyCookieNames) > 0 && value == "!" {
earlyCookie, earlyErr := promptOpenConnectEarlyCookie(prompter, earlyCookieNames[0])
if earlyErr != nil {
return nil, false, earlyErr
}
return &daemon.OpenConnectBrowserResult{Cookies: []*daemon.OpenConnectBrowserCookie{earlyCookie}}, true, nil
}
if value == "" {
writeAuthLine("cookie value must not be empty")
continue
}
cookies = append(cookies, &daemon.OpenConnectBrowserCookie{Name: name, Value: value})
break
}
}
if len(cookies) == 1 {
writeAuthLine("submitting 1 cookie")
} else {
writeAuthLine(F.ToString("submitting ", len(cookies), " cookies"))
}
return &daemon.OpenConnectBrowserResult{FinalURL: request.GetFinalURL(), Cookies: cookies}, false, nil
}
func promptOpenConnectEarlyCookie(prompter *authPrompter, name string) (*daemon.OpenConnectBrowserCookie, error) {
for {
value, err := prompter.read(`Error cookie "`+name+`": `, true)
if err != nil {
return nil, err
}
if value == "" {
writeAuthLine("cookie value must not be empty")
continue
}
return &daemon.OpenConnectBrowserCookie{Name: name, Value: value}, nil
}
}
@@ -0,0 +1,59 @@
package main
import (
"context"
"os"
"github.com/sagernet/sing-box/daemon"
E "github.com/sagernet/sing/common/exceptions"
"github.com/spf13/cobra"
)
var commandAPIOpenConnectCancelFlagEndpoint string
var commandAPIOpenConnectCancel = &cobra.Command{
Use: "cancel",
Short: "Cancel the pending OpenConnect authentication challenge",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runAPIOpenConnectCancel()
},
}
func init() {
commandAPIOpenConnectCancel.Flags().StringVar(&commandAPIOpenConnectCancelFlagEndpoint, "endpoint", "", "OpenConnect endpoint tag (default: the only configured endpoint)")
commandAPIOpenConnect.AddCommand(commandAPIOpenConnectCancel)
}
func runAPIOpenConnectCancel() error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
ctx, cancel := context.WithCancel(globalCtx)
defer cancel()
_, endpoints, err := subscribeOpenConnectStatus(ctx, client)
if err != nil {
return err
}
endpointStatus, err := resolveVPNEndpoint(endpoints, commandAPIOpenConnectCancelFlagEndpoint, "openconnect")
if err != nil {
return err
}
endpointTag := endpointStatus.GetEndpointTag()
challenge := endpointStatus.GetAuthChallenge()
if challenge == nil {
return E.New("no pending authentication challenge on ", endpointTag)
}
_, err = client.CancelOpenConnectAuthChallenge(ctx, &daemon.OpenConnectAuthChallengeCancel{
EndpointTag: endpointTag,
ChallengeID: challenge.GetId(),
})
if err != nil {
return err
}
os.Stdout.WriteString(endpointTag + ": authentication challenge canceled; the client will restart authentication\n")
return nil
}
+331
View File
@@ -0,0 +1,331 @@
package main
import (
"context"
"net/netip"
"net/url"
"os"
"os/exec"
"runtime"
"slices"
"strings"
"sync"
"time"
"github.com/sagernet/sing/common"
E "github.com/sagernet/sing/common/exceptions"
"golang.org/x/term"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
var (
errAuthInterrupted = E.New("interrupted")
errAuthChallengeWithdrawn = E.New("challenge no longer pending, waiting for the next one")
errAuthDeadlineExpired = E.New("challenge deadline expired; the server will retry the connection")
errAuthNotInteractive = E.New("authentication requires an interactive terminal")
)
var authInputIsTerminal = term.IsTerminal(int(os.Stdin.Fd())) && stderrIsTerminal
type vpnEndpointStatus interface {
GetEndpointTag() string
}
func resolveVPNEndpoint[T vpnEndpointStatus](endpoints []T, endpointTag string, domain string) (T, error) {
var zero T
if endpointTag != "" {
index := slices.IndexFunc(endpoints, func(it T) bool {
return it.GetEndpointTag() == endpointTag
})
if index == -1 {
return zero, E.New("endpoint not found: ", endpointTag)
}
return endpoints[index], nil
}
switch len(endpoints) {
case 0:
return zero, E.New("no ", domain, " endpoint is configured")
case 1:
return endpoints[0], nil
default:
return zero, E.New("multiple ", domain, " endpoints; select one with -e: ", strings.Join(common.Map(endpoints, func(it T) string {
return it.GetEndpointTag()
}), ", "))
}
}
type vpnStatusWatcher[T any] struct {
access sync.Mutex
updated chan struct{}
endpoints []T
err error
}
func newVPNStatusWatcher[T any](endpoints []T, recv func() ([]T, error)) *vpnStatusWatcher[T] {
watcher := &vpnStatusWatcher[T]{
updated: make(chan struct{}),
endpoints: endpoints,
}
go watcher.run(recv)
return watcher
}
func (w *vpnStatusWatcher[T]) run(recv func() ([]T, error)) {
for {
endpoints, err := recv()
w.access.Lock()
if err != nil {
w.err = err
} else {
w.endpoints = endpoints
}
close(w.updated)
w.updated = make(chan struct{})
w.access.Unlock()
if err != nil {
return
}
}
}
func (w *vpnStatusWatcher[T]) current() ([]T, <-chan struct{}, error) {
w.access.Lock()
defer w.access.Unlock()
return w.endpoints, w.updated, w.err
}
type interactiveReadResult struct {
line string
err error
}
type interactiveReadRequest struct {
prompt string
hidden bool
result chan interactiveReadResult
}
type interactiveInput struct {
requests chan interactiveReadRequest
}
func newInteractiveInput() *interactiveInput {
input := &interactiveInput{requests: make(chan interactiveReadRequest)}
go input.run()
return input
}
func (i *interactiveInput) run() {
for request := range i.requests {
os.Stderr.WriteString(request.prompt)
line, err := readTerminalLine(request.hidden)
request.result <- interactiveReadResult{line: line, err: err}
}
}
func readTerminalLine(hidden bool) (string, error) {
if hidden {
line, err := term.ReadPassword(int(os.Stdin.Fd()))
os.Stderr.WriteString("\n")
if err != nil {
return "", err
}
return string(line), nil
}
var builder strings.Builder
buffer := make([]byte, 1)
for {
n, err := os.Stdin.Read(buffer)
if n > 0 {
if buffer[0] == '\n' {
break
}
builder.WriteByte(buffer[0])
}
if err != nil {
if builder.Len() == 0 {
return "", err
}
break
}
}
return strings.TrimSuffix(builder.String(), "\r"), nil
}
type authPrompter struct {
ctx context.Context
input *interactiveInput
once sync.Once
aborted chan struct{}
abortErr error
}
func (p *authPrompter) abort(cause error) {
p.once.Do(func() {
p.abortErr = cause
close(p.aborted)
})
}
func (p *authPrompter) read(prompt string, hidden bool) (string, error) {
result := make(chan interactiveReadResult, 1)
select {
case p.input.requests <- interactiveReadRequest{prompt: prompt, hidden: hidden, result: result}:
case <-p.aborted:
return "", p.abortErr
case <-p.ctx.Done():
return "", errAuthInterrupted
}
select {
case value := <-result:
return value.line, value.err
case <-p.aborted:
return "", p.abortErr
case <-p.ctx.Done():
return "", errAuthInterrupted
}
}
func (p *authPrompter) promptText(label string, value string) (string, error) {
prompt := strings.TrimSuffix(label, ":")
if value != "" {
prompt += " [" + value + "]"
}
line, err := p.read(prompt+": ", false)
if err != nil {
return "", err
}
if line == "" {
return value, nil
}
return line, nil
}
func (p *authPrompter) promptPassword(label string, value string) (string, error) {
prompt := strings.TrimSuffix(label, ":")
if value != "" {
prompt += " (unchanged)"
}
line, err := p.read(prompt+": ", true)
if err != nil {
return "", err
}
if line == "" {
return value, nil
}
return line, nil
}
func (p *authPrompter) promptConfirm(prompt string) (bool, error) {
line, err := p.read(prompt, false)
if err != nil {
return false, err
}
switch strings.ToLower(strings.TrimSpace(line)) {
case "", "y", "yes":
return true, nil
default:
return false, nil
}
}
func writeAuthLine(message string) {
os.Stderr.WriteString(message + "\n")
}
func writeAuthError(domain string, message string) {
os.Stderr.WriteString(domain + " auth: " + message + "\n")
}
func writeAuthHeader(endpointTag string, title string) {
os.Stderr.WriteString("\n" + endpointTag + ": " + title + "\n")
}
func writeAuthBanner(banner string) {
var output strings.Builder
for line := range strings.SplitSeq(strings.ReplaceAll(banner, "\r\n", "\n"), "\n") {
output.WriteString(" " + line + "\n")
}
os.Stderr.WriteString(output.String())
}
type submitOutcome int
const (
submitRejected submitOutcome = iota
submitStale
submitFatal
)
func classifySubmitError(err error) (submitOutcome, string) {
grpcStatus, isStatus := status.FromError(err)
if !isStatus {
return submitFatal, err.Error()
}
switch grpcStatus.Code() {
case codes.Unavailable, codes.Canceled, codes.DeadlineExceeded, codes.Unauthenticated, codes.Unimplemented:
return submitFatal, grpcStatus.Message()
}
if strings.Contains(grpcStatus.Message(), "no pending") {
return submitStale, grpcStatus.Message()
}
return submitRejected, grpcStatus.Message()
}
func wrapAuthError(domain string, err error) error {
if err == nil {
return nil
}
_, isStatus := status.FromError(err)
if isStatus {
return err
}
return E.Cause(err, domain+" auth")
}
func formatVPNConnectedSince(connectedSince int64) string {
if connectedSince == 0 {
return ""
}
since := time.Unix(connectedSince, 0).Local()
return since.Format(time.RFC3339) + " (" + time.Since(since).Truncate(time.Second).String() + ")"
}
func formatAuthDeadline(deadline int64) string {
if deadline == 0 {
return ""
}
return max(time.Until(time.Unix(deadline, 0)).Truncate(time.Second), 0).String()
}
func isLoopbackHost(host string) bool {
if host == "localhost" {
return true
}
address, err := netip.ParseAddr(host)
if err != nil {
return false
}
return address.IsLoopback()
}
func warnPlaintextAPIConnection() {
parsed, err := url.Parse(commandAPIServerURL)
if err != nil || parsed.Scheme == "https" || isLoopbackHost(parsed.Hostname()) {
return
}
writeAuthLine("warning: submitting single sign-on credentials over a plaintext API connection")
}
func openURLInBrowser(target string) error {
switch runtime.GOOS {
case "darwin":
return exec.Command("open", target).Start()
case "windows":
return exec.Command("rundll32", "url.dll,FileProtocolHandler", target).Start()
default:
return exec.Command("xdg-open", target).Start()
}
}
@@ -0,0 +1,93 @@
package main
import (
"context"
"os"
"strings"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/daemon"
F "github.com/sagernet/sing/common/format"
"github.com/spf13/cobra"
)
var commandAPIOpenConnectStatus = &cobra.Command{
Use: "status",
Short: "Print OpenConnect endpoint status",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runAPIOpenConnectStatus()
},
}
func init() {
commandAPIOpenConnect.AddCommand(commandAPIOpenConnectStatus)
}
func runAPIOpenConnectStatus() error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
ctx, cancel := context.WithCancel(globalCtx)
defer cancel()
_, endpoints, err := subscribeOpenConnectStatus(ctx, client)
if err != nil {
return err
}
if len(endpoints) == 0 {
writeStderrLine("no openconnect endpoint is configured")
return nil
}
for index, endpointStatus := range endpoints {
if index > 0 {
os.Stdout.WriteString("\n")
}
writeOpenConnectStatusBlock(endpointStatus)
}
return nil
}
func writeOpenConnectStatusBlock(endpointStatus *daemon.OpenConnectEndpointStatus) {
var block blockWriter
block.addLine("Endpoint", endpointStatus.GetEndpointTag())
block.addLine("State", endpointStatus.GetState())
challenge := endpointStatus.GetAuthChallenge()
tunnelInfo := endpointStatus.GetTunnelInfo()
switch {
case challenge != nil:
block.addLine("Challenge", openConnectChallengeSummary(challenge))
if challenge.GetMessage() != "" {
block.addLine("Message", challenge.GetMessage())
}
if challenge.GetError() != "" {
block.addLine("Error", challenge.GetError())
}
case tunnelInfo != nil:
block.addLine("Server", tunnelInfo.GetServer())
block.addLine("Flavor", tunnelInfo.GetFlavor())
block.addLine("Transport", tunnelInfo.GetTransport())
if len(tunnelInfo.GetIpv4()) > 0 {
block.addLine("IPv4", strings.Join(tunnelInfo.GetIpv4(), ", "))
}
if len(tunnelInfo.GetIpv6()) > 0 {
block.addLine("IPv6", strings.Join(tunnelInfo.GetIpv6(), ", "))
}
if len(tunnelInfo.GetDns()) > 0 {
block.addLine("DNS", strings.Join(tunnelInfo.GetDns(), ", "))
}
if tunnelInfo.GetMtu() > 0 {
block.addLine("MTU", F.ToString(tunnelInfo.GetMtu()))
}
block.addLine("Connected since", formatVPNConnectedSince(tunnelInfo.GetConnectedSince()))
case endpointStatus.GetState() == adapter.OpenConnectStateError:
block.addLine("Error", endpointStatus.GetError())
}
block.flush()
if challenge != nil {
writeStderrLine("")
writeStderrLine(`run "sing-box api openconnect auth" to answer`)
}
}
+62
View File
@@ -0,0 +1,62 @@
package main
import (
"context"
"errors"
"io"
"github.com/sagernet/sing-box/daemon"
E "github.com/sagernet/sing/common/exceptions"
"github.com/spf13/cobra"
"google.golang.org/grpc"
"google.golang.org/protobuf/types/known/emptypb"
)
const (
openVPNChallengeCredentials = "credentials"
openVPNChallengeSecret = "secret"
openVPNChallengeMessage = "message"
openVPNChallengeOpenURL = "open-url"
)
var commandAPIOpenVPN = &cobra.Command{
Use: "openvpn",
Short: "Manage OpenVPN authentication",
}
func init() {
commandAPIRoot.AddCommand(commandAPIOpenVPN)
}
func subscribeOpenVPNStatus(ctx context.Context, client daemon.StartedServiceClient) (grpc.ServerStreamingClient[daemon.OpenVPNStatusUpdate], []*daemon.OpenVPNEndpointStatus, error) {
stream, err := client.SubscribeOpenVPNStatus(ctx, &emptypb.Empty{})
if err != nil {
return nil, nil, err
}
endpoints, err := recvOpenVPNStatus(stream)
if err != nil {
return nil, nil, err
}
return stream, endpoints, nil
}
func recvOpenVPNStatus(stream grpc.ServerStreamingClient[daemon.OpenVPNStatusUpdate]) ([]*daemon.OpenVPNEndpointStatus, error) {
update, err := stream.Recv()
if err != nil {
if errors.Is(err, io.EOF) {
return nil, E.New("api service closed the status stream")
}
return nil, err
}
return update.GetEndpoints(), nil
}
func openVPNChallengeSummary(challenge *daemon.OpenVPNChallenge) string {
switch challenge.GetKind() {
case openVPNChallengeMessage, openVPNChallengeOpenURL:
return challenge.GetKind() + " (not answerable)"
default:
return challenge.GetKind()
}
}
+355
View File
@@ -0,0 +1,355 @@
package main
import (
"context"
"errors"
"os"
"os/signal"
"slices"
"strings"
"syscall"
"time"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/daemon"
E "github.com/sagernet/sing/common/exceptions"
"github.com/spf13/cobra"
)
var commandAPIOpenVPNAuthFlagEndpoint string
var commandAPIOpenVPNAuth = &cobra.Command{
Use: "auth",
Short: "Answer OpenVPN authentication challenges",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
err := runAPIOpenVPNAuth()
if errors.Is(err, errAuthInterrupted) {
writeAuthLine(`interrupted; the challenge is still pending — run "sing-box api openvpn auth" again, or "sing-box api openvpn cancel" to stop the client`)
os.Exit(130)
}
return wrapAuthError("openvpn", err)
},
}
func init() {
commandAPIOpenVPNAuth.Flags().StringVar(&commandAPIOpenVPNAuthFlagEndpoint, "endpoint", "", "OpenVPN endpoint tag (default: the only configured endpoint)")
commandAPIOpenVPN.AddCommand(commandAPIOpenVPNAuth)
}
func runAPIOpenVPNAuth() error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
ctx, cancel := signal.NotifyContext(globalCtx, os.Interrupt, syscall.SIGTERM)
defer cancel()
stream, endpoints, err := subscribeOpenVPNStatus(ctx, client)
if err != nil {
return err
}
endpointStatus, err := resolveVPNEndpoint(endpoints, commandAPIOpenVPNAuthFlagEndpoint, "openvpn")
if err != nil {
return err
}
endpointTag := endpointStatus.GetEndpointTag()
if endpointStatus.GetChallenge() == nil {
switch endpointStatus.GetState() {
case adapter.OpenVPNStateConnected:
return E.New("endpoint ", endpointTag, " is already connected")
case adapter.OpenVPNStateError:
return E.New("endpoint ", endpointTag, " failed: ", endpointStatus.GetError())
}
}
watcher := newVPNStatusWatcher(endpoints, func() ([]*daemon.OpenVPNEndpointStatus, error) {
return recvOpenVPNStatus(stream)
})
err = openVPNAuthLoop(ctx, client, watcher, newInteractiveInput(), endpointTag)
if err != nil && ctx.Err() != nil {
return errAuthInterrupted
}
return err
}
func openVPNAuthLoop(
ctx context.Context,
client daemon.StartedServiceClient,
watcher *vpnStatusWatcher[*daemon.OpenVPNEndpointStatus],
input *interactiveInput,
endpointTag string,
) error {
var (
renderedID string
waitingPrinted bool
)
for {
endpoints, updated, streamErr := watcher.current()
if streamErr != nil {
return streamErr
}
index := slices.IndexFunc(endpoints, func(it *daemon.OpenVPNEndpointStatus) bool {
return it.GetEndpointTag() == endpointTag
})
if index == -1 {
return E.New("endpoint not found: ", endpointTag)
}
endpointStatus := endpoints[index]
challenge := endpointStatus.GetChallenge()
switch {
case challenge == nil && endpointStatus.GetState() == adapter.OpenVPNStateConnected:
os.Stdout.WriteString(endpointTag + ": connected\n")
return nil
case challenge == nil && endpointStatus.GetState() == adapter.OpenVPNStateError:
return E.New("endpoint ", endpointTag, " failed: ", endpointStatus.GetError())
case challenge != nil && challenge.GetId() != renderedID:
renderedID = challenge.GetId()
waitingPrinted = false
handleErr := handleOpenVPNChallenge(ctx, client, watcher, input, endpointTag, challenge)
switch {
case handleErr == nil:
case errors.Is(handleErr, errAuthChallengeWithdrawn):
writeAuthLine(errAuthChallengeWithdrawn.Error())
case errors.Is(handleErr, errAuthDeadlineExpired):
writeAuthError("openvpn", errAuthDeadlineExpired.Error())
default:
return handleErr
}
continue
case challenge == nil && !waitingPrinted:
waitingPrinted = true
writeAuthLine("waiting for an authentication challenge on " + endpointTag + "...")
}
select {
case <-updated:
case <-ctx.Done():
return errAuthInterrupted
}
}
}
func handleOpenVPNChallenge(
ctx context.Context,
client daemon.StartedServiceClient,
watcher *vpnStatusWatcher[*daemon.OpenVPNEndpointStatus],
input *interactiveInput,
endpointTag string,
challenge *daemon.OpenVPNChallenge,
) error {
prompter := &authPrompter{ctx: ctx, input: input, aborted: make(chan struct{})}
watchCtx, cancelWatch := context.WithCancel(ctx)
defer cancelWatch()
go watchOpenVPNChallenge(watchCtx, watcher, endpointTag, challenge.GetId(), prompter)
switch challenge.GetKind() {
case openVPNChallengeCredentials:
return submitOpenVPNCredentials(ctx, client, prompter, endpointTag, challenge)
case openVPNChallengeSecret:
return submitOpenVPNSecret(ctx, client, prompter, endpointTag, challenge)
case openVPNChallengeMessage:
writeAuthHeader(endpointTag, "notice")
writeAuthLine(challenge.GetMessage() + openVPNRemainingSuffix(challenge))
return nil
case openVPNChallengeOpenURL:
return openOpenVPNChallengeURL(prompter, endpointTag, challenge)
default:
return E.New("unsupported authentication challenge kind: ", challenge.GetKind())
}
}
func watchOpenVPNChallenge(
ctx context.Context,
watcher *vpnStatusWatcher[*daemon.OpenVPNEndpointStatus],
endpointTag string,
challengeID string,
prompter *authPrompter,
) {
timer := time.NewTimer(time.Hour)
timer.Stop()
defer timer.Stop()
for {
endpoints, updated, streamErr := watcher.current()
if streamErr != nil {
prompter.abort(streamErr)
return
}
index := slices.IndexFunc(endpoints, func(it *daemon.OpenVPNEndpointStatus) bool {
return it.GetEndpointTag() == endpointTag
})
if index == -1 || endpoints[index].GetChallenge().GetId() != challengeID {
prompter.abort(errAuthChallengeWithdrawn)
return
}
var expired <-chan time.Time
deadline := endpoints[index].GetChallenge().GetDeadline()
if deadline != 0 {
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
timer.Reset(time.Until(time.Unix(deadline, 0)))
expired = timer.C
}
select {
case <-updated:
case <-expired:
prompter.abort(errAuthDeadlineExpired)
return
case <-ctx.Done():
return
}
}
}
func submitOpenVPNCredentials(
ctx context.Context,
client daemon.StartedServiceClient,
prompter *authPrompter,
endpointTag string,
challenge *daemon.OpenVPNChallenge,
) error {
if !authInputIsTerminal {
return errAuthNotInteractive
}
writeAuthHeader(endpointTag, "authentication")
if challenge.GetPreviousError() != "" {
writeAuthLine("previous attempt failed: " + challenge.GetPreviousError())
writeAuthLine("")
}
secretLabel := challenge.GetSecretMessage()
if secretLabel == "" {
secretLabel = "Secret"
}
for {
username, err := prompter.promptText("Username", challenge.GetUsername())
if err != nil {
return err
}
password, err := prompter.promptPassword("Password", "")
if err != nil {
return err
}
secret, err := prompter.read(strings.TrimSuffix(secretLabel, ":")+": ", !challenge.GetEcho())
if err != nil {
return err
}
answered, err := submitOpenVPNChallengeResponse(ctx, client, &daemon.OpenVPNChallengeSubmission{
EndpointTag: endpointTag,
ChallengeID: challenge.GetId(),
Username: username,
Password: password,
Secret: secret,
})
if err != nil {
return err
}
if answered {
return nil
}
}
}
func submitOpenVPNSecret(
ctx context.Context,
client daemon.StartedServiceClient,
prompter *authPrompter,
endpointTag string,
challenge *daemon.OpenVPNChallenge,
) error {
if !authInputIsTerminal {
return errAuthNotInteractive
}
writeAuthHeader(endpointTag, "authentication")
contextWritten := false
if challenge.GetPreviousError() != "" {
writeAuthLine("previous attempt failed: " + challenge.GetPreviousError())
contextWritten = true
}
if challenge.GetUsername() != "" {
writeAuthLine("user: " + challenge.GetUsername())
contextWritten = true
}
if contextWritten {
writeAuthLine("")
}
label := challenge.GetMessage()
if challenge.GetDeadline() != 0 {
if label != "" {
writeAuthLine(label + openVPNRemainingSuffix(challenge))
}
label = "Code"
}
if label == "" {
label = "Secret"
}
for {
secret, err := prompter.read(strings.TrimSuffix(label, ":")+": ", !challenge.GetEcho())
if err != nil {
return err
}
answered, err := submitOpenVPNChallengeResponse(ctx, client, &daemon.OpenVPNChallengeSubmission{
EndpointTag: endpointTag,
ChallengeID: challenge.GetId(),
Secret: secret,
})
if err != nil {
return err
}
if answered {
return nil
}
}
}
func submitOpenVPNChallengeResponse(ctx context.Context, client daemon.StartedServiceClient, submission *daemon.OpenVPNChallengeSubmission) (bool, error) {
_, err := client.SubmitOpenVPNChallengeResponse(ctx, submission)
if err == nil {
return true, nil
}
outcome, message := classifySubmitError(err)
switch outcome {
case submitStale:
return false, errAuthChallengeWithdrawn
case submitFatal:
return false, err
}
writeAuthError("openvpn", "submit rejected: "+message)
return false, nil
}
func openOpenVPNChallengeURL(prompter *authPrompter, endpointTag string, challenge *daemon.OpenVPNChallenge) error {
writeAuthHeader(endpointTag, "authentication")
if challenge.GetPreviousError() != "" {
writeAuthLine("previous attempt failed: " + challenge.GetPreviousError())
}
writeAuthLine("Complete authentication in your browser:")
writeAuthLine("")
writeAuthLine(" " + challenge.GetUrl())
writeAuthLine("")
if authInputIsTerminal {
confirmed, err := prompter.promptConfirm("Open it now? [Y/n] ")
if err != nil {
return err
}
if confirmed {
openErr := openURLInBrowser(challenge.GetUrl())
if openErr != nil {
writeAuthLine("failed to open the default browser: " + openErr.Error())
} else {
writeAuthLine("opened in the default browser; waiting for the server" + openVPNRemainingSuffix(challenge))
return nil
}
}
}
writeAuthLine("waiting for the server" + openVPNRemainingSuffix(challenge))
return nil
}
func openVPNRemainingSuffix(challenge *daemon.OpenVPNChallenge) string {
if challenge.GetDeadline() == 0 {
return ""
}
return " (" + formatAuthDeadline(challenge.GetDeadline()) + " remaining)"
}
+61
View File
@@ -0,0 +1,61 @@
package main
import (
"context"
"os"
"github.com/sagernet/sing-box/daemon"
E "github.com/sagernet/sing/common/exceptions"
"github.com/spf13/cobra"
)
var commandAPIOpenVPNCancelFlagEndpoint string
var commandAPIOpenVPNCancel = &cobra.Command{
Use: "cancel",
Short: "Cancel the pending OpenVPN challenge and stop the client",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runAPIOpenVPNCancel()
},
}
func init() {
commandAPIOpenVPNCancel.Flags().StringVar(&commandAPIOpenVPNCancelFlagEndpoint, "endpoint", "", "OpenVPN endpoint tag (default: the only configured endpoint)")
commandAPIOpenVPN.AddCommand(commandAPIOpenVPNCancel)
}
func runAPIOpenVPNCancel() error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
ctx, cancel := context.WithCancel(globalCtx)
defer cancel()
_, endpoints, err := subscribeOpenVPNStatus(ctx, client)
if err != nil {
return err
}
endpointStatus, err := resolveVPNEndpoint(endpoints, commandAPIOpenVPNCancelFlagEndpoint, "openvpn")
if err != nil {
return err
}
endpointTag := endpointStatus.GetEndpointTag()
challenge := endpointStatus.GetChallenge()
if challenge == nil {
return E.New("no pending authentication challenge on ", endpointTag)
}
_, err = client.CancelOpenVPNChallenge(ctx, &daemon.OpenVPNChallengeCancel{
EndpointTag: endpointTag,
ChallengeID: challenge.GetId(),
})
if err != nil {
return err
}
// sing-openvpn treats a canceled challenge as terminal: unlike OpenConnect, the client does not
// reconnect afterwards.
os.Stdout.WriteString(endpointTag + ": authentication challenge canceled; the client has stopped\n")
return nil
}
+99
View File
@@ -0,0 +1,99 @@
package main
import (
"context"
"os"
"strings"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/daemon"
F "github.com/sagernet/sing/common/format"
"github.com/spf13/cobra"
)
var commandAPIOpenVPNStatus = &cobra.Command{
Use: "status",
Short: "Print OpenVPN endpoint status",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runAPIOpenVPNStatus()
},
}
func init() {
commandAPIOpenVPN.AddCommand(commandAPIOpenVPNStatus)
}
func runAPIOpenVPNStatus() error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
ctx, cancel := context.WithCancel(globalCtx)
defer cancel()
_, endpoints, err := subscribeOpenVPNStatus(ctx, client)
if err != nil {
return err
}
if len(endpoints) == 0 {
writeStderrLine("no openvpn endpoint is configured")
return nil
}
for index, endpointStatus := range endpoints {
if index > 0 {
os.Stdout.WriteString("\n")
}
writeOpenVPNStatusBlock(endpointStatus)
}
return nil
}
func writeOpenVPNStatusBlock(endpointStatus *daemon.OpenVPNEndpointStatus) {
var block blockWriter
block.addLine("Endpoint", endpointStatus.GetEndpointTag())
block.addLine("State", endpointStatus.GetState())
challenge := endpointStatus.GetChallenge()
tunnelInfo := endpointStatus.GetTunnelInfo()
switch {
case challenge != nil:
block.addLine("Challenge", openVPNChallengeSummary(challenge))
if challenge.GetMessage() != "" {
block.addLine("Message", challenge.GetMessage())
}
if challenge.GetUrl() != "" {
block.addLine("URL", challenge.GetUrl())
}
if challenge.GetDeadline() != 0 {
block.addLine("Deadline", "in "+formatAuthDeadline(challenge.GetDeadline()))
}
if challenge.GetPreviousError() != "" {
block.addLine("Error", challenge.GetPreviousError())
}
case tunnelInfo != nil:
block.addLine("Server", tunnelInfo.GetServer())
block.addLine("Network", tunnelInfo.GetNetwork())
block.addLine("Cipher", tunnelInfo.GetCipher())
if len(tunnelInfo.GetIpv4()) > 0 {
block.addLine("IPv4", strings.Join(tunnelInfo.GetIpv4(), ", "))
}
if len(tunnelInfo.GetIpv6()) > 0 {
block.addLine("IPv6", strings.Join(tunnelInfo.GetIpv6(), ", "))
}
if len(tunnelInfo.GetDns()) > 0 {
block.addLine("DNS", strings.Join(tunnelInfo.GetDns(), ", "))
}
if tunnelInfo.GetMtu() > 0 {
block.addLine("MTU", F.ToString(tunnelInfo.GetMtu()))
}
block.addLine("Connected since", formatVPNConnectedSince(tunnelInfo.GetConnectedSince()))
case endpointStatus.GetState() == adapter.OpenVPNStateError:
block.addLine("Error", endpointStatus.GetError())
}
block.flush()
if challenge != nil {
writeStderrLine("")
writeStderrLine(`run "sing-box api openvpn auth" to continue`)
}
}
+53
View File
@@ -0,0 +1,53 @@
package main
import (
F "github.com/sagernet/sing/common/format"
"github.com/spf13/cobra"
"google.golang.org/protobuf/types/known/emptypb"
)
var commandAPIOutbounds = &cobra.Command{
Use: "outbounds",
Short: "List outbounds",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runAPIOutbounds()
},
}
func init() {
commandAPIRoot.AddCommand(commandAPIOutbounds)
}
func runAPIOutbounds() error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
stream, err := client.SubscribeOutbounds(globalCtx, &emptypb.Empty{})
if err != nil {
return err
}
outbounds, err := stream.Recv()
if err != nil {
return err
}
table := tableWriter{
header: []string{"TAG", "TYPE", "DELAY"},
emptyMessage: "no outbounds",
}
for _, item := range outbounds.GetOutbounds() {
table.addRow(item.GetTag(), item.GetType(), formatDelay(item.GetUrlTestDelay()))
}
table.flush()
return nil
}
func formatDelay(delay int32) string {
if delay <= 0 {
return ""
}
return F.ToString(delay, " ms")
}
+149
View File
@@ -0,0 +1,149 @@
package main
import (
"os"
"strings"
"github.com/sagernet/sing/common"
"github.com/mattn/go-runewidth"
"golang.org/x/term"
)
var (
stdoutIsTerminal = term.IsTerminal(int(os.Stdout.Fd()))
stderrIsTerminal = term.IsTerminal(int(os.Stderr.Fd()))
)
func writeStderrLine(message string) {
if !stderrIsTerminal {
return
}
os.Stderr.WriteString(message + "\n")
}
func writeProgress(message string) {
if !stderrIsTerminal {
return
}
os.Stderr.WriteString("\r" + message)
}
func stripColors(message string) string {
if !strings.Contains(message, "\x1b[") {
return message
}
var builder strings.Builder
start := 0
for index := 0; index < len(message); {
if message[index] != '\x1b' || index+1 >= len(message) || message[index+1] != '[' {
index++
continue
}
end := index + 2
for end < len(message) && message[end] != 'm' {
end++
}
if end >= len(message) {
break
}
builder.WriteString(message[start:index])
index = end + 1
start = index
}
builder.WriteString(message[start:])
return builder.String()
}
type tableWriter struct {
header []string
emptyMessage string
rows [][]string
}
func (t *tableWriter) addRow(cells ...string) {
t.rows = append(t.rows, common.Map(cells, func(it string) string {
if it == "" {
return "-"
}
return it
}))
}
func (t *tableWriter) flush() {
if len(t.rows) == 0 {
writeStderrLine(t.emptyMessage)
return
}
if !stdoutIsTerminal {
var output strings.Builder
for _, row := range t.rows {
output.WriteString(strings.Join(row, "\t"))
output.WriteString("\n")
}
os.Stdout.WriteString(output.String())
return
}
widths := common.Map(t.header, func(it string) int {
return runewidth.StringWidth(it)
})
for _, row := range t.rows {
for index, cell := range row {
widths[index] = max(widths[index], runewidth.StringWidth(cell))
}
}
renderRow := func(cells []string) string {
var builder strings.Builder
for index, cell := range cells {
if index > 0 {
builder.WriteString(" ")
}
builder.WriteString(cell)
if index < len(cells)-1 {
builder.WriteString(strings.Repeat(" ", widths[index]-runewidth.StringWidth(cell)))
}
}
return builder.String()
}
writeStderrLine(renderRow(t.header))
var output strings.Builder
for _, row := range t.rows {
output.WriteString(renderRow(row))
output.WriteString("\n")
}
os.Stdout.WriteString(output.String())
}
type blockLine struct {
label string
value string
}
type blockWriter struct {
lines []blockLine
}
func (b *blockWriter) addLine(label string, value string) {
if value == "" {
value = "-"
}
b.lines = append(b.lines, blockLine{label: label, value: value})
}
func (b *blockWriter) flush() {
if len(b.lines) == 0 {
return
}
labelWidth := len(common.MaxBy(b.lines, func(it blockLine) int {
return len(it.label)
}).label) + 3
var output strings.Builder
for _, line := range b.lines {
output.WriteString(line.label)
output.WriteString(":")
output.WriteString(strings.Repeat(" ", labelWidth-len(line.label)-1))
output.WriteString(line.value)
output.WriteString("\n")
}
os.Stdout.WriteString(output.String())
}
+99
View File
@@ -0,0 +1,99 @@
package main
import (
"context"
"strings"
"sync"
"time"
"github.com/sagernet/sing-box/daemon"
"github.com/sagernet/sing/common/byteformats"
F "github.com/sagernet/sing/common/format"
"github.com/spf13/cobra"
"google.golang.org/protobuf/types/known/emptypb"
)
var commandAPIStatus = &cobra.Command{
Use: "status",
Short: "Print the service status",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runAPIStatus()
},
}
func init() {
commandAPIRoot.AddCommand(commandAPIStatus)
}
func runAPIStatus() error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
ctx, cancel := context.WithCancel(globalCtx)
defer cancel()
statusStream, err := client.SubscribeStatus(ctx, &daemon.SubscribeStatusRequest{Interval: int64(time.Second)})
if err != nil {
return err
}
var (
waitGroup sync.WaitGroup
serviceStatus *daemon.ServiceStatus
startedAt *daemon.StartedAt
)
waitGroup.Go(func() {
serviceStatusStream, statusErr := client.SubscribeServiceStatus(ctx, &emptypb.Empty{})
if statusErr != nil {
return
}
serviceStatus, _ = serviceStatusStream.Recv()
})
waitGroup.Go(func() {
startedAt, _ = client.GetStartedAt(ctx, &emptypb.Empty{})
})
status, err := statusStream.Recv()
if err != nil {
return err
}
rateStatus, err := statusStream.Recv()
if err == nil {
status = rateStatus
}
waitGroup.Wait()
var state string
if serviceStatus != nil {
state = strings.ToLower(serviceStatus.GetStatus().String())
}
var uptime string
if startedAt.GetStartedAt() > 0 {
uptime = time.Since(time.UnixMilli(startedAt.GetStartedAt())).Truncate(time.Second).String()
}
var connections string
if status.GetTrafficAvailable() {
connections = F.ToString(status.GetConnectionsIn(), " in / ", status.GetConnectionsOut(), " out")
} else {
connections = F.ToString("- in / ", status.GetConnectionsOut(), " out")
}
var uplink, downlink string
if status.GetTrafficAvailable() {
uplink = F.ToString(byteformats.FormatBytes(uint64(status.GetUplink())), "/s (", byteformats.FormatBytes(uint64(status.GetUplinkTotal())), " total)")
downlink = F.ToString(byteformats.FormatBytes(uint64(status.GetDownlink())), "/s (", byteformats.FormatBytes(uint64(status.GetDownlinkTotal())), " total)")
}
var block blockWriter
block.addLine("State", state)
block.addLine("Uptime", uptime)
block.addLine("Memory", byteformats.FormatMemoryBytes(status.GetMemory()))
block.addLine("Goroutines", F.ToString(status.GetGoroutines()))
block.addLine("Connections", connections)
block.addLine("Uplink", uplink)
block.addLine("Downlink", downlink)
if serviceStatus.GetStatus() == daemon.ServiceStatus_FATAL {
block.addLine("Error", serviceStatus.GetErrorMessage())
}
block.flush()
return nil
}
+82
View File
@@ -0,0 +1,82 @@
package main
import (
"fmt"
"os"
"github.com/sagernet/sing-box/common/stun"
"github.com/sagernet/sing-box/daemon"
E "github.com/sagernet/sing/common/exceptions"
"github.com/spf13/cobra"
)
var (
commandAPISTUNFlagServer string
commandAPISTUNFlagOutbound string
)
var commandAPISTUN = &cobra.Command{
Use: "stun",
Short: "Run a STUN test",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runAPISTUN()
},
}
func init() {
commandAPISTUN.Flags().StringVar(&commandAPISTUNFlagServer, "server", stun.DefaultServer, "STUN server address")
commandAPISTUN.Flags().StringVarP(&commandAPISTUNFlagOutbound, "outbound", "o", "", "Use specified tag instead of default outbound")
commandAPIRoot.AddCommand(commandAPISTUN)
}
func runAPISTUN() error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
stream, err := client.StartSTUNTest(globalCtx, &daemon.STUNTestRequest{
Server: commandAPISTUNFlagServer,
OutboundTag: commandAPISTUNFlagOutbound,
})
if err != nil {
return err
}
writeStderrLine("==== STUN TEST ====")
for {
progress, recvErr := stream.Recv()
if recvErr != nil {
return recvErr
}
if !progress.GetIsFinal() {
switch stun.Phase(progress.GetPhase()) {
case stun.PhaseBinding:
if progress.GetExternalAddr() != "" {
writeProgress(fmt.Sprintf("External Address: %s (%d ms)", progress.GetExternalAddr(), progress.GetLatencyMs()))
} else {
writeProgress("Sending binding request...")
}
case stun.PhaseNATMapping:
writeProgress("Detecting NAT mapping behavior...")
case stun.PhaseNATFiltering:
writeProgress("Detecting NAT filtering behavior...")
}
continue
}
writeStderrLine("")
if progress.GetError() != "" {
return E.New(progress.GetError())
}
fmt.Fprintf(os.Stdout, "External Address: %s\n", progress.GetExternalAddr())
fmt.Fprintf(os.Stdout, "Latency: %d ms\n", progress.GetLatencyMs())
if progress.GetNatTypeSupported() {
fmt.Fprintf(os.Stdout, "NAT Mapping: %s\n", stun.NATMapping(progress.GetNatMapping()))
fmt.Fprintf(os.Stdout, "NAT Filtering: %s\n", stun.NATFiltering(progress.GetNatFiltering()))
} else {
fmt.Fprintln(os.Stdout, "NAT Type Detection: not supported by server")
}
return nil
}
}
+88
View File
@@ -0,0 +1,88 @@
package main
import (
"context"
"strings"
"github.com/sagernet/sing-box/daemon"
"github.com/sagernet/sing/common"
E "github.com/sagernet/sing/common/exceptions"
"github.com/spf13/cobra"
"google.golang.org/protobuf/types/known/emptypb"
)
const commandAPITailscaleEndpointUsage = "Tailscale endpoint tag (default: the only Tailscale endpoint)"
var commandAPITailscaleFlagEndpoint string
var commandAPITailscale = &cobra.Command{
Use: "tailscale",
Short: "Manage Tailscale endpoints",
}
func init() {
commandAPIRoot.AddCommand(commandAPITailscale)
}
func fetchTailscaleStatus(client daemon.StartedServiceClient) ([]*daemon.TailscaleEndpointStatus, error) {
ctx, cancel := context.WithCancel(globalCtx)
defer cancel()
stream, err := client.SubscribeTailscaleStatus(ctx, &emptypb.Empty{})
if err != nil {
return nil, err
}
update, err := stream.Recv()
if err != nil {
return nil, err
}
endpoints := update.GetEndpoints()
common.SortBy(endpoints, func(it *daemon.TailscaleEndpointStatus) string {
return it.GetEndpointTag()
})
return endpoints, nil
}
func resolveTailscaleEndpointStatus(endpoints []*daemon.TailscaleEndpointStatus) (*daemon.TailscaleEndpointStatus, error) {
if len(endpoints) == 0 {
return nil, E.New("no tailscale endpoint found")
}
if commandAPITailscaleFlagEndpoint != "" {
endpoint := common.Find(endpoints, func(it *daemon.TailscaleEndpointStatus) bool {
return it.GetEndpointTag() == commandAPITailscaleFlagEndpoint
})
if endpoint == nil {
return nil, E.New("unknown tailscale endpoint: ", commandAPITailscaleFlagEndpoint, "\nknown endpoints:\n", formatTailscaleEndpointTags(endpoints))
}
return endpoint, nil
}
if len(endpoints) > 1 {
return nil, E.New("multiple tailscale endpoints, use --endpoint to select one:\n", formatTailscaleEndpointTags(endpoints))
}
return endpoints[0], nil
}
func fetchTailscaleEndpoint(client daemon.StartedServiceClient) (*daemon.TailscaleEndpointStatus, error) {
endpoints, err := fetchTailscaleStatus(client)
if err != nil {
return nil, err
}
return resolveTailscaleEndpointStatus(endpoints)
}
func resolveTailscaleEndpointTag(client daemon.StartedServiceClient) (string, error) {
if commandAPITailscaleFlagEndpoint != "" {
return commandAPITailscaleFlagEndpoint, nil
}
endpoint, err := fetchTailscaleEndpoint(client)
if err != nil {
return "", err
}
return endpoint.GetEndpointTag(), nil
}
func formatTailscaleEndpointTags(endpoints []*daemon.TailscaleEndpointStatus) string {
return strings.Join(common.Map(endpoints, func(it *daemon.TailscaleEndpointStatus) string {
return " " + it.GetEndpointTag()
}), "\n")
}
@@ -0,0 +1,15 @@
package main
import (
"github.com/spf13/cobra"
)
var commandAPITailscaleCertificate = &cobra.Command{
Use: "certificate",
Short: "Manage Tailscale HTTPS certificates",
}
func init() {
commandAPITailscaleCertificate.PersistentFlags().StringVar(&commandAPITailscaleFlagEndpoint, "endpoint", "", commandAPITailscaleEndpointUsage)
commandAPITailscale.AddCommand(commandAPITailscaleCertificate)
}
@@ -0,0 +1,108 @@
package main
import (
"os"
"slices"
"strings"
"time"
"github.com/sagernet/sing-box/daemon"
"github.com/sagernet/sing/common"
E "github.com/sagernet/sing/common/exceptions"
"github.com/spf13/cobra"
)
var (
commandAPITailscaleCertificateExportFlagCertificateFile string
commandAPITailscaleCertificateExportFlagKeyFile string
commandAPITailscaleCertificateExportFlagMinValidity time.Duration
)
var commandAPITailscaleCertificateExport = &cobra.Command{
Use: "export <domain>",
Short: "Export the HTTPS certificate and private key for a domain",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runAPITailscaleCertificateExport(args[0])
},
}
func init() {
commandAPITailscaleCertificateExport.Flags().StringVar(&commandAPITailscaleCertificateExportFlagCertificateFile, "cert-file", "", "Output certificate file (default: <domain>.crt, \"-\" for stdout)")
commandAPITailscaleCertificateExport.Flags().StringVar(&commandAPITailscaleCertificateExportFlagKeyFile, "key-file", "", "Output private key file (default: <domain>.key, \"-\" for stdout)")
commandAPITailscaleCertificateExport.Flags().DurationVar(&commandAPITailscaleCertificateExportFlagMinValidity, "min-validity", 0, "Renew the certificate if it expires within this duration")
commandAPITailscaleCertificate.AddCommand(commandAPITailscaleCertificateExport)
}
func runAPITailscaleCertificateExport(domain string) error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
endpoint, err := fetchTailscaleEndpoint(client)
if err != nil {
return err
}
certDomains := endpoint.GetCertDomains()
if len(certDomains) == 0 {
return E.New("no certificate domains, enable HTTPS in the Tailscale admin console")
}
if !slices.Contains(certDomains, domain) {
return E.New("unknown certificate domain: ", domain, "\nknown domains:\n", formatTailscaleCertificateDomains(certDomains))
}
certificateFile := commandAPITailscaleCertificateExportFlagCertificateFile
if certificateFile == "" {
certificateFile = domain + ".crt"
}
keyFile := commandAPITailscaleCertificateExportFlagKeyFile
if keyFile == "" {
keyFile = domain + ".key"
}
writeStderrLine("fetching certificate for " + domain)
certificate, err := client.GetTailscaleCertificate(globalCtx, &daemon.TailscaleCertificateRequest{
EndpointTag: endpoint.GetEndpointTag(),
Domain: domain,
MinValiditySeconds: int64(commandAPITailscaleCertificateExportFlagMinValidity / time.Second),
})
if err != nil {
return err
}
if certificateFile == "-" && keyFile == "-" {
_, err = os.Stdout.Write(certificate.GetCertificatePEM())
if err != nil {
return err
}
_, err = os.Stdout.Write(certificate.GetPrivateKeyPEM())
return err
}
err = writeTailscaleCertificateFile(certificateFile, certificate.GetCertificatePEM(), 0o644)
if err != nil {
return E.Cause(err, "write certificate")
}
err = writeTailscaleCertificateFile(keyFile, certificate.GetPrivateKeyPEM(), 0o600)
if err != nil {
return E.Cause(err, "write private key")
}
return nil
}
func writeTailscaleCertificateFile(path string, content []byte, mode os.FileMode) error {
if path == "-" {
_, err := os.Stdout.Write(content)
return err
}
err := os.WriteFile(path, content, mode)
if err != nil {
return err
}
writeStderrLine("wrote " + path)
return nil
}
func formatTailscaleCertificateDomains(domains []string) string {
return strings.Join(common.Map(domains, func(it string) string {
return " " + it
}), "\n")
}
@@ -0,0 +1,39 @@
package main
import (
"github.com/spf13/cobra"
)
var commandAPITailscaleCertificateList = &cobra.Command{
Use: "list",
Short: "List domains that certificates can be issued for",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runAPITailscaleCertificateList()
},
}
func init() {
commandAPITailscaleCertificate.AddCommand(commandAPITailscaleCertificateList)
}
func runAPITailscaleCertificateList() error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
endpoint, err := fetchTailscaleEndpoint(client)
if err != nil {
return err
}
table := tableWriter{
header: []string{"DOMAIN"},
emptyMessage: "no certificate domains, enable HTTPS in the Tailscale admin console",
}
for _, domain := range endpoint.GetCertDomains() {
table.addRow(domain)
}
table.flush()
return nil
}
@@ -0,0 +1,48 @@
package main
import (
"os"
F "github.com/sagernet/sing/common/format"
"github.com/spf13/cobra"
)
var commandAPITailscaleExitNode = &cobra.Command{
Use: "exit-node",
Short: "Print the current Tailscale exit node",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runAPITailscaleExitNode()
},
}
func init() {
commandAPITailscaleExitNode.PersistentFlags().StringVar(&commandAPITailscaleFlagEndpoint, "endpoint", "", commandAPITailscaleEndpointUsage)
commandAPITailscale.AddCommand(commandAPITailscaleExitNode)
}
func runAPITailscaleExitNode() error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
endpoint, err := fetchTailscaleEndpoint(client)
if err != nil {
return err
}
exitNode := endpoint.GetExitNode()
if exitNode == nil {
os.Stdout.WriteString("none\n")
return nil
}
name := tailscalePeerName(exitNode)
address := tailscalePeerAddress(exitNode)
if address == "" {
os.Stdout.WriteString(name + "\n")
return nil
}
os.Stdout.WriteString(F.ToString(name, " (", address, ")", "\n"))
return nil
}
@@ -0,0 +1,36 @@
package main
import (
"github.com/sagernet/sing-box/daemon"
"github.com/spf13/cobra"
)
var commandAPITailscaleExitNodeClear = &cobra.Command{
Use: "clear",
Short: "Stop using a Tailscale exit node",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runAPITailscaleExitNodeClear()
},
}
func init() {
commandAPITailscaleExitNode.AddCommand(commandAPITailscaleExitNodeClear)
}
func runAPITailscaleExitNodeClear() error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
endpointTag, err := resolveTailscaleEndpointTag(client)
if err != nil {
return err
}
_, err = client.SetTailscaleExitNode(globalCtx, &daemon.SetTailscaleExitNodeRequest{
EndpointTag: endpointTag,
})
return err
}
@@ -0,0 +1,55 @@
package main
import (
"github.com/sagernet/sing/common"
"github.com/spf13/cobra"
)
var commandAPITailscaleExitNodeList = &cobra.Command{
Use: "list",
Short: "List available Tailscale exit nodes",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runAPITailscaleExitNodeList()
},
}
func init() {
commandAPITailscaleExitNode.AddCommand(commandAPITailscaleExitNodeList)
}
func runAPITailscaleExitNodeList() error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
endpoint, err := fetchTailscaleEndpoint(client)
if err != nil {
return err
}
selectedStableID := endpoint.GetExitNode().GetStableID()
candidates := common.Filter(tailscalePeerEntries(endpoint), func(it tailscalePeerEntry) bool {
return !it.self && it.peer.GetExitNodeOption()
})
sortTailscalePeerEntries(candidates)
table := tableWriter{
header: []string{"DNS NAME", "IP", "ONLINE", "STATUS"},
emptyMessage: "no exit nodes",
}
for _, entry := range candidates {
var exitNodeStatus string
if selectedStableID != "" && entry.peer.GetStableID() == selectedStableID {
exitNodeStatus = "selected"
}
table.addRow(
tailscalePeerName(entry.peer),
tailscalePeerAddress(entry.peer),
formatYesNo(entry.peer.GetOnline()),
exitNodeStatus,
)
}
table.flush()
return nil
}
@@ -0,0 +1,41 @@
package main
import (
"github.com/sagernet/sing-box/daemon"
"github.com/spf13/cobra"
)
var commandAPITailscaleExitNodeSet = &cobra.Command{
Use: "set <peer>",
Short: "Use a Tailscale peer as exit node",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runAPITailscaleExitNodeSet(args[0])
},
}
func init() {
commandAPITailscaleExitNode.AddCommand(commandAPITailscaleExitNodeSet)
}
func runAPITailscaleExitNodeSet(selector string) error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
endpoint, err := fetchTailscaleEndpoint(client)
if err != nil {
return err
}
entry, err := resolveTailscalePeer(tailscalePeerEntries(endpoint), selector)
if err != nil {
return err
}
_, err = client.SetTailscaleExitNode(globalCtx, &daemon.SetTailscaleExitNodeRequest{
EndpointTag: endpoint.GetEndpointTag(),
StableID: entry.peer.GetStableID(),
})
return err
}
+37
View File
@@ -0,0 +1,37 @@
package main
import (
"github.com/sagernet/sing-box/daemon"
"github.com/spf13/cobra"
)
var commandAPITailscaleLogout = &cobra.Command{
Use: "logout",
Short: "Log out of the Tailscale network",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runAPITailscaleLogout()
},
}
func init() {
commandAPITailscaleLogout.Flags().StringVar(&commandAPITailscaleFlagEndpoint, "endpoint", "", commandAPITailscaleEndpointUsage)
commandAPITailscale.AddCommand(commandAPITailscaleLogout)
}
func runAPITailscaleLogout() error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
endpointTag, err := resolveTailscaleEndpointTag(client)
if err != nil {
return err
}
_, err = client.TailscaleLogout(globalCtx, &daemon.TailscaleLogoutRequest{
EndpointTag: endpointTag,
})
return err
}
+161
View File
@@ -0,0 +1,161 @@
package main
import (
"net/netip"
"slices"
"strings"
"github.com/sagernet/sing-box/daemon"
"github.com/sagernet/sing-box/dns"
"github.com/sagernet/sing/common"
E "github.com/sagernet/sing/common/exceptions"
"github.com/spf13/cobra"
)
var commandAPITailscalePeer = &cobra.Command{
Use: "peer",
Short: "Manage Tailscale peers",
}
func init() {
commandAPITailscalePeer.PersistentFlags().StringVar(&commandAPITailscaleFlagEndpoint, "endpoint", "", commandAPITailscaleEndpointUsage)
commandAPITailscale.AddCommand(commandAPITailscalePeer)
}
type tailscalePeerEntry struct {
peer *daemon.TailscalePeer
group *daemon.TailscaleUserGroup
self bool
}
func tailscalePeerEntries(endpoint *daemon.TailscaleEndpointStatus) []tailscalePeerEntry {
var entries []tailscalePeerEntry
if endpoint.GetSelf() != nil {
entries = append(entries, tailscalePeerEntry{peer: endpoint.GetSelf(), self: true})
}
for _, group := range endpoint.GetUserGroups() {
for _, peer := range group.GetPeers() {
entries = append(entries, tailscalePeerEntry{peer: peer, group: group})
}
}
return entries
}
func resolveTailscalePeer(entries []tailscalePeerEntry, selector string) (tailscalePeerEntry, error) {
selectorAddress, addressErr := netip.ParseAddr(selector)
matchers := []func(peer *daemon.TailscalePeer) bool{
func(peer *daemon.TailscalePeer) bool {
return peer.GetStableID() == selector
},
func(peer *daemon.TailscalePeer) bool {
if addressErr != nil {
return false
}
return slices.ContainsFunc(peer.GetTailscaleIPs(), func(it string) bool {
address, parseErr := netip.ParseAddr(it)
if parseErr != nil {
return false
}
return address.Unmap() == selectorAddress.Unmap()
})
},
func(peer *daemon.TailscalePeer) bool {
dnsName := peer.GetDnsName()
if dnsName == "" {
return false
}
return strings.EqualFold(dnsName, selector) || strings.EqualFold(dns.FqdnToDomain(dnsName), selector)
},
func(peer *daemon.TailscalePeer) bool {
label, _, _ := strings.Cut(peer.GetDnsName(), ".")
if label == "" {
return false
}
return strings.EqualFold(label, selector)
},
func(peer *daemon.TailscalePeer) bool {
hostName := peer.GetHostName()
if hostName == "" {
return false
}
return strings.EqualFold(hostName, selector)
},
}
for _, matcher := range matchers {
matches := common.Filter(entries, func(it tailscalePeerEntry) bool {
return matcher(it.peer)
})
if len(matches) == 1 {
return matches[0], nil
}
if len(matches) > 1 {
return tailscalePeerEntry{}, newTailscaleAmbiguousPeerError(selector, matches)
}
}
return tailscalePeerEntry{}, E.New("peer not found: ", selector)
}
func newTailscaleAmbiguousPeerError(selector string, matches []tailscalePeerEntry) error {
sortTailscalePeerEntries(matches)
names := common.Map(matches, func(it tailscalePeerEntry) string {
return tailscalePeerName(it.peer)
})
addresses := common.Map(matches, func(it tailscalePeerEntry) string {
address := tailscalePeerAddress(it.peer)
if address == "" {
return "-"
}
return address
})
nameWidth := len(common.MaxBy(names, func(it string) int {
return len(it)
}))
addressWidth := len(common.MaxBy(addresses, func(it string) int {
return len(it)
}))
var builder strings.Builder
builder.WriteString("ambiguous peer: ")
builder.WriteString(selector)
for index, entry := range matches {
builder.WriteString("\n ")
builder.WriteString(names[index])
builder.WriteString(strings.Repeat(" ", nameWidth-len(names[index])+3))
builder.WriteString(addresses[index])
builder.WriteString(strings.Repeat(" ", addressWidth-len(addresses[index])+3))
builder.WriteString(entry.peer.GetStableID())
}
return E.New(builder.String())
}
func sortTailscalePeerEntries(entries []tailscalePeerEntry) {
common.SortBy(entries, func(it tailscalePeerEntry) string {
return strings.ToLower(tailscalePeerName(it.peer))
})
}
func tailscalePeerName(peer *daemon.TailscalePeer) string {
name := dns.FqdnToDomain(peer.GetDnsName())
if name == "" {
name = peer.GetHostName()
}
if name == "" {
name = peer.GetStableID()
}
return name
}
func tailscalePeerAddress(peer *daemon.TailscalePeer) string {
addresses := peer.GetTailscaleIPs()
if len(addresses) == 0 {
return ""
}
return addresses[0]
}
func formatYesNo(value bool) string {
if value {
return "yes"
}
return "no"
}
@@ -0,0 +1,49 @@
package main
import (
"github.com/spf13/cobra"
)
var commandAPITailscalePeerList = &cobra.Command{
Use: "list",
Short: "List Tailscale peers",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runAPITailscalePeerList()
},
}
func init() {
commandAPITailscalePeer.AddCommand(commandAPITailscalePeerList)
}
func runAPITailscalePeerList() error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
endpoint, err := fetchTailscaleEndpoint(client)
if err != nil {
return err
}
entries := tailscalePeerEntries(endpoint)
sortableEntries := entries
if len(sortableEntries) > 0 && sortableEntries[0].self {
sortableEntries = sortableEntries[1:]
}
sortTailscalePeerEntries(sortableEntries)
table := tableWriter{
header: []string{"DNS NAME", "IP", "ONLINE"},
emptyMessage: "no peers",
}
for _, entry := range entries {
table.addRow(
tailscalePeerName(entry.peer),
tailscalePeerAddress(entry.peer),
formatYesNo(entry.peer.GetOnline()),
)
}
table.flush()
return nil
}
+102
View File
@@ -0,0 +1,102 @@
package main
import (
"strings"
"time"
"github.com/sagernet/sing-box/daemon"
"github.com/sagernet/sing-box/dns"
F "github.com/sagernet/sing/common/format"
"github.com/spf13/cobra"
)
const commandAPITailscalePeerLabelWidth = len("SSH host keys") + 3
var commandAPITailscalePeerShow = &cobra.Command{
Use: "show <peer>",
Short: "Print Tailscale peer details",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runAPITailscalePeerShow(args[0])
},
}
func init() {
commandAPITailscalePeer.AddCommand(commandAPITailscalePeerShow)
}
func runAPITailscalePeerShow(selector string) error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
endpoint, err := fetchTailscaleEndpoint(client)
if err != nil {
return err
}
entry, err := resolveTailscalePeer(tailscalePeerEntries(endpoint), selector)
if err != nil {
return err
}
peer := entry.peer
exitNode := "no"
if peer.GetExitNode() {
exitNode = "in use"
} else if peer.GetExitNodeOption() {
exitNode = "offered"
}
var block blockWriter
block.addLine("DNS name", dns.FqdnToDomain(peer.GetDnsName()))
block.addLine("Host name", peer.GetHostName())
block.addLine("Stable ID", peer.GetStableID())
block.addLine("User", formatTailscaleUser(entry.group))
block.addLine("OS", peer.GetOs())
block.addLine("IPs", strings.Join(peer.GetTailscaleIPs(), ", "))
block.addLine("Online", formatYesNo(peer.GetOnline()))
block.addLine("Active", formatYesNo(peer.GetActive()))
block.addLine("Expired", formatYesNo(peer.GetExpired()))
block.addLine("Sharee node", formatYesNo(peer.GetShareeNode()))
block.addLine("Exit node", exitNode)
block.addLine("Rx", F.ToString(peer.GetRxBytes()))
block.addLine("Tx", F.ToString(peer.GetTxBytes()))
block.addLine("Key expiry", formatTailscaleTime(peer.GetKeyExpiry()))
block.addLine("Last seen", formatTailscaleTime(peer.GetLastSeen()))
block.addLine("SSH host keys", formatTailscaleSSHHostKeys(peer.GetSshHostKeys()))
block.flush()
return nil
}
func formatTailscaleUser(group *daemon.TailscaleUserGroup) string {
loginName := group.GetLoginName()
displayName := group.GetDisplayName()
if displayName == "" || displayName == loginName {
return loginName
}
if loginName == "" {
return displayName
}
return F.ToString(loginName, " (", displayName, ")")
}
func formatTailscaleTime(timestamp int64) string {
if timestamp == 0 {
return ""
}
return time.Unix(timestamp, 0).Local().Format(time.RFC3339)
}
func formatTailscaleSSHHostKeys(hostKeys []string) string {
if len(hostKeys) == 0 {
return ""
}
var builder strings.Builder
builder.WriteString(F.ToString(len(hostKeys)))
for _, hostKey := range hostKeys {
builder.WriteString("\n")
builder.WriteString(strings.Repeat(" ", commandAPITailscalePeerLabelWidth))
builder.WriteString(hostKey)
}
return builder.String()
}
+143
View File
@@ -0,0 +1,143 @@
package main
import (
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/sagernet/sing-box/daemon"
E "github.com/sagernet/sing/common/exceptions"
F "github.com/sagernet/sing/common/format"
"github.com/spf13/cobra"
)
const (
commandAPITailscalePingCount = 10
commandAPITailscalePingTimeout = 5 * time.Second
)
var commandAPITailscalePing = &cobra.Command{
Use: "ping <peer>",
Short: "Ping a Tailscale peer",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runAPITailscalePing(args[0])
},
}
func init() {
commandAPITailscalePing.Flags().StringVar(&commandAPITailscaleFlagEndpoint, "endpoint", "", commandAPITailscaleEndpointUsage)
commandAPITailscale.AddCommand(commandAPITailscalePing)
}
func runAPITailscalePing(selector string) error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
endpoint, err := fetchTailscaleEndpoint(client)
if err != nil {
return err
}
entry, err := resolveTailscalePeer(tailscalePeerEntries(endpoint), selector)
if err != nil {
return err
}
peerAddress := tailscalePeerAddress(entry.peer)
if peerAddress == "" {
return E.New("peer has no tailscale address: ", tailscalePeerName(entry.peer))
}
peerName, _, _ := strings.Cut(tailscalePeerName(entry.peer), ".")
ctx, cancel := signal.NotifyContext(globalCtx, os.Interrupt, syscall.SIGTERM)
defer cancel()
stream, err := client.StartTailscalePing(ctx, &daemon.TailscalePingRequest{
EndpointTag: endpoint.GetEndpointTag(),
PeerIP: peerAddress,
})
if err != nil {
return err
}
responses := make(chan *daemon.TailscalePingResponse)
streamErrors := make(chan error, 1)
go func() {
for {
pingResponse, pingErr := stream.Recv()
if pingErr != nil {
streamErrors <- pingErr
return
}
select {
case responses <- pingResponse:
case <-ctx.Done():
return
}
}
}()
timer := time.NewTimer(commandAPITailscalePingTimeout)
defer timer.Stop()
var (
pongCount int
lastPeerRelay string
)
for pongCount < commandAPITailscalePingCount {
var (
response *daemon.TailscalePingResponse
recvErr error
)
select {
case response = <-responses:
case recvErr = <-streamErrors:
case <-timer.C:
return E.New("no reply from ", peerName, " (", peerAddress, ") after ", commandAPITailscalePingTimeout.String())
case <-ctx.Done():
}
if ctx.Err() != nil {
if pongCount > 0 {
return nil
}
return E.New("interrupted")
}
if recvErr != nil {
return recvErr
}
if response.GetError() != "" {
return E.New("ping error: ", response.GetError())
}
os.Stdout.WriteString(formatTailscalePong(peerName, peerAddress, response) + "\n")
pongCount++
if response.GetEndpoint() != "" {
return nil
}
lastPeerRelay = response.GetPeerRelay()
timer.Reset(commandAPITailscalePingTimeout)
}
if lastPeerRelay != "" {
os.Stdout.WriteString(F.ToString("direct connection not established, relayed by peer relay ", lastPeerRelay, "\n"))
} else {
os.Stdout.WriteString("direct connection not established\n")
}
return nil
}
func formatTailscalePong(peerName string, peerAddress string, response *daemon.TailscalePingResponse) string {
via := response.GetEndpoint()
if via == "" {
if response.GetPeerRelay() != "" {
via = F.ToString("peer relay ", response.GetPeerRelay())
} else if response.GetDerpRegionCode() != "" {
via = F.ToString("DERP(", response.GetDerpRegionCode(), ")")
} else {
via = F.ToString("DERP(", response.GetDerpRegionID(), ")")
}
}
latency := time.Duration(response.GetLatencyMs() * float64(time.Millisecond))
rounded := latency.Round(time.Millisecond)
if rounded == 0 {
rounded = latency.Round(time.Microsecond)
}
return F.ToString("pong from ", peerName, " (", peerAddress, ") via ", via, " in ", rounded.String())
}
+78
View File
@@ -0,0 +1,78 @@
package main
import (
"os/exec"
"os/user"
"strings"
E "github.com/sagernet/sing/common/exceptions"
"github.com/spf13/cobra"
)
var commandAPITailscaleSSH = &cobra.Command{
Use: "ssh [user@]<peer>",
Short: "SSH into a Tailscale peer",
Long: "SSH into a Tailscale peer.\n\n" +
"The local ssh binary is executed against the Tailscale address of the peer, so the machine running " +
"this command must be able to route Tailscale addresses into the Tailscale endpoint itself, " +
"usually by running behind a sing-box instance with a tun inbound.\n\n" +
"The user defaults to the current user, and Tailscale SSH authentication is not available: " +
"the peer is reached as an ordinary SSH server.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runAPITailscaleSSH(args[0])
},
}
func init() {
commandAPITailscaleSSH.Flags().StringVar(&commandAPITailscaleFlagEndpoint, "endpoint", "", commandAPITailscaleEndpointUsage)
commandAPITailscale.AddCommand(commandAPITailscaleSSH)
}
func runAPITailscaleSSH(target string) error {
loginName := ""
selector := target
nameIndex := strings.LastIndex(target, "@")
if nameIndex != -1 {
loginName = target[:nameIndex]
selector = target[nameIndex+1:]
}
if loginName == "" {
currentUser, userErr := user.Current()
if userErr == nil {
loginName = currentUser.Username
_, domainUserName, isDomainUser := strings.Cut(loginName, "\\")
if isDomainUser {
loginName = domainUserName
}
}
}
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
endpoint, err := fetchTailscaleEndpoint(client)
if err != nil {
return err
}
entry, err := resolveTailscalePeer(tailscalePeerEntries(endpoint), selector)
if err != nil {
return err
}
peerAddress := tailscalePeerAddress(entry.peer)
if peerAddress == "" {
return E.New("peer has no tailscale address: ", tailscalePeerName(entry.peer))
}
clientConn.Close()
sshPath, err := exec.LookPath("ssh")
if err != nil {
return E.New("ssh not found in PATH")
}
destination := peerAddress
if loginName != "" {
destination = loginName + "@" + peerAddress
}
return executeSSH(sshPath, []string{"ssh", destination})
}

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