diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 59972255f..3d2aee6c1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -93,10 +93,6 @@ jobs: - { os: windows, arch: "386", legacy_win7: true, legacy_name: "windows-7" } - { os: windows, arch: arm64 } - - { os: darwin, arch: amd64 } - - { os: darwin, arch: arm64 } - - { os: darwin, arch: amd64, legacy_go124: true, legacy_name: "macos-11" } - - { os: android, arch: arm64, ndk: "aarch64-linux-android21" } - { os: android, arch: arm, ndk: "armv7a-linux-androideabi21" } - { os: android, arch: amd64, ndk: "x86_64-linux-android21" } @@ -146,7 +142,7 @@ jobs: - name: Set build tags run: | set -xeuo pipefail - TAGS='with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,badlinkname,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}" - name: Build if: matrix.os != 'android' @@ -285,6 +281,77 @@ jobs: with: name: binary-${{ matrix.os }}_${{ matrix.arch }}${{ matrix.goarm && format('v{0}', matrix.goarm) }}${{ matrix.go386 && format('_{0}', matrix.go386) }}${{ matrix.gomips && format('_{0}', matrix.gomips) }}${{ matrix.legacy_name && format('-legacy-{0}', matrix.legacy_name) }} path: "dist" + build_darwin: + name: Build Darwin binaries + if: github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Binary' + runs-on: macos-latest + needs: + - calculate_version + strategy: + matrix: + include: + - { arch: amd64 } + - { arch: arm64 } + - { arch: amd64, legacy_go124: true, legacy_name: "macos-11" } + steps: + - name: Checkout + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + with: + fetch-depth: 0 + - name: Setup Go + if: ${{ ! matrix.legacy_go124 }} + uses: actions/setup-go@v5 + with: + go-version: ^1.25.3 + - name: Setup Go 1.24 + if: matrix.legacy_go124 + uses: actions/setup-go@v5 + with: + go-version: ~1.24.6 + - name: Set tag + run: |- + git ls-remote --exit-code --tags origin v${{ needs.calculate_version.outputs.version }} || echo "PUBLISHED=false" >> "$GITHUB_ENV" + git tag v${{ needs.calculate_version.outputs.version }} -f + - name: Set build tags + run: | + set -xeuo pipefail + TAGS='with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,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: name: Build Android if: github.event_name != 'workflow_dispatch' || inputs.build == 'All' || inputs.build == 'Android' @@ -619,6 +686,7 @@ jobs: needs: - calculate_version - build + - build_darwin - build_android - build_apple steps: diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index 6f84a899c..f2b8a50bc 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -85,7 +85,7 @@ jobs: - name: Set build tags run: | set -xeuo pipefail - TAGS='with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,badlinkname,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}" - name: Build run: | diff --git a/.goreleaser.fury.yaml b/.goreleaser.fury.yaml deleted file mode 100644 index 3763db014..000000000 --- a/.goreleaser.fury.yaml +++ /dev/null @@ -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 - 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 }}" diff --git a/.goreleaser.yaml b/.goreleaser.yaml deleted file mode 100644 index 6ee53c5cb..000000000 --- a/.goreleaser.yaml +++ /dev/null @@ -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 - 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 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 5273b2e75..5162d4613 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,7 @@ RUN set -ex \ && export COMMIT=$(git rev-parse --short HEAD) \ && export VERSION=$(go run ./cmd/internal/read_tag) \ && go build -v -trimpath -tags \ - "with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,badlinkname,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 \ -ldflags "-X \"github.com/sagernet/sing-box/constant.Version=$VERSION\" -s -w -buildid= -checklinkname=0" \ ./cmd/sing-box diff --git a/Makefile b/Makefile index baeac7094..2298e66e4 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ NAME = sing-box COMMIT = $(shell git rev-parse --short HEAD) -TAGS ?= with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,badlinkname,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) GOHOSTARCH = $(shell go env GOHOSTARCH) diff --git a/constant/proxy.go b/constant/proxy.go index cf12c48d6..a54a3a75d 100644 --- a/constant/proxy.go +++ b/constant/proxy.go @@ -28,6 +28,7 @@ const ( TypeDERP = "derp" TypeResolved = "resolved" TypeSSMAPI = "ssm-api" + TypeCCM = "ccm" ) const ( diff --git a/docs/configuration/service/ccm.md b/docs/configuration/service/ccm.md new file mode 100644 index 000000000..e0ccfa1f8 --- /dev/null +++ b/docs/configuration/service/ccm.md @@ -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 +``` diff --git a/docs/configuration/service/ccm.zh.md b/docs/configuration/service/ccm.zh.md new file mode 100644 index 000000000..fa5e5d512 --- /dev/null +++ b/docs/configuration/service/ccm.zh.md @@ -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 +``` diff --git a/docs/configuration/service/index.md b/docs/configuration/service/index.md index 87a0042db..2bd1a4a3f 100644 --- a/docs/configuration/service/index.md +++ b/docs/configuration/service/index.md @@ -23,6 +23,7 @@ icon: material/new-box | Type | Format | |------------|------------------------| +| `ccm` | [CCM](./ccm) | | `derp` | [DERP](./derp) | | `resolved` | [Resolved](./resolved) | | `ssm-api` | [SSM API](./ssm-api) | diff --git a/docs/configuration/service/index.zh.md b/docs/configuration/service/index.zh.md index d534aa85f..b4a73eda9 100644 --- a/docs/configuration/service/index.zh.md +++ b/docs/configuration/service/index.zh.md @@ -23,6 +23,7 @@ icon: material/new-box | 类型 | 格式 | |-----------|------------------------| +| `ccm` | [CCM](./ccm) | | `derp` | [DERP](./derp) | | `resolved`| [Resolved](./resolved) | | `ssm-api` | [SSM API](./ssm-api) | diff --git a/go.mod b/go.mod index 5b1bff87f..c34abc0e9 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/sagernet/sing-box go 1.24.7 require ( + github.com/anthropics/anthropic-sdk-go v1.14.0 github.com/anytls/sing-anytls v0.0.11 github.com/caddyserver/certmagic v0.23.0 github.com/coder/websocket v1.8.13 @@ -13,6 +14,7 @@ require ( github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 github.com/gofrs/uuid/v5 v5.3.2 github.com/insomniacslk/dhcp v0.0.0-20250417080101-5f8cf70e8c5f + github.com/keybase/go-keychain v0.0.1 github.com/libdns/alidns v1.0.5-libdns.v1.beta1 github.com/libdns/cloudflare v0.2.2-0.20250708034226-c574dccb31a6 github.com/logrusorgru/aurora v2.0.3+incompatible @@ -113,6 +115,10 @@ require ( github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/zeebo/blake3 v0.2.4 // indirect diff --git a/go.sum b/go.sum index c420641af..ebeb026e6 100644 --- a/go.sum +++ b/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/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= 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/go.mod h1:7rjN6IukwysmdusYsrV51Fgu1uW6vsrdd6ctjnEAln8= github.com/caddyserver/certmagic v0.23.0 h1:CfpZ/50jMfG4+1J/u2LV6piJq4HOfO6ppOnOf7DkFEU= @@ -94,6 +96,8 @@ github.com/insomniacslk/dhcp v0.0.0-20250417080101-5f8cf70e8c5f/go.mod h1:zhFlBe github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I= github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E= +github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= +github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= @@ -209,6 +213,16 @@ github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da h1:jVRUZPRs github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4= github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA= github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM= github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA= github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= diff --git a/include/ccm.go b/include/ccm.go new file mode 100644 index 000000000..a75201485 --- /dev/null +++ b/include/ccm.go @@ -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) +} diff --git a/include/ccm_stub.go b/include/ccm_stub.go new file mode 100644 index 000000000..eac29eebd --- /dev/null +++ b/include/ccm_stub.go @@ -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`) + }) +} diff --git a/include/ccm_stub_darwin.go b/include/ccm_stub_darwin.go new file mode 100644 index 000000000..f2ad73816 --- /dev/null +++ b/include/ccm_stub_darwin.go @@ -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`) + }) +} diff --git a/include/registry.go b/include/registry.go index 94d56db1e..9965bc5e1 100644 --- a/include/registry.go +++ b/include/registry.go @@ -134,6 +134,7 @@ func ServiceRegistry() *service.Registry { ssmapi.RegisterService(registry) registerDERPService(registry) + registerCCMService(registry) return registry } diff --git a/option/ccm.go b/option/ccm.go new file mode 100644 index 000000000..c916aaf22 --- /dev/null +++ b/option/ccm.go @@ -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"` +} diff --git a/release/local/common.sh b/release/local/common.sh new file mode 100755 index 000000000..d24bba475 --- /dev/null +++ b/release/local/common.sh @@ -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 +} diff --git a/release/local/debug.sh b/release/local/debug.sh index d6bd3057e..d86519992 100755 --- a/release/local/debug.sh +++ b/release/local/debug.sh @@ -2,21 +2,25 @@ set -e -o pipefail -if [ -d /usr/local/go ]; then - export PATH="$PATH:/usr/local/go/bin" -fi +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/common.sh" -DIR=$(dirname "$0") -PROJECT=$DIR/../.. +setup_environment -pushd $PROJECT +echo "Updating sing-box from git repository..." +cd "$PROJECT_DIR" git fetch git reset FETCH_HEAD --hard git clean -fdx -go install -v -trimpath -ldflags "-s -w -buildid=" -tags with_quic,with_acme,debug ./cmd/sing-box -popd -sudo systemctl stop sing-box -sudo cp $(go env GOPATH)/bin/sing-box /usr/local/bin/ -sudo systemctl start sing-box +BUILD_TAGS=$(get_build_tags "debug") + +build_sing_box "$BUILD_TAGS" + +stop_service +install_binary +start_service + +echo "" +echo "Following service logs (Ctrl+C to exit)..." sudo journalctl -u sing-box --output cat -f diff --git a/release/local/install.sh b/release/local/install.sh index 24e9d006e..d5bf94fcc 100755 --- a/release/local/install.sh +++ b/release/local/install.sh @@ -2,19 +2,18 @@ set -e -o pipefail -if [ -d /usr/local/go ]; then - export PATH="$PATH:/usr/local/go/bin" -fi +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/common.sh" -DIR=$(dirname "$0") -PROJECT=$DIR/../.. +setup_environment -pushd $PROJECT -go install -v -trimpath -ldflags "-s -w -buildid=" -tags with_quic,with_wireguard,with_acme ./cmd/sing-box -popd +BUILD_TAGS=$(get_build_tags) -sudo cp $(go env GOPATH)/bin/sing-box /usr/local/bin/ -sudo mkdir -p /usr/local/etc/sing-box -sudo cp $PROJECT/release/config/config.json /usr/local/etc/sing-box/config.json -sudo cp $DIR/sing-box.service /etc/systemd/system -sudo systemctl daemon-reload +build_sing_box "$BUILD_TAGS" +install_binary +setup_config +setup_systemd + +echo "" +echo "Installation complete!" +echo "To enable and start the service, run: $SCRIPT_DIR/enable.sh" diff --git a/release/local/reinstall.sh b/release/local/reinstall.sh index 71d071095..1daaa1810 100755 --- a/release/local/reinstall.sh +++ b/release/local/reinstall.sh @@ -2,17 +2,18 @@ set -e -o pipefail -if [ -d /usr/local/go ]; then - export PATH="$PATH:/usr/local/go/bin" -fi +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/common.sh" -DIR=$(dirname "$0") -PROJECT=$DIR/../.. +setup_environment -pushd $PROJECT -go install -v -trimpath -ldflags "-s -w -buildid=" -tags with_quic,with_wireguard,with_acme ./cmd/sing-box -popd +BUILD_TAGS=$(get_build_tags) -sudo systemctl stop sing-box -sudo cp $(go env GOPATH)/bin/sing-box /usr/local/bin/ -sudo systemctl start sing-box +build_sing_box "$BUILD_TAGS" + +stop_service +install_binary +start_service + +echo "" +echo "Reinstallation complete!" diff --git a/release/local/uninstall.sh b/release/local/uninstall.sh index d40107ba9..b9c89ab0d 100755 --- a/release/local/uninstall.sh +++ b/release/local/uninstall.sh @@ -1,8 +1,30 @@ #!/usr/bin/env bash -sudo systemctl stop sing-box -sudo rm -rf /var/lib/sing-box -sudo rm -rf /usr/local/bin/sing-box -sudo rm -rf /usr/local/etc/sing-box -sudo rm -rf /etc/systemd/system/sing-box.service +set -e -o pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +echo "Uninstalling sing-box..." + +if systemctl is-active --quiet sing-box 2>/dev/null; then + echo "Stopping sing-box service..." + sudo systemctl stop sing-box +fi + +if systemctl is-enabled --quiet sing-box 2>/dev/null; then + echo "Disabling sing-box service..." + sudo systemctl disable sing-box +fi + +echo "Removing files..." +sudo rm -rf "$INSTALL_DATA_PATH" +sudo rm -rf "$INSTALL_BIN_PATH/$BINARY_NAME" +sudo rm -rf "$INSTALL_CONFIG_PATH" +sudo rm -rf "$SYSTEMD_SERVICE_PATH/sing-box.service" + +echo "Reloading systemd..." sudo systemctl daemon-reload + +echo "" +echo "Uninstallation complete!" diff --git a/release/local/update.sh b/release/local/update.sh index 86ea315d3..2331d2703 100755 --- a/release/local/update.sh +++ b/release/local/update.sh @@ -2,13 +2,15 @@ set -e -o pipefail -DIR=$(dirname "$0") -PROJECT=$DIR/../.. +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/common.sh" -pushd $PROJECT +echo "Updating sing-box from git repository..." +cd "$PROJECT_DIR" git fetch git reset FETCH_HEAD --hard git clean -fdx -popd -$DIR/reinstall.sh \ No newline at end of file +echo "" +echo "Running reinstall..." +exec "$SCRIPT_DIR/reinstall.sh" \ No newline at end of file diff --git a/service/ccm/credential.go b/service/ccm/credential.go new file mode 100644 index 000000000..5fd797215 --- /dev/null +++ b/service/ccm/credential.go @@ -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 +} diff --git a/service/ccm/credential_darwin.go b/service/ccm/credential_darwin.go new file mode 100644 index 000000000..24047b858 --- /dev/null +++ b/service/ccm/credential_darwin.go @@ -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) +} diff --git a/service/ccm/credential_other.go b/service/ccm/credential_other.go new file mode 100644 index 000000000..11888b508 --- /dev/null +++ b/service/ccm/credential_other.go @@ -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) +} diff --git a/service/ccm/service.go b/service/ccm/service.go new file mode 100644 index 000000000..84074694f --- /dev/null +++ b/service/ccm/service.go @@ -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 +} diff --git a/service/ccm/service_usage.go b/service/ccm/service_usage.go new file mode 100644 index 000000000..53ae46587 --- /dev/null +++ b/service/ccm/service_usage.go @@ -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 +} diff --git a/service/ccm/service_user.go b/service/ccm/service_user.go new file mode 100644 index 000000000..94637ed81 --- /dev/null +++ b/service/ccm/service_user.go @@ -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 +}