Add claude code multiplexer service
This commit is contained in:
78
.github/workflows/build.yml
vendored
78
.github/workflows/build.yml
vendored
@@ -93,10 +93,6 @@ jobs:
|
|||||||
- { os: windows, arch: "386", legacy_win7: true, legacy_name: "windows-7" }
|
- { os: windows, arch: "386", legacy_win7: true, legacy_name: "windows-7" }
|
||||||
- { os: windows, arch: arm64 }
|
- { os: windows, arch: arm64 }
|
||||||
|
|
||||||
- { os: darwin, arch: amd64 }
|
|
||||||
- { os: darwin, arch: arm64 }
|
|
||||||
- { os: darwin, arch: amd64, legacy_go124: true, legacy_name: "macos-11" }
|
|
||||||
|
|
||||||
- { os: android, arch: arm64, ndk: "aarch64-linux-android21" }
|
- { os: android, arch: arm64, ndk: "aarch64-linux-android21" }
|
||||||
- { os: android, arch: arm, ndk: "armv7a-linux-androideabi21" }
|
- { os: android, arch: arm, ndk: "armv7a-linux-androideabi21" }
|
||||||
- { os: android, arch: amd64, ndk: "x86_64-linux-android21" }
|
- { os: android, arch: amd64, ndk: "x86_64-linux-android21" }
|
||||||
@@ -146,7 +142,7 @@ jobs:
|
|||||||
- name: Set build tags
|
- name: Set build tags
|
||||||
run: |
|
run: |
|
||||||
set -xeuo pipefail
|
set -xeuo pipefail
|
||||||
TAGS='with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,badlinkname,tfogo_checklinkname0'
|
TAGS='with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,badlinkname,tfogo_checklinkname0'
|
||||||
echo "BUILD_TAGS=${TAGS}" >> "${GITHUB_ENV}"
|
echo "BUILD_TAGS=${TAGS}" >> "${GITHUB_ENV}"
|
||||||
- name: Build
|
- name: Build
|
||||||
if: matrix.os != 'android'
|
if: matrix.os != 'android'
|
||||||
@@ -285,6 +281,77 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: binary-${{ matrix.os }}_${{ matrix.arch }}${{ matrix.goarm && format('v{0}', matrix.goarm) }}${{ matrix.go386 && format('_{0}', matrix.go386) }}${{ matrix.gomips && format('_{0}', matrix.gomips) }}${{ matrix.legacy_name && format('-legacy-{0}', matrix.legacy_name) }}
|
name: binary-${{ matrix.os }}_${{ matrix.arch }}${{ matrix.goarm && format('v{0}', matrix.goarm) }}${{ matrix.go386 && format('_{0}', matrix.go386) }}${{ matrix.gomips && format('_{0}', matrix.gomips) }}${{ matrix.legacy_name && format('-legacy-{0}', matrix.legacy_name) }}
|
||||||
path: "dist"
|
path: "dist"
|
||||||
|
build_darwin:
|
||||||
|
name: Build Darwin binaries
|
||||||
|
if: github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Binary'
|
||||||
|
runs-on: macos-latest
|
||||||
|
needs:
|
||||||
|
- calculate_version
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- { arch: amd64 }
|
||||||
|
- { arch: arm64 }
|
||||||
|
- { arch: amd64, legacy_go124: true, legacy_name: "macos-11" }
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Setup Go
|
||||||
|
if: ${{ ! matrix.legacy_go124 }}
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: ^1.25.3
|
||||||
|
- name: Setup Go 1.24
|
||||||
|
if: matrix.legacy_go124
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: ~1.24.6
|
||||||
|
- name: Set tag
|
||||||
|
run: |-
|
||||||
|
git ls-remote --exit-code --tags origin v${{ needs.calculate_version.outputs.version }} || echo "PUBLISHED=false" >> "$GITHUB_ENV"
|
||||||
|
git tag v${{ needs.calculate_version.outputs.version }} -f
|
||||||
|
- name: Set build tags
|
||||||
|
run: |
|
||||||
|
set -xeuo pipefail
|
||||||
|
TAGS='with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,badlinkname,tfogo_checklinkname0'
|
||||||
|
echo "BUILD_TAGS=${TAGS}" >> "${GITHUB_ENV}"
|
||||||
|
- name: Build
|
||||||
|
run: |
|
||||||
|
set -xeuo pipefail
|
||||||
|
mkdir -p dist
|
||||||
|
go build -v -trimpath -o dist/sing-box -tags "${BUILD_TAGS}" \
|
||||||
|
-ldflags '-s -buildid= -X github.com/sagernet/sing-box/constant.Version=${{ needs.calculate_version.outputs.version }} -checklinkname=0' \
|
||||||
|
./cmd/sing-box
|
||||||
|
env:
|
||||||
|
CGO_ENABLED: "1"
|
||||||
|
GOOS: darwin
|
||||||
|
GOARCH: ${{ matrix.arch }}
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- name: Set name
|
||||||
|
run: |-
|
||||||
|
DIR_NAME="sing-box-${{ needs.calculate_version.outputs.version }}-darwin-${{ matrix.arch }}"
|
||||||
|
if [[ -n "${{ matrix.legacy_name }}" ]]; then
|
||||||
|
DIR_NAME="${DIR_NAME}-legacy-${{ matrix.legacy_name }}"
|
||||||
|
fi
|
||||||
|
echo "DIR_NAME=${DIR_NAME}" >> "${GITHUB_ENV}"
|
||||||
|
- name: Archive
|
||||||
|
run: |
|
||||||
|
set -xeuo pipefail
|
||||||
|
cd dist
|
||||||
|
mkdir -p "${DIR_NAME}"
|
||||||
|
cp ../LICENSE "${DIR_NAME}"
|
||||||
|
cp sing-box "${DIR_NAME}"
|
||||||
|
tar -czvf "${DIR_NAME}.tar.gz" "${DIR_NAME}"
|
||||||
|
rm -r "${DIR_NAME}"
|
||||||
|
- name: Cleanup
|
||||||
|
run: rm dist/sing-box
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: binary-darwin_${{ matrix.arch }}${{ matrix.legacy_name && format('-legacy-{0}', matrix.legacy_name) }}
|
||||||
|
path: "dist"
|
||||||
build_android:
|
build_android:
|
||||||
name: Build Android
|
name: Build Android
|
||||||
if: github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Android'
|
if: github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Android'
|
||||||
@@ -619,6 +686,7 @@ jobs:
|
|||||||
needs:
|
needs:
|
||||||
- calculate_version
|
- calculate_version
|
||||||
- build
|
- build
|
||||||
|
- build_darwin
|
||||||
- build_android
|
- build_android
|
||||||
- build_apple
|
- build_apple
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
2
.github/workflows/linux.yml
vendored
2
.github/workflows/linux.yml
vendored
@@ -85,7 +85,7 @@ jobs:
|
|||||||
- name: Set build tags
|
- name: Set build tags
|
||||||
run: |
|
run: |
|
||||||
set -xeuo pipefail
|
set -xeuo pipefail
|
||||||
TAGS='with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,badlinkname,tfogo_checklinkname0'
|
TAGS='with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,badlinkname,tfogo_checklinkname0'
|
||||||
echo "BUILD_TAGS=${TAGS}" >> "${GITHUB_ENV}"
|
echo "BUILD_TAGS=${TAGS}" >> "${GITHUB_ENV}"
|
||||||
- name: Build
|
- name: Build
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
project_name: sing-box
|
|
||||||
builds:
|
|
||||||
- id: main
|
|
||||||
main: ./cmd/sing-box
|
|
||||||
flags:
|
|
||||||
- -v
|
|
||||||
- -trimpath
|
|
||||||
ldflags:
|
|
||||||
- -X github.com/sagernet/sing-box/constant.Version={{ .Version }}
|
|
||||||
- -s
|
|
||||||
- -buildid=
|
|
||||||
tags:
|
|
||||||
- with_gvisor
|
|
||||||
- with_quic
|
|
||||||
- with_dhcp
|
|
||||||
- with_wireguard
|
|
||||||
- with_utls
|
|
||||||
- with_acme
|
|
||||||
- with_clash_api
|
|
||||||
- with_tailscale
|
|
||||||
env:
|
|
||||||
- CGO_ENABLED=0
|
|
||||||
targets:
|
|
||||||
- linux_386
|
|
||||||
- linux_amd64_v1
|
|
||||||
- linux_arm64
|
|
||||||
- linux_arm_7
|
|
||||||
- linux_s390x
|
|
||||||
- linux_riscv64
|
|
||||||
- linux_mips64le
|
|
||||||
mod_timestamp: '{{ .CommitTimestamp }}'
|
|
||||||
snapshot:
|
|
||||||
name_template: "{{ .Version }}.{{ .ShortCommit }}"
|
|
||||||
nfpms:
|
|
||||||
- &template
|
|
||||||
id: package
|
|
||||||
package_name: sing-box
|
|
||||||
file_name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ with .Mips }}_{{ . }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}'
|
|
||||||
builds:
|
|
||||||
- main
|
|
||||||
homepage: https://sing-box.sagernet.org/
|
|
||||||
maintainer: nekohasekai <contact-git@sekai.icu>
|
|
||||||
description: The universal proxy platform.
|
|
||||||
license: GPLv3 or later
|
|
||||||
formats:
|
|
||||||
- deb
|
|
||||||
- rpm
|
|
||||||
priority: extra
|
|
||||||
contents:
|
|
||||||
- src: release/config/config.json
|
|
||||||
dst: /etc/sing-box/config.json
|
|
||||||
type: "config|noreplace"
|
|
||||||
|
|
||||||
- src: release/config/sing-box.service
|
|
||||||
dst: /usr/lib/systemd/system/sing-box.service
|
|
||||||
- src: release/config/sing-box@.service
|
|
||||||
dst: /usr/lib/systemd/system/sing-box@.service
|
|
||||||
- src: release/config/sing-box.sysusers
|
|
||||||
dst: /usr/lib/sysusers.d/sing-box.conf
|
|
||||||
- src: release/config/sing-box.rules
|
|
||||||
dst: /usr/share/polkit-1/rules.d/sing-box.rules
|
|
||||||
- src: release/config/sing-box-split-dns.xml
|
|
||||||
dst: /usr/share/dbus-1/system.d/sing-box-split-dns.conf
|
|
||||||
|
|
||||||
- src: release/completions/sing-box.bash
|
|
||||||
dst: /usr/share/bash-completion/completions/sing-box.bash
|
|
||||||
- src: release/completions/sing-box.fish
|
|
||||||
dst: /usr/share/fish/vendor_completions.d/sing-box.fish
|
|
||||||
- src: release/completions/sing-box.zsh
|
|
||||||
dst: /usr/share/zsh/site-functions/_sing-box
|
|
||||||
|
|
||||||
- src: LICENSE
|
|
||||||
dst: /usr/share/licenses/sing-box/LICENSE
|
|
||||||
deb:
|
|
||||||
signature:
|
|
||||||
key_file: "{{ .Env.NFPM_KEY_PATH }}"
|
|
||||||
fields:
|
|
||||||
Bugs: https://github.com/SagerNet/sing-box/issues
|
|
||||||
rpm:
|
|
||||||
signature:
|
|
||||||
key_file: "{{ .Env.NFPM_KEY_PATH }}"
|
|
||||||
conflicts:
|
|
||||||
- sing-box-beta
|
|
||||||
- id: package_beta
|
|
||||||
<<: *template
|
|
||||||
package_name: sing-box-beta
|
|
||||||
file_name_template: '{{ .ProjectName }}-beta_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ with .Mips }}_{{ . }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}'
|
|
||||||
formats:
|
|
||||||
- deb
|
|
||||||
- rpm
|
|
||||||
conflicts:
|
|
||||||
- sing-box
|
|
||||||
release:
|
|
||||||
disable: true
|
|
||||||
furies:
|
|
||||||
- account: sagernet
|
|
||||||
ids:
|
|
||||||
- package
|
|
||||||
disable: "{{ not (not .Prerelease) }}"
|
|
||||||
- account: sagernet
|
|
||||||
ids:
|
|
||||||
- package_beta
|
|
||||||
disable: "{{ not .Prerelease }}"
|
|
||||||
213
.goreleaser.yaml
213
.goreleaser.yaml
@@ -1,213 +0,0 @@
|
|||||||
version: 2
|
|
||||||
project_name: sing-box
|
|
||||||
builds:
|
|
||||||
- &template
|
|
||||||
id: main
|
|
||||||
main: ./cmd/sing-box
|
|
||||||
flags:
|
|
||||||
- -v
|
|
||||||
- -trimpath
|
|
||||||
ldflags:
|
|
||||||
- -X github.com/sagernet/sing-box/constant.Version={{ .Version }}
|
|
||||||
- -s
|
|
||||||
- -buildid=
|
|
||||||
tags:
|
|
||||||
- with_gvisor
|
|
||||||
- with_quic
|
|
||||||
- with_dhcp
|
|
||||||
- with_wireguard
|
|
||||||
- with_utls
|
|
||||||
- with_acme
|
|
||||||
- with_clash_api
|
|
||||||
- with_tailscale
|
|
||||||
env:
|
|
||||||
- CGO_ENABLED=0
|
|
||||||
- GOTOOLCHAIN=local
|
|
||||||
targets:
|
|
||||||
- linux_386
|
|
||||||
- linux_amd64_v1
|
|
||||||
- linux_arm64
|
|
||||||
- linux_arm_6
|
|
||||||
- linux_arm_7
|
|
||||||
- linux_s390x
|
|
||||||
- linux_riscv64
|
|
||||||
- linux_mips64le
|
|
||||||
- windows_amd64_v1
|
|
||||||
- windows_386
|
|
||||||
- windows_arm64
|
|
||||||
- darwin_amd64_v1
|
|
||||||
- darwin_arm64
|
|
||||||
mod_timestamp: '{{ .CommitTimestamp }}'
|
|
||||||
- id: legacy
|
|
||||||
<<: *template
|
|
||||||
tags:
|
|
||||||
- with_gvisor
|
|
||||||
- with_quic
|
|
||||||
- with_dhcp
|
|
||||||
- with_wireguard
|
|
||||||
- with_utls
|
|
||||||
- with_acme
|
|
||||||
- with_clash_api
|
|
||||||
- with_tailscale
|
|
||||||
env:
|
|
||||||
- CGO_ENABLED=0
|
|
||||||
- GOROOT={{ .Env.GOPATH }}/go_legacy
|
|
||||||
tool: "{{ .Env.GOPATH }}/go_legacy/bin/go"
|
|
||||||
targets:
|
|
||||||
- windows_amd64_v1
|
|
||||||
- windows_386
|
|
||||||
- id: android
|
|
||||||
<<: *template
|
|
||||||
env:
|
|
||||||
- CGO_ENABLED=1
|
|
||||||
- GOTOOLCHAIN=local
|
|
||||||
overrides:
|
|
||||||
- goos: android
|
|
||||||
goarch: arm
|
|
||||||
goarm: 7
|
|
||||||
env:
|
|
||||||
- CC=armv7a-linux-androideabi21-clang
|
|
||||||
- CXX=armv7a-linux-androideabi21-clang++
|
|
||||||
- goos: android
|
|
||||||
goarch: arm64
|
|
||||||
env:
|
|
||||||
- CC=aarch64-linux-android21-clang
|
|
||||||
- CXX=aarch64-linux-android21-clang++
|
|
||||||
- goos: android
|
|
||||||
goarch: 386
|
|
||||||
env:
|
|
||||||
- CC=i686-linux-android21-clang
|
|
||||||
- CXX=i686-linux-android21-clang++
|
|
||||||
- goos: android
|
|
||||||
goarch: amd64
|
|
||||||
goamd64: v1
|
|
||||||
env:
|
|
||||||
- CC=x86_64-linux-android21-clang
|
|
||||||
- CXX=x86_64-linux-android21-clang++
|
|
||||||
targets:
|
|
||||||
- android_arm_7
|
|
||||||
- android_arm64
|
|
||||||
- android_386
|
|
||||||
- android_amd64
|
|
||||||
archives:
|
|
||||||
- &template
|
|
||||||
id: archive
|
|
||||||
builds:
|
|
||||||
- main
|
|
||||||
- android
|
|
||||||
formats:
|
|
||||||
- tar.gz
|
|
||||||
format_overrides:
|
|
||||||
- goos: windows
|
|
||||||
formats:
|
|
||||||
- zip
|
|
||||||
wrap_in_directory: true
|
|
||||||
files:
|
|
||||||
- LICENSE
|
|
||||||
name_template: '{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ if and .Mips (not (eq .Mips "hardfloat")) }}_{{ .Mips }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}'
|
|
||||||
- id: archive-legacy
|
|
||||||
<<: *template
|
|
||||||
builds:
|
|
||||||
- legacy
|
|
||||||
name_template: '{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}-legacy'
|
|
||||||
nfpms:
|
|
||||||
- id: package
|
|
||||||
package_name: sing-box
|
|
||||||
file_name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ if and .Mips (not (eq .Mips "hardfloat")) }}_{{ .Mips }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}'
|
|
||||||
builds:
|
|
||||||
- main
|
|
||||||
homepage: https://sing-box.sagernet.org/
|
|
||||||
maintainer: nekohasekai <contact-git@sekai.icu>
|
|
||||||
description: The universal proxy platform.
|
|
||||||
license: GPLv3 or later
|
|
||||||
formats:
|
|
||||||
- deb
|
|
||||||
- rpm
|
|
||||||
- archlinux
|
|
||||||
# - apk
|
|
||||||
# - ipk
|
|
||||||
priority: extra
|
|
||||||
contents:
|
|
||||||
- src: release/config/config.json
|
|
||||||
dst: /etc/sing-box/config.json
|
|
||||||
type: "config|noreplace"
|
|
||||||
|
|
||||||
- src: release/config/sing-box.service
|
|
||||||
dst: /usr/lib/systemd/system/sing-box.service
|
|
||||||
- src: release/config/sing-box@.service
|
|
||||||
dst: /usr/lib/systemd/system/sing-box@.service
|
|
||||||
- src: release/config/sing-box.sysusers
|
|
||||||
dst: /usr/lib/sysusers.d/sing-box.conf
|
|
||||||
- src: release/config/sing-box.rules
|
|
||||||
dst: /usr/share/polkit-1/rules.d/sing-box.rules
|
|
||||||
- src: release/config/sing-box-split-dns.xml
|
|
||||||
dst: /usr/share/dbus-1/system.d/sing-box-split-dns.conf
|
|
||||||
|
|
||||||
- src: release/completions/sing-box.bash
|
|
||||||
dst: /usr/share/bash-completion/completions/sing-box.bash
|
|
||||||
- src: release/completions/sing-box.fish
|
|
||||||
dst: /usr/share/fish/vendor_completions.d/sing-box.fish
|
|
||||||
- src: release/completions/sing-box.zsh
|
|
||||||
dst: /usr/share/zsh/site-functions/_sing-box
|
|
||||||
|
|
||||||
- src: LICENSE
|
|
||||||
dst: /usr/share/licenses/sing-box/LICENSE
|
|
||||||
deb:
|
|
||||||
signature:
|
|
||||||
key_file: "{{ .Env.NFPM_KEY_PATH }}"
|
|
||||||
fields:
|
|
||||||
Bugs: https://github.com/SagerNet/sing-box/issues
|
|
||||||
rpm:
|
|
||||||
signature:
|
|
||||||
key_file: "{{ .Env.NFPM_KEY_PATH }}"
|
|
||||||
overrides:
|
|
||||||
apk:
|
|
||||||
contents:
|
|
||||||
- src: release/config/config.json
|
|
||||||
dst: /etc/sing-box/config.json
|
|
||||||
type: config
|
|
||||||
|
|
||||||
- src: release/config/sing-box.initd
|
|
||||||
dst: /etc/init.d/sing-box
|
|
||||||
|
|
||||||
- src: release/completions/sing-box.bash
|
|
||||||
dst: /usr/share/bash-completion/completions/sing-box.bash
|
|
||||||
- src: release/completions/sing-box.fish
|
|
||||||
dst: /usr/share/fish/vendor_completions.d/sing-box.fish
|
|
||||||
- src: release/completions/sing-box.zsh
|
|
||||||
dst: /usr/share/zsh/site-functions/_sing-box
|
|
||||||
|
|
||||||
- src: LICENSE
|
|
||||||
dst: /usr/share/licenses/sing-box/LICENSE
|
|
||||||
ipk:
|
|
||||||
contents:
|
|
||||||
- src: release/config/config.json
|
|
||||||
dst: /etc/sing-box/config.json
|
|
||||||
type: config
|
|
||||||
|
|
||||||
- src: release/config/openwrt.init
|
|
||||||
dst: /etc/init.d/sing-box
|
|
||||||
- src: release/config/openwrt.conf
|
|
||||||
dst: /etc/config/sing-box
|
|
||||||
source:
|
|
||||||
enabled: false
|
|
||||||
name_template: '{{ .ProjectName }}-{{ .Version }}.source'
|
|
||||||
prefix_template: '{{ .ProjectName }}-{{ .Version }}/'
|
|
||||||
checksum:
|
|
||||||
disable: true
|
|
||||||
name_template: '{{ .ProjectName }}-{{ .Version }}.checksum'
|
|
||||||
signs:
|
|
||||||
- artifacts: checksum
|
|
||||||
release:
|
|
||||||
github:
|
|
||||||
owner: SagerNet
|
|
||||||
name: sing-box
|
|
||||||
draft: true
|
|
||||||
prerelease: auto
|
|
||||||
mode: replace
|
|
||||||
ids:
|
|
||||||
- archive
|
|
||||||
- package
|
|
||||||
skip_upload: true
|
|
||||||
partial:
|
|
||||||
by: target
|
|
||||||
@@ -13,7 +13,7 @@ RUN set -ex \
|
|||||||
&& export COMMIT=$(git rev-parse --short HEAD) \
|
&& export COMMIT=$(git rev-parse --short HEAD) \
|
||||||
&& export VERSION=$(go run ./cmd/internal/read_tag) \
|
&& export VERSION=$(go run ./cmd/internal/read_tag) \
|
||||||
&& go build -v -trimpath -tags \
|
&& go build -v -trimpath -tags \
|
||||||
"with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,badlinkname,tfogo_checklinkname0" \
|
"with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,badlinkname,tfogo_checklinkname0" \
|
||||||
-o /go/bin/sing-box \
|
-o /go/bin/sing-box \
|
||||||
-ldflags "-X \"github.com/sagernet/sing-box/constant.Version=$VERSION\" -s -w -buildid= -checklinkname=0" \
|
-ldflags "-X \"github.com/sagernet/sing-box/constant.Version=$VERSION\" -s -w -buildid= -checklinkname=0" \
|
||||||
./cmd/sing-box
|
./cmd/sing-box
|
||||||
|
|||||||
2
Makefile
2
Makefile
@@ -1,6 +1,6 @@
|
|||||||
NAME = sing-box
|
NAME = sing-box
|
||||||
COMMIT = $(shell git rev-parse --short HEAD)
|
COMMIT = $(shell git rev-parse --short HEAD)
|
||||||
TAGS ?= with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,badlinkname,tfogo_checklinkname0
|
TAGS ?= with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,badlinkname,tfogo_checklinkname0
|
||||||
|
|
||||||
GOHOSTOS = $(shell go env GOHOSTOS)
|
GOHOSTOS = $(shell go env GOHOSTOS)
|
||||||
GOHOSTARCH = $(shell go env GOHOSTARCH)
|
GOHOSTARCH = $(shell go env GOHOSTARCH)
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const (
|
|||||||
TypeDERP = "derp"
|
TypeDERP = "derp"
|
||||||
TypeResolved = "resolved"
|
TypeResolved = "resolved"
|
||||||
TypeSSMAPI = "ssm-api"
|
TypeSSMAPI = "ssm-api"
|
||||||
|
TypeCCM = "ccm"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
104
docs/configuration/service/ccm.md
Normal file
104
docs/configuration/service/ccm.md
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
---
|
||||||
|
icon: material/new-box
|
||||||
|
---
|
||||||
|
|
||||||
|
!!! question "Since sing-box 1.13.0"
|
||||||
|
|
||||||
|
# CCM
|
||||||
|
|
||||||
|
CCM (Claude Code Multiplexer) service is a multiplexing service that allows you to access your local Claude Code subscription remotely through custom tokens.
|
||||||
|
|
||||||
|
It handles OAuth authentication with Claude's API on your local machine while allowing remote Claude Code to authenticate using Auth Tokens via the `ANTHROPIC_AUTH_TOKEN` environment variable.
|
||||||
|
|
||||||
|
### Structure
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "ccm",
|
||||||
|
|
||||||
|
... // Listen Fields
|
||||||
|
|
||||||
|
"credential_path": "",
|
||||||
|
"usages_path": "",
|
||||||
|
"users": [],
|
||||||
|
"headers": {},
|
||||||
|
"detour": "",
|
||||||
|
"tls": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Listen Fields
|
||||||
|
|
||||||
|
See [Listen Fields](/configuration/shared/listen/) for details.
|
||||||
|
|
||||||
|
### Fields
|
||||||
|
|
||||||
|
#### credential_path
|
||||||
|
|
||||||
|
Path to the Claude Code OAuth credentials file.
|
||||||
|
|
||||||
|
Defaults to `~/.claude/.credentials.json` if not specified.
|
||||||
|
|
||||||
|
On macOS, credentials are read from the system keychain first, then fall back to the file if unavailable.
|
||||||
|
|
||||||
|
Refreshed tokens are automatically written back to the same location.
|
||||||
|
|
||||||
|
#### usages_path
|
||||||
|
|
||||||
|
Path to the file for storing aggregated API usage statistics.
|
||||||
|
|
||||||
|
Usage tracking is disabled if not specified.
|
||||||
|
|
||||||
|
When enabled, the service tracks and saves comprehensive statistics including:
|
||||||
|
- Request counts
|
||||||
|
- Token usage (input, output, cache read, cache creation)
|
||||||
|
- Calculated costs in USD based on Claude API pricing
|
||||||
|
|
||||||
|
Statistics are organized by model, context window (200k standard vs 1M premium), and optionally by user when authentication is enabled.
|
||||||
|
|
||||||
|
The statistics file is automatically saved every minute and upon service shutdown.
|
||||||
|
|
||||||
|
#### users
|
||||||
|
|
||||||
|
List of authorized users for token authentication.
|
||||||
|
|
||||||
|
If empty, no authentication is required.
|
||||||
|
|
||||||
|
Claude Code authenticates by setting the `ANTHROPIC_AUTH_TOKEN` environment variable to their token value.
|
||||||
|
|
||||||
|
#### headers
|
||||||
|
|
||||||
|
Custom HTTP headers to send to the Claude API.
|
||||||
|
|
||||||
|
These headers will override any existing headers with the same name.
|
||||||
|
|
||||||
|
#### detour
|
||||||
|
|
||||||
|
Outbound tag for connecting to the Claude API.
|
||||||
|
|
||||||
|
#### tls
|
||||||
|
|
||||||
|
TLS configuration, see [TLS](/configuration/shared/tls/#inbound).
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"type": "ccm",
|
||||||
|
"listen": "127.0.0.1",
|
||||||
|
"listen_port": 8080
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Connect to the CCM service:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export ANTHROPIC_BASE_URL="http://127.0.0.1:8080"
|
||||||
|
export ANTHROPIC_AUTH_TOKEN="sk-ant-ccm-auth-token-not-required-in-this-context"
|
||||||
|
|
||||||
|
claude
|
||||||
|
```
|
||||||
104
docs/configuration/service/ccm.zh.md
Normal file
104
docs/configuration/service/ccm.zh.md
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
---
|
||||||
|
icon: material/new-box
|
||||||
|
---
|
||||||
|
|
||||||
|
!!! question "自 sing-box 1.13.0 起"
|
||||||
|
|
||||||
|
# CCM
|
||||||
|
|
||||||
|
CCM(Claude Code 多路复用器)服务是一个多路复用服务,允许您通过自定义令牌远程访问本地的 Claude Code 订阅。
|
||||||
|
|
||||||
|
它在本地机器上处理与 Claude API 的 OAuth 身份验证,同时允许远程 Claude Code 通过 `ANTHROPIC_AUTH_TOKEN` 环境变量使用认证令牌进行身份验证。
|
||||||
|
|
||||||
|
### 结构
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "ccm",
|
||||||
|
|
||||||
|
... // 监听字段
|
||||||
|
|
||||||
|
"credential_path": "",
|
||||||
|
"usages_path": "",
|
||||||
|
"users": [],
|
||||||
|
"headers": {},
|
||||||
|
"detour": "",
|
||||||
|
"tls": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 监听字段
|
||||||
|
|
||||||
|
参阅 [监听字段](/zh/configuration/shared/listen/) 了解详情。
|
||||||
|
|
||||||
|
### 字段
|
||||||
|
|
||||||
|
#### credential_path
|
||||||
|
|
||||||
|
Claude Code OAuth 凭据文件的路径。
|
||||||
|
|
||||||
|
如果未指定,默认使用 `~/.claude/.credentials.json`。
|
||||||
|
|
||||||
|
在 macOS 上,首先从系统钥匙串读取凭据,如果不可用则回退到文件。
|
||||||
|
|
||||||
|
刷新的令牌会自动写回相同位置。
|
||||||
|
|
||||||
|
#### usages_path
|
||||||
|
|
||||||
|
用于存储聚合 API 使用统计信息的文件路径。
|
||||||
|
|
||||||
|
如果未指定,使用跟踪将被禁用。
|
||||||
|
|
||||||
|
启用后,服务会跟踪并保存全面的统计信息,包括:
|
||||||
|
- 请求计数
|
||||||
|
- 令牌使用量(输入、输出、缓存读取、缓存创建)
|
||||||
|
- 基于 Claude API 定价计算的美元成本
|
||||||
|
|
||||||
|
统计信息按模型、上下文窗口(200k 标准版 vs 1M 高级版)以及可选的用户(启用身份验证时)进行组织。
|
||||||
|
|
||||||
|
统计文件每分钟自动保存一次,并在服务关闭时保存。
|
||||||
|
|
||||||
|
#### users
|
||||||
|
|
||||||
|
用于令牌身份验证的授权用户列表。
|
||||||
|
|
||||||
|
如果为空,则不需要身份验证。
|
||||||
|
|
||||||
|
Claude Code 通过设置 `ANTHROPIC_AUTH_TOKEN` 环境变量为其令牌值进行身份验证。
|
||||||
|
|
||||||
|
#### headers
|
||||||
|
|
||||||
|
发送到 Claude API 的自定义 HTTP 头。
|
||||||
|
|
||||||
|
这些头会覆盖同名的现有头。
|
||||||
|
|
||||||
|
#### detour
|
||||||
|
|
||||||
|
用于连接 Claude API 的出站标签。
|
||||||
|
|
||||||
|
#### tls
|
||||||
|
|
||||||
|
TLS 配置,参阅 [TLS](/zh/configuration/shared/tls/#inbound)。
|
||||||
|
|
||||||
|
### 示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"type": "ccm",
|
||||||
|
"listen": "127.0.0.1",
|
||||||
|
"listen_port": 8080
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
连接到 CCM 服务:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export ANTHROPIC_BASE_URL="http://127.0.0.1:8080"
|
||||||
|
export ANTHROPIC_AUTH_TOKEN="sk-ant-ccm-auth-token-not-required-in-this-context"
|
||||||
|
|
||||||
|
claude
|
||||||
|
```
|
||||||
@@ -23,6 +23,7 @@ icon: material/new-box
|
|||||||
|
|
||||||
| Type | Format |
|
| Type | Format |
|
||||||
|------------|------------------------|
|
|------------|------------------------|
|
||||||
|
| `ccm` | [CCM](./ccm) |
|
||||||
| `derp` | [DERP](./derp) |
|
| `derp` | [DERP](./derp) |
|
||||||
| `resolved` | [Resolved](./resolved) |
|
| `resolved` | [Resolved](./resolved) |
|
||||||
| `ssm-api` | [SSM API](./ssm-api) |
|
| `ssm-api` | [SSM API](./ssm-api) |
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ icon: material/new-box
|
|||||||
|
|
||||||
| 类型 | 格式 |
|
| 类型 | 格式 |
|
||||||
|-----------|------------------------|
|
|-----------|------------------------|
|
||||||
|
| `ccm` | [CCM](./ccm) |
|
||||||
| `derp` | [DERP](./derp) |
|
| `derp` | [DERP](./derp) |
|
||||||
| `resolved`| [Resolved](./resolved) |
|
| `resolved`| [Resolved](./resolved) |
|
||||||
| `ssm-api` | [SSM API](./ssm-api) |
|
| `ssm-api` | [SSM API](./ssm-api) |
|
||||||
|
|||||||
6
go.mod
6
go.mod
@@ -3,6 +3,7 @@ module github.com/sagernet/sing-box
|
|||||||
go 1.24.7
|
go 1.24.7
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/anthropics/anthropic-sdk-go v1.14.0
|
||||||
github.com/anytls/sing-anytls v0.0.11
|
github.com/anytls/sing-anytls v0.0.11
|
||||||
github.com/caddyserver/certmagic v0.23.0
|
github.com/caddyserver/certmagic v0.23.0
|
||||||
github.com/coder/websocket v1.8.13
|
github.com/coder/websocket v1.8.13
|
||||||
@@ -13,6 +14,7 @@ require (
|
|||||||
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466
|
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466
|
||||||
github.com/gofrs/uuid/v5 v5.3.2
|
github.com/gofrs/uuid/v5 v5.3.2
|
||||||
github.com/insomniacslk/dhcp v0.0.0-20250417080101-5f8cf70e8c5f
|
github.com/insomniacslk/dhcp v0.0.0-20250417080101-5f8cf70e8c5f
|
||||||
|
github.com/keybase/go-keychain v0.0.1
|
||||||
github.com/libdns/alidns v1.0.5-libdns.v1.beta1
|
github.com/libdns/alidns v1.0.5-libdns.v1.beta1
|
||||||
github.com/libdns/cloudflare v0.2.2-0.20250708034226-c574dccb31a6
|
github.com/libdns/cloudflare v0.2.2-0.20250708034226-c574dccb31a6
|
||||||
github.com/logrusorgru/aurora v2.0.3+incompatible
|
github.com/logrusorgru/aurora v2.0.3+incompatible
|
||||||
@@ -113,6 +115,10 @@ require (
|
|||||||
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect
|
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect
|
||||||
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect
|
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect
|
||||||
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da // indirect
|
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da // indirect
|
||||||
|
github.com/tidwall/gjson v1.18.0 // indirect
|
||||||
|
github.com/tidwall/match v1.1.1 // indirect
|
||||||
|
github.com/tidwall/pretty v1.2.1 // indirect
|
||||||
|
github.com/tidwall/sjson v1.2.5 // indirect
|
||||||
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect
|
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect
|
||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
github.com/zeebo/blake3 v0.2.4 // indirect
|
github.com/zeebo/blake3 v0.2.4 // indirect
|
||||||
|
|||||||
14
go.sum
14
go.sum
@@ -8,6 +8,8 @@ github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7V
|
|||||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||||
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
|
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
|
||||||
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
|
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
|
||||||
|
github.com/anthropics/anthropic-sdk-go v1.14.0 h1:EzNQvnZlaDHe2UPkoUySDz3ixRgNbwKdH8KtFpv7pi4=
|
||||||
|
github.com/anthropics/anthropic-sdk-go v1.14.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
|
||||||
github.com/anytls/sing-anytls v0.0.11 h1:w8e9Uj1oP3m4zxkyZDewPk0EcQbvVxb7Nn+rapEx4fc=
|
github.com/anytls/sing-anytls v0.0.11 h1:w8e9Uj1oP3m4zxkyZDewPk0EcQbvVxb7Nn+rapEx4fc=
|
||||||
github.com/anytls/sing-anytls v0.0.11/go.mod h1:7rjN6IukwysmdusYsrV51Fgu1uW6vsrdd6ctjnEAln8=
|
github.com/anytls/sing-anytls v0.0.11/go.mod h1:7rjN6IukwysmdusYsrV51Fgu1uW6vsrdd6ctjnEAln8=
|
||||||
github.com/caddyserver/certmagic v0.23.0 h1:CfpZ/50jMfG4+1J/u2LV6piJq4HOfO6ppOnOf7DkFEU=
|
github.com/caddyserver/certmagic v0.23.0 h1:CfpZ/50jMfG4+1J/u2LV6piJq4HOfO6ppOnOf7DkFEU=
|
||||||
@@ -95,6 +97,8 @@ github.com/insomniacslk/dhcp v0.0.0-20250417080101-5f8cf70e8c5f/go.mod h1:zhFlBe
|
|||||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||||
github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I=
|
github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I=
|
||||||
github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E=
|
github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E=
|
||||||
|
github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
|
||||||
|
github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
|
||||||
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
||||||
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||||
@@ -216,6 +220,16 @@ github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da h1:jVRUZPRs
|
|||||||
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
|
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
|
||||||
github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA=
|
github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA=
|
||||||
github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk=
|
github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk=
|
||||||
|
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||||
|
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||||
|
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||||
|
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||||
|
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||||
|
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
|
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||||
|
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
|
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||||
|
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||||
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM=
|
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM=
|
||||||
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA=
|
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA=
|
||||||
github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0=
|
github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0=
|
||||||
|
|||||||
12
include/ccm.go
Normal file
12
include/ccm.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
//go:build with_ccm && (!darwin || cgo)
|
||||||
|
|
||||||
|
package include
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/sagernet/sing-box/adapter/service"
|
||||||
|
"github.com/sagernet/sing-box/service/ccm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func registerCCMService(registry *service.Registry) {
|
||||||
|
ccm.RegisterService(registry)
|
||||||
|
}
|
||||||
20
include/ccm_stub.go
Normal file
20
include/ccm_stub.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
//go:build !with_ccm
|
||||||
|
|
||||||
|
package include
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/sagernet/sing-box/adapter"
|
||||||
|
"github.com/sagernet/sing-box/adapter/service"
|
||||||
|
C "github.com/sagernet/sing-box/constant"
|
||||||
|
"github.com/sagernet/sing-box/log"
|
||||||
|
"github.com/sagernet/sing-box/option"
|
||||||
|
E "github.com/sagernet/sing/common/exceptions"
|
||||||
|
)
|
||||||
|
|
||||||
|
func registerCCMService(registry *service.Registry) {
|
||||||
|
service.Register[option.CCMServiceOptions](registry, C.TypeCCM, func(ctx context.Context, logger log.ContextLogger, tag string, options option.CCMServiceOptions) (adapter.Service, error) {
|
||||||
|
return nil, E.New(`CCM is not included in this build, rebuild with -tags with_CCM`)
|
||||||
|
})
|
||||||
|
}
|
||||||
20
include/ccm_stub_darwin.go
Normal file
20
include/ccm_stub_darwin.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
//go:build with_ccm && darwin && !cgo
|
||||||
|
|
||||||
|
package include
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/sagernet/sing-box/adapter"
|
||||||
|
"github.com/sagernet/sing-box/adapter/service"
|
||||||
|
C "github.com/sagernet/sing-box/constant"
|
||||||
|
"github.com/sagernet/sing-box/log"
|
||||||
|
"github.com/sagernet/sing-box/option"
|
||||||
|
E "github.com/sagernet/sing/common/exceptions"
|
||||||
|
)
|
||||||
|
|
||||||
|
func registerCCMService(registry *service.Registry) {
|
||||||
|
service.Register[option.CCMServiceOptions](registry, C.TypeCCM, func(ctx context.Context, logger log.ContextLogger, tag string, options option.CCMServiceOptions) (adapter.Service, error) {
|
||||||
|
return nil, E.New(`CCM requires CGO on darwin, rebuild with CGO_ENABLED=1`)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -134,6 +134,7 @@ func ServiceRegistry() *service.Registry {
|
|||||||
ssmapi.RegisterService(registry)
|
ssmapi.RegisterService(registry)
|
||||||
|
|
||||||
registerDERPService(registry)
|
registerDERPService(registry)
|
||||||
|
registerCCMService(registry)
|
||||||
|
|
||||||
return registry
|
return registry
|
||||||
}
|
}
|
||||||
|
|||||||
20
option/ccm.go
Normal file
20
option/ccm.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package option
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/sagernet/sing/common/json/badoption"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CCMServiceOptions struct {
|
||||||
|
ListenOptions
|
||||||
|
InboundTLSOptionsContainer
|
||||||
|
CredentialPath string `json:"credential_path,omitempty"`
|
||||||
|
Users []CCMUser `json:"users,omitempty"`
|
||||||
|
Headers badoption.HTTPHeader `json:"headers,omitempty"`
|
||||||
|
Detour string `json:"detour,omitempty"`
|
||||||
|
UsagesPath string `json:"usages_path,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CCMUser struct {
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Token string `json:"token,omitempty"`
|
||||||
|
}
|
||||||
100
release/local/common.sh
Executable file
100
release/local/common.sh
Executable file
@@ -0,0 +1,100 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -e -o pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
|
BINARY_NAME="sing-box"
|
||||||
|
|
||||||
|
INSTALL_BIN_PATH="/usr/local/bin"
|
||||||
|
INSTALL_CONFIG_PATH="/usr/local/etc/sing-box"
|
||||||
|
INSTALL_DATA_PATH="/var/lib/sing-box"
|
||||||
|
SYSTEMD_SERVICE_PATH="/etc/systemd/system"
|
||||||
|
|
||||||
|
DEFAULT_BUILD_TAGS="with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,badlinkname,tfogo_checklinkname0"
|
||||||
|
|
||||||
|
setup_environment() {
|
||||||
|
if [ -d /usr/local/go ]; then
|
||||||
|
export PATH="$PATH:/usr/local/go/bin"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v go &> /dev/null; then
|
||||||
|
echo "Error: Go is not installed or not in PATH"
|
||||||
|
echo "Run install_go.sh to install Go"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
get_build_tags() {
|
||||||
|
local extra_tags="$1"
|
||||||
|
if [ -n "$extra_tags" ]; then
|
||||||
|
echo "${DEFAULT_BUILD_TAGS},${extra_tags}"
|
||||||
|
else
|
||||||
|
echo "${DEFAULT_BUILD_TAGS}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
get_version() {
|
||||||
|
cd "$PROJECT_DIR"
|
||||||
|
GOHOSTOS=$(go env GOHOSTOS)
|
||||||
|
GOHOSTARCH=$(go env GOHOSTARCH)
|
||||||
|
CGO_ENABLED=0 GOOS=$GOHOSTOS GOARCH=$GOHOSTARCH go run github.com/sagernet/sing-box/cmd/internal/read_tag@latest
|
||||||
|
}
|
||||||
|
|
||||||
|
get_ldflags() {
|
||||||
|
local version
|
||||||
|
version=$(get_version)
|
||||||
|
echo "-X 'github.com/sagernet/sing-box/constant.Version=${version}' -s -w -buildid= -checklinkname=0"
|
||||||
|
}
|
||||||
|
|
||||||
|
build_sing_box() {
|
||||||
|
local tags="$1"
|
||||||
|
local ldflags
|
||||||
|
ldflags=$(get_ldflags)
|
||||||
|
|
||||||
|
echo "Building sing-box with tags: $tags"
|
||||||
|
cd "$PROJECT_DIR"
|
||||||
|
export GOTOOLCHAIN=local
|
||||||
|
go install -v -trimpath -ldflags "$ldflags" -tags "$tags" ./cmd/sing-box
|
||||||
|
}
|
||||||
|
|
||||||
|
install_binary() {
|
||||||
|
local gopath
|
||||||
|
gopath=$(go env GOPATH)
|
||||||
|
echo "Installing binary to $INSTALL_BIN_PATH/$BINARY_NAME"
|
||||||
|
sudo cp "${gopath}/bin/${BINARY_NAME}" "${INSTALL_BIN_PATH}/"
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_config() {
|
||||||
|
echo "Setting up configuration"
|
||||||
|
sudo mkdir -p "$INSTALL_CONFIG_PATH"
|
||||||
|
if [ ! -f "$INSTALL_CONFIG_PATH/config.json" ]; then
|
||||||
|
sudo cp "$PROJECT_DIR/release/config/config.json" "$INSTALL_CONFIG_PATH/config.json"
|
||||||
|
echo "Default config installed to $INSTALL_CONFIG_PATH/config.json"
|
||||||
|
else
|
||||||
|
echo "Config already exists at $INSTALL_CONFIG_PATH/config.json (not overwriting)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_systemd() {
|
||||||
|
echo "Setting up systemd service"
|
||||||
|
sudo cp "$SCRIPT_DIR/sing-box.service" "$SYSTEMD_SERVICE_PATH/"
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_service() {
|
||||||
|
if systemctl is-active --quiet sing-box; then
|
||||||
|
echo "Stopping sing-box service"
|
||||||
|
sudo systemctl stop sing-box
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
start_service() {
|
||||||
|
echo "Starting sing-box service"
|
||||||
|
sudo systemctl start sing-box
|
||||||
|
}
|
||||||
|
|
||||||
|
restart_service() {
|
||||||
|
echo "Restarting sing-box service"
|
||||||
|
sudo systemctl restart sing-box
|
||||||
|
}
|
||||||
@@ -2,21 +2,25 @@
|
|||||||
|
|
||||||
set -e -o pipefail
|
set -e -o pipefail
|
||||||
|
|
||||||
if [ -d /usr/local/go ]; then
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
export PATH="$PATH:/usr/local/go/bin"
|
source "$SCRIPT_DIR/common.sh"
|
||||||
fi
|
|
||||||
|
|
||||||
DIR=$(dirname "$0")
|
setup_environment
|
||||||
PROJECT=$DIR/../..
|
|
||||||
|
|
||||||
pushd $PROJECT
|
echo "Updating sing-box from git repository..."
|
||||||
|
cd "$PROJECT_DIR"
|
||||||
git fetch
|
git fetch
|
||||||
git reset FETCH_HEAD --hard
|
git reset FETCH_HEAD --hard
|
||||||
git clean -fdx
|
git clean -fdx
|
||||||
go install -v -trimpath -ldflags "-s -w -buildid=" -tags with_quic,with_acme,debug ./cmd/sing-box
|
|
||||||
popd
|
|
||||||
|
|
||||||
sudo systemctl stop sing-box
|
BUILD_TAGS=$(get_build_tags "debug")
|
||||||
sudo cp $(go env GOPATH)/bin/sing-box /usr/local/bin/
|
|
||||||
sudo systemctl start sing-box
|
build_sing_box "$BUILD_TAGS"
|
||||||
|
|
||||||
|
stop_service
|
||||||
|
install_binary
|
||||||
|
start_service
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Following service logs (Ctrl+C to exit)..."
|
||||||
sudo journalctl -u sing-box --output cat -f
|
sudo journalctl -u sing-box --output cat -f
|
||||||
|
|||||||
@@ -2,19 +2,18 @@
|
|||||||
|
|
||||||
set -e -o pipefail
|
set -e -o pipefail
|
||||||
|
|
||||||
if [ -d /usr/local/go ]; then
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
export PATH="$PATH:/usr/local/go/bin"
|
source "$SCRIPT_DIR/common.sh"
|
||||||
fi
|
|
||||||
|
|
||||||
DIR=$(dirname "$0")
|
setup_environment
|
||||||
PROJECT=$DIR/../..
|
|
||||||
|
|
||||||
pushd $PROJECT
|
BUILD_TAGS=$(get_build_tags)
|
||||||
go install -v -trimpath -ldflags "-s -w -buildid=" -tags with_quic,with_wireguard,with_acme ./cmd/sing-box
|
|
||||||
popd
|
|
||||||
|
|
||||||
sudo cp $(go env GOPATH)/bin/sing-box /usr/local/bin/
|
build_sing_box "$BUILD_TAGS"
|
||||||
sudo mkdir -p /usr/local/etc/sing-box
|
install_binary
|
||||||
sudo cp $PROJECT/release/config/config.json /usr/local/etc/sing-box/config.json
|
setup_config
|
||||||
sudo cp $DIR/sing-box.service /etc/systemd/system
|
setup_systemd
|
||||||
sudo systemctl daemon-reload
|
|
||||||
|
echo ""
|
||||||
|
echo "Installation complete!"
|
||||||
|
echo "To enable and start the service, run: $SCRIPT_DIR/enable.sh"
|
||||||
|
|||||||
@@ -2,17 +2,18 @@
|
|||||||
|
|
||||||
set -e -o pipefail
|
set -e -o pipefail
|
||||||
|
|
||||||
if [ -d /usr/local/go ]; then
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
export PATH="$PATH:/usr/local/go/bin"
|
source "$SCRIPT_DIR/common.sh"
|
||||||
fi
|
|
||||||
|
|
||||||
DIR=$(dirname "$0")
|
setup_environment
|
||||||
PROJECT=$DIR/../..
|
|
||||||
|
|
||||||
pushd $PROJECT
|
BUILD_TAGS=$(get_build_tags)
|
||||||
go install -v -trimpath -ldflags "-s -w -buildid=" -tags with_quic,with_wireguard,with_acme ./cmd/sing-box
|
|
||||||
popd
|
|
||||||
|
|
||||||
sudo systemctl stop sing-box
|
build_sing_box "$BUILD_TAGS"
|
||||||
sudo cp $(go env GOPATH)/bin/sing-box /usr/local/bin/
|
|
||||||
sudo systemctl start sing-box
|
stop_service
|
||||||
|
install_binary
|
||||||
|
start_service
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Reinstallation complete!"
|
||||||
|
|||||||
@@ -1,8 +1,30 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
sudo systemctl stop sing-box
|
set -e -o pipefail
|
||||||
sudo rm -rf /var/lib/sing-box
|
|
||||||
sudo rm -rf /usr/local/bin/sing-box
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
sudo rm -rf /usr/local/etc/sing-box
|
source "$SCRIPT_DIR/common.sh"
|
||||||
sudo rm -rf /etc/systemd/system/sing-box.service
|
|
||||||
|
echo "Uninstalling sing-box..."
|
||||||
|
|
||||||
|
if systemctl is-active --quiet sing-box 2>/dev/null; then
|
||||||
|
echo "Stopping sing-box service..."
|
||||||
|
sudo systemctl stop sing-box
|
||||||
|
fi
|
||||||
|
|
||||||
|
if systemctl is-enabled --quiet sing-box 2>/dev/null; then
|
||||||
|
echo "Disabling sing-box service..."
|
||||||
|
sudo systemctl disable sing-box
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Removing files..."
|
||||||
|
sudo rm -rf "$INSTALL_DATA_PATH"
|
||||||
|
sudo rm -rf "$INSTALL_BIN_PATH/$BINARY_NAME"
|
||||||
|
sudo rm -rf "$INSTALL_CONFIG_PATH"
|
||||||
|
sudo rm -rf "$SYSTEMD_SERVICE_PATH/sing-box.service"
|
||||||
|
|
||||||
|
echo "Reloading systemd..."
|
||||||
sudo systemctl daemon-reload
|
sudo systemctl daemon-reload
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Uninstallation complete!"
|
||||||
|
|||||||
@@ -2,13 +2,15 @@
|
|||||||
|
|
||||||
set -e -o pipefail
|
set -e -o pipefail
|
||||||
|
|
||||||
DIR=$(dirname "$0")
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
PROJECT=$DIR/../..
|
source "$SCRIPT_DIR/common.sh"
|
||||||
|
|
||||||
pushd $PROJECT
|
echo "Updating sing-box from git repository..."
|
||||||
|
cd "$PROJECT_DIR"
|
||||||
git fetch
|
git fetch
|
||||||
git reset FETCH_HEAD --hard
|
git reset FETCH_HEAD --hard
|
||||||
git clean -fdx
|
git clean -fdx
|
||||||
popd
|
|
||||||
|
|
||||||
$DIR/reinstall.sh
|
echo ""
|
||||||
|
echo "Running reinstall..."
|
||||||
|
exec "$SCRIPT_DIR/reinstall.sh"
|
||||||
136
service/ccm/credential.go
Normal file
136
service/ccm/credential.go
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
package ccm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/user"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
E "github.com/sagernet/sing/common/exceptions"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
oauth2ClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
||||||
|
oauth2TokenURL = "https://console.anthropic.com/v1/oauth/token"
|
||||||
|
claudeAPIBaseURL = "https://api.anthropic.com"
|
||||||
|
tokenRefreshBufferMs = 60000
|
||||||
|
anthropicBetaOAuthValue = "oauth-2025-04-20"
|
||||||
|
)
|
||||||
|
|
||||||
|
func getRealUser() (*user.User, error) {
|
||||||
|
if sudoUser := os.Getenv("SUDO_USER"); sudoUser != "" {
|
||||||
|
sudoUserInfo, err := user.Lookup(sudoUser)
|
||||||
|
if err == nil {
|
||||||
|
return sudoUserInfo, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return user.Current()
|
||||||
|
}
|
||||||
|
|
||||||
|
func getDefaultCredentialsPath() (string, error) {
|
||||||
|
userInfo, err := getRealUser()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(userInfo.HomeDir, ".claude", ".credentials.json"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readCredentialsFromFile(path string) (*oauthCredentials, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var credentialsContainer struct {
|
||||||
|
ClaudeAIAuth *oauthCredentials `json:"claudeAiOauth,omitempty"`
|
||||||
|
}
|
||||||
|
err = json.Unmarshal(data, &credentialsContainer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if credentialsContainer.ClaudeAIAuth == nil {
|
||||||
|
return nil, E.New("claudeAiOauth field not found in credentials")
|
||||||
|
}
|
||||||
|
return credentialsContainer.ClaudeAIAuth, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeCredentialsToFile(oauthCredentials *oauthCredentials, path string) error {
|
||||||
|
data, err := json.MarshalIndent(map[string]any{
|
||||||
|
"claudeAiOauth": oauthCredentials,
|
||||||
|
}, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, data, 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
type oauthCredentials struct {
|
||||||
|
AccessToken string `json:"accessToken"`
|
||||||
|
RefreshToken string `json:"refreshToken"`
|
||||||
|
ExpiresAt int64 `json:"expiresAt"`
|
||||||
|
Scopes []string `json:"scopes,omitempty"`
|
||||||
|
SubscriptionType string `json:"subscriptionType,omitempty"`
|
||||||
|
IsMax bool `json:"isMax,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *oauthCredentials) needsRefresh() bool {
|
||||||
|
if c.ExpiresAt == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return time.Now().UnixMilli() >= c.ExpiresAt-tokenRefreshBufferMs
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshToken(httpClient *http.Client, credentials *oauthCredentials) (*oauthCredentials, error) {
|
||||||
|
if credentials.RefreshToken == "" {
|
||||||
|
return nil, E.New("refresh token is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
requestBody, err := json.Marshal(map[string]string{
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
"refresh_token": credentials.RefreshToken,
|
||||||
|
"client_id": oauth2ClientID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, E.Cause(err, "marshal request")
|
||||||
|
}
|
||||||
|
|
||||||
|
request, err := http.NewRequest("POST", oauth2TokenURL, bytes.NewReader(requestBody))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
request.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
response, err := httpClient.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(response.Body)
|
||||||
|
return nil, E.New("refresh failed: ", response.Status, " ", string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenResponse struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
}
|
||||||
|
err = json.NewDecoder(response.Body).Decode(&tokenResponse)
|
||||||
|
if err != nil {
|
||||||
|
return nil, E.Cause(err, "decode response")
|
||||||
|
}
|
||||||
|
|
||||||
|
newCredentials := *credentials
|
||||||
|
newCredentials.AccessToken = tokenResponse.AccessToken
|
||||||
|
if tokenResponse.RefreshToken != "" {
|
||||||
|
newCredentials.RefreshToken = tokenResponse.RefreshToken
|
||||||
|
}
|
||||||
|
newCredentials.ExpiresAt = time.Now().UnixMilli() + int64(tokenResponse.ExpiresIn)*1000
|
||||||
|
|
||||||
|
return &newCredentials, nil
|
||||||
|
}
|
||||||
116
service/ccm/credential_darwin.go
Normal file
116
service/ccm/credential_darwin.go
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
//go:build darwin && cgo
|
||||||
|
|
||||||
|
package ccm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
E "github.com/sagernet/sing/common/exceptions"
|
||||||
|
|
||||||
|
"github.com/keybase/go-keychain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func getKeychainServiceName() string {
|
||||||
|
configDirectory := os.Getenv("CLAUDE_CONFIG_DIR")
|
||||||
|
if configDirectory == "" {
|
||||||
|
return "Claude Code-credentials"
|
||||||
|
}
|
||||||
|
|
||||||
|
userInfo, err := getRealUser()
|
||||||
|
if err != nil {
|
||||||
|
return "Claude Code-credentials"
|
||||||
|
}
|
||||||
|
defaultConfigDirectory := filepath.Join(userInfo.HomeDir, ".claude")
|
||||||
|
if configDirectory == defaultConfigDirectory {
|
||||||
|
return "Claude Code-credentials"
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := sha256.Sum256([]byte(configDirectory))
|
||||||
|
return "Claude Code-credentials-" + hex.EncodeToString(hash[:])[:8]
|
||||||
|
}
|
||||||
|
|
||||||
|
func platformReadCredentials(customPath string) (*oauthCredentials, error) {
|
||||||
|
if customPath != "" {
|
||||||
|
return readCredentialsFromFile(customPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
userInfo, err := getRealUser()
|
||||||
|
if err == nil {
|
||||||
|
query := keychain.NewItem()
|
||||||
|
query.SetSecClass(keychain.SecClassGenericPassword)
|
||||||
|
query.SetService(getKeychainServiceName())
|
||||||
|
query.SetAccount(userInfo.Username)
|
||||||
|
query.SetMatchLimit(keychain.MatchLimitOne)
|
||||||
|
query.SetReturnData(true)
|
||||||
|
|
||||||
|
results, err := keychain.QueryItem(query)
|
||||||
|
if err == nil && len(results) == 1 {
|
||||||
|
var container struct {
|
||||||
|
ClaudeAIAuth *oauthCredentials `json:"claudeAiOauth,omitempty"`
|
||||||
|
}
|
||||||
|
unmarshalErr := json.Unmarshal(results[0].Data, &container)
|
||||||
|
if unmarshalErr == nil && container.ClaudeAIAuth != nil {
|
||||||
|
return container.ClaudeAIAuth, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil && err != keychain.ErrorItemNotFound {
|
||||||
|
return nil, E.Cause(err, "query keychain")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultPath, err := getDefaultCredentialsPath()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return readCredentialsFromFile(defaultPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func platformWriteCredentials(oauthCredentials *oauthCredentials, customPath string) error {
|
||||||
|
if customPath != "" {
|
||||||
|
return writeCredentialsToFile(oauthCredentials, customPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
userInfo, err := getRealUser()
|
||||||
|
if err == nil {
|
||||||
|
data, err := json.Marshal(map[string]any{"claudeAiOauth": oauthCredentials})
|
||||||
|
if err == nil {
|
||||||
|
serviceName := getKeychainServiceName()
|
||||||
|
item := keychain.NewItem()
|
||||||
|
item.SetSecClass(keychain.SecClassGenericPassword)
|
||||||
|
item.SetService(serviceName)
|
||||||
|
item.SetAccount(userInfo.Username)
|
||||||
|
item.SetData(data)
|
||||||
|
item.SetAccessible(keychain.AccessibleWhenUnlocked)
|
||||||
|
|
||||||
|
err = keychain.AddItem(item)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == keychain.ErrorDuplicateItem {
|
||||||
|
query := keychain.NewItem()
|
||||||
|
query.SetSecClass(keychain.SecClassGenericPassword)
|
||||||
|
query.SetService(serviceName)
|
||||||
|
query.SetAccount(userInfo.Username)
|
||||||
|
|
||||||
|
updateItem := keychain.NewItem()
|
||||||
|
updateItem.SetData(data)
|
||||||
|
|
||||||
|
updateErr := keychain.UpdateItem(query, updateItem)
|
||||||
|
if updateErr == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultPath, err := getDefaultCredentialsPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writeCredentialsToFile(oauthCredentials, defaultPath)
|
||||||
|
}
|
||||||
25
service/ccm/credential_other.go
Normal file
25
service/ccm/credential_other.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
//go:build !darwin
|
||||||
|
|
||||||
|
package ccm
|
||||||
|
|
||||||
|
func platformReadCredentials(customPath string) (*oauthCredentials, error) {
|
||||||
|
if customPath == "" {
|
||||||
|
var err error
|
||||||
|
customPath, err = getDefaultCredentialsPath()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return readCredentialsFromFile(customPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func platformWriteCredentials(oauthCredentials *oauthCredentials, customPath string) error {
|
||||||
|
if customPath == "" {
|
||||||
|
var err error
|
||||||
|
customPath, err = getDefaultCredentialsPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return writeCredentialsToFile(oauthCredentials, customPath)
|
||||||
|
}
|
||||||
541
service/ccm/service.go
Normal file
541
service/ccm/service.go
Normal file
@@ -0,0 +1,541 @@
|
|||||||
|
package ccm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sagernet/sing-box/adapter"
|
||||||
|
boxService "github.com/sagernet/sing-box/adapter/service"
|
||||||
|
"github.com/sagernet/sing-box/common/dialer"
|
||||||
|
"github.com/sagernet/sing-box/common/listener"
|
||||||
|
"github.com/sagernet/sing-box/common/tls"
|
||||||
|
C "github.com/sagernet/sing-box/constant"
|
||||||
|
"github.com/sagernet/sing-box/log"
|
||||||
|
"github.com/sagernet/sing-box/option"
|
||||||
|
"github.com/sagernet/sing/common"
|
||||||
|
"github.com/sagernet/sing/common/buf"
|
||||||
|
E "github.com/sagernet/sing/common/exceptions"
|
||||||
|
M "github.com/sagernet/sing/common/metadata"
|
||||||
|
N "github.com/sagernet/sing/common/network"
|
||||||
|
aTLS "github.com/sagernet/sing/common/tls"
|
||||||
|
|
||||||
|
"github.com/anthropics/anthropic-sdk-go"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"golang.org/x/net/http2"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
contextWindowStandard = 200000
|
||||||
|
contextWindowPremium = 1000000
|
||||||
|
premiumContextThreshold = 200000
|
||||||
|
)
|
||||||
|
|
||||||
|
func RegisterService(registry *boxService.Registry) {
|
||||||
|
boxService.Register[option.CCMServiceOptions](registry, C.TypeCCM, NewService)
|
||||||
|
}
|
||||||
|
|
||||||
|
type errorResponse struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Error errorDetails `json:"error"`
|
||||||
|
RequestID string `json:"request_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type errorDetails struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSONError(w http.ResponseWriter, r *http.Request, statusCode int, errorType string, message string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(statusCode)
|
||||||
|
|
||||||
|
json.NewEncoder(w).Encode(errorResponse{
|
||||||
|
Type: "error",
|
||||||
|
Error: errorDetails{
|
||||||
|
Type: errorType,
|
||||||
|
Message: message,
|
||||||
|
},
|
||||||
|
RequestID: r.Header.Get("Request-Id"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func isHopByHopHeader(header string) bool {
|
||||||
|
switch strings.ToLower(header) {
|
||||||
|
case "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailers", "transfer-encoding", "upgrade", "host":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
boxService.Adapter
|
||||||
|
ctx context.Context
|
||||||
|
logger log.ContextLogger
|
||||||
|
credentialPath string
|
||||||
|
credentials *oauthCredentials
|
||||||
|
users []option.CCMUser
|
||||||
|
httpClient *http.Client
|
||||||
|
httpHeaders http.Header
|
||||||
|
listener *listener.Listener
|
||||||
|
tlsConfig tls.ServerConfig
|
||||||
|
httpServer *http.Server
|
||||||
|
userManager *UserManager
|
||||||
|
accessMutex sync.RWMutex
|
||||||
|
usageTracker *AggregatedUsage
|
||||||
|
trackingGroup sync.WaitGroup
|
||||||
|
shuttingDown bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(ctx context.Context, logger log.ContextLogger, tag string, options option.CCMServiceOptions) (adapter.Service, error) {
|
||||||
|
serviceDialer, err := dialer.NewWithOptions(dialer.Options{
|
||||||
|
Context: ctx,
|
||||||
|
Options: option.DialerOptions{
|
||||||
|
Detour: options.Detour,
|
||||||
|
},
|
||||||
|
RemoteIsDomain: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, E.Cause(err, "create dialer")
|
||||||
|
}
|
||||||
|
|
||||||
|
httpClient := &http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
ForceAttemptHTTP2: true,
|
||||||
|
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
return serviceDialer.DialContext(ctx, network, M.ParseSocksaddr(addr))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
userManager := &UserManager{
|
||||||
|
tokenMap: make(map[string]string),
|
||||||
|
}
|
||||||
|
|
||||||
|
var usageTracker *AggregatedUsage
|
||||||
|
if options.UsagesPath != "" {
|
||||||
|
usageTracker = &AggregatedUsage{
|
||||||
|
LastUpdated: time.Now(),
|
||||||
|
Combinations: make([]CostCombination, 0),
|
||||||
|
filePath: options.UsagesPath,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
service := &Service{
|
||||||
|
Adapter: boxService.NewAdapter(C.TypeCCM, tag),
|
||||||
|
ctx: ctx,
|
||||||
|
logger: logger,
|
||||||
|
credentialPath: options.CredentialPath,
|
||||||
|
users: options.Users,
|
||||||
|
httpClient: httpClient,
|
||||||
|
httpHeaders: options.Headers.Build(),
|
||||||
|
listener: listener.New(listener.Options{
|
||||||
|
Context: ctx,
|
||||||
|
Logger: logger,
|
||||||
|
Network: []string{N.NetworkTCP},
|
||||||
|
Listen: options.ListenOptions,
|
||||||
|
}),
|
||||||
|
userManager: userManager,
|
||||||
|
usageTracker: usageTracker,
|
||||||
|
}
|
||||||
|
|
||||||
|
if options.TLS != nil {
|
||||||
|
tlsConfig, err := tls.NewServer(ctx, logger, common.PtrValueOrDefault(options.TLS))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
service.tlsConfig = tlsConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
return service, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Start(stage adapter.StartStage) error {
|
||||||
|
if stage != adapter.StartStateStart {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
s.userManager.UpdateUsers(s.users)
|
||||||
|
|
||||||
|
credentials, err := platformReadCredentials(s.credentialPath)
|
||||||
|
if err != nil {
|
||||||
|
return E.Cause(err, "read credentials")
|
||||||
|
}
|
||||||
|
s.credentials = credentials
|
||||||
|
|
||||||
|
if s.usageTracker != nil {
|
||||||
|
err = s.usageTracker.Load()
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("load usage statistics: ", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Mount("/", s)
|
||||||
|
|
||||||
|
s.httpServer = &http.Server{Handler: router}
|
||||||
|
|
||||||
|
if s.tlsConfig != nil {
|
||||||
|
err = s.tlsConfig.Start()
|
||||||
|
if err != nil {
|
||||||
|
return E.Cause(err, "create TLS config")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tcpListener, err := s.listener.ListenTCP()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.tlsConfig != nil {
|
||||||
|
if !common.Contains(s.tlsConfig.NextProtos(), http2.NextProtoTLS) {
|
||||||
|
s.tlsConfig.SetNextProtos(append([]string{"h2"}, s.tlsConfig.NextProtos()...))
|
||||||
|
}
|
||||||
|
tcpListener = aTLS.NewListener(tcpListener, s.tlsConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
serveErr := s.httpServer.Serve(tcpListener)
|
||||||
|
if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
|
||||||
|
s.logger.Error("serve error: ", serveErr)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) getAccessToken() (string, error) {
|
||||||
|
s.accessMutex.RLock()
|
||||||
|
if !s.credentials.needsRefresh() {
|
||||||
|
token := s.credentials.AccessToken
|
||||||
|
s.accessMutex.RUnlock()
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
s.accessMutex.RUnlock()
|
||||||
|
|
||||||
|
s.accessMutex.Lock()
|
||||||
|
defer s.accessMutex.Unlock()
|
||||||
|
|
||||||
|
if !s.credentials.needsRefresh() {
|
||||||
|
return s.credentials.AccessToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
newCredentials, err := refreshToken(s.httpClient, s.credentials)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.credentials = newCredentials
|
||||||
|
|
||||||
|
err = platformWriteCredentials(newCredentials, s.credentialPath)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("persist refreshed token: ", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return newCredentials.AccessToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectContextWindow(betaHeader string, inputTokens int64) int {
|
||||||
|
if inputTokens > premiumContextThreshold {
|
||||||
|
features := strings.Split(betaHeader, ",")
|
||||||
|
for _, feature := range features {
|
||||||
|
if strings.TrimSpace(feature) == "context-1m" {
|
||||||
|
return contextWindowPremium
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return contextWindowStandard
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !strings.HasPrefix(r.URL.Path, "/v1/") {
|
||||||
|
writeJSONError(w, r, http.StatusNotFound, "not_found_error", "Not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var username string
|
||||||
|
if len(s.users) > 0 {
|
||||||
|
authHeader := r.Header.Get("Authorization")
|
||||||
|
if authHeader == "" {
|
||||||
|
s.logger.Warn("authentication failed for request from ", r.RemoteAddr, ": missing Authorization header")
|
||||||
|
writeJSONError(w, r, http.StatusUnauthorized, "authentication_error", "missing api key")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clientToken := strings.TrimPrefix(authHeader, "Bearer ")
|
||||||
|
if clientToken == authHeader {
|
||||||
|
s.logger.Warn("authentication failed for request from ", r.RemoteAddr, ": invalid Authorization format")
|
||||||
|
writeJSONError(w, r, http.StatusUnauthorized, "authentication_error", "invalid api key format")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var ok bool
|
||||||
|
username, ok = s.userManager.Authenticate(clientToken)
|
||||||
|
if !ok {
|
||||||
|
s.logger.Warn("authentication failed for request from ", r.RemoteAddr, ": unknown key: ", clientToken)
|
||||||
|
writeJSONError(w, r, http.StatusUnauthorized, "authentication_error", "invalid api key")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var requestModel string
|
||||||
|
var messagesCount int
|
||||||
|
|
||||||
|
if s.usageTracker != nil && r.Body != nil {
|
||||||
|
bodyBytes, err := io.ReadAll(r.Body)
|
||||||
|
if err == nil {
|
||||||
|
var request struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Messages []anthropic.MessageParam `json:"messages"`
|
||||||
|
}
|
||||||
|
err := json.Unmarshal(bodyBytes, &request)
|
||||||
|
if err == nil {
|
||||||
|
requestModel = request.Model
|
||||||
|
messagesCount = len(request.Messages)
|
||||||
|
}
|
||||||
|
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken, err := s.getAccessToken()
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("get access token: ", err)
|
||||||
|
writeJSONError(w, r, http.StatusUnauthorized, "authentication_error", "Authentication failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
proxyURL := claudeAPIBaseURL + r.URL.RequestURI()
|
||||||
|
proxyRequest, err := http.NewRequestWithContext(r.Context(), r.Method, proxyURL, r.Body)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("create proxy request: ", err)
|
||||||
|
writeJSONError(w, r, http.StatusInternalServerError, "api_error", "Internal server error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, values := range r.Header {
|
||||||
|
if !isHopByHopHeader(key) && key != "Authorization" {
|
||||||
|
proxyRequest.Header[key] = values
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
anthropicBetaHeader := proxyRequest.Header.Get("anthropic-beta")
|
||||||
|
if anthropicBetaHeader != "" {
|
||||||
|
proxyRequest.Header.Set("anthropic-beta", anthropicBetaOAuthValue+","+anthropicBetaHeader)
|
||||||
|
} else {
|
||||||
|
proxyRequest.Header.Set("anthropic-beta", anthropicBetaOAuthValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, values := range s.httpHeaders {
|
||||||
|
proxyRequest.Header.Del(key)
|
||||||
|
proxyRequest.Header[key] = values
|
||||||
|
}
|
||||||
|
|
||||||
|
proxyRequest.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
|
|
||||||
|
response, err := s.httpClient.Do(proxyRequest)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, r, http.StatusBadGateway, "api_error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
|
||||||
|
for key, values := range response.Header {
|
||||||
|
if !isHopByHopHeader(key) {
|
||||||
|
w.Header()[key] = values
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.WriteHeader(response.StatusCode)
|
||||||
|
|
||||||
|
if s.usageTracker != nil && response.StatusCode == http.StatusOK {
|
||||||
|
s.handleResponseWithTracking(w, response, requestModel, anthropicBetaHeader, messagesCount, username)
|
||||||
|
} else {
|
||||||
|
mediaType, _, err := mime.ParseMediaType(response.Header.Get("Content-Type"))
|
||||||
|
if err == nil && mediaType != "text/event-stream" {
|
||||||
|
_, _ = io.Copy(w, response.Body)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flusher, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
s.logger.Error("streaming not supported")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
buffer := make([]byte, buf.BufferSize)
|
||||||
|
for {
|
||||||
|
n, err := response.Body.Read(buffer)
|
||||||
|
if n > 0 {
|
||||||
|
_, writeError := w.Write(buffer[:n])
|
||||||
|
if writeError != nil {
|
||||||
|
s.logger.Error("write streaming response: ", writeError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) handleResponseWithTracking(writer http.ResponseWriter, response *http.Response, requestModel string, anthropicBetaHeader string, messagesCount int, username string) {
|
||||||
|
mediaType, _, err := mime.ParseMediaType(response.Header.Get("Content-Type"))
|
||||||
|
isStreaming := err == nil && mediaType == "text/event-stream"
|
||||||
|
|
||||||
|
if !isStreaming {
|
||||||
|
bodyBytes, err := io.ReadAll(response.Body)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("read response body: ", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var message anthropic.Message
|
||||||
|
var usage anthropic.Usage
|
||||||
|
var responseModel string
|
||||||
|
err = json.Unmarshal(bodyBytes, &message)
|
||||||
|
if err == nil {
|
||||||
|
responseModel = string(message.Model)
|
||||||
|
usage = message.Usage
|
||||||
|
}
|
||||||
|
if responseModel == "" {
|
||||||
|
responseModel = requestModel
|
||||||
|
}
|
||||||
|
|
||||||
|
if usage.InputTokens > 0 || usage.OutputTokens > 0 {
|
||||||
|
if responseModel != "" {
|
||||||
|
contextWindow := detectContextWindow(anthropicBetaHeader, usage.InputTokens)
|
||||||
|
s.usageTracker.AddUsage(
|
||||||
|
responseModel,
|
||||||
|
contextWindow,
|
||||||
|
messagesCount,
|
||||||
|
usage.InputTokens,
|
||||||
|
usage.OutputTokens,
|
||||||
|
usage.CacheReadInputTokens,
|
||||||
|
usage.CacheCreationInputTokens,
|
||||||
|
username,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = writer.Write(bodyBytes)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
flusher, ok := writer.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
s.logger.Error("streaming not supported")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var accumulatedUsage anthropic.Usage
|
||||||
|
var responseModel string
|
||||||
|
buffer := make([]byte, buf.BufferSize)
|
||||||
|
var leftover []byte
|
||||||
|
|
||||||
|
for {
|
||||||
|
n, err := response.Body.Read(buffer)
|
||||||
|
if n > 0 {
|
||||||
|
data := append(leftover, buffer[:n]...)
|
||||||
|
lines := bytes.Split(data, []byte("\n"))
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
leftover = lines[len(lines)-1]
|
||||||
|
lines = lines[:len(lines)-1]
|
||||||
|
} else {
|
||||||
|
leftover = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
line = bytes.TrimSpace(line)
|
||||||
|
if len(line) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if bytes.HasPrefix(line, []byte("data: ")) {
|
||||||
|
eventData := bytes.TrimPrefix(line, []byte("data: "))
|
||||||
|
if bytes.Equal(eventData, []byte("[DONE]")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var event anthropic.MessageStreamEventUnion
|
||||||
|
err := json.Unmarshal(eventData, &event)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch event.Type {
|
||||||
|
case "message_start":
|
||||||
|
messageStart := event.AsMessageStart()
|
||||||
|
if messageStart.Message.Model != "" {
|
||||||
|
responseModel = string(messageStart.Message.Model)
|
||||||
|
}
|
||||||
|
if messageStart.Message.Usage.InputTokens > 0 {
|
||||||
|
accumulatedUsage.InputTokens = messageStart.Message.Usage.InputTokens
|
||||||
|
accumulatedUsage.CacheReadInputTokens = messageStart.Message.Usage.CacheReadInputTokens
|
||||||
|
accumulatedUsage.CacheCreationInputTokens = messageStart.Message.Usage.CacheCreationInputTokens
|
||||||
|
}
|
||||||
|
case "message_delta":
|
||||||
|
messageDelta := event.AsMessageDelta()
|
||||||
|
if messageDelta.Usage.OutputTokens > 0 {
|
||||||
|
accumulatedUsage.OutputTokens = messageDelta.Usage.OutputTokens
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, writeError := writer.Write(buffer[:n])
|
||||||
|
if writeError != nil {
|
||||||
|
s.logger.Error("write streaming response: ", writeError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if responseModel == "" {
|
||||||
|
responseModel = requestModel
|
||||||
|
}
|
||||||
|
|
||||||
|
if accumulatedUsage.InputTokens > 0 || accumulatedUsage.OutputTokens > 0 {
|
||||||
|
if responseModel != "" {
|
||||||
|
contextWindow := detectContextWindow(anthropicBetaHeader, accumulatedUsage.InputTokens)
|
||||||
|
s.usageTracker.AddUsage(
|
||||||
|
responseModel,
|
||||||
|
contextWindow,
|
||||||
|
messagesCount,
|
||||||
|
accumulatedUsage.InputTokens,
|
||||||
|
accumulatedUsage.OutputTokens,
|
||||||
|
accumulatedUsage.CacheReadInputTokens,
|
||||||
|
accumulatedUsage.CacheCreationInputTokens,
|
||||||
|
username,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Close() error {
|
||||||
|
err := common.Close(
|
||||||
|
common.PtrOrNil(s.httpServer),
|
||||||
|
common.PtrOrNil(s.listener),
|
||||||
|
s.tlsConfig,
|
||||||
|
)
|
||||||
|
|
||||||
|
if s.usageTracker != nil {
|
||||||
|
s.usageTracker.cancelPendingSave()
|
||||||
|
saveErr := s.usageTracker.Save()
|
||||||
|
if saveErr != nil {
|
||||||
|
s.logger.Error("save usage statistics: ", saveErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
407
service/ccm/service_usage.go
Normal file
407
service/ccm/service_usage.go
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
package ccm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sagernet/sing-box/log"
|
||||||
|
E "github.com/sagernet/sing/common/exceptions"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UsageStats struct {
|
||||||
|
RequestCount int `json:"request_count"`
|
||||||
|
MessagesCount int `json:"messages_count"`
|
||||||
|
InputTokens int64 `json:"input_tokens"`
|
||||||
|
OutputTokens int64 `json:"output_tokens"`
|
||||||
|
CacheReadInputTokens int64 `json:"cache_read_input_tokens"`
|
||||||
|
CacheCreationInputTokens int64 `json:"cache_creation_input_tokens"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CostCombination struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
ContextWindow int `json:"context_window"`
|
||||||
|
Total UsageStats `json:"total"`
|
||||||
|
ByUser map[string]UsageStats `json:"by_user"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AggregatedUsage struct {
|
||||||
|
LastUpdated time.Time `json:"last_updated"`
|
||||||
|
Combinations []CostCombination `json:"combinations"`
|
||||||
|
mutex sync.Mutex
|
||||||
|
filePath string
|
||||||
|
logger log.ContextLogger
|
||||||
|
lastSaveTime time.Time
|
||||||
|
pendingSave bool
|
||||||
|
saveTimer *time.Timer
|
||||||
|
saveMutex sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
type UsageStatsJSON struct {
|
||||||
|
RequestCount int `json:"request_count"`
|
||||||
|
MessagesCount int `json:"messages_count"`
|
||||||
|
InputTokens int64 `json:"input_tokens"`
|
||||||
|
OutputTokens int64 `json:"output_tokens"`
|
||||||
|
CacheReadInputTokens int64 `json:"cache_read_input_tokens"`
|
||||||
|
CacheCreationInputTokens int64 `json:"cache_creation_input_tokens"`
|
||||||
|
CostUSD float64 `json:"cost_usd"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CostCombinationJSON struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
ContextWindow int `json:"context_window"`
|
||||||
|
Total UsageStatsJSON `json:"total"`
|
||||||
|
ByUser map[string]UsageStatsJSON `json:"by_user"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CostsSummaryJSON struct {
|
||||||
|
TotalUSD float64 `json:"total_usd"`
|
||||||
|
ByUser map[string]float64 `json:"by_user"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AggregatedUsageJSON struct {
|
||||||
|
LastUpdated time.Time `json:"last_updated"`
|
||||||
|
Costs CostsSummaryJSON `json:"costs"`
|
||||||
|
Combinations []CostCombinationJSON `json:"combinations"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModelPricing struct {
|
||||||
|
InputPrice float64
|
||||||
|
OutputPrice float64
|
||||||
|
CacheReadPrice float64
|
||||||
|
CacheWritePrice float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type modelFamily struct {
|
||||||
|
pattern *regexp.Regexp
|
||||||
|
standardPricing ModelPricing
|
||||||
|
premiumPricing *ModelPricing
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
opus4Pricing = ModelPricing{
|
||||||
|
InputPrice: 15.0,
|
||||||
|
OutputPrice: 75.0,
|
||||||
|
CacheReadPrice: 1.5,
|
||||||
|
CacheWritePrice: 18.75,
|
||||||
|
}
|
||||||
|
|
||||||
|
sonnet4StandardPricing = ModelPricing{
|
||||||
|
InputPrice: 3.0,
|
||||||
|
OutputPrice: 15.0,
|
||||||
|
CacheReadPrice: 0.3,
|
||||||
|
CacheWritePrice: 3.75,
|
||||||
|
}
|
||||||
|
|
||||||
|
sonnet4PremiumPricing = ModelPricing{
|
||||||
|
InputPrice: 6.0,
|
||||||
|
OutputPrice: 22.5,
|
||||||
|
CacheReadPrice: 0.6,
|
||||||
|
CacheWritePrice: 7.5,
|
||||||
|
}
|
||||||
|
|
||||||
|
haiku4Pricing = ModelPricing{
|
||||||
|
InputPrice: 1.0,
|
||||||
|
OutputPrice: 5.0,
|
||||||
|
CacheReadPrice: 0.1,
|
||||||
|
CacheWritePrice: 1.25,
|
||||||
|
}
|
||||||
|
|
||||||
|
haiku35Pricing = ModelPricing{
|
||||||
|
InputPrice: 0.8,
|
||||||
|
OutputPrice: 4.0,
|
||||||
|
CacheReadPrice: 0.08,
|
||||||
|
CacheWritePrice: 1.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
sonnet35Pricing = ModelPricing{
|
||||||
|
InputPrice: 3.0,
|
||||||
|
OutputPrice: 15.0,
|
||||||
|
CacheReadPrice: 0.3,
|
||||||
|
CacheWritePrice: 3.75,
|
||||||
|
}
|
||||||
|
|
||||||
|
modelFamilies = []modelFamily{
|
||||||
|
{
|
||||||
|
pattern: regexp.MustCompile(`^claude-(?:opus-4-|4-opus-|opus-4-1-)`),
|
||||||
|
standardPricing: opus4Pricing,
|
||||||
|
premiumPricing: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: regexp.MustCompile(`^claude-3-7-sonnet-`),
|
||||||
|
standardPricing: sonnet4StandardPricing,
|
||||||
|
premiumPricing: &sonnet4PremiumPricing,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: regexp.MustCompile(`^claude-(?:sonnet-4-|4-sonnet-)`),
|
||||||
|
standardPricing: sonnet4StandardPricing,
|
||||||
|
premiumPricing: &sonnet4PremiumPricing,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: regexp.MustCompile(`^claude-haiku-4-`),
|
||||||
|
standardPricing: haiku4Pricing,
|
||||||
|
premiumPricing: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: regexp.MustCompile(`^claude-3-5-haiku-`),
|
||||||
|
standardPricing: haiku35Pricing,
|
||||||
|
premiumPricing: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: regexp.MustCompile(`^claude-3-5-sonnet-`),
|
||||||
|
standardPricing: sonnet35Pricing,
|
||||||
|
premiumPricing: nil,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func getPricing(model string, contextWindow int) ModelPricing {
|
||||||
|
isPremium := contextWindow >= contextWindowPremium
|
||||||
|
|
||||||
|
for _, family := range modelFamilies {
|
||||||
|
if family.pattern.MatchString(model) {
|
||||||
|
if isPremium && family.premiumPricing != nil {
|
||||||
|
return *family.premiumPricing
|
||||||
|
}
|
||||||
|
return family.standardPricing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sonnet4StandardPricing
|
||||||
|
}
|
||||||
|
|
||||||
|
func calculateCost(stats UsageStats, model string, contextWindow int) float64 {
|
||||||
|
pricing := getPricing(model, contextWindow)
|
||||||
|
|
||||||
|
cost := (float64(stats.InputTokens)*pricing.InputPrice +
|
||||||
|
float64(stats.OutputTokens)*pricing.OutputPrice +
|
||||||
|
float64(stats.CacheReadInputTokens)*pricing.CacheReadPrice +
|
||||||
|
float64(stats.CacheCreationInputTokens)*pricing.CacheWritePrice) / 1_000_000
|
||||||
|
|
||||||
|
return math.Round(cost*100) / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *AggregatedUsage) ToJSON() *AggregatedUsageJSON {
|
||||||
|
u.mutex.Lock()
|
||||||
|
defer u.mutex.Unlock()
|
||||||
|
|
||||||
|
result := &AggregatedUsageJSON{
|
||||||
|
LastUpdated: u.LastUpdated,
|
||||||
|
Combinations: make([]CostCombinationJSON, len(u.Combinations)),
|
||||||
|
Costs: CostsSummaryJSON{
|
||||||
|
TotalUSD: 0,
|
||||||
|
ByUser: make(map[string]float64),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, combo := range u.Combinations {
|
||||||
|
totalCost := calculateCost(combo.Total, combo.Model, combo.ContextWindow)
|
||||||
|
|
||||||
|
result.Costs.TotalUSD += totalCost
|
||||||
|
|
||||||
|
comboJSON := CostCombinationJSON{
|
||||||
|
Model: combo.Model,
|
||||||
|
ContextWindow: combo.ContextWindow,
|
||||||
|
Total: UsageStatsJSON{
|
||||||
|
RequestCount: combo.Total.RequestCount,
|
||||||
|
MessagesCount: combo.Total.MessagesCount,
|
||||||
|
InputTokens: combo.Total.InputTokens,
|
||||||
|
OutputTokens: combo.Total.OutputTokens,
|
||||||
|
CacheReadInputTokens: combo.Total.CacheReadInputTokens,
|
||||||
|
CacheCreationInputTokens: combo.Total.CacheCreationInputTokens,
|
||||||
|
CostUSD: totalCost,
|
||||||
|
},
|
||||||
|
ByUser: make(map[string]UsageStatsJSON),
|
||||||
|
}
|
||||||
|
|
||||||
|
for user, userStats := range combo.ByUser {
|
||||||
|
userCost := calculateCost(userStats, combo.Model, combo.ContextWindow)
|
||||||
|
result.Costs.ByUser[user] += userCost
|
||||||
|
|
||||||
|
comboJSON.ByUser[user] = UsageStatsJSON{
|
||||||
|
RequestCount: userStats.RequestCount,
|
||||||
|
MessagesCount: userStats.MessagesCount,
|
||||||
|
InputTokens: userStats.InputTokens,
|
||||||
|
OutputTokens: userStats.OutputTokens,
|
||||||
|
CacheReadInputTokens: userStats.CacheReadInputTokens,
|
||||||
|
CacheCreationInputTokens: userStats.CacheCreationInputTokens,
|
||||||
|
CostUSD: userCost,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Combinations[i] = comboJSON
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Costs.TotalUSD = math.Round(result.Costs.TotalUSD*100) / 100
|
||||||
|
for user, cost := range result.Costs.ByUser {
|
||||||
|
result.Costs.ByUser[user] = math.Round(cost*100) / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *AggregatedUsage) Load() error {
|
||||||
|
u.mutex.Lock()
|
||||||
|
defer u.mutex.Unlock()
|
||||||
|
|
||||||
|
data, err := os.ReadFile(u.filePath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var temp struct {
|
||||||
|
LastUpdated time.Time `json:"last_updated"`
|
||||||
|
Combinations []CostCombination `json:"combinations"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err = json.Unmarshal(data, &temp)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
u.LastUpdated = temp.LastUpdated
|
||||||
|
u.Combinations = temp.Combinations
|
||||||
|
|
||||||
|
for i := range u.Combinations {
|
||||||
|
if u.Combinations[i].ByUser == nil {
|
||||||
|
u.Combinations[i].ByUser = make(map[string]UsageStats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *AggregatedUsage) Save() error {
|
||||||
|
jsonData := u.ToJSON()
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(jsonData, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpFile := u.filePath + ".tmp"
|
||||||
|
err = os.WriteFile(tmpFile, data, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer os.Remove(tmpFile)
|
||||||
|
err = os.Rename(tmpFile, u.filePath)
|
||||||
|
if err == nil {
|
||||||
|
u.saveMutex.Lock()
|
||||||
|
u.lastSaveTime = time.Now()
|
||||||
|
u.saveMutex.Unlock()
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *AggregatedUsage) AddUsage(model string, contextWindow int, messagesCount int, inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens int64, user string) error {
|
||||||
|
if model == "" {
|
||||||
|
return E.New("model cannot be empty")
|
||||||
|
}
|
||||||
|
if contextWindow <= 0 {
|
||||||
|
return E.New("contextWindow must be positive")
|
||||||
|
}
|
||||||
|
|
||||||
|
u.mutex.Lock()
|
||||||
|
defer u.mutex.Unlock()
|
||||||
|
|
||||||
|
u.LastUpdated = time.Now()
|
||||||
|
|
||||||
|
// Find or create combination
|
||||||
|
var combo *CostCombination
|
||||||
|
for i := range u.Combinations {
|
||||||
|
if u.Combinations[i].Model == model && u.Combinations[i].ContextWindow == contextWindow {
|
||||||
|
combo = &u.Combinations[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if combo == nil {
|
||||||
|
newCombo := CostCombination{
|
||||||
|
Model: model,
|
||||||
|
ContextWindow: contextWindow,
|
||||||
|
Total: UsageStats{},
|
||||||
|
ByUser: make(map[string]UsageStats),
|
||||||
|
}
|
||||||
|
u.Combinations = append(u.Combinations, newCombo)
|
||||||
|
combo = &u.Combinations[len(u.Combinations)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update total stats
|
||||||
|
combo.Total.RequestCount++
|
||||||
|
combo.Total.MessagesCount += messagesCount
|
||||||
|
combo.Total.InputTokens += inputTokens
|
||||||
|
combo.Total.OutputTokens += outputTokens
|
||||||
|
combo.Total.CacheReadInputTokens += cacheReadTokens
|
||||||
|
combo.Total.CacheCreationInputTokens += cacheCreationTokens
|
||||||
|
|
||||||
|
// Update per-user stats if user is specified
|
||||||
|
if user != "" {
|
||||||
|
userStats := combo.ByUser[user]
|
||||||
|
userStats.RequestCount++
|
||||||
|
userStats.MessagesCount += messagesCount
|
||||||
|
userStats.InputTokens += inputTokens
|
||||||
|
userStats.OutputTokens += outputTokens
|
||||||
|
userStats.CacheReadInputTokens += cacheReadTokens
|
||||||
|
userStats.CacheCreationInputTokens += cacheCreationTokens
|
||||||
|
combo.ByUser[user] = userStats
|
||||||
|
}
|
||||||
|
|
||||||
|
go u.scheduleSave()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *AggregatedUsage) scheduleSave() {
|
||||||
|
const saveInterval = time.Minute
|
||||||
|
|
||||||
|
u.saveMutex.Lock()
|
||||||
|
defer u.saveMutex.Unlock()
|
||||||
|
|
||||||
|
timeSinceLastSave := time.Since(u.lastSaveTime)
|
||||||
|
|
||||||
|
if timeSinceLastSave >= saveInterval {
|
||||||
|
go u.saveAsync()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.pendingSave {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u.pendingSave = true
|
||||||
|
remainingTime := saveInterval - timeSinceLastSave
|
||||||
|
|
||||||
|
u.saveTimer = time.AfterFunc(remainingTime, func() {
|
||||||
|
u.saveMutex.Lock()
|
||||||
|
u.pendingSave = false
|
||||||
|
u.saveMutex.Unlock()
|
||||||
|
u.saveAsync()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *AggregatedUsage) saveAsync() {
|
||||||
|
err := u.Save()
|
||||||
|
if err != nil {
|
||||||
|
if u.logger != nil {
|
||||||
|
u.logger.Error("save usage statistics: ", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *AggregatedUsage) cancelPendingSave() {
|
||||||
|
u.saveMutex.Lock()
|
||||||
|
defer u.saveMutex.Unlock()
|
||||||
|
|
||||||
|
if u.saveTimer != nil {
|
||||||
|
u.saveTimer.Stop()
|
||||||
|
u.saveTimer = nil
|
||||||
|
}
|
||||||
|
u.pendingSave = false
|
||||||
|
}
|
||||||
29
service/ccm/service_user.go
Normal file
29
service/ccm/service_user.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package ccm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/sagernet/sing-box/option"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserManager struct {
|
||||||
|
accessMutex sync.RWMutex
|
||||||
|
tokenMap map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UserManager) UpdateUsers(users []option.CCMUser) {
|
||||||
|
m.accessMutex.Lock()
|
||||||
|
defer m.accessMutex.Unlock()
|
||||||
|
tokenMap := make(map[string]string, len(users))
|
||||||
|
for _, user := range users {
|
||||||
|
tokenMap[user.Token] = user.Name
|
||||||
|
}
|
||||||
|
m.tokenMap = tokenMap
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UserManager) Authenticate(token string) (string, bool) {
|
||||||
|
m.accessMutex.RLock()
|
||||||
|
username, found := m.tokenMap[token]
|
||||||
|
m.accessMutex.RUnlock()
|
||||||
|
return username, found
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user