mirror of
https://github.com/shtorm-7/sing-box-extended.git
synced 2026-09-15 21:00:27 +00:00
Merge tag 'v1.14.0'
This commit is contained in:
@@ -1 +1 @@
|
||||
ec9a39c5ba3b4a8d625ede04deaf3c9020afb916
|
||||
45832ab074849607406baa3e3a2c4660274602ed
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
- 其他
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
type CertificateStore interface {
|
||||
LifecycleService
|
||||
Pool() *x509.CertPool
|
||||
ExclusiveAnchors() bool
|
||||
}
|
||||
|
||||
func RootPoolFromContext(ctx context.Context) *x509.CertPool {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package adapter
|
||||
|
||||
type NetworkNamespaceManager interface {
|
||||
ResolvePath(nameOrPath string) string
|
||||
}
|
||||
+7
-3
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !with_usbip || !(linux || (darwin && cgo) || windows)
|
||||
|
||||
package adapter
|
||||
|
||||
type USBIPDynamicServer interface {
|
||||
usbipNotIncluded()
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Submodule
+1
Submodule clients/desktop added at cebee0d527
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, ","))
|
||||
|
||||
@@ -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, " ")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -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 + ")"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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`)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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`)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user