From 38a0e397f64cfdc4e9cde61f6e698e9fbdf0ec18 Mon Sep 17 00:00:00 2001 From: Alumos <1281610535@qq.com> Date: Thu, 12 Nov 2026 23:20:32 +0800 Subject: [PATCH 1/2] feat: identity hardening modelled on sub2api - Codex: learn free-text os_info platforms, present the canonical platform before a native client is seen, forward native identity headers and keep session identifiers consistent across headers, turn metadata, client_metadata and prompt_cache_key. - Codex: opt-in per-account identifier convergence (device, session, full) from a rotatable seed (migration 039). - Codex: turn in-stream server_is_overloaded and slow_down into retryable 503 responses before anything reaches the client. - Claude: opt-in Claude Code (Node.js 24) TLS ClientHello via uTLS, also through HTTP and SOCKS5 proxies. Co-Authored-By: Claude Opus 5.5 --- PRODUCT.md | 2 + THIRD_PARTY_NOTICES.md | 6 + go.mod | 4 +- internal/accounts/credential.go | 4 +- internal/accounts/fingerprint.go | 132 ++++++++-- internal/accounts/fingerprint_test.go | 59 ++++- internal/accounts/refresh.go | 4 +- internal/backup/backup_test.go | 3 + internal/server/accounts.go | 7 +- internal/storage/db/accounts.sql.go | 34 ++- internal/storage/db/models.go | 51 ++-- .../migrations/039_client_identity.sql | 6 + internal/storage/queries/accounts.sql | 4 + internal/upstream/channel.go | 2 +- internal/upstream/client.go | 20 +- internal/upstream/client_test.go | 6 +- internal/upstream/codex_identity.go | 229 +++++++++++++++++ internal/upstream/engine.go | 13 + internal/upstream/fingerprint_test.go | 5 +- internal/upstream/identity_test.go | 237 +++++++++++++----- internal/upstream/proxy.go | 63 +++-- internal/upstream/tlsfingerprint.go | 156 ++++++++++++ licenses/utls.txt | 27 ++ sqlc.yaml | 2 + .../components/AccountFingerprint.test.tsx | 2 + web/src/components/AccountFingerprint.tsx | 98 +++++++- web/src/lib/accounts.ts | 13 +- web/src/locales/ar.json | 16 +- web/src/locales/de.json | 16 +- web/src/locales/en.ts | 22 +- web/src/locales/es.json | 16 +- web/src/locales/fr.json | 16 +- web/src/locales/ja.json | 16 +- web/src/locales/ko.json | 16 +- web/src/locales/pt.json | 16 +- web/src/locales/ru.json | 16 +- web/src/locales/zh-TW.json | 16 +- web/src/locales/zh.ts | 22 +- 38 files changed, 1213 insertions(+), 164 deletions(-) create mode 100644 internal/storage/migrations/039_client_identity.sql create mode 100644 internal/upstream/codex_identity.go create mode 100644 internal/upstream/tlsfingerprint.go create mode 100644 licenses/utls.txt diff --git a/PRODUCT.md b/PRODUCT.md index 2bdd55a1..8f190419 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -111,6 +111,8 @@ Resource groups also offer account failover, enabled by default for new and exis Each upstream account presents one client fingerprint, following CLIProxyAPI; different accounts may present different fingerprints. The profile is stored on the account (`client_profile`) and is learned from the first native client that uses it: Codex keeps that client's User-Agent platform and terminal, and Claude keeps the operating system and architecture its Claude Code client reports in X-Stainless headers. Every later client, on any machine or tool, presents the pinned values. Client versions stay under SubLane's version policy: Codex uses the managed Codex version, and Claude runtime and SDK versions follow the SDK's measured baseline for the managed Claude Code release. Claude accounts also keep one durable device ID, and the SDK's device-profile stabilization keeps the remaining Claude headers consistent per account. Administrators can view, set or clear an account's fingerprint from the account menu (validated values only; empty fields fall back to learning or defaults) and can give a Claude account a new device ID; changes are audited. +Identity hardening follows sub2api's production-tested rules. Codex requests always present one self-consistent client: originator, User-Agent product and `Version` agree and follow the managed Codex release (the backend rejects mismatches and versions below 0.144.0), and accounts that have not learned a native client present the canonical `(Ubuntu 22.4.0; x86_64) xterm-256color` platform instead of a platform-less agent. The identity headers native clients send (installation, window and turn-state headers and turn metadata) are forwarded, and every identifier carrier is kept consistent with the outgoing session: the `Session_id` header, turn metadata, `client_metadata` and `prompt_cache_key`. Codex accounts can opt in to identifier convergence (`device`: one installation ID; `session`: also one session ID with one thread per real conversation; `full`: one thread), derived from a per-account random seed that a device reset rotates. Convergence is off by default because sub2api users reported shrinking quotas when it was on by default. When the Codex backend sheds load by client identity it answers HTTP 200 with `server_is_overloaded` or `slow_down` as the first stream event; SubLane detects that before anything reaches the client and turns it into a retryable 503 that the gateway retries and, through cooldowns, fails over. Claude accounts can opt in to Claude Code's TLS fingerprint: connections, including through HTTP or SOCKS5 proxies, perform a Node.js 24 ClientHello (JA3 `44f88fca027f27bab4bb08d4af15f23e`, as captured by sub2api) over HTTP/1.1 using uTLS. + Provider requests whose connection fails before any byte is sent (DNS, TCP dial or proxy CONNECT) are retried up to twice after 250 ms and 1 s on the same account; failures after a request may have reached the provider are never replayed. API channels can be renamed and given a replacement key in place at `/channels`; leaving the key blank keeps the stored secret. Base URL, platform and protocol remain fixed so a stored key never reaches another upstream; those changes still require a separate channel. Edits retain the channel ID, group assignments, limits and request history; a key change resets verification and invalidates the model catalog in the same transaction. Resource groups can be deleted when no active personal key or resource allowance references them. Deletion retires the group: routing, grants, resources, model policy and bindings are removed in one audited transaction, while request history and usage aggregates keep their workspace scope. Retired groups keep their names; uniqueness applies only to active groups, so the name can be reused. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index fded5dd0..7abba940 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -44,6 +44,12 @@ The Radix dropdown menu is pinned to 2.1.16 alongside Dialog 1.1.15 so they shar - Integration: public `sdk/cliproxy`, auth/executor interfaces, `sdk/auth` Antigravity helpers, `sdk/api`, `sdk/config`, and translator packages. SubLane retains OAuth state, encrypted persistence, refresh orchestration, routing, and transport ownership. Codex/Claude OAuth parameters and provider metadata handling are adapted from this version’s MIT-licensed sources. Antigravity authorization and refresh reuse public SDK interfaces; Google installed-app client credentials are not duplicated in SubLane source. - The SDK starts an internal loopback HTTP listener with all routes blocked, an empty credential registry, a no-op watcher, and automatic refresh disabled. Its CLI, management panel, Home mode, and Redis service are not enabled. +## uTLS + +- Project: https://github.com/refraction-networking/utls, version `v1.8.2` (pinned in `go.mod` and `go.sum`). +- License: BSD-3-Clause; full text in `licenses/utls.txt`. +- Used to present Claude Code's TLS ClientHello on Claude accounts that opt in to the TLS fingerprint. The Node.js 24 ClientHello parameters follow the capture published by sub2api (https://github.com/Wei-Shaw/sub2api); no sub2api source code is included. + ## Provider logos (Lobe Icons) - Project: https://github.com/lobehub/lobe-icons diff --git a/go.mod b/go.mod index 86941440..52c70c36 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,8 @@ module github.com/murongg/SubLane go 1.26.0 require ( + golang.org/x/net v0.58.0 + github.com/refraction-networking/utls v1.8.2 filippo.io/age v1.3.2 github.com/fxamacker/cbor/v2 v2.9.4 github.com/gin-gonic/gin v1.10.1 @@ -71,7 +73,6 @@ require ( github.com/pion/turn/v5 v5.0.12 // indirect github.com/pion/webrtc/v4 v4.2.17 // indirect github.com/redis/go-redis/v9 v9.19.0 // indirect - github.com/refraction-networking/utls v1.8.2 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect github.com/tidwall/gjson v1.18.0 // indirect @@ -86,7 +87,6 @@ require ( github.com/x448/float16 v0.8.4 // indirect go.uber.org/atomic v1.11.0 // indirect golang.org/x/arch v0.8.0 // indirect - golang.org/x/net v0.58.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sync v0.23.0 // indirect golang.org/x/text v0.42.0 // indirect diff --git a/internal/accounts/credential.go b/internal/accounts/credential.go index ae5aa02a..a4ca8321 100644 --- a/internal/accounts/credential.go +++ b/internal/accounts/credential.go @@ -15,7 +15,9 @@ type Credential struct { APIProtocol string `json:"api_protocol,omitempty"` ProxyURL string `json:"-"` // Profile is the account's pinned client fingerprint; it is stored on the account, not the credential. - Profile ClientProfile `json:"-"` + Profile ClientProfile `json:"-"` + // Identity is the account\'s opt-in identity hardening; it is stored on the account. + Identity ClientIdentity `json:"-"` Provider string `json:"provider,omitempty"` Metadata map[string]json.RawMessage `json:"metadata,omitempty"` AccessToken string `json:"access_token"` diff --git a/internal/accounts/fingerprint.go b/internal/accounts/fingerprint.go index c3d35f0d..e01d6102 100644 --- a/internal/accounts/fingerprint.go +++ b/internal/accounts/fingerprint.go @@ -3,12 +3,15 @@ package accounts import ( "context" "crypto/rand" + "crypto/sha256" "database/sql" "encoding/hex" "encoding/json" "errors" + "fmt" "net/http" "regexp" + "strings" "github.com/murongg/SubLane/internal/audit" "github.com/murongg/SubLane/internal/storage/db" @@ -33,19 +36,39 @@ type ClientProfile struct { PackageVersion string `json:"package_version,omitempty"` } +// ClientIdentity holds opt-in identity hardening for one account, modelled on sub2api. +type ClientIdentity struct { + // Mode converges Codex identifiers: "off" (pass client IDs through), "device" (one installation + // ID), "session" (also one session ID, one thread per real conversation) or "full" (one thread). + Mode string `json:"mode"` + // Seed is the system-managed random value converged identifiers derive from; never exposed. + Seed string `json:"-"` + // TLS makes a Claude account's connections present Claude Code's (Node.js 24) TLS handshake. + TLS bool `json:"tls"` +} + // Fingerprint is the administrator view of an account's client fingerprint. DeviceID is a short -// prefix of the Claude device identifier, never the full value. +// prefix of the Claude device or converged Codex installation identifier, never the full value. type Fingerprint struct { - Provider string `json:"provider"` - Profile ClientProfile `json:"profile"` - DeviceID string `json:"device_id,omitempty"` + Provider string `json:"provider"` + Profile ClientProfile `json:"profile"` + Identity ClientIdentity `json:"identity"` + DeviceID string `json:"device_id,omitempty"` } +// CodexDefaultPlatform is presented by Codex accounts that have not learned a native client yet, +// matching sub2api's canonical identity, so no request leaves with a platform-less agent. +const CodexDefaultPlatform = "(Ubuntu 22.4.0; x86_64) xterm-256color" + +var convergenceModes = map[string]bool{"off": true, "device": true, "session": true, "full": true} + var ( - // codexClientAgent matches a native Codex User-Agent: - // "/ ( ; ) [ ()]". - codexClientAgent = regexp.MustCompile(`^(?:codex_cli_rs|codex-tui|codex_exec|codex_vscode)/[0-9]+\.[0-9]+\.[0-9]+[0-9A-Za-z.+-]{0,24} (\([A-Za-z][A-Za-z0-9 ._-]{0,48} [0-9][0-9A-Za-z._-]{0,24}; [A-Za-z0-9_]{1,16}\)(?: [A-Za-z0-9][A-Za-z0-9._+-]{0,40}(?:/[A-Za-z0-9._+-]{1,40})?)?)`) - codexPlatform = regexp.MustCompile(`^\([A-Za-z][A-Za-z0-9 ._-]{0,48} [0-9][0-9A-Za-z._-]{0,24}; [A-Za-z0-9_]{1,16}\)(?: [A-Za-z0-9][A-Za-z0-9._+-]{0,40}(?:/[A-Za-z0-9._+-]{1,40})?)?$`) + // codexClientAgent matches a native Codex User-Agent as codex-rs builds it + // ("/ ( ; ) [ ()]"). The + // os_info values are free text such as "Mac OS 15.5.0", "Windows 10.0.26100" or "Arch Linux + // Rolling Release"; the terminal is TERM_PROGRAM[/version], TERM, or a detected name. + codexClientAgent = regexp.MustCompile(`^(?:codex_cli_rs|codex-tui|codex_exec|codex_vscode)/[0-9]+\.[0-9]+\.[0-9]+[0-9A-Za-z.+-]{0,24} (\([A-Za-z][ -'*-:<-~]{0,63}; [A-Za-z0-9_]{1,16}\)(?: [!-'*-~]{1,64})?)`) + codexPlatform = regexp.MustCompile(`^\([A-Za-z][ -'*-:<-~]{0,63}; [A-Za-z0-9_]{1,16}\)(?: [!-'*-~]{1,64})?$`) claudeClient = regexp.MustCompile(`^claude-cli/[0-9]+\.[0-9]+\.[0-9]+ \(external, [a-z-]{1,32}\)$`) // The Stainless values the Anthropic SDK reports on the platforms Claude Code runs on. claudeOS = regexp.MustCompile(`^(MacOS|Windows|Linux|FreeBSD)$`) @@ -136,7 +159,10 @@ func (s *Service) Fingerprint(ctx context.Context, id string) (Fingerprint, erro if !fingerprintProvider(row.Provider) { return Fingerprint{}, ErrFingerprintUnsupported } - result := Fingerprint{Provider: row.Provider, Profile: decodeProfile(row.ClientProfile)} + result := Fingerprint{Provider: row.Provider, Profile: decodeProfile(row.ClientProfile), Identity: identityOf(row)} + if row.Provider == "codex" && result.Identity.Mode != "off" { + result.DeviceID = CodexInstallationID(result.Identity.Seed)[:8] + } if row.Provider == "claude" { credential, err := s.decrypt(row) if err != nil { @@ -149,19 +175,38 @@ func (s *Service) Fingerprint(ctx context.Context, id string) (Fingerprint, erro return result, nil } -// SetFingerprint stores an administrator's fingerprint. An empty profile clears it, so the next -// native client is learned again. -func (s *Service) SetFingerprint(ctx context.Context, id string, profile ClientProfile) (Fingerprint, error) { - return s.changeFingerprint(ctx, id, &profile, false, "account.fingerprint") +// SetFingerprint stores an administrator's fingerprint and identity settings. An empty profile clears +// it, so the next native client is learned again. +func (s *Service) SetFingerprint(ctx context.Context, id string, profile ClientProfile, identity ClientIdentity) (Fingerprint, error) { + return s.changeFingerprint(ctx, id, &profile, &identity, false, "account.fingerprint") } -// ResetFingerprint clears the profile; with device it also gives a Claude account a new device ID, -// which the provider sees as a new machine. +// ResetFingerprint clears the profile; with device it also gives the account a new device identity +// (a Claude device ID or a Codex convergence seed), which the provider sees as a new machine. func (s *Service) ResetFingerprint(ctx context.Context, id string, device bool) (Fingerprint, error) { - return s.changeFingerprint(ctx, id, nil, device, "account.fingerprint_reset") + return s.changeFingerprint(ctx, id, nil, nil, device, "account.fingerprint_reset") +} + +func identityOf(row db.Account) ClientIdentity { + identity := ClientIdentity{Mode: row.FingerprintMode, TLS: row.TlsFingerprint} + if row.FingerprintSeed != nil { + identity.Seed = *row.FingerprintSeed + } + if !convergenceModes[identity.Mode] { + identity.Mode = "off" + } + return identity } -func (s *Service) changeFingerprint(ctx context.Context, id string, profile *ClientProfile, device bool, action string) (Fingerprint, error) { +func newSeed() (string, error) { + raw := make([]byte, 16) + if _, err := rand.Read(raw); err != nil { + return "", err + } + return stableUUID(raw), nil +} + +func (s *Service) changeFingerprint(ctx context.Context, id string, profile *ClientProfile, requested *ClientIdentity, device bool, action string) (Fingerprint, error) { s.mu.Lock() tx, err := s.db.BeginTx(ctx, nil) if err != nil { @@ -179,7 +224,7 @@ func (s *Service) changeFingerprint(ctx context.Context, id string, profile *Cli s.mu.Unlock() return Fingerprint{}, err } - if !fingerprintProvider(row.Provider) || device && row.Provider != "claude" { + if !fingerprintProvider(row.Provider) { s.mu.Unlock() return Fingerprint{}, ErrFingerprintUnsupported } @@ -202,7 +247,32 @@ func (s *Service) changeFingerprint(ctx context.Context, id string, profile *Cli s.mu.Unlock() return Fingerprint{}, err } - if device { + identity := identityOf(row) + if requested != nil { + // Convergence applies to Codex identifiers and the TLS profile to Claude connections only. + if !convergenceModes[requested.Mode] || row.Provider != "codex" && requested.Mode != "off" || row.Provider != "claude" && requested.TLS { + s.mu.Unlock() + return Fingerprint{}, ErrInput + } + identity.Mode, identity.TLS = requested.Mode, requested.TLS + } + if row.Provider == "codex" && (identity.Seed == "" && identity.Mode != "off" || device) { + seed, err := newSeed() + if err != nil { + s.mu.Unlock() + return Fingerprint{}, err + } + identity.Seed = seed + } + var seed *string + if identity.Seed != "" { + seed = &identity.Seed + } + if _, err := q.SetClientIdentity(ctx, db.SetClientIdentityParams{ID: id, TenantID: s.tenantID, FingerprintMode: identity.Mode, FingerprintSeed: seed, TlsFingerprint: identity.TLS, UpdatedAt: s.now().Unix()}); err != nil { + s.mu.Unlock() + return Fingerprint{}, err + } + if device && row.Provider == "claude" { credential, err := s.decrypt(row) if err != nil { s.mu.Unlock() @@ -233,3 +303,27 @@ func (s *Service) changeFingerprint(ctx context.Context, id string, profile *Cli s.mu.Unlock() return s.Fingerprint(ctx, id) } + +// stableUUID formats 16 bytes as a version 4, variant 1 UUID. +func stableUUID(b []byte) string { + b[6] = b[6]&0x0f | 0x40 + b[8] = b[8]&0x3f | 0x80 + return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) +} + +// derivedUUID deterministically derives a UUIDv4-shaped identifier: one seed always yields one value. +func derivedUUID(parts ...string) string { + sum := sha256.Sum256([]byte(strings.Join(parts, ":"))) + return stableUUID(sum[:16]) +} + +// CodexInstallationID is the account's converged Codex installation identifier. +func CodexInstallationID(seed string) string { return derivedUUID("sublane:codex-install-id:v1", seed) } + +// CodexSessionID is the account's converged Codex session identifier. +func CodexSessionID(seed string) string { return derivedUUID("sublane:codex-session-id:v1", seed) } + +// CodexThreadID derives one converged thread per real client conversation. +func CodexThreadID(seed, conversation string) string { + return derivedUUID("sublane:codex-thread-id:v1", seed, conversation) +} diff --git a/internal/accounts/fingerprint_test.go b/internal/accounts/fingerprint_test.go index c1fe7207..a88ad22d 100644 --- a/internal/accounts/fingerprint_test.go +++ b/internal/accounts/fingerprint_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "net/http" + "strings" "testing" "time" ) @@ -17,6 +18,9 @@ func TestObservedProfileKeepsOnlyNativeMachineAttributes(t *testing.T) { {"codex", http.Header{"User-Agent": {"codex_cli_rs/0.46.0 (Mac OS 15.5.0; arm64) iTerm.app/3.6.1"}}, ClientProfile{Source: "learned", Platform: "(Mac OS 15.5.0; arm64) iTerm.app/3.6.1"}}, {"codex", http.Header{"User-Agent": {"codex-tui/0.154.0 (Windows 10.0.26100; x86_64) WindowsTerminal (codex-tui)"}}, ClientProfile{Source: "learned", Platform: "(Windows 10.0.26100; x86_64) WindowsTerminal"}}, {"codex", http.Header{"User-Agent": {"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"}}, ClientProfile{}}, + // os_info reports free-text versions, and the terminal falls back to TERM over SSH. + {"codex", http.Header{"User-Agent": {"codex_cli_rs/0.155.1 (Arch Linux Rolling Release; x86_64) xterm-256color"}}, ClientProfile{Source: "learned", Platform: "(Arch Linux Rolling Release; x86_64) xterm-256color"}}, + {"codex", http.Header{"User-Agent": {"codex_exec/0.155.1 (Ubuntu 24.4.0; aarch64) unknown"}}, ClientProfile{Source: "learned", Platform: "(Ubuntu 24.4.0; aarch64) unknown"}}, {"claude", http.Header{"User-Agent": {"claude-cli/2.1.287 (external, cli)"}, "X-Stainless-Os": {"Windows"}, "X-Stainless-Arch": {"x64"}, "X-Stainless-Runtime-Version": {"v22.1.0"}}, ClientProfile{Source: "learned", OS: "Windows", Arch: "x64"}}, {"claude", http.Header{"User-Agent": {"SyntheticClient/1.0"}, "X-Stainless-Os": {"Windows"}, "X-Stainless-Arch": {"x64"}}, ClientProfile{}}, {"claude", http.Header{"User-Agent": {"claude-cli/2.1.287 (external, cli)"}, "X-Stainless-Os": {"Plan9"}, "X-Stainless-Arch": {"x64"}}, ClientProfile{}}, @@ -54,14 +58,14 @@ func TestManualFingerprintValidatesAndResetRelearns(t *testing.T) { if err != nil || before.Provider != "claude" || len(before.DeviceID) != 8 || before.Profile.Source != "" { t.Fatalf("initial fingerprint: %+v %v", before, err) } - if _, err := s.SetFingerprint(ctx, account.ID, ClientProfile{OS: "Plan9"}); !errors.Is(err, ErrInput) { + if _, err := s.SetFingerprint(ctx, account.ID, ClientProfile{OS: "Plan9"}, ClientIdentity{Mode: "off"}); !errors.Is(err, ErrInput) { t.Fatal("invalid OS accepted", err) } - if _, err := s.SetFingerprint(ctx, account.ID, ClientProfile{Platform: "(Mac OS 15.5.0; arm64)"}); !errors.Is(err, ErrInput) { + if _, err := s.SetFingerprint(ctx, account.ID, ClientProfile{Platform: "(Mac OS 15.5.0; arm64)"}, ClientIdentity{Mode: "off"}); !errors.Is(err, ErrInput) { t.Fatal("Codex field accepted for Claude", err) } - manual, err := s.SetFingerprint(ctx, account.ID, ClientProfile{OS: "Linux", Arch: "x64", RuntimeVersion: "v22.1.0"}) - if err != nil || manual.Profile.Source != "manual" || manual.Profile.OS != "Linux" || manual.Profile.RuntimeVersion != "v22.1.0" { + manual, err := s.SetFingerprint(ctx, account.ID, ClientProfile{OS: "Linux", Arch: "x64", RuntimeVersion: "v22.1.0"}, ClientIdentity{Mode: "off", TLS: true}) + if err != nil || manual.Profile.Source != "manual" || manual.Profile.OS != "Linux" || manual.Profile.RuntimeVersion != "v22.1.0" || !manual.Identity.TLS { t.Fatalf("manual fingerprint: %+v %v", manual, err) } // A manual profile is never overwritten by a learned one. @@ -69,11 +73,12 @@ func TestManualFingerprintValidatesAndResetRelearns(t *testing.T) { t.Fatal(err) } credential, err := s.Prepare(ctx, account.ID, nil) - if err != nil || credential.Profile.OS != "Linux" { + if err != nil || credential.Profile.OS != "Linux" || !credential.Identity.TLS { t.Fatalf("manual fingerprint did not reach execution: %+v %v", credential.Profile, err) } reset, err := s.ResetFingerprint(ctx, account.ID, true) - if err != nil || reset.Profile != (ClientProfile{}) || reset.DeviceID == before.DeviceID || len(reset.DeviceID) != 8 { + // Reset clears the learned or manual profile but keeps the administrator's identity settings. + if err != nil || reset.Profile != (ClientProfile{}) || reset.DeviceID == before.DeviceID || len(reset.DeviceID) != 8 || !reset.Identity.TLS { t.Fatalf("reset kept fingerprint or device: %+v %v", reset, err) } if err := s.LearnClientProfile(ctx, account.ID, ClientProfile{Source: "learned", OS: "Windows", Arch: "x64"}); err != nil { @@ -89,8 +94,44 @@ func TestFingerprintRejectsProvidersWithoutOne(t *testing.T) { if _, err := s.Fingerprint(context.Background(), account.ID); !errors.Is(err, ErrFingerprintUnsupported) { t.Fatal(err) } - codex, codexAccount, _ := refreshFixture(t, "codex", time.Hour) - if _, err := codex.ResetFingerprint(context.Background(), codexAccount.ID, true); !errors.Is(err, ErrFingerprintUnsupported) { - t.Fatal("Codex accepted a device reset", err) +} + +func TestCodexConvergenceSeedsAndRotatesInstallation(t *testing.T) { + s, account, _ := refreshFixture(t, "codex", time.Hour) + ctx := context.Background() + if _, err := s.SetFingerprint(ctx, account.ID, ClientProfile{}, ClientIdentity{Mode: "loud"}); !errors.Is(err, ErrInput) { + t.Fatal("unknown mode accepted", err) + } + if _, err := s.SetFingerprint(ctx, account.ID, ClientProfile{}, ClientIdentity{Mode: "off", TLS: true}); !errors.Is(err, ErrInput) { + t.Fatal("Claude TLS profile accepted for Codex", err) + } + converged, err := s.SetFingerprint(ctx, account.ID, ClientProfile{}, ClientIdentity{Mode: "session"}) + if err != nil || converged.Identity.Mode != "session" || len(converged.DeviceID) != 8 { + t.Fatalf("convergence not enabled: %+v %v", converged, err) + } + credential, err := s.Prepare(ctx, account.ID, nil) + if err != nil || credential.Identity.Seed == "" || CodexInstallationID(credential.Identity.Seed)[:8] != converged.DeviceID { + t.Fatalf("execution lacks the converged seed: %+v %v", credential.Identity, err) + } + // The converged identity is stable until an administrator asks for a new device. + again, err := s.SetFingerprint(ctx, account.ID, ClientProfile{}, ClientIdentity{Mode: "full"}) + if err != nil || again.DeviceID != converged.DeviceID { + t.Fatalf("mode change rotated the device: %+v %v", again, err) + } + rotated, err := s.ResetFingerprint(ctx, account.ID, true) + if err != nil || rotated.DeviceID == converged.DeviceID || rotated.Identity.Mode != "full" { + t.Fatalf("device reset kept the installation: %+v %v", rotated, err) + } +} + +func TestDerivedCodexIdentifiersAreStableUUIDs(t *testing.T) { + seed := "6f1f8e7a-6b1c-4c55-9a43-0c1d2e3f4a5b" + for _, id := range []string{CodexInstallationID(seed), CodexSessionID(seed), CodexThreadID(seed, "a")} { + if len(id) != 36 || id[14] != '4' || !strings.ContainsRune("89ab", rune(id[19])) { + t.Fatal("not a UUIDv4", id) + } + } + if CodexInstallationID(seed) != CodexInstallationID(seed) || CodexThreadID(seed, "a") == CodexThreadID(seed, "b") || CodexSessionID(seed) == CodexInstallationID(seed) { + t.Fatal("derived identifiers are not stable and distinct") } } diff --git a/internal/accounts/refresh.go b/internal/accounts/refresh.go index 8ed6b71a..46d92c95 100644 --- a/internal/accounts/refresh.go +++ b/internal/accounts/refresh.go @@ -73,7 +73,7 @@ func (s *Service) prepare(ctx context.Context, id, rejectedToken string, refresh return Credential{}, err } } - credential.Profile = decodeProfile(row.ClientProfile) + credential.Profile, credential.Identity = decodeProfile(row.ClientProfile), identityOf(row) if row.ProxyID != nil { credential.ProxyURL, err = s.proxyURL(ctx, *row.ProxyID) if err != nil { @@ -143,7 +143,7 @@ func (s *Service) prepare(ctx context.Context, id, rejectedToken string, refresh return Credential{}, err } delete(s.refreshes, id) - updated.ProxyURL, updated.Profile = credential.ProxyURL, credential.Profile + updated.ProxyURL, updated.Profile, updated.Identity = credential.ProxyURL, credential.Profile, credential.Identity return updated, nil } diff --git a/internal/backup/backup_test.go b/internal/backup/backup_test.go index d93b1efc..9dc6cec6 100644 --- a/internal/backup/backup_test.go +++ b/internal/backup/backup_test.go @@ -212,6 +212,9 @@ ALTER TABLE accounts DROP COLUMN reset_check_at; ALTER TABLE accounts DROP COLUMN usage_revision; ALTER TABLE account_affinity DROP COLUMN rebound; ALTER TABLE account_affinity DROP COLUMN model; +ALTER TABLE accounts DROP COLUMN fingerprint_mode; +ALTER TABLE accounts DROP COLUMN fingerprint_seed; +ALTER TABLE accounts DROP COLUMN tls_fingerprint; ALTER TABLE accounts DROP COLUMN client_profile; ALTER TABLE account_groups DROP COLUMN quota_failover; ALTER TABLE account_affinity DROP COLUMN foreign_state; diff --git a/internal/server/accounts.go b/internal/server/accounts.go index 1a366ead..85f109f5 100644 --- a/internal/server/accounts.go +++ b/internal/server/accounts.go @@ -244,11 +244,16 @@ func (h *accountHTTP) setFingerprint(w http.ResponseWriter, r *http.Request) { Arch string `json:"arch"` RuntimeVersion string `json:"runtime_version"` PackageVersion string `json:"package_version"` + Mode string `json:"mode"` + TLS bool `json:"tls"` } if !decodeJSON(w, r, &input) { return } - fingerprint, err := h.service.SetFingerprint(r.Context(), chi.URLParam(r, "id"), accounts.ClientProfile{Platform: input.Platform, OS: input.OS, Arch: input.Arch, RuntimeVersion: input.RuntimeVersion, PackageVersion: input.PackageVersion}) + if input.Mode == "" { + input.Mode = "off" + } + fingerprint, err := h.service.SetFingerprint(r.Context(), chi.URLParam(r, "id"), accounts.ClientProfile{Platform: input.Platform, OS: input.OS, Arch: input.Arch, RuntimeVersion: input.RuntimeVersion, PackageVersion: input.PackageVersion}, accounts.ClientIdentity{Mode: input.Mode, TLS: input.TLS}) if err != nil { accountError(w, err) return diff --git a/internal/storage/db/accounts.sql.go b/internal/storage/db/accounts.sql.go index 8fd5e33a..0eb1e720 100644 --- a/internal/storage/db/accounts.sql.go +++ b/internal/storage/db/accounts.sql.go @@ -93,7 +93,7 @@ func (q *Queries) DeleteAccount(ctx context.Context, arg DeleteAccountParams) (i } const getAccount = `-- name: GetAccount :one -SELECT id, provider, name, account_id, email, "plan", enabled, status, credential, expires_at, created_at, updated_at, max_concurrency, models_snapshot, models_revision, tenant_id, proxy_id, reset_config, reset_revision, reset_next_run_at, reset_time_zone, reset_check_at, usage_revision, client_profile FROM accounts WHERE id = ?1 AND tenant_id = ?2 +SELECT id, provider, name, account_id, email, "plan", enabled, status, credential, expires_at, created_at, updated_at, max_concurrency, models_snapshot, models_revision, tenant_id, proxy_id, reset_config, reset_revision, reset_next_run_at, reset_time_zone, reset_check_at, usage_revision, client_profile, fingerprint_mode, fingerprint_seed, tls_fingerprint FROM accounts WHERE id = ?1 AND tenant_id = ?2 ` type GetAccountParams struct { @@ -129,6 +129,9 @@ func (q *Queries) GetAccount(ctx context.Context, arg GetAccountParams) (Account &i.ResetCheckAt, &i.UsageRevision, &i.ClientProfile, + &i.FingerprintMode, + &i.FingerprintSeed, + &i.TlsFingerprint, ) return i, err } @@ -265,6 +268,35 @@ func (q *Queries) SetAccountStatus(ctx context.Context, arg SetAccountStatusPara return err } +const setClientIdentity = `-- name: SetClientIdentity :execrows +UPDATE accounts SET fingerprint_mode = ?1, fingerprint_seed = ?2, tls_fingerprint = ?3, updated_at = ?4 +WHERE id = ?5 AND tenant_id = ?6 +` + +type SetClientIdentityParams struct { + FingerprintMode string + FingerprintSeed *string + TlsFingerprint bool + UpdatedAt int64 + ID string + TenantID int64 +} + +func (q *Queries) SetClientIdentity(ctx context.Context, arg SetClientIdentityParams) (int64, error) { + result, err := q.db.ExecContext(ctx, setClientIdentity, + arg.FingerprintMode, + arg.FingerprintSeed, + arg.TlsFingerprint, + arg.UpdatedAt, + arg.ID, + arg.TenantID, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const setClientProfile = `-- name: SetClientProfile :execrows UPDATE accounts SET client_profile = ?1, updated_at = ?2 WHERE id = ?3 AND tenant_id = ?4 diff --git a/internal/storage/db/models.go b/internal/storage/db/models.go index 7b339eb6..8f20211f 100644 --- a/internal/storage/db/models.go +++ b/internal/storage/db/models.go @@ -5,30 +5,33 @@ package db type Account struct { - ID string - Provider string - Name string - AccountID string - Email string - Plan string - Enabled bool - Status string - Credential []byte - ExpiresAt int64 - CreatedAt int64 - UpdatedAt int64 - MaxConcurrency int64 - ModelsSnapshot []byte - ModelsRevision int64 - TenantID int64 - ProxyID *string - ResetConfig string - ResetRevision int64 - ResetNextRunAt int64 - ResetTimeZone string - ResetCheckAt int64 - UsageRevision int64 - ClientProfile *string + ID string + Provider string + Name string + AccountID string + Email string + Plan string + Enabled bool + Status string + Credential []byte + ExpiresAt int64 + CreatedAt int64 + UpdatedAt int64 + MaxConcurrency int64 + ModelsSnapshot []byte + ModelsRevision int64 + TenantID int64 + ProxyID *string + ResetConfig string + ResetRevision int64 + ResetNextRunAt int64 + ResetTimeZone string + ResetCheckAt int64 + UsageRevision int64 + ClientProfile *string + FingerprintMode string + FingerprintSeed *string + TlsFingerprint bool } type AccountGroup struct { diff --git a/internal/storage/migrations/039_client_identity.sql b/internal/storage/migrations/039_client_identity.sql new file mode 100644 index 00000000..ead82e67 --- /dev/null +++ b/internal/storage/migrations/039_client_identity.sql @@ -0,0 +1,6 @@ +-- Opt-in identity hardening per account, following sub2api. fingerprint_mode converges Codex device +-- and session identifiers (off, device, session, full); fingerprint_seed is the system-managed random +-- seed they derive from; tls_fingerprint makes Claude connections present Claude Code's TLS handshake. +ALTER TABLE accounts ADD COLUMN fingerprint_mode TEXT NOT NULL DEFAULT 'off' CHECK(fingerprint_mode IN ('off','device','session','full')); +ALTER TABLE accounts ADD COLUMN fingerprint_seed TEXT CHECK(fingerprint_seed IS NULL OR length(fingerprint_seed) = 36); +ALTER TABLE accounts ADD COLUMN tls_fingerprint INTEGER NOT NULL DEFAULT 0 CHECK(tls_fingerprint IN (0,1)); diff --git a/internal/storage/queries/accounts.sql b/internal/storage/queries/accounts.sql index e942605e..7531c6eb 100644 --- a/internal/storage/queries/accounts.sql +++ b/internal/storage/queries/accounts.sql @@ -43,3 +43,7 @@ WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id) AND client_profile I -- name: SetClientProfile :execrows UPDATE accounts SET client_profile = sqlc.narg(client_profile), updated_at = sqlc.arg(updated_at) WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id); + +-- name: SetClientIdentity :execrows +UPDATE accounts SET fingerprint_mode = sqlc.arg(fingerprint_mode), fingerprint_seed = sqlc.narg(fingerprint_seed), tls_fingerprint = sqlc.arg(tls_fingerprint), updated_at = sqlc.arg(updated_at) +WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id); diff --git a/internal/upstream/channel.go b/internal/upstream/channel.go index 1ca9e853..41547a0e 100644 --- a/internal/upstream/channel.go +++ b/internal/upstream/channel.go @@ -104,7 +104,7 @@ func channelSession(headers http.Header, body []byte) string { } func (c *Client) executionTransport(credential accounts.Credential, headers http.Header, body []byte) *engineTransport { - var base http.RoundTripper = &boundTransport{base: c.inference.Transport, address: credential.ProxyURL} + var base http.RoundTripper = &boundTransport{base: c.inference.Transport, address: credential.ProxyURL, fingerprint: usesTLSFingerprint(credential)} if credential.Kind() == "openai" { base = &channelTransport{base: base, credential: credential, session: channelSession(headers, body)} } diff --git a/internal/upstream/client.go b/internal/upstream/client.go index f1c65be6..704b0615 100644 --- a/internal/upstream/client.go +++ b/internal/upstream/client.go @@ -411,22 +411,20 @@ func upstreamHeaders(request *http.Request, credential accounts.Credential, clie request.Header.Set("Accept", "text/event-stream") } // Client session metadata is useful; client credentials, cookies and arbitrary proxy headers are never forwarded. - for _, key := range []string{"X-Codex-Turn-Metadata", "X-Client-Request-Id", "Session_id", "X-Codex-Beta-Features"} { - if value := client.Get(key); len(value) > 0 && len(value) <= 1024 { - request.Header.Set(key, value) - } + if client != nil { + newCodexIdentity(credential, client).apply(request.Header) } } -// applyCodexVersion presents one client per account: the managed version plus the account's pinned -// platform. Accounts without a learned platform keep the bare version agent. +// applyCodexVersion presents one self-consistent client per account: originator, agent product and +// version header always agree (the backend rejects mismatches), the version follows SubLane's +// managed Codex release, and the platform is the account's pinned one or the canonical default. func applyCodexVersion(headers http.Header, version, platform string) { - headers.Set("Originator", "codex_cli_rs") - agent := "codex_cli_rs/" + version - if platform != "" { - agent += " " + platform + if platform == "" { + platform = accounts.CodexDefaultPlatform } - headers.Set("User-Agent", agent) + headers.Set("Originator", "codex_cli_rs") + headers.Set("User-Agent", "codex_cli_rs/"+version+" "+platform) headers.Set("Version", version) } diff --git a/internal/upstream/client_test.go b/internal/upstream/client_test.go index 5910f136..f1720318 100644 --- a/internal/upstream/client_test.go +++ b/internal/upstream/client_test.go @@ -235,7 +235,7 @@ func TestModelsRequestsVersionGatedCatalog(t *testing.T) { } w.Header().Set("Content-Type", "application/json") // Model discovery can succeed with only hidden entries for an obsolete client. - if r.URL.Query().Get("client_version") != "0.155.1" || r.Header.Get("User-Agent") != "codex_cli_rs/0.155.1" { + if r.URL.Query().Get("client_version") != "0.155.1" || r.Header.Get("User-Agent") != "codex_cli_rs/0.155.1 "+accounts.CodexDefaultPlatform { io.WriteString(w, `{"models":[{"slug":"synthetic-hidden","visibility":"hide"}]}`) return } @@ -284,7 +284,7 @@ func TestDiscoveryCapturesOneConfiguredVersion(t *testing.T) { var current atomic.Value current.Store("0.200.0") client := NewWithTransport(usageTransport(func(r *http.Request) (*http.Response, error) { - if r.URL.Query().Get("client_version") != "0.200.0" || r.Header.Get("User-Agent") != "codex_cli_rs/0.200.0" || r.Header.Get("Version") != "0.200.0" { + if r.URL.Query().Get("client_version") != "0.200.0" || r.Header.Get("User-Agent") != "codex_cli_rs/0.200.0 "+accounts.CodexDefaultPlatform || r.Header.Get("Version") != "0.200.0" { t.Error("request version disagrees across headers/query") } current.Store("0.300.0") @@ -299,7 +299,7 @@ func TestDiscoveryCapturesOneConfiguredVersion(t *testing.T) { func TestConfiguredCodexVersionReachesSDKForwarding(t *testing.T) { client := NewWithTransport(usageTransport(func(r *http.Request) (*http.Response, error) { - if r.Header.Get("Version") != "0.200.0" || r.Header.Get("User-Agent") != "codex_cli_rs/0.200.0" || r.Header.Get("Originator") != "codex_cli_rs" { + if r.Header.Get("Version") != "0.200.0" || r.Header.Get("User-Agent") != "codex_cli_rs/0.200.0 "+accounts.CodexDefaultPlatform || r.Header.Get("Originator") != "codex_cli_rs" { t.Error("SDK ignored configured version") } return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": {"text/event-stream"}}, Body: io.NopCloser(strings.NewReader("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"synthetic\",\"output\":[]}}\n\n"))}, nil diff --git a/internal/upstream/codex_identity.go b/internal/upstream/codex_identity.go new file mode 100644 index 00000000..2012da29 --- /dev/null +++ b/internal/upstream/codex_identity.go @@ -0,0 +1,229 @@ +package upstream + +import ( + "bufio" + "bytes" + "crypto/rand" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + "unicode/utf16" + + "github.com/murongg/SubLane/internal/accounts" +) + +// codexIdentity is the client identity one Codex request presents upstream. It follows sub2api's +// proven rules: the agent, originator and version always come from SubLane's managed identity (see +// applyCodexVersion); identity headers real clients send are forwarded; and an account may opt in +// to converging installation, session and thread identifiers so several users look like one +// device. Every carrier of an identifier (headers, turn metadata, client_metadata and +// prompt_cache_key) is rewritten together, because mismatches between them are themselves a signal. +type codexIdentity struct { + headers http.Header + fields map[string]any + // session is the outgoing session ID that prompt_cache_key and client_metadata must match. + session string + mode string + ids map[string]string +} + +// Identity headers Codex sends on Responses requests; everything else from the client is dropped. +var codexIdentityHeaders = []string{"X-Codex-Installation-Id", "X-Codex-Window-Id", "X-Codex-Turn-State", "X-Codex-Turn-Metadata", "X-Client-Request-Id", "X-Codex-Beta-Features", "Session_id"} + +func newCodexIdentity(credential accounts.Credential, client http.Header) *codexIdentity { + id := &codexIdentity{headers: make(http.Header), fields: map[string]any{}, ids: map[string]string{}, mode: credential.Identity.Mode} + for _, key := range codexIdentityHeaders { + if value := client.Get(key); value != "" && len(value) <= 4096 { + id.headers.Set(key, value) + } + } + // The gateway already replaced the client's session with one isolated per member conversation. + conversation := id.headers.Get("Session_id") + seed := credential.Identity.Seed + if id.mode != "off" && id.mode != "" && seed != "" { + installation := accounts.CodexInstallationID(seed) + id.headers.Set("X-Codex-Installation-Id", installation) + id.fields["installation_id"], id.ids["x-codex-installation-id"] = installation, installation + if id.mode == "session" || id.mode == "full" { + session := accounts.CodexSessionID(seed) + // Session mode keeps one thread per real conversation, like a user running several + // windows or subagents; full mode collapses everything into one thread. + thread := session + if id.mode == "session" && conversation != "" { + thread = accounts.CodexThreadID(seed, conversation) + } + window, turn := thread+":0", randomUUID() + id.headers.Set("X-Codex-Window-Id", window) + id.headers.Set("X-Client-Request-Id", thread) + id.headers.Set("Thread-Id", thread) + conversation = session + id.fields["thread_id"], id.fields["turn_id"], id.fields["window_id"] = thread, turn, window + id.fields["turn_started_at_unix_ms"] = time.Now().UnixMilli() + id.ids["thread_id"], id.ids["turn_id"], id.ids["x-codex-window-id"] = thread, turn, window + } + } + if conversation != "" { + id.headers.Set("Session_id", conversation) + id.fields["session_id"], id.ids["session_id"], id.session = conversation, conversation, conversation + } + if raw := id.headers.Get("X-Codex-Turn-Metadata"); raw != "" { + if rebuilt, ok := rewriteTurnMetadata(raw, id.fields); ok { + id.headers.Set("X-Codex-Turn-Metadata", rebuilt) + } else { + id.headers.Del("X-Codex-Turn-Metadata") + } + } + return id +} + +// apply replaces every case variant of each identity header, so SDK-set values cannot duplicate them. +func (id *codexIdentity) apply(h http.Header) { + if id == nil { + return + } + for key, values := range id.headers { + for existing := range h { + if strings.EqualFold(existing, key) || key == "Session_id" && strings.EqualFold(existing, "Session-Id") || key == "Thread-Id" && strings.EqualFold(existing, "Thread_id") { + delete(h, existing) + } + } + h[key] = []string{values[0]} + } +} + +// rewriteBody keeps the body's identifiers in step with the outgoing headers. +func (id *codexIdentity) rewriteBody(body []byte) []byte { + if id == nil || len(body) == 0 { + return body + } + var input map[string]json.RawMessage + if json.Unmarshal(body, &input) != nil || input == nil { + return body + } + changed := false + // Native Codex sends its session ID as prompt_cache_key. + if id.session != "" && len(input["prompt_cache_key"]) > 0 { + input["prompt_cache_key"], _ = json.Marshal(id.session) + changed = true + } + var metadata map[string]any + if raw := input["client_metadata"]; len(raw) > 0 && json.Unmarshal(raw, &metadata) == nil && metadata != nil { + for key, value := range id.ids { + metadata[key] = value + } + if embedded, ok := metadata["x-codex-turn-metadata"].(string); ok { + if rebuilt, ok := rewriteTurnMetadata(embedded, id.fields); ok { + metadata["x-codex-turn-metadata"] = rebuilt + } + } + input["client_metadata"], _ = json.Marshal(metadata) + changed = true + } + if !changed { + return body + } + rewritten, err := json.Marshal(input) + if err != nil { + return body + } + return rewritten +} + +// rewriteTurnMetadata overwrites identifier fields of Codex turn metadata. Codex also carries it as +// an HTTP header, so the result escapes non-ASCII as JSON \u sequences. +func rewriteTurnMetadata(raw string, fields map[string]any) (string, bool) { + var metadata map[string]any + if json.Unmarshal([]byte(raw), &metadata) != nil || metadata == nil { + return "", false + } + for key, value := range fields { + metadata[key] = value + } + encoded, err := json.Marshal(metadata) + if err != nil { + return "", false + } + var out strings.Builder + for _, r := range string(encoded) { + switch { + case r < 0x7f: + out.WriteRune(r) + case r <= 0xffff: + fmt.Fprintf(&out, `\u%04x`, r) + default: + high, low := utf16.EncodeRune(r) + fmt.Fprintf(&out, `\u%04x\u%04x`, high, low) + } + } + return out.String(), true +} + +func randomUUID() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + b[6] = b[6]&0x0f | 0x40 + b[8] = b[8]&0x3f | 0x80 + return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) +} + +// When capacity is short, the Codex backend sheds load by client identity: it answers HTTP 200 and +// the stream's first real event reports server_is_overloaded (or slow_down). Nothing has reached the +// client yet, so the response becomes a retryable 503 that the gateway retries and fails over. +const overloadPeekLimit = 64 << 10 + +var codexOverloadCodes = map[string]bool{"server_is_overloaded": true, "slow_down": true} + +func detectCodexOverload(response *http.Response) (*http.Response, bool) { + reader := bufio.NewReaderSize(response.Body, 4096) + var seen bytes.Buffer + for seen.Len() < overloadPeekLimit { + line, err := reader.ReadBytes('\n') + seen.Write(line) + if data, ok := bytes.CutPrefix(bytes.TrimSpace(line), []byte("data:")); ok { + var event struct { + Type string `json:"type"` + Code string `json:"code"` + Error struct { + Code string `json:"code"` + } `json:"error"` + Response struct { + Error struct { + Code string `json:"code"` + } `json:"error"` + } `json:"response"` + } + if json.Unmarshal(bytes.TrimSpace(data), &event) == nil { + switch event.Type { + case "response.created", "response.in_progress": + // Handshake events carry nothing the client needs before the first real event. + case "error", "response.failed": + if codexOverloadCodes[event.Code] || codexOverloadCodes[event.Error.Code] || codexOverloadCodes[event.Response.Error.Code] { + response.Body.Close() + return &http.Response{StatusCode: http.StatusServiceUnavailable, Status: "503 Service Unavailable", Header: http.Header{"Retry-After": {"1"}, "Content-Type": {"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"error":{"code":"server_is_overloaded"}}`)), Request: response.Request}, true + } + return replayBody(response, &seen, reader), false + default: + return replayBody(response, &seen, reader), false + } + } + } + if err != nil { + break + } + } + return replayBody(response, &seen, reader), false +} + +func replayBody(response *http.Response, seen *bytes.Buffer, rest io.Reader) *http.Response { + response.Body = &replayedBody{Reader: io.MultiReader(bytes.NewReader(seen.Bytes()), rest), Closer: response.Body} + return response +} + +type replayedBody struct { + io.Reader + io.Closer +} diff --git a/internal/upstream/engine.go b/internal/upstream/engine.go index 885bed01..d142fd28 100644 --- a/internal/upstream/engine.go +++ b/internal/upstream/engine.go @@ -300,6 +300,10 @@ func (c *Client) runSDK(ctx context.Context, credential accounts.Credential, bod } version := c.codexVersion() transport := c.executionTransport(credential, headers, body) + if credential.Kind() == "codex" { + transport.codex = newCodexIdentity(credential, headers) + body = transport.codex.rewriteBody(body) + } transport.validateGeminiStream = credential.Kind() == "antigravity" && (opts.SourceFormat == translator.FormatClaude || opts.SourceFormat == translator.FormatGemini) if !opts.Stream { transport.maxResponseBytes = MaxBody @@ -419,6 +423,7 @@ type engineTransport struct { maxResponseBytes int64 codexVersion string codexPlatform string + codex *codexIdentity claudeProfile accounts.ClientProfile grokVersion string base http.RoundTripper @@ -438,6 +443,7 @@ func (t *engineTransport) RoundTrip(r *http.Request) (*http.Response, error) { // Clone before overriding SDK headers; unrelated providers and shared request objects remain untouched. r = r.Clone(r.Context()) applyCodexVersion(r.Header, t.codexVersion, t.codexPlatform) + t.codex.apply(r.Header) } if t.provider == "claude" && r.URL.Host == "api.anthropic.com" { // The account's own platform replaces the SDK's shared baseline; empty fields keep it. @@ -456,6 +462,10 @@ func (t *engineTransport) RoundTrip(r *http.Request) (*http.Response, error) { t.mu.Unlock() return nil, err } + overloaded := false + if t.provider == "codex" && r.URL.Host == "chatgpt.com" && strings.HasSuffix(r.URL.Path, "/responses") && response.StatusCode == http.StatusOK && strings.HasPrefix(response.Header.Get("Content-Type"), "text/event-stream") { + response, overloaded = detectCodexOverload(response) + } if response.StatusCode >= 300 && response.StatusCode < 400 { response.Body.Close() return nil, ErrUpstream @@ -474,6 +484,9 @@ func (t *engineTransport) RoundTrip(r *http.Request) (*http.Response, error) { if response.StatusCode >= 400 { t.retryAfter = response.Header.Get("Retry-After") } + if overloaded { + t.failureKind = "server_overloaded" + } t.mu.Unlock() limit := t.maxResponseBytes if limit == 0 && response.StatusCode >= 400 { diff --git a/internal/upstream/fingerprint_test.go b/internal/upstream/fingerprint_test.go index e82794da..f4cb0e41 100644 --- a/internal/upstream/fingerprint_test.go +++ b/internal/upstream/fingerprint_test.go @@ -19,9 +19,10 @@ func TestCodexAgentPresentsAccountPlatform(t *testing.T) { if headers.Get("User-Agent") != "codex_cli_rs/0.155.1 (Mac OS 15.5.0; arm64) iTerm.app/3.6.1" || headers.Get("Version") != "0.155.1" { t.Fatal("account platform missing", headers) } + // An account no native client has used yet still presents a complete agent, never a bare one. applyCodexVersion(headers, "0.155.1", "") - if headers.Get("User-Agent") != "codex_cli_rs/0.155.1" { - t.Fatal("unlearned account changed agent", headers.Get("User-Agent")) + if headers.Get("User-Agent") != "codex_cli_rs/0.155.1 "+accounts.CodexDefaultPlatform { + t.Fatal("unlearned account sent an incomplete agent", headers.Get("User-Agent")) } } diff --git a/internal/upstream/identity_test.go b/internal/upstream/identity_test.go index 7cccbfe9..95323149 100644 --- a/internal/upstream/identity_test.go +++ b/internal/upstream/identity_test.go @@ -1,87 +1,212 @@ package upstream import ( + "bufio" "context" + "crypto/md5" + "crypto/tls" + "encoding/hex" "encoding/json" "fmt" "io" + "net" "net/http" - "path/filepath" + "net/http/httptest" + "net/url" "strings" "testing" "time" "github.com/murongg/SubLane/internal/accounts" - "github.com/murongg/SubLane/internal/auth" - "github.com/murongg/SubLane/internal/storage" - "github.com/murongg/SubLane/internal/vault" ) -func TestClaudeOAuthExecutionKeepsPersistedIdentity(t *testing.T) { - ctx := context.Background() - dir := t.TempDir() - db, err := storage.Open(ctx, filepath.Join(dir, "test.db")) - if err != nil { - t.Fatal(err) +const identitySeed = "6f1f8e7a-6b1c-4c55-9a43-0c1d2e3f4a5b" + +func identityClient() http.Header { + return http.Header{ + "X-Codex-Installation-Id": {"client-installation"}, + "X-Codex-Window-Id": {"client-window"}, + "X-Codex-Turn-Metadata": {`{"installation_id":"client-installation","session_id":"client-session","thread_id":"client-thread","sandbox":"seatbelt"}`}, + "Session_id": {"isolated-conversation"}, + "Cookie": {"synthetic-cookie"}, } - defer db.Close() - users, err := auth.New(db) - if err != nil { - t.Fatal(err) +} + +func TestCodexIdentityOffForwardsClientIdentityConsistently(t *testing.T) { + id := newCodexIdentity(accounts.Credential{Identity: accounts.ClientIdentity{Mode: "off"}}, identityClient()) + headers := http.Header{"session_id": {"sdk-session"}} + id.apply(headers) + if headers.Get("X-Codex-Installation-Id") != "client-installation" || headers.Get("X-Codex-Window-Id") != "client-window" || headers.Get("Cookie") != "" { + t.Fatal("client identity headers not forwarded exactly", headers) } - if _, err := users.Setup(ctx, "owner-test", "synthetic pass 42", "Synthetic workspace"); err != nil { - t.Fatal(err) + if len(headers["session_id"]) != 0 || headers.Get("Session_id") != "isolated-conversation" { + t.Fatal("session header duplicated or not isolated", headers) } - cipher, err := vault.Open(filepath.Join(dir, "key"), true) - if err != nil { - t.Fatal(err) + var metadata map[string]string + if json.Unmarshal([]byte(headers.Get("X-Codex-Turn-Metadata")), &metadata) != nil || metadata["session_id"] != "isolated-conversation" || metadata["installation_id"] != "client-installation" || metadata["sandbox"] != "seatbelt" { + t.Fatal("turn metadata disagrees with the outgoing session", headers.Get("X-Codex-Turn-Metadata")) } - service := accounts.New(db, cipher) - uuid := "00000000-0000-4000-8000-000000000001" - c := accounts.Credential{Provider: "claude", AccessToken: "sk-ant-oat01-synthetic-test", RefreshToken: "synthetic-refresh", AccountID: uuid, Email: "member@example.test", ExpiresAt: time.Now().Add(time.Hour).Unix(), Metadata: map[string]json.RawMessage{"account_uuid": json.RawMessage(`"` + uuid + `"`)}} - account, err := service.Authorize(ctx, "Synthetic Claude subscription", c, "") - if err != nil { - t.Fatal(err) + body := id.rewriteBody([]byte(`{"prompt_cache_key":"0123abcd","client_metadata":{"session_id":"client-session"}}`)) + if !strings.Contains(string(body), `"prompt_cache_key":"isolated-conversation"`) || !strings.Contains(string(body), `"session_id":"isolated-conversation"`) { + t.Fatal("body identifiers disagree with headers", string(body)) } - var identities []string - client := NewWithTransport(usageTransport(func(r *http.Request) (*http.Response, error) { - if r.Header.Get("Authorization") != "Bearer "+c.AccessToken { - return nil, fmt.Errorf("Claude OAuth authentication was not exercised") - } - raw, err := io.ReadAll(r.Body) - if err != nil { - return nil, err +} + +func TestCodexIdentityConvergesEveryCarrier(t *testing.T) { + for _, mode := range []string{"device", "session", "full"} { + t.Run(mode, func(t *testing.T) { + id := newCodexIdentity(accounts.Credential{Identity: accounts.ClientIdentity{Mode: mode, Seed: identitySeed}}, identityClient()) + headers := http.Header{} + id.apply(headers) + installation, session := accounts.CodexInstallationID(identitySeed), accounts.CodexSessionID(identitySeed) + thread := session + if mode == "session" { + thread = accounts.CodexThreadID(identitySeed, "isolated-conversation") + } + var metadata map[string]any + if json.Unmarshal([]byte(headers.Get("X-Codex-Turn-Metadata")), &metadata) != nil || metadata["installation_id"] != installation || headers.Get("X-Codex-Installation-Id") != installation { + t.Fatal("installation not converged", headers) + } + body := string(id.rewriteBody([]byte(`{"prompt_cache_key":"0123abcd","client_metadata":{"x-codex-installation-id":"client-installation","session_id":"client-session"}}`))) + if !strings.Contains(body, installation) || strings.Contains(body, "client-installation") { + t.Fatal("body installation not converged", body) + } + if mode == "device" { + // Device mode leaves each member's own conversation identifiers alone. + if headers.Get("Session_id") != "isolated-conversation" || headers.Get("X-Codex-Window-Id") != "client-window" { + t.Fatal("device mode changed conversation identifiers", headers) + } + return + } + if headers.Get("Session_id") != session || headers.Get("Thread-Id") != thread || headers.Get("X-Client-Request-Id") != thread || headers.Get("X-Codex-Window-Id") != thread+":0" { + t.Fatal("session identifiers not converged", headers) + } + if metadata["session_id"] != session || metadata["thread_id"] != thread || metadata["window_id"] != thread+":0" || metadata["turn_id"] == "" { + t.Fatal("turn metadata not converged", metadata) + } + if !strings.Contains(body, `"prompt_cache_key":"`+session+`"`) || !strings.Contains(body, `"thread_id":"`+thread+`"`) { + t.Fatal("body session identifiers not converged", body) + } + }) + } +} + +func TestCodexOverloadInStreamBecomesRetryable(t *testing.T) { + stream := func(events ...string) *http.Response { + return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": {"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(strings.Join(events, "")))} + } + created := "event: response.created\ndata: {\"type\":\"response.created\"}\n\n" + for name, failure := range map[string]string{ + "error event": "event: error\ndata: {\"type\":\"error\",\"code\":\"server_is_overloaded\"}\n\n", + "failed response": "data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"code\":\"slow_down\"}}}\n\n", + } { + response, overloaded := detectCodexOverload(stream(created, failure)) + if !overloaded || response.StatusCode != http.StatusServiceUnavailable || response.Header.Get("Retry-After") == "" { + t.Fatal(name, "not converted", response.StatusCode) } - var body struct { - Metadata struct { - UserID string `json:"user_id"` - } `json:"metadata"` + } + original := created + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"synthetic\"}\n\n" + "data: {\"type\":\"error\",\"code\":\"server_is_overloaded\"}\n\n" + response, overloaded := detectCodexOverload(stream(original)) + raw, _ := io.ReadAll(response.Body) + // Once real output started the stream belongs to the client and is replayed byte for byte. + if overloaded || string(raw) != original { + t.Fatal("generated stream altered", overloaded, string(raw)) + } +} + +// ja3 computes the JA3 hash of a captured ClientHello, skipping GREASE values as JA3 does. +func ja3(hello *tls.ClientHelloInfo) string { + grease := func(v uint16) bool { return v&0x0f0f == 0x0a0a && v>>8 == v&0xff } + join := func(values []uint16) string { + parts := []string{} + for _, v := range values { + if !grease(v) { + parts = append(parts, fmt.Sprint(v)) + } } - if err := json.Unmarshal(raw, &body); err != nil { - return nil, err + return strings.Join(parts, "-") + } + curves := make([]uint16, len(hello.SupportedCurves)) + for i, c := range hello.SupportedCurves { + curves[i] = uint16(c) + } + points := make([]uint16, len(hello.SupportedPoints)) + for i, p := range hello.SupportedPoints { + points[i] = uint16(p) + } + // Every modern client, Node included, sends legacy_version TLS 1.2 (771). + sum := md5.Sum([]byte("771," + join(hello.CipherSuites) + "," + join(hello.Extensions) + "," + join(curves) + "," + join(points))) + return hex.EncodeToString(sum[:]) +} + +// captureHello serves TLS locally and reports the first ClientHello it receives. +func captureHello(t *testing.T) (string, <-chan *tls.ClientHelloInfo) { + t.Helper() + hellos := make(chan *tls.ClientHelloInfo, 1) + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {})) + server.TLS = &tls.Config{GetConfigForClient: func(hello *tls.ClientHelloInfo) (*tls.Config, error) { + select { + case hellos <- hello: + default: } - identities = append(identities, body.Metadata.UserID) - response := `{"id":"msg_synthetic","type":"message","role":"assistant","model":"claude-sonnet-4-5","content":[{"type":"text","text":"synthetic"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}` - return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(response))}, nil - })) - defer client.Close() - for range 2 { - prepared, err := service.Prepare(ctx, account.ID, nil) + return nil, nil + }} + server.StartTLS() + t.Cleanup(server.Close) + return server.Listener.Addr().String(), hellos +} + +func TestClaudeTLSFingerprintMatchesClaudeCode(t *testing.T) { + const claudeCodeJA3 = "44f88fca027f27bab4bb08d4af15f23e" + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + direct, hellos := captureHello(t) + // The test certificate is untrusted, so the handshake fails after the ClientHello was sent. + if conn, err := dialFingerprinted(ctx, nil, direct); err == nil { + conn.Close() + } + hello := <-hellos + if got := ja3(hello); got != claudeCodeJA3 || len(hello.SupportedProtos) != 1 || hello.SupportedProtos[0] != "http/1.1" { + t.Fatalf("direct JA3 %s, ALPN %v", got, hello.SupportedProtos) + } + + // Through an HTTP proxy the handshake must still be ours, inside the CONNECT tunnel. + target, tunneled := captureHello(t) + proxy, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer proxy.Close() + go func() { + conn, err := proxy.Accept() if err != nil { - t.Fatal(err) + return } - stream, err := client.Messages(ctx, prepared, []byte(`{"model":"claude-sonnet-4-5","max_tokens":1,"messages":[{"role":"user","content":"synthetic"}]}`), http.Header{"Session_id": {"synthetic-session"}}) - if err != nil { - t.Fatal(err) + defer conn.Close() + request, err := http.ReadRequest(bufio.NewReader(conn)) + if err != nil || request.Method != http.MethodConnect || request.Host != target || request.Header.Get("Proxy-Authorization") == "" { + io.WriteString(conn, "HTTP/1.1 407 Proxy Authentication Required\r\n\r\n") + return } - _, err = io.Copy(io.Discard, stream.Body) - stream.Body.Close() + upstream, err := net.Dial("tcp", target) if err != nil { - t.Fatal(err) + return } - service = accounts.New(db, cipher) + defer upstream.Close() + io.WriteString(conn, "HTTP/1.1 200 Connection Established\r\n\r\n") + go io.Copy(upstream, conn) + io.Copy(conn, upstream) + }() + exit, _ := url.Parse("http://synthetic-user:synthetic-pass@" + proxy.Addr().String()) + if conn, err := dialFingerprinted(ctx, exit, target); err == nil { + conn.Close() } - if len(identities) != 2 || identities[0] == "" || identities[0] != identities[1] { - t.Fatal("same account and session changed Claude identity across restart") + select { + case hello := <-tunneled: + if got := ja3(hello); got != claudeCodeJA3 { + t.Fatalf("proxied JA3 %s", got) + } + case <-ctx.Done(): + t.Fatal("no ClientHello reached the target through the proxy") } } diff --git a/internal/upstream/proxy.go b/internal/upstream/proxy.go index d9cbde08..aa62702c 100644 --- a/internal/upstream/proxy.go +++ b/internal/upstream/proxy.go @@ -19,22 +19,37 @@ func WithProxyURL(ctx context.Context, address string) context.Context { return context.WithValue(ctx, proxyContextKey{}, address) } +type tlsFingerprintKey struct{} + +// usesTLSFingerprint reports whether the account opted in to Claude Code's TLS handshake. +func usesTLSFingerprint(credential accounts.Credential) bool { + return credential.Kind() == "claude" && credential.Identity.TLS +} + func credentialContext(ctx context.Context, credential accounts.Credential) context.Context { + if usesTLSFingerprint(credential) { + ctx = context.WithValue(ctx, tlsFingerprintKey{}, true) + } return WithProxyURL(ctx, credential.ProxyURL) } // Some SDK paths create a fresh request context. The per-call transport keeps -// the account's selected exit even when the SDK does not preserve context values. +// the account's selected exit and TLS identity even when the SDK does not preserve context values. type boundTransport struct { - base http.RoundTripper - address string + base http.RoundTripper + address string + fingerprint bool } func (t *boundTransport) RoundTrip(request *http.Request) (*http.Response, error) { - if t.address == "" { + if t.address == "" && !t.fingerprint { return t.base.RoundTrip(request) } - return t.base.RoundTrip(request.Clone(WithProxyURL(request.Context(), t.address))) + ctx := WithProxyURL(request.Context(), t.address) + if t.fingerprint { + ctx = context.WithValue(ctx, tlsFingerprintKey{}, true) + } + return t.base.RoundTrip(request.Clone(ctx)) } // proxyTransport keeps a separate connection pool per proxy. A request takes one @@ -48,18 +63,31 @@ type proxyTransport struct { func (t *proxyTransport) RoundTrip(request *http.Request) (*http.Response, error) { address, _ := request.Context().Value(proxyContextKey{}).(string) - if address == "" { + fingerprint, _ := request.Context().Value(tlsFingerprintKey{}).(bool) + base, real := t.base.(*http.Transport) + // Only HTTPS through a real transport can carry a custom handshake; synthetic test transports + // and plain HTTP keep the ordinary route. + fingerprint = fingerprint && real && request.URL.Scheme == "https" + if address == "" && !fingerprint { return t.base.RoundTrip(request) } - u, err := url.Parse(address) - if err != nil || u == nil || u.Host == "" { - return nil, ErrUpstream + var u *url.URL + if address != "" { + var err error + u, err = url.Parse(address) + if err != nil || u == nil || u.Host == "" { + return nil, ErrUpstream + } + } + key := address + if fingerprint { + key = "tls|" + address } t.mu.Lock() if t.routes == nil { t.routes = make(map[string]*http.Transport) } - transport := t.routes[address] + transport := t.routes[key] if transport == nil { // Proxy edits can introduce new addresses indefinitely; retire the oldest // idle pool without disturbing requests already using its transport. @@ -69,14 +97,17 @@ func (t *proxyTransport) RoundTrip(request *http.Request) (*http.Response, error t.routes[oldest].CloseIdleConnections() delete(t.routes, oldest) } - if base, ok := t.base.(*http.Transport); ok { - transport = base.Clone() + if !real { + base = http.DefaultTransport.(*http.Transport) + } + if fingerprint { + transport = fingerprintTransport(base, u) } else { - transport = http.DefaultTransport.(*http.Transport).Clone() + transport = base.Clone() + transport.Proxy = http.ProxyURL(u) } - transport.Proxy = http.ProxyURL(u) - t.routes[address] = transport - t.order = append(t.order, address) + t.routes[key] = transport + t.order = append(t.order, key) } t.mu.Unlock() return transport.RoundTrip(request) diff --git a/internal/upstream/tlsfingerprint.go b/internal/upstream/tlsfingerprint.go new file mode 100644 index 00000000..05808317 --- /dev/null +++ b/internal/upstream/tlsfingerprint.go @@ -0,0 +1,156 @@ +package upstream + +import ( + "bufio" + "context" + "crypto/tls" + "encoding/base64" + "errors" + "fmt" + "net" + "net/http" + "net/url" + "time" + + utls "github.com/refraction-networking/utls" + "golang.org/x/net/proxy" +) + +// Claude accounts may opt in to presenting Claude Code's TLS handshake instead of Go's, as sub2api +// does. The ClientHello below reproduces Node.js 24.x, the runtime Claude Code ships on; its +// parameters come from sub2api's published capture (JA3 44f88fca027f27bab4bb08d4af15f23e, JA4 +// t13d1714h1_5b57614c22b0_7baf387fc6ff). Node offers only http/1.1 over ALPN, so these connections +// use HTTP/1.1 like the real client. +func claudeCodeHello() *utls.ClientHelloSpec { + curves := []utls.CurveID{utls.X25519, utls.CurveP256, utls.CurveP384} + signatures := []utls.SignatureScheme{0x0403, 0x0804, 0x0401, 0x0503, 0x0805, 0x0501, 0x0806, 0x0601, 0x0201} + return &utls.ClientHelloSpec{ + CipherSuites: []uint16{ + 0x1301, 0x1302, 0x1303, // TLS 1.3 AES-GCM and ChaCha20 + 0xc02b, 0xc02f, 0xc02c, 0xc030, // ECDHE AES-GCM + 0xcca9, 0xcca8, // ECDHE ChaCha20-Poly1305 + 0xc009, 0xc013, 0xc00a, 0xc014, // ECDHE AES-CBC + 0x009c, 0x009d, 0x002f, 0x0035, // RSA AES-GCM and AES-CBC + }, + CompressionMethods: []uint8{0}, + TLSVersMin: utls.VersionTLS10, + TLSVersMax: utls.VersionTLS13, + // Node's extension order matters for the fingerprint. + Extensions: []utls.TLSExtension{ + &utls.SNIExtension{}, + &utls.GREASEEncryptedClientHelloExtension{}, + &utls.ExtendedMasterSecretExtension{}, + &utls.RenegotiationInfoExtension{}, + &utls.SupportedCurvesExtension{Curves: curves}, + &utls.SupportedPointsExtension{SupportedPoints: []uint8{0}}, + &utls.SessionTicketExtension{}, + &utls.ALPNExtension{AlpnProtocols: []string{"http/1.1"}}, + &utls.StatusRequestExtension{}, + &utls.SignatureAlgorithmsExtension{SupportedSignatureAlgorithms: signatures}, + &utls.SCTExtension{}, + &utls.KeyShareExtension{KeyShares: []utls.KeyShare{{Group: utls.X25519}}}, + &utls.PSKKeyExchangeModesExtension{Modes: []uint8{utls.PskModeDHE}}, + &utls.SupportedVersionsExtension{Versions: []uint16{utls.VersionTLS13, utls.VersionTLS12}}, + }, + } +} + +var fingerprintDialer = &net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second} + +// dialFingerprinted opens addr directly or through the account's proxy, then performs the TLS +// handshake itself. http.Transport cannot apply a custom handshake inside its own proxy tunnel, so +// the tunnel (HTTP CONNECT or SOCKS5) is established here too. +func dialFingerprinted(ctx context.Context, exit *url.URL, addr string) (net.Conn, error) { + host, _, err := net.SplitHostPort(addr) + if err != nil { + return nil, err + } + conn, err := tunnel(ctx, exit, addr) + if err != nil { + return nil, err + } + client := utls.UClient(conn, &utls.Config{ServerName: host, NextProtos: []string{"http/1.1"}}, utls.HelloCustom) + if err := client.ApplyPreset(claudeCodeHello()); err != nil { + conn.Close() + return nil, err + } + if err := client.HandshakeContext(ctx); err != nil { + conn.Close() + return nil, err + } + return client, nil +} + +func tunnel(ctx context.Context, exit *url.URL, addr string) (net.Conn, error) { + if exit == nil { + return fingerprintDialer.DialContext(ctx, "tcp", addr) + } + switch exit.Scheme { + case "socks5": + var auth *proxy.Auth + if exit.User != nil { + password, _ := exit.User.Password() + auth = &proxy.Auth{User: exit.User.Username(), Password: password} + } + socks, err := proxy.SOCKS5("tcp", exit.Host, auth, fingerprintDialer) + if err != nil { + return nil, err + } + contextual, ok := socks.(proxy.ContextDialer) + if !ok { + return nil, errors.New("socks5 dialer lacks context support") + } + return contextual.DialContext(ctx, "tcp", addr) + case "http", "https": + conn, err := fingerprintDialer.DialContext(ctx, "tcp", exit.Host) + if err != nil { + return nil, err + } + if exit.Scheme == "https" { + secured := tls.Client(conn, &tls.Config{ServerName: exit.Hostname()}) + if err := secured.HandshakeContext(ctx); err != nil { + conn.Close() + return nil, err + } + conn = secured + } + if deadline, ok := ctx.Deadline(); ok { + _ = conn.SetDeadline(deadline) + } + request := &http.Request{Method: http.MethodConnect, URL: &url.URL{Opaque: addr}, Host: addr, Header: make(http.Header)} + if exit.User != nil { + password, _ := exit.User.Password() + request.Header.Set("Proxy-Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(exit.User.Username()+":"+password))) + } + if err := request.Write(conn); err != nil { + conn.Close() + return nil, err + } + // The proxy sends nothing after its CONNECT response until the client speaks first. + response, err := http.ReadResponse(bufio.NewReader(conn), request) + if err != nil { + conn.Close() + return nil, err + } + response.Body.Close() + if response.StatusCode != http.StatusOK { + conn.Close() + return nil, fmt.Errorf("proxy CONNECT: %s", response.Status) + } + _ = conn.SetDeadline(time.Time{}) + return conn, nil + } + return nil, ErrUpstream +} + +// fingerprintTransport clones base so each fingerprinted route keeps its own HTTP/1.1 pool. +func fingerprintTransport(base *http.Transport, exit *url.URL) *http.Transport { + transport := base.Clone() + transport.Proxy = nil + transport.ForceAttemptHTTP2 = false + transport.TLSNextProto = map[string]func(string, *tls.Conn) http.RoundTripper{} + transport.DialTLSContext = func(ctx context.Context, _, addr string) (net.Conn, error) { + return dialFingerprinted(ctx, exit, addr) + } + return transport +} diff --git a/licenses/utls.txt b/licenses/utls.txt new file mode 100644 index 00000000..6a66aea5 --- /dev/null +++ b/licenses/utls.txt @@ -0,0 +1,27 @@ +Copyright (c) 2009 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/sqlc.yaml b/sqlc.yaml index 612aaa96..d4f9b2c9 100644 --- a/sqlc.yaml +++ b/sqlc.yaml @@ -30,3 +30,5 @@ sql: go_type: "bool" - column: "account_groups.quota_failover" go_type: "bool" + - column: "accounts.tls_fingerprint" + go_type: "bool" diff --git a/web/src/components/AccountFingerprint.test.tsx b/web/src/components/AccountFingerprint.test.tsx index 3921c50f..b721e4bc 100644 --- a/web/src/components/AccountFingerprint.test.tsx +++ b/web/src/components/AccountFingerprint.test.tsx @@ -62,6 +62,8 @@ it('saves a manual Codex platform with the spaces it contains', async () => { arch: '', runtime_version: '', package_version: '', + mode: 'off', + tls: false, }, }, ]) diff --git a/web/src/components/AccountFingerprint.tsx b/web/src/components/AccountFingerprint.tsx index 2b5a5a47..9fad315d 100644 --- a/web/src/components/AccountFingerprint.tsx +++ b/web/src/components/AccountFingerprint.tsx @@ -9,6 +9,7 @@ import { saveAccountFingerprint, type Account, type AccountFingerprint as Fingerprint, + type FingerprintIdentity, type FingerprintInput, } from '@/lib/accounts' import { Button } from './ui/Button' @@ -32,6 +33,20 @@ const empty: FingerprintInput = { // The Stainless values the Anthropic SDK reports on the platforms Claude Code runs on. const claudeOS = ['MacOS', 'Windows', 'Linux', 'FreeBSD'] const claudeArch = ['arm64', 'x64', 'x86', 'arm'] +const modes = ['off', 'device', 'session', 'full'] as const +const modeHints = { + off: 'fingerprintModeOffHint', + device: 'fingerprintModeDeviceHint', + session: 'fingerprintModeSessionHint', + full: 'fingerprintModeFullHint', +} as const +const modeLabels = { + off: 'fingerprintModeOff', + device: 'fingerprintModeDevice', + session: 'fingerprintModeSession', + full: 'fingerprintModeFull', +} as const +const noIdentity: FingerprintIdentity = { mode: 'off', tls: false } const selectClass = 'h-9 w-full rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50' @@ -58,6 +73,10 @@ export function AccountFingerprint({ // Edits start from the stored fingerprint; null means the form shows the stored values. const [edited, setValues] = useState(null) const values = edited ?? (query.data ? profileInput(query.data) : empty) + const [editedIdentity, setIdentity] = useState( + null, + ) + const identity = editedIdentity ?? query.data?.identity ?? noIdentity const [device, setDevice] = useState(false) const save = useMutation({ mutationFn: saveAccountFingerprint, @@ -67,6 +86,7 @@ export function AccountFingerprint({ mutationFn: resetAccountFingerprint, onSuccess: () => { setValues(null) + setIdentity(null) setDevice(false) return query.refetch() }, @@ -82,7 +102,7 @@ export function AccountFingerprint({ const profile = Object.fromEntries( Object.entries(values).map(([key, value]) => [key, value.trim()]), ) as FingerprintInput - save.mutate({ id: account.id, profile }) + save.mutate({ id: account.id, profile, identity }) } const source = query.data?.profile.source ?? '' return ( @@ -160,6 +180,42 @@ export function AccountFingerprint({ > {t('fingerprintCodexPlatformHint')}

+ + +
+

{t(modeHints[identity.mode])}

+ {identity.mode !== 'off' && ( +

+ {t('fingerprintConvergenceWarning')} +

+ )} +
) : (
@@ -228,10 +284,38 @@ export function AccountFingerprint({

{t('fingerprintClaudeHint')}

+ +

+ {t('fingerprintTLSHint')} +

+
+ )} + {(query.data.provider === 'claude' || + query.data.identity.mode !== 'off') && ( +
{query.data.device_id && (

- {t('fingerprintDevice')}:{' '} - {query.data.device_id}… + {t( + query.data.provider === 'codex' + ? 'fingerprintInstallation' + : 'fingerprintDevice', + )} + : {query.data.device_id}…

)}

export type FingerprintInput = Omit +export type FingerprintIdentity = AccountFingerprint['identity'] export const fingerprintProviders = new Set(['codex', 'claude']) export function fetchAccountFingerprint(id: string) { return request( @@ -165,11 +172,15 @@ export function fetchAccountFingerprint(id: string) { export function saveAccountFingerprint(input: { id: string profile: FingerprintInput + identity: FingerprintIdentity }) { return request( `/api/accounts/${encodeURIComponent(input.id)}/fingerprint`, fingerprintSchema, - { method: 'PUT', body: JSON.stringify(input.profile) }, + { + method: 'PUT', + body: JSON.stringify({ ...input.profile, ...input.identity }), + }, ) } export function resetAccountFingerprint(input: { diff --git a/web/src/locales/ar.json b/web/src/locales/ar.json index adb03038..c0c02338 100644 --- a/web/src/locales/ar.json +++ b/web/src/locales/ar.json @@ -605,13 +605,27 @@ "fingerprintSourceManual": "مضبوطة يدويًا", "fingerprintSourceNone": "لم تُتعلَّم بعد. سيضبطها العميل الأصلي التالي.", "fingerprintCodexPlatform": "المنصة والطرفية", - "fingerprintCodexPlatformHint": "جزء المنصة من User-Agent الخاص بـ Codex، مثل (Mac OS 15.5.0; arm64) iTerm.app/3.6.1. يدير SubLane إصدار Codex.", + "fingerprintCodexPlatformHint": "جزء المنصة من User-Agent الخاص بـ Codex، مثل (Mac OS 15.5.0; arm64) iTerm.app/3.6.1. يدير SubLane إصدار Codex. تستخدم الحسابات التي لم يستخدمها عميل أصلي بعد (Ubuntu 22.4.0; x86_64) xterm-256color.", "fingerprintClaudeOS": "نظام التشغيل", "fingerprintClaudeArch": "البنية", "fingerprintRuntimeVersion": "إصدار بيئة التشغيل", "fingerprintPackageVersion": "إصدار SDK", "fingerprintClaudeHint": "تُرسل في ترويسات X-Stainless. يُتعلَّم نظام التشغيل والبنية من أول عميل Claude Code، وتتبع إصدارات بيئة التشغيل وSDK إصدار Claude Code المُدار ما لم تُضبط هنا.", "fingerprintAutomatic": "تلقائي", + "fingerprintConvergence": "توحيد المعرّفات", + "fingerprintModeOff": "متوقف", + "fingerprintModeDevice": "الجهاز", + "fingerprintModeSession": "الجلسة", + "fingerprintModeFull": "كامل", + "fingerprintModeOffHint": "يحتفظ كل عميل بمعرّفات التثبيت والجلسة والسلسلة الخاصة به (يواصل SubLane عزل الجلسات لكل عضو).", + "fingerprintModeDeviceHint": "يتشارك جميع العملاء معرّف تثبيت واحدًا، فيرى المزوّد جهازًا واحدًا بعدة جلسات.", + "fingerprintModeSessionHint": "جهاز واحد وجلسة واحدة؛ تحتفظ كل محادثة حقيقية بسلسلتها، كأن مستخدمًا واحدًا يفتح عدة نوافذ أو وكلاء فرعيين.", + "fingerprintModeFullHint": "جهاز واحد وجلسة واحدة وسلسلة واحدة لكل العملاء. أكثر الأوضاع تشددًا.", + "fingerprintConvergenceWarning": "الإيقاف الافتراضي له سبب: عندما فعّل sub2api التوحيد افتراضيًا أبلغ مستخدمون عن تقلص الحصص. فعّله أولاً على حساب واحد وراقب حصته.", + "fingerprintTLS": "بصمة TLS الخاصة بـ Claude Code", + "fingerprintTLSHint": "تستخدم الاتصالات مصافحة TLS الخاصة بـ Claude Code على Node.js 24 (JA3 44f88fca…) بدلاً من Go، عبر HTTP/1.1 مثل العميل الحقيقي.", + "fingerprintInstallation": "معرّف التثبيت", + "fingerprintResetInstallation": "إنشاء معرّف تثبيت جديد أيضًا", "fingerprintDevice": "معرّف الجهاز", "fingerprintResetDevice": "إنشاء معرّف جهاز جديد أيضًا", "fingerprintResetDeviceHint": "سيرى المزوّد هذا الحساب على جهاز جديد.", diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 00f3825d..596893e6 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -605,13 +605,27 @@ "fingerprintSourceManual": "Manuell festgelegt", "fingerprintSourceNone": "Noch nicht gelernt. Der nächste native Client legt ihn fest.", "fingerprintCodexPlatform": "Plattform und Terminal", - "fingerprintCodexPlatformHint": "Der Plattformteil des Codex-User-Agents, zum Beispiel (Mac OS 15.5.0; arm64) iTerm.app/3.6.1. SubLane verwaltet die Codex-Version.", + "fingerprintCodexPlatformHint": "Der Plattformteil des Codex-User-Agents, zum Beispiel (Mac OS 15.5.0; arm64) iTerm.app/3.6.1. SubLane verwaltet die Codex-Version. Konten, die noch kein nativer Client genutzt hat, verwenden (Ubuntu 22.4.0; x86_64) xterm-256color.", "fingerprintClaudeOS": "Betriebssystem", "fingerprintClaudeArch": "Architektur", "fingerprintRuntimeVersion": "Laufzeitversion", "fingerprintPackageVersion": "SDK-Version", "fingerprintClaudeHint": "Wird als X-Stainless-Header gesendet. Betriebssystem und Architektur werden vom ersten Claude-Code-Client gelernt; Laufzeit- und SDK-Version folgen der verwalteten Claude-Code-Version, sofern hier nichts festgelegt ist.", "fingerprintAutomatic": "Automatisch", + "fingerprintConvergence": "Kennungs-Konvergenz", + "fingerprintModeOff": "Aus", + "fingerprintModeDevice": "Gerät", + "fingerprintModeSession": "Sitzung", + "fingerprintModeFull": "Vollständig", + "fingerprintModeOffHint": "Jeder Client behält seine eigenen Installations-, Sitzungs- und Thread-Kennungen (SubLane trennt Sitzungen weiterhin pro Mitglied).", + "fingerprintModeDeviceHint": "Alle Clients teilen eine Installations-ID, der Anbieter sieht ein Gerät mit mehreren Sitzungen.", + "fingerprintModeSessionHint": "Ein Gerät und eine Sitzung; jede echte Unterhaltung behält ihren eigenen Thread, wie ein Nutzer mit mehreren Fenstern oder Subagenten.", + "fingerprintModeFullHint": "Ein Gerät, eine Sitzung und ein Thread für alle Clients. Der aggressivste Modus.", + "fingerprintConvergenceWarning": "Standardmäßig aus, aus gutem Grund: Als sub2api die Konvergenz standardmäßig aktivierte, meldeten Nutzer schrumpfende Kontingente. Zuerst bei einem Konto aktivieren und dessen Kontingent beobachten.", + "fingerprintTLS": "Claude-Code-TLS-Fingerabdruck", + "fingerprintTLSHint": "Verbindungen nutzen den TLS-Handshake von Claude Code unter Node.js 24 (JA3 44f88fca…) statt dem von Go, über HTTP/1.1 wie der echte Client.", + "fingerprintInstallation": "Installations-ID", + "fingerprintResetInstallation": "Auch eine neue Installations-ID erzeugen", "fingerprintDevice": "Geräte-ID", "fingerprintResetDevice": "Auch eine neue Geräte-ID erzeugen", "fingerprintResetDeviceHint": "Der Anbieter sieht dieses Konto auf einem neuen Gerät.", diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index 72d720b7..7aba02fe 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -937,7 +937,7 @@ export const en = { fingerprintSourceNone: 'Not learned yet. The next native client sets it.', fingerprintCodexPlatform: 'Platform and terminal', fingerprintCodexPlatformHint: - 'The platform part of the Codex User-Agent, for example (Mac OS 15.5.0; arm64) iTerm.app/3.6.1. SubLane manages the Codex version.', + 'The platform part of the Codex User-Agent, for example (Mac OS 15.5.0; arm64) iTerm.app/3.6.1. SubLane manages the Codex version. Accounts no native client has used yet present (Ubuntu 22.4.0; x86_64) xterm-256color.', fingerprintClaudeOS: 'Operating system', fingerprintClaudeArch: 'Architecture', fingerprintRuntimeVersion: 'Runtime version', @@ -945,6 +945,26 @@ export const en = { fingerprintClaudeHint: 'Sent as X-Stainless headers. Operating system and architecture are learned from the first Claude Code client; runtime and SDK versions follow the managed Claude Code release unless set here.', fingerprintAutomatic: 'Automatic', + fingerprintConvergence: 'Identifier convergence', + fingerprintModeOff: 'Off', + fingerprintModeDevice: 'Device', + fingerprintModeSession: 'Session', + fingerprintModeFull: 'Full', + fingerprintModeOffHint: + 'Each client keeps its own installation, session and thread identifiers (SubLane still isolates sessions per member).', + fingerprintModeDeviceHint: + 'All clients share one installation ID, so the provider sees one device with several sessions.', + fingerprintModeSessionHint: + 'One device and one session; each real conversation keeps its own thread, like one user running several windows or subagents.', + fingerprintModeFullHint: + 'One device, one session and one thread for every client. The most aggressive mode.', + fingerprintConvergenceWarning: + 'Off by default for a reason: when sub2api enabled convergence by default, users reported shrinking quotas. Turn it on for one account first and watch its quota.', + fingerprintTLS: 'Claude Code TLS fingerprint', + fingerprintTLSHint: + 'Connections present the TLS handshake of Claude Code on Node.js 24 (JA3 44f88fca…) instead of Go’s, over HTTP/1.1 like the real client.', + fingerprintInstallation: 'Installation ID', + fingerprintResetInstallation: 'Also generate a new installation ID', fingerprintDevice: 'Device ID', fingerprintResetDevice: 'Also generate a new device ID', fingerprintResetDeviceHint: diff --git a/web/src/locales/es.json b/web/src/locales/es.json index 9aa100ed..cef1f3e0 100644 --- a/web/src/locales/es.json +++ b/web/src/locales/es.json @@ -605,13 +605,27 @@ "fingerprintSourceManual": "Configurada manualmente", "fingerprintSourceNone": "Aún no aprendida. El siguiente cliente nativo la establecerá.", "fingerprintCodexPlatform": "Plataforma y terminal", - "fingerprintCodexPlatformHint": "La parte de plataforma del User-Agent de Codex, por ejemplo (Mac OS 15.5.0; arm64) iTerm.app/3.6.1. SubLane gestiona la versión de Codex.", + "fingerprintCodexPlatformHint": "La parte de plataforma del User-Agent de Codex, por ejemplo (Mac OS 15.5.0; arm64) iTerm.app/3.6.1. SubLane gestiona la versión de Codex. Las cuentas que aún no usó ningún cliente nativo presentan (Ubuntu 22.4.0; x86_64) xterm-256color.", "fingerprintClaudeOS": "Sistema operativo", "fingerprintClaudeArch": "Arquitectura", "fingerprintRuntimeVersion": "Versión del entorno de ejecución", "fingerprintPackageVersion": "Versión del SDK", "fingerprintClaudeHint": "Se envía como encabezados X-Stainless. El sistema operativo y la arquitectura se aprenden del primer cliente de Claude Code; las versiones del entorno y del SDK siguen la versión gestionada de Claude Code salvo que se indiquen aquí.", "fingerprintAutomatic": "Automático", + "fingerprintConvergence": "Convergencia de identificadores", + "fingerprintModeOff": "Desactivada", + "fingerprintModeDevice": "Dispositivo", + "fingerprintModeSession": "Sesión", + "fingerprintModeFull": "Completa", + "fingerprintModeOffHint": "Cada cliente conserva sus propios identificadores de instalación, sesión e hilo (SubLane sigue aislando las sesiones por miembro).", + "fingerprintModeDeviceHint": "Todos los clientes comparten un ID de instalación, así que el proveedor ve un dispositivo con varias sesiones.", + "fingerprintModeSessionHint": "Un dispositivo y una sesión; cada conversación real conserva su propio hilo, como un usuario con varias ventanas o subagentes.", + "fingerprintModeFullHint": "Un dispositivo, una sesión y un hilo para todos los clientes. El modo más agresivo.", + "fingerprintConvergenceWarning": "Está desactivada por defecto por un motivo: cuando sub2api la activó por defecto, hubo informes de cuotas reducidas. Actívala primero en una cuenta y vigila su cuota.", + "fingerprintTLS": "Huella TLS de Claude Code", + "fingerprintTLSHint": "Las conexiones presentan el handshake TLS de Claude Code sobre Node.js 24 (JA3 44f88fca…) en lugar del de Go, por HTTP/1.1 como el cliente real.", + "fingerprintInstallation": "ID de instalación", + "fingerprintResetInstallation": "Generar también un nuevo ID de instalación", "fingerprintDevice": "ID de dispositivo", "fingerprintResetDevice": "Generar también un nuevo ID de dispositivo", "fingerprintResetDeviceHint": "El proveedor verá esta cuenta en un dispositivo nuevo.", diff --git a/web/src/locales/fr.json b/web/src/locales/fr.json index 09db1065..e1f2200c 100644 --- a/web/src/locales/fr.json +++ b/web/src/locales/fr.json @@ -605,13 +605,27 @@ "fingerprintSourceManual": "Définie manuellement", "fingerprintSourceNone": "Pas encore apprise. Le prochain client natif la définira.", "fingerprintCodexPlatform": "Plateforme et terminal", - "fingerprintCodexPlatformHint": "La partie plateforme du User-Agent de Codex, par exemple (Mac OS 15.5.0; arm64) iTerm.app/3.6.1. SubLane gère la version de Codex.", + "fingerprintCodexPlatformHint": "La partie plateforme du User-Agent de Codex, par exemple (Mac OS 15.5.0; arm64) iTerm.app/3.6.1. SubLane gère la version de Codex. Les comptes encore jamais utilisés par un client natif présentent (Ubuntu 22.4.0; x86_64) xterm-256color.", "fingerprintClaudeOS": "Système d’exploitation", "fingerprintClaudeArch": "Architecture", "fingerprintRuntimeVersion": "Version de l’environnement d’exécution", "fingerprintPackageVersion": "Version du SDK", "fingerprintClaudeHint": "Envoyée dans les en-têtes X-Stainless. Le système et l’architecture sont appris du premier client Claude Code ; les versions de l’environnement et du SDK suivent la version gérée de Claude Code sauf si elles sont définies ici.", "fingerprintAutomatic": "Automatique", + "fingerprintConvergence": "Convergence des identifiants", + "fingerprintModeOff": "Désactivée", + "fingerprintModeDevice": "Appareil", + "fingerprintModeSession": "Session", + "fingerprintModeFull": "Complète", + "fingerprintModeOffHint": "Chaque client conserve ses propres identifiants d’installation, de session et de fil (SubLane isole toujours les sessions par membre).", + "fingerprintModeDeviceHint": "Tous les clients partagent un identifiant d’installation : le fournisseur voit un appareil avec plusieurs sessions.", + "fingerprintModeSessionHint": "Un appareil et une session ; chaque conversation réelle garde son propre fil, comme un utilisateur avec plusieurs fenêtres ou sous-agents.", + "fingerprintModeFullHint": "Un appareil, une session et un fil pour tous les clients. Le mode le plus agressif.", + "fingerprintConvergenceWarning": "Désactivée par défaut pour une raison : quand sub2api l’a activée par défaut, des utilisateurs ont signalé des quotas réduits. Activez-la d’abord sur un compte et surveillez son quota.", + "fingerprintTLS": "Empreinte TLS de Claude Code", + "fingerprintTLSHint": "Les connexions présentent la poignée de main TLS de Claude Code sous Node.js 24 (JA3 44f88fca…) au lieu de celle de Go, en HTTP/1.1 comme le vrai client.", + "fingerprintInstallation": "ID d’installation", + "fingerprintResetInstallation": "Générer aussi un nouvel ID d’installation", "fingerprintDevice": "ID d’appareil", "fingerprintResetDevice": "Générer aussi un nouvel ID d’appareil", "fingerprintResetDeviceHint": "Le fournisseur verra ce compte sur un nouvel appareil.", diff --git a/web/src/locales/ja.json b/web/src/locales/ja.json index 0c9b85e6..76534c56 100644 --- a/web/src/locales/ja.json +++ b/web/src/locales/ja.json @@ -605,13 +605,27 @@ "fingerprintSourceManual": "手動設定", "fingerprintSourceNone": "未学習です。次のネイティブクライアントが設定します。", "fingerprintCodexPlatform": "プラットフォームとターミナル", - "fingerprintCodexPlatformHint": "Codex User-Agent のプラットフォーム部分です(例: (Mac OS 15.5.0; arm64) iTerm.app/3.6.1)。Codex のバージョンは SubLane が管理します。", + "fingerprintCodexPlatformHint": "Codex User-Agent のプラットフォーム部分です(例: (Mac OS 15.5.0; arm64) iTerm.app/3.6.1)。Codex のバージョンは SubLane が管理します。まだネイティブクライアントが使っていないアカウントは (Ubuntu 22.4.0; x86_64) xterm-256color を使います。", "fingerprintClaudeOS": "OS", "fingerprintClaudeArch": "アーキテクチャ", "fingerprintRuntimeVersion": "ランタイムバージョン", "fingerprintPackageVersion": "SDK バージョン", "fingerprintClaudeHint": "X-Stainless ヘッダーとして送信されます。OS とアーキテクチャは最初の Claude Code クライアントから学習し、ランタイムと SDK のバージョンはここで設定しない限り管理された Claude Code リリースに従います。", "fingerprintAutomatic": "自動", + "fingerprintConvergence": "識別子の収束", + "fingerprintModeOff": "オフ", + "fingerprintModeDevice": "デバイス", + "fingerprintModeSession": "セッション", + "fingerprintModeFull": "フル", + "fingerprintModeOffHint": "各クライアントが自分のインストール ID、セッション ID、スレッド ID を保持します(SubLane はメンバーごとのセッション分離を続けます)。", + "fingerprintModeDeviceHint": "全クライアントが 1 つのインストール ID を共有し、上流からは 1 台のデバイスで複数セッションに見えます。", + "fingerprintModeSessionHint": "1 台のデバイスと 1 つのセッション。実際の会話ごとに独自のスレッドを持ち、1 人が複数ウィンドウやサブエージェントを使う形に見えます。", + "fingerprintModeFullHint": "全クライアントで 1 台のデバイス、1 つのセッション、1 つのスレッド。最も強いモードです。", + "fingerprintConvergenceWarning": "既定でオフなのには理由があります。sub2api が収束を既定で有効にした際、クォータが減ったという報告がありました。まず 1 つのアカウントで有効にしてクォータを確認してください。", + "fingerprintTLS": "Claude Code の TLS フィンガープリント", + "fingerprintTLSHint": "接続時に Go ではなく Node.js 24 上の Claude Code の TLS ハンドシェイク(JA3 44f88fca…)を使い、実クライアントと同じく HTTP/1.1 で通信します。", + "fingerprintInstallation": "インストール ID", + "fingerprintResetInstallation": "インストール ID も再生成する", "fingerprintDevice": "デバイス ID", "fingerprintResetDevice": "デバイス ID も再生成する", "fingerprintResetDeviceHint": "上流からは新しいデバイスでの利用に見えます。", diff --git a/web/src/locales/ko.json b/web/src/locales/ko.json index d4f0283b..52730d1f 100644 --- a/web/src/locales/ko.json +++ b/web/src/locales/ko.json @@ -605,13 +605,27 @@ "fingerprintSourceManual": "수동 설정", "fingerprintSourceNone": "아직 학습되지 않았습니다. 다음 네이티브 클라이언트가 설정합니다.", "fingerprintCodexPlatform": "플랫폼 및 터미널", - "fingerprintCodexPlatformHint": "Codex User-Agent의 플랫폼 부분입니다(예: (Mac OS 15.5.0; arm64) iTerm.app/3.6.1). Codex 버전은 SubLane이 관리합니다.", + "fingerprintCodexPlatformHint": "Codex User-Agent의 플랫폼 부분입니다(예: (Mac OS 15.5.0; arm64) iTerm.app/3.6.1). Codex 버전은 SubLane이 관리합니다. 아직 네이티브 클라이언트가 사용하지 않은 계정은 (Ubuntu 22.4.0; x86_64) xterm-256color를 사용합니다.", "fingerprintClaudeOS": "운영체제", "fingerprintClaudeArch": "아키텍처", "fingerprintRuntimeVersion": "런타임 버전", "fingerprintPackageVersion": "SDK 버전", "fingerprintClaudeHint": "X-Stainless 헤더로 전송됩니다. 운영체제와 아키텍처는 첫 Claude Code 클라이언트에서 학습하며, 런타임과 SDK 버전은 여기서 설정하지 않으면 관리되는 Claude Code 릴리스를 따릅니다.", "fingerprintAutomatic": "자동", + "fingerprintConvergence": "식별자 수렴", + "fingerprintModeOff": "끔", + "fingerprintModeDevice": "기기", + "fingerprintModeSession": "세션", + "fingerprintModeFull": "전체", + "fingerprintModeOffHint": "각 클라이언트가 자체 설치 ID, 세션 ID, 스레드 ID를 유지합니다(SubLane은 계속 구성원별로 세션을 분리합니다).", + "fingerprintModeDeviceHint": "모든 클라이언트가 설치 ID 하나를 공유하여 업스트림에는 기기 하나에 세션 여러 개로 보입니다.", + "fingerprintModeSessionHint": "기기 하나와 세션 하나. 실제 대화마다 고유한 스레드를 유지해, 한 사용자가 여러 창이나 하위 에이전트를 쓰는 것처럼 보입니다.", + "fingerprintModeFullHint": "모든 클라이언트가 기기 하나, 세션 하나, 스레드 하나를 씁니다. 가장 강한 모드입니다.", + "fingerprintConvergenceWarning": "기본값이 꺼짐인 데는 이유가 있습니다. sub2api가 수렴을 기본으로 켰을 때 할당량이 줄었다는 보고가 있었습니다. 먼저 계정 하나에서 켜고 할당량을 지켜보세요.", + "fingerprintTLS": "Claude Code TLS 지문", + "fingerprintTLSHint": "연결할 때 Go 대신 Node.js 24에서 실행되는 Claude Code의 TLS 핸드셰이크(JA3 44f88fca…)를 사용하며, 실제 클라이언트처럼 HTTP/1.1로 통신합니다.", + "fingerprintInstallation": "설치 ID", + "fingerprintResetInstallation": "설치 ID도 새로 생성", "fingerprintDevice": "기기 ID", "fingerprintResetDevice": "기기 ID도 새로 생성", "fingerprintResetDeviceHint": "업스트림에서는 이 계정이 새 기기에서 사용되는 것으로 보입니다.", diff --git a/web/src/locales/pt.json b/web/src/locales/pt.json index e8ab49df..6784554b 100644 --- a/web/src/locales/pt.json +++ b/web/src/locales/pt.json @@ -605,13 +605,27 @@ "fingerprintSourceManual": "Definida manualmente", "fingerprintSourceNone": "Ainda não aprendida. O próximo cliente nativo vai defini-la.", "fingerprintCodexPlatform": "Plataforma e terminal", - "fingerprintCodexPlatformHint": "A parte de plataforma do User-Agent do Codex, por exemplo (Mac OS 15.5.0; arm64) iTerm.app/3.6.1. O SubLane gerencia a versão do Codex.", + "fingerprintCodexPlatformHint": "A parte de plataforma do User-Agent do Codex, por exemplo (Mac OS 15.5.0; arm64) iTerm.app/3.6.1. O SubLane gerencia a versão do Codex. Contas ainda não usadas por um cliente nativo apresentam (Ubuntu 22.4.0; x86_64) xterm-256color.", "fingerprintClaudeOS": "Sistema operacional", "fingerprintClaudeArch": "Arquitetura", "fingerprintRuntimeVersion": "Versão do runtime", "fingerprintPackageVersion": "Versão do SDK", "fingerprintClaudeHint": "Enviada nos cabeçalhos X-Stainless. Sistema operacional e arquitetura são aprendidos do primeiro cliente Claude Code; as versões de runtime e SDK seguem a versão gerenciada do Claude Code, a menos que sejam definidas aqui.", "fingerprintAutomatic": "Automático", + "fingerprintConvergence": "Convergência de identificadores", + "fingerprintModeOff": "Desligada", + "fingerprintModeDevice": "Dispositivo", + "fingerprintModeSession": "Sessão", + "fingerprintModeFull": "Completa", + "fingerprintModeOffHint": "Cada cliente mantém seus próprios identificadores de instalação, sessão e thread (o SubLane continua isolando sessões por membro).", + "fingerprintModeDeviceHint": "Todos os clientes compartilham um ID de instalação, então o provedor vê um dispositivo com várias sessões.", + "fingerprintModeSessionHint": "Um dispositivo e uma sessão; cada conversa real mantém sua própria thread, como um usuário com várias janelas ou subagentes.", + "fingerprintModeFullHint": "Um dispositivo, uma sessão e uma thread para todos os clientes. O modo mais agressivo.", + "fingerprintConvergenceWarning": "Desligada por padrão por um motivo: quando o sub2api a ativou por padrão, houve relatos de cotas reduzidas. Ative primeiro em uma conta e acompanhe a cota.", + "fingerprintTLS": "Impressão TLS do Claude Code", + "fingerprintTLSHint": "As conexões usam o handshake TLS do Claude Code no Node.js 24 (JA3 44f88fca…) em vez do Go, via HTTP/1.1 como o cliente real.", + "fingerprintInstallation": "ID de instalação", + "fingerprintResetInstallation": "Gerar também um novo ID de instalação", "fingerprintDevice": "ID do dispositivo", "fingerprintResetDevice": "Gerar também um novo ID de dispositivo", "fingerprintResetDeviceHint": "O provedor verá esta conta em um novo dispositivo.", diff --git a/web/src/locales/ru.json b/web/src/locales/ru.json index 552ff724..a7e1b884 100644 --- a/web/src/locales/ru.json +++ b/web/src/locales/ru.json @@ -605,13 +605,27 @@ "fingerprintSourceManual": "Задан вручную", "fingerprintSourceNone": "Ещё не получен. Его задаст следующий нативный клиент.", "fingerprintCodexPlatform": "Платформа и терминал", - "fingerprintCodexPlatformHint": "Часть User-Agent Codex с платформой, например (Mac OS 15.5.0; arm64) iTerm.app/3.6.1. Версией Codex управляет SubLane.", + "fingerprintCodexPlatformHint": "Часть User-Agent Codex с платформой, например (Mac OS 15.5.0; arm64) iTerm.app/3.6.1. Версией Codex управляет SubLane. Аккаунты, которые ещё не использовал нативный клиент, используют (Ubuntu 22.4.0; x86_64) xterm-256color.", "fingerprintClaudeOS": "Операционная система", "fingerprintClaudeArch": "Архитектура", "fingerprintRuntimeVersion": "Версия среды выполнения", "fingerprintPackageVersion": "Версия SDK", "fingerprintClaudeHint": "Отправляется в заголовках X-Stainless. ОС и архитектура берутся у первого клиента Claude Code; версии среды выполнения и SDK следуют управляемому выпуску Claude Code, если не заданы здесь.", "fingerprintAutomatic": "Автоматически", + "fingerprintConvergence": "Сведение идентификаторов", + "fingerprintModeOff": "Выкл.", + "fingerprintModeDevice": "Устройство", + "fingerprintModeSession": "Сессия", + "fingerprintModeFull": "Полное", + "fingerprintModeOffHint": "Каждый клиент сохраняет свои идентификаторы установки, сессии и потока (SubLane по-прежнему изолирует сессии по участникам).", + "fingerprintModeDeviceHint": "Все клиенты используют один ID установки, и провайдер видит одно устройство с несколькими сессиями.", + "fingerprintModeSessionHint": "Одно устройство и одна сессия; каждый реальный диалог получает свой поток, как у пользователя с несколькими окнами или субагентами.", + "fingerprintModeFullHint": "Одно устройство, одна сессия и один поток для всех клиентов. Самый агрессивный режим.", + "fingerprintConvergenceWarning": "По умолчанию выключено не случайно: когда sub2api включил сведение по умолчанию, пользователи сообщали об уменьшении квоты. Сначала включите на одном аккаунте и следите за квотой.", + "fingerprintTLS": "TLS-отпечаток Claude Code", + "fingerprintTLSHint": "Соединения используют TLS-рукопожатие Claude Code на Node.js 24 (JA3 44f88fca…) вместо Go и работают по HTTP/1.1, как настоящий клиент.", + "fingerprintInstallation": "ID установки", + "fingerprintResetInstallation": "Также создать новый ID установки", "fingerprintDevice": "ID устройства", "fingerprintResetDevice": "Также создать новый ID устройства", "fingerprintResetDeviceHint": "Провайдер увидит этот аккаунт на новом устройстве.", diff --git a/web/src/locales/zh-TW.json b/web/src/locales/zh-TW.json index ae091bb3..11e6eea8 100644 --- a/web/src/locales/zh-TW.json +++ b/web/src/locales/zh-TW.json @@ -605,13 +605,27 @@ "fingerprintSourceManual": "手動設定", "fingerprintSourceNone": "尚未學習,下一個原生用戶端會設定它。", "fingerprintCodexPlatform": "平台與終端", - "fingerprintCodexPlatformHint": "Codex User-Agent 中的平台部分,例如 (Mac OS 15.5.0; arm64) iTerm.app/3.6.1。Codex 版本號由 SubLane 統一管理。", + "fingerprintCodexPlatformHint": "Codex User-Agent 中的平台部分,例如 (Mac OS 15.5.0; arm64) iTerm.app/3.6.1。Codex 版本號由 SubLane 統一管理。尚未有原生用戶端使用過的帳號使用 (Ubuntu 22.4.0; x86_64) xterm-256color。", "fingerprintClaudeOS": "作業系統", "fingerprintClaudeArch": "架構", "fingerprintRuntimeVersion": "執行環境版本", "fingerprintPackageVersion": "SDK 版本", "fingerprintClaudeHint": "以 X-Stainless 標頭傳送。作業系統和架構從第一個 Claude Code 用戶端學習;執行環境和 SDK 版本跟隨 SubLane 管理的 Claude Code 版本,除非在此手動設定。", "fingerprintAutomatic": "自動", + "fingerprintConvergence": "識別碼收斂", + "fingerprintModeOff": "關閉", + "fingerprintModeDevice": "裝置", + "fingerprintModeSession": "工作階段", + "fingerprintModeFull": "完全", + "fingerprintModeOffHint": "每個用戶端保留自己的安裝 ID、工作階段 ID 和執行緒 ID(SubLane 仍依成員隔離工作階段)。", + "fingerprintModeDeviceHint": "所有用戶端共用一個安裝 ID,上游看到一台裝置、多個工作階段。", + "fingerprintModeSessionHint": "一台裝置、一個工作階段;每個真實對話保留自己的執行緒,就像一個使用者開了多個視窗或子代理。", + "fingerprintModeFullHint": "所有用戶端共用一台裝置、一個工作階段、一個執行緒,最激進的模式。", + "fingerprintConvergenceWarning": "預設關閉是有原因的:sub2api 曾把收斂設為預設開啟,之後有使用者回報額度縮水。建議先在一個帳號上開啟並觀察額度。", + "fingerprintTLS": "Claude Code TLS 指紋", + "fingerprintTLSHint": "連線時使用 Node.js 24 上的 Claude Code 的 TLS 握手(JA3 44f88fca…),而非 Go 的握手,並像真實用戶端一樣使用 HTTP/1.1。", + "fingerprintInstallation": "安裝 ID", + "fingerprintResetInstallation": "同時重新產生安裝 ID", "fingerprintDevice": "裝置 ID", "fingerprintResetDevice": "同時重新產生裝置 ID", "fingerprintResetDeviceHint": "上游會把此帳號視為在一台新裝置上登入。", diff --git a/web/src/locales/zh.ts b/web/src/locales/zh.ts index 0db4cf1a..dc2b1def 100644 --- a/web/src/locales/zh.ts +++ b/web/src/locales/zh.ts @@ -856,7 +856,7 @@ export const zh: Record = { fingerprintSourceNone: '尚未学习,下一个原生客户端会设置它。', fingerprintCodexPlatform: '平台与终端', fingerprintCodexPlatformHint: - 'Codex User-Agent 中的平台部分,例如 (Mac OS 15.5.0; arm64) iTerm.app/3.6.1。Codex 版本号由 SubLane 统一管理。', + 'Codex User-Agent 中的平台部分,例如 (Mac OS 15.5.0; arm64) iTerm.app/3.6.1。Codex 版本号由 SubLane 统一管理。还没有原生客户端使用过的账号使用 (Ubuntu 22.4.0; x86_64) xterm-256color。', fingerprintClaudeOS: '操作系统', fingerprintClaudeArch: '架构', fingerprintRuntimeVersion: '运行时版本', @@ -864,6 +864,26 @@ export const zh: Record = { fingerprintClaudeHint: '以 X-Stainless 请求头发送。操作系统和架构从第一个 Claude Code 客户端学习;运行时和 SDK 版本跟随 SubLane 管理的 Claude Code 版本,除非在此手动设置。', fingerprintAutomatic: '自动', + fingerprintConvergence: '标识收敛', + fingerprintModeOff: '关闭', + fingerprintModeDevice: '设备', + fingerprintModeSession: '会话', + fingerprintModeFull: '完全', + fingerprintModeOffHint: + '每个客户端保留自己的安装 ID、会话 ID 和线程 ID(SubLane 仍按成员隔离会话)。', + fingerprintModeDeviceHint: + '所有客户端共用一个安装 ID,上游看到一台设备、多个会话。', + fingerprintModeSessionHint: + '一台设备、一个会话;每个真实对话保留自己的线程,就像一个用户开了多个窗口或子代理。', + fingerprintModeFullHint: + '所有客户端共用一台设备、一个会话、一个线程,最激进的模式。', + fingerprintConvergenceWarning: + '默认关闭是有原因的:sub2api 曾把收敛设为默认开启,随后有用户报告额度缩水。建议先在一个账号上开启并观察额度。', + fingerprintTLS: 'Claude Code TLS 指纹', + fingerprintTLSHint: + '连接时使用 Node.js 24 上的 Claude Code 的 TLS 握手(JA3 44f88fca…),而不是 Go 的握手,并像真实客户端一样走 HTTP/1.1。', + fingerprintInstallation: '安装 ID', + fingerprintResetInstallation: '同时重新生成安装 ID', fingerprintDevice: '设备 ID', fingerprintResetDevice: '同时重新生成设备 ID', fingerprintResetDeviceHint: '上游会把此账号视为在一台新设备上登录。', From 4d8615111cef2a6034c3b425df81296d1fa4de1f Mon Sep 17 00:00:00 2001 From: Alumos <1281610535@qq.com> Date: Thu, 12 Nov 2026 23:45:48 +0800 Subject: [PATCH 2/2] fix: non-blocking overload peek, Codex convergence on by default - Peek at most 100 ms for an in-stream overload rejection so slow streams keep keepalive and idle-timeout behavior (fixes hanging server tests). - Codex accounts converge identifiers in session mode by default, seeding existing accounts in migration 039 and new ones on first use; session mode keeps one prompt cache key per real conversation. - Dial the TLS fingerprint test by name so SNI is sent. Co-Authored-By: Claude Opus 5.5 --- PRODUCT.md | 2 +- internal/accounts/fingerprint.go | 18 ++- internal/accounts/refresh.go | 5 +- .../migrations/039_client_identity.sql | 11 +- internal/upstream/codex_identity.go | 119 ++++++++++++++---- internal/upstream/identity_test.go | 7 +- web/src/locales/ar.json | 4 +- web/src/locales/de.json | 4 +- web/src/locales/en.ts | 4 +- web/src/locales/es.json | 4 +- web/src/locales/fr.json | 4 +- web/src/locales/ja.json | 4 +- web/src/locales/ko.json | 4 +- web/src/locales/pt.json | 4 +- web/src/locales/ru.json | 4 +- web/src/locales/zh-TW.json | 4 +- web/src/locales/zh.ts | 4 +- 17 files changed, 149 insertions(+), 57 deletions(-) diff --git a/PRODUCT.md b/PRODUCT.md index 8f190419..12170283 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -111,7 +111,7 @@ Resource groups also offer account failover, enabled by default for new and exis Each upstream account presents one client fingerprint, following CLIProxyAPI; different accounts may present different fingerprints. The profile is stored on the account (`client_profile`) and is learned from the first native client that uses it: Codex keeps that client's User-Agent platform and terminal, and Claude keeps the operating system and architecture its Claude Code client reports in X-Stainless headers. Every later client, on any machine or tool, presents the pinned values. Client versions stay under SubLane's version policy: Codex uses the managed Codex version, and Claude runtime and SDK versions follow the SDK's measured baseline for the managed Claude Code release. Claude accounts also keep one durable device ID, and the SDK's device-profile stabilization keeps the remaining Claude headers consistent per account. Administrators can view, set or clear an account's fingerprint from the account menu (validated values only; empty fields fall back to learning or defaults) and can give a Claude account a new device ID; changes are audited. -Identity hardening follows sub2api's production-tested rules. Codex requests always present one self-consistent client: originator, User-Agent product and `Version` agree and follow the managed Codex release (the backend rejects mismatches and versions below 0.144.0), and accounts that have not learned a native client present the canonical `(Ubuntu 22.4.0; x86_64) xterm-256color` platform instead of a platform-less agent. The identity headers native clients send (installation, window and turn-state headers and turn metadata) are forwarded, and every identifier carrier is kept consistent with the outgoing session: the `Session_id` header, turn metadata, `client_metadata` and `prompt_cache_key`. Codex accounts can opt in to identifier convergence (`device`: one installation ID; `session`: also one session ID with one thread per real conversation; `full`: one thread), derived from a per-account random seed that a device reset rotates. Convergence is off by default because sub2api users reported shrinking quotas when it was on by default. When the Codex backend sheds load by client identity it answers HTTP 200 with `server_is_overloaded` or `slow_down` as the first stream event; SubLane detects that before anything reaches the client and turns it into a retryable 503 that the gateway retries and, through cooldowns, fails over. Claude accounts can opt in to Claude Code's TLS fingerprint: connections, including through HTTP or SOCKS5 proxies, perform a Node.js 24 ClientHello (JA3 `44f88fca027f27bab4bb08d4af15f23e`, as captured by sub2api) over HTTP/1.1 using uTLS. +Identity hardening follows sub2api's production-tested rules. Codex requests always present one self-consistent client: originator, User-Agent product and `Version` agree and follow the managed Codex release (the backend rejects mismatches and versions below 0.144.0), and accounts that have not learned a native client present the canonical `(Ubuntu 22.4.0; x86_64) xterm-256color` platform instead of a platform-less agent. The identity headers native clients send (installation, window and turn-state headers and turn metadata) are forwarded, and every identifier carrier is kept consistent with the outgoing session: the `Session_id` header, turn metadata, `client_metadata` and `prompt_cache_key`. Codex accounts converge identifiers by default in `session` mode (one installation ID and one session ID, with one thread per real conversation), sub2api's recommended non-off mode; administrators can switch an account to `off`, `device` (one installation ID only) or `full` (one thread). Identifiers derive from a per-account random seed that a device reset rotates. sub2api users reported shrinking quotas with convergence; collapsing every conversation into one prompt cache key is a likely cause, so `session` mode keeps one `prompt_cache_key` per real conversation (its thread) and only `full` mode collapses it. When the Codex backend sheds load by client identity it answers HTTP 200 with `server_is_overloaded` or `slow_down` as the first stream event; SubLane checks the first stream events for at most 100 ms (so slow-starting streams keep their keepalive and idle-timeout policies), and when the rejection is there, before anything reaches the client, turns it into a retryable 503 that the gateway retries and, through cooldowns, fails over. Claude accounts can opt in to Claude Code's TLS fingerprint: connections, including through HTTP or SOCKS5 proxies, perform a Node.js 24 ClientHello (JA3 `44f88fca027f27bab4bb08d4af15f23e`, as captured by sub2api) over HTTP/1.1 using uTLS. Provider requests whose connection fails before any byte is sent (DNS, TCP dial or proxy CONNECT) are retried up to twice after 250 ms and 1 s on the same account; failures after a request may have reached the provider are never replayed. diff --git a/internal/accounts/fingerprint.go b/internal/accounts/fingerprint.go index e01d6102..aa2ad206 100644 --- a/internal/accounts/fingerprint.go +++ b/internal/accounts/fingerprint.go @@ -192,12 +192,28 @@ func identityOf(row db.Account) ClientIdentity { if row.FingerprintSeed != nil { identity.Seed = *row.FingerprintSeed } - if !convergenceModes[identity.Mode] { + // Convergence only exists for Codex identifiers; other providers always report off. + if !convergenceModes[identity.Mode] || row.Provider != "codex" { identity.Mode = "off" } return identity } +// ensureSeed gives a converging Codex account its seed on first use, so every creation path +// (authorization, import, restore) converges without a separate setup step. +func (s *Service) ensureSeed(ctx context.Context, row db.Account, identity ClientIdentity) (ClientIdentity, error) { + if row.Provider != "codex" || identity.Mode == "off" || identity.Seed != "" { + return identity, nil + } + seed, err := newSeed() + if err != nil { + return identity, err + } + identity.Seed = seed + _, err = s.queries.SetClientIdentity(ctx, db.SetClientIdentityParams{ID: row.ID, TenantID: s.tenantID, FingerprintMode: row.FingerprintMode, FingerprintSeed: &seed, TlsFingerprint: row.TlsFingerprint, UpdatedAt: row.UpdatedAt}) + return identity, err +} + func newSeed() (string, error) { raw := make([]byte, 16) if _, err := rand.Read(raw); err != nil { diff --git a/internal/accounts/refresh.go b/internal/accounts/refresh.go index 46d92c95..ec94b25d 100644 --- a/internal/accounts/refresh.go +++ b/internal/accounts/refresh.go @@ -73,7 +73,10 @@ func (s *Service) prepare(ctx context.Context, id, rejectedToken string, refresh return Credential{}, err } } - credential.Profile, credential.Identity = decodeProfile(row.ClientProfile), identityOf(row) + credential.Profile = decodeProfile(row.ClientProfile) + if credential.Identity, err = s.ensureSeed(ctx, row, identityOf(row)); err != nil { + return Credential{}, err + } if row.ProxyID != nil { credential.ProxyURL, err = s.proxyURL(ctx, *row.ProxyID) if err != nil { diff --git a/internal/storage/migrations/039_client_identity.sql b/internal/storage/migrations/039_client_identity.sql index ead82e67..aa50b755 100644 --- a/internal/storage/migrations/039_client_identity.sql +++ b/internal/storage/migrations/039_client_identity.sql @@ -1,6 +1,9 @@ --- Opt-in identity hardening per account, following sub2api. fingerprint_mode converges Codex device --- and session identifiers (off, device, session, full); fingerprint_seed is the system-managed random --- seed they derive from; tls_fingerprint makes Claude connections present Claude Code's TLS handshake. -ALTER TABLE accounts ADD COLUMN fingerprint_mode TEXT NOT NULL DEFAULT 'off' CHECK(fingerprint_mode IN ('off','device','session','full')); +-- Identity hardening per account, following sub2api. fingerprint_mode converges Codex device and +-- session identifiers (off, device, session, full) and defaults to session, sub2api's recommended +-- non-off mode; fingerprint_seed is the system-managed random seed converged identifiers derive from; +-- tls_fingerprint makes Claude connections present Claude Code's TLS handshake (opt-in). +ALTER TABLE accounts ADD COLUMN fingerprint_mode TEXT NOT NULL DEFAULT 'session' CHECK(fingerprint_mode IN ('off','device','session','full')); ALTER TABLE accounts ADD COLUMN fingerprint_seed TEXT CHECK(fingerprint_seed IS NULL OR length(fingerprint_seed) = 36); ALTER TABLE accounts ADD COLUMN tls_fingerprint INTEGER NOT NULL DEFAULT 0 CHECK(tls_fingerprint IN (0,1)); +-- Existing Codex accounts get their own seed now; newer accounts are seeded on first use. +UPDATE accounts SET fingerprint_seed = lower(hex(randomblob(18))) WHERE provider = 'codex'; diff --git a/internal/upstream/codex_identity.go b/internal/upstream/codex_identity.go index 2012da29..f20a3bf3 100644 --- a/internal/upstream/codex_identity.go +++ b/internal/upstream/codex_identity.go @@ -9,6 +9,7 @@ import ( "io" "net/http" "strings" + "sync" "time" "unicode/utf16" @@ -24,10 +25,15 @@ import ( type codexIdentity struct { headers http.Header fields map[string]any - // session is the outgoing session ID that prompt_cache_key and client_metadata must match. + // session is the outgoing session ID that client_metadata and turn metadata must match. session string - mode string - ids map[string]string + // cacheKey is the outgoing prompt_cache_key: the conversation's thread when convergence keeps + // one, otherwise the session. Session mode deliberately keeps one cache key per real + // conversation: collapsing every conversation into one key (as full mode does) defeats the + // provider's prompt cache and spends more uncached tokens. + cacheKey string + mode string + ids map[string]string } // Identity headers Codex sends on Responses requests; everything else from the client is dropped. @@ -63,11 +69,15 @@ func newCodexIdentity(credential accounts.Credential, client http.Header) *codex id.fields["thread_id"], id.fields["turn_id"], id.fields["window_id"] = thread, turn, window id.fields["turn_started_at_unix_ms"] = time.Now().UnixMilli() id.ids["thread_id"], id.ids["turn_id"], id.ids["x-codex-window-id"] = thread, turn, window + id.cacheKey = thread } } if conversation != "" { id.headers.Set("Session_id", conversation) id.fields["session_id"], id.ids["session_id"], id.session = conversation, conversation, conversation + if id.cacheKey == "" { + id.cacheKey = conversation + } } if raw := id.headers.Get("X-Codex-Turn-Metadata"); raw != "" { if rebuilt, ok := rewriteTurnMetadata(raw, id.fields); ok { @@ -104,9 +114,9 @@ func (id *codexIdentity) rewriteBody(body []byte) []byte { return body } changed := false - // Native Codex sends its session ID as prompt_cache_key. - if id.session != "" && len(input["prompt_cache_key"]) > 0 { - input["prompt_cache_key"], _ = json.Marshal(id.session) + // Native Codex sends its conversation ID as prompt_cache_key. + if id.cacheKey != "" && len(input["prompt_cache_key"]) > 0 { + input["prompt_cache_key"], _ = json.Marshal(id.cacheKey) changed = true } var metadata map[string]any @@ -172,16 +182,86 @@ func randomUUID() string { // When capacity is short, the Codex backend sheds load by client identity: it answers HTTP 200 and // the stream's first real event reports server_is_overloaded (or slow_down). Nothing has reached the // client yet, so the response becomes a retryable 503 that the gateway retries and fails over. +// +// The rejection arrives with the response headers, so the peek waits at most overloadPeekWindow: +// a stream that is merely slow to start reaches the gateway at once, keeping its keepalive and +// idle-timeout policies intact. const overloadPeekLimit = 64 << 10 +var overloadPeekWindow = 100 * time.Millisecond + var codexOverloadCodes = map[string]bool{"server_is_overloaded": true, "slow_down": true} +type streamChunk struct { + data []byte + err error +} + +// peekedBody replays what the peek consumed, then continues from the background reader. +type peekedBody struct { + pending []byte + err error + chunks <-chan streamChunk + stop chan struct{} + once sync.Once + body io.Closer +} + +func (b *peekedBody) Read(p []byte) (int, error) { + for len(b.pending) == 0 { + if b.err != nil { + return 0, b.err + } + chunk := <-b.chunks + b.pending, b.err = chunk.data, chunk.err + } + n := copy(p, b.pending) + b.pending = b.pending[n:] + return n, nil +} + +func (b *peekedBody) Close() error { + b.once.Do(func() { close(b.stop) }) + return b.body.Close() +} + func detectCodexOverload(response *http.Response) (*http.Response, bool) { - reader := bufio.NewReaderSize(response.Body, 4096) + chunks, stop := make(chan streamChunk), make(chan struct{}) + go func(reader *bufio.Reader) { + for { + line, err := reader.ReadBytes('\n') + select { + case chunks <- streamChunk{line, err}: + case <-stop: + return + } + if err != nil { + return + } + } + }(bufio.NewReaderSize(response.Body, 4096)) + body := &peekedBody{chunks: chunks, stop: stop, body: response.Body} var seen bytes.Buffer + timer := time.NewTimer(overloadPeekWindow) + defer timer.Stop() + finish := func() (*http.Response, bool) { + body.pending = seen.Bytes() + response.Body = body + return response, false + } for seen.Len() < overloadPeekLimit { - line, err := reader.ReadBytes('\n') - seen.Write(line) + var line []byte + select { + case chunk := <-chunks: + seen.Write(chunk.data) + if chunk.err != nil { + body.err = chunk.err + return finish() + } + line = chunk.data + case <-timer.C: + return finish() + } if data, ok := bytes.CutPrefix(bytes.TrimSpace(line), []byte("data:")); ok { var event struct { Type string `json:"type"` @@ -201,29 +281,16 @@ func detectCodexOverload(response *http.Response) (*http.Response, bool) { // Handshake events carry nothing the client needs before the first real event. case "error", "response.failed": if codexOverloadCodes[event.Code] || codexOverloadCodes[event.Error.Code] || codexOverloadCodes[event.Response.Error.Code] { - response.Body.Close() + body.Close() return &http.Response{StatusCode: http.StatusServiceUnavailable, Status: "503 Service Unavailable", Header: http.Header{"Retry-After": {"1"}, "Content-Type": {"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"error":{"code":"server_is_overloaded"}}`)), Request: response.Request}, true } - return replayBody(response, &seen, reader), false + return finish() default: - return replayBody(response, &seen, reader), false + return finish() } } } - if err != nil { - break - } } - return replayBody(response, &seen, reader), false -} - -func replayBody(response *http.Response, seen *bytes.Buffer, rest io.Reader) *http.Response { - response.Body = &replayedBody{Reader: io.MultiReader(bytes.NewReader(seen.Bytes()), rest), Closer: response.Body} - return response -} - -type replayedBody struct { - io.Reader - io.Closer + return finish() } diff --git a/internal/upstream/identity_test.go b/internal/upstream/identity_test.go index 95323149..13cdfa1e 100644 --- a/internal/upstream/identity_test.go +++ b/internal/upstream/identity_test.go @@ -84,7 +84,8 @@ func TestCodexIdentityConvergesEveryCarrier(t *testing.T) { if metadata["session_id"] != session || metadata["thread_id"] != thread || metadata["window_id"] != thread+":0" || metadata["turn_id"] == "" { t.Fatal("turn metadata not converged", metadata) } - if !strings.Contains(body, `"prompt_cache_key":"`+session+`"`) || !strings.Contains(body, `"thread_id":"`+thread+`"`) { + // Each real conversation keeps its own cache key (its thread); only full mode collapses it. + if !strings.Contains(body, `"prompt_cache_key":"`+thread+`"`) || !strings.Contains(body, `"thread_id":"`+thread+`"`) { t.Fatal("body session identifiers not converged", body) } }) @@ -153,7 +154,9 @@ func captureHello(t *testing.T) (string, <-chan *tls.ClientHelloInfo) { }} server.StartTLS() t.Cleanup(server.Close) - return server.Listener.Addr().String(), hellos + // TLS never sends SNI for an IP address, so dial by name as a real client does. + _, port, _ := net.SplitHostPort(server.Listener.Addr().String()) + return net.JoinHostPort("localhost", port), hellos } func TestClaudeTLSFingerprintMatchesClaudeCode(t *testing.T) { diff --git a/web/src/locales/ar.json b/web/src/locales/ar.json index c0c02338..a92516ca 100644 --- a/web/src/locales/ar.json +++ b/web/src/locales/ar.json @@ -615,13 +615,13 @@ "fingerprintConvergence": "توحيد المعرّفات", "fingerprintModeOff": "متوقف", "fingerprintModeDevice": "الجهاز", - "fingerprintModeSession": "الجلسة", + "fingerprintModeSession": "الجلسة (افتراضي)", "fingerprintModeFull": "كامل", "fingerprintModeOffHint": "يحتفظ كل عميل بمعرّفات التثبيت والجلسة والسلسلة الخاصة به (يواصل SubLane عزل الجلسات لكل عضو).", "fingerprintModeDeviceHint": "يتشارك جميع العملاء معرّف تثبيت واحدًا، فيرى المزوّد جهازًا واحدًا بعدة جلسات.", "fingerprintModeSessionHint": "جهاز واحد وجلسة واحدة؛ تحتفظ كل محادثة حقيقية بسلسلتها، كأن مستخدمًا واحدًا يفتح عدة نوافذ أو وكلاء فرعيين.", "fingerprintModeFullHint": "جهاز واحد وجلسة واحدة وسلسلة واحدة لكل العملاء. أكثر الأوضاع تشددًا.", - "fingerprintConvergenceWarning": "الإيقاف الافتراضي له سبب: عندما فعّل sub2api التوحيد افتراضيًا أبلغ مستخدمون عن تقلص الحصص. فعّله أولاً على حساب واحد وراقب حصته.", + "fingerprintConvergenceWarning": "التوحيد مفعّل افتراضيًا (وضع الجلسة). أبلغ مستخدمو sub2api عن تقلص الحصص مع التوحيد، والسبب المرجّح هو مفتاح ذاكرة تخزين مؤقت واحد لكل المحادثات؛ لذا يحتفظ وضع الجلسة بمفتاح لكل محادثة حقيقية، ولا يدمجها إلا الوضع الكامل. إذا استُهلكت حصة حساب بسرعة غير معتادة فغيّره إلى الجهاز أو متوقف.", "fingerprintTLS": "بصمة TLS الخاصة بـ Claude Code", "fingerprintTLSHint": "تستخدم الاتصالات مصافحة TLS الخاصة بـ Claude Code على Node.js 24 (JA3 44f88fca…) بدلاً من Go، عبر HTTP/1.1 مثل العميل الحقيقي.", "fingerprintInstallation": "معرّف التثبيت", diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 596893e6..1adb1b9f 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -615,13 +615,13 @@ "fingerprintConvergence": "Kennungs-Konvergenz", "fingerprintModeOff": "Aus", "fingerprintModeDevice": "Gerät", - "fingerprintModeSession": "Sitzung", + "fingerprintModeSession": "Sitzung (Standard)", "fingerprintModeFull": "Vollständig", "fingerprintModeOffHint": "Jeder Client behält seine eigenen Installations-, Sitzungs- und Thread-Kennungen (SubLane trennt Sitzungen weiterhin pro Mitglied).", "fingerprintModeDeviceHint": "Alle Clients teilen eine Installations-ID, der Anbieter sieht ein Gerät mit mehreren Sitzungen.", "fingerprintModeSessionHint": "Ein Gerät und eine Sitzung; jede echte Unterhaltung behält ihren eigenen Thread, wie ein Nutzer mit mehreren Fenstern oder Subagenten.", "fingerprintModeFullHint": "Ein Gerät, eine Sitzung und ein Thread für alle Clients. Der aggressivste Modus.", - "fingerprintConvergenceWarning": "Standardmäßig aus, aus gutem Grund: Als sub2api die Konvergenz standardmäßig aktivierte, meldeten Nutzer schrumpfende Kontingente. Zuerst bei einem Konto aktivieren und dessen Kontingent beobachten.", + "fingerprintConvergenceWarning": "Konvergenz ist standardmäßig aktiv (Sitzungsmodus). sub2api-Nutzer meldeten mit Konvergenz schrumpfende Kontingente; eine wahrscheinliche Ursache ist ein einziger Prompt-Cache-Schlüssel für alle Unterhaltungen. Der Sitzungsmodus behält daher pro echter Unterhaltung einen Schlüssel, nur der vollständige Modus fasst sie zusammen. Sinkt das Kontingent eines Kontos ungewöhnlich schnell, auf Gerät oder Aus stellen.", "fingerprintTLS": "Claude-Code-TLS-Fingerabdruck", "fingerprintTLSHint": "Verbindungen nutzen den TLS-Handshake von Claude Code unter Node.js 24 (JA3 44f88fca…) statt dem von Go, über HTTP/1.1 wie der echte Client.", "fingerprintInstallation": "Installations-ID", diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index 7aba02fe..dee97385 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -948,7 +948,7 @@ export const en = { fingerprintConvergence: 'Identifier convergence', fingerprintModeOff: 'Off', fingerprintModeDevice: 'Device', - fingerprintModeSession: 'Session', + fingerprintModeSession: 'Session (default)', fingerprintModeFull: 'Full', fingerprintModeOffHint: 'Each client keeps its own installation, session and thread identifiers (SubLane still isolates sessions per member).', @@ -959,7 +959,7 @@ export const en = { fingerprintModeFullHint: 'One device, one session and one thread for every client. The most aggressive mode.', fingerprintConvergenceWarning: - 'Off by default for a reason: when sub2api enabled convergence by default, users reported shrinking quotas. Turn it on for one account first and watch its quota.', + 'Convergence is on by default (session mode). sub2api users reported shrinking quotas with convergence; a likely cause is one prompt cache key for every conversation, so session mode keeps one cache key per real conversation and only full mode collapses it. If an account’s quota drains unusually fast, switch it to device or off.', fingerprintTLS: 'Claude Code TLS fingerprint', fingerprintTLSHint: 'Connections present the TLS handshake of Claude Code on Node.js 24 (JA3 44f88fca…) instead of Go’s, over HTTP/1.1 like the real client.', diff --git a/web/src/locales/es.json b/web/src/locales/es.json index cef1f3e0..45493596 100644 --- a/web/src/locales/es.json +++ b/web/src/locales/es.json @@ -615,13 +615,13 @@ "fingerprintConvergence": "Convergencia de identificadores", "fingerprintModeOff": "Desactivada", "fingerprintModeDevice": "Dispositivo", - "fingerprintModeSession": "Sesión", + "fingerprintModeSession": "Sesión (predeterminado)", "fingerprintModeFull": "Completa", "fingerprintModeOffHint": "Cada cliente conserva sus propios identificadores de instalación, sesión e hilo (SubLane sigue aislando las sesiones por miembro).", "fingerprintModeDeviceHint": "Todos los clientes comparten un ID de instalación, así que el proveedor ve un dispositivo con varias sesiones.", "fingerprintModeSessionHint": "Un dispositivo y una sesión; cada conversación real conserva su propio hilo, como un usuario con varias ventanas o subagentes.", "fingerprintModeFullHint": "Un dispositivo, una sesión y un hilo para todos los clientes. El modo más agresivo.", - "fingerprintConvergenceWarning": "Está desactivada por defecto por un motivo: cuando sub2api la activó por defecto, hubo informes de cuotas reducidas. Actívala primero en una cuenta y vigila su cuota.", + "fingerprintConvergenceWarning": "La convergencia está activada por defecto (modo sesión). Usuarios de sub2api informaron cuotas reducidas con convergencia; una causa probable es una sola clave de caché de prompt para todas las conversaciones, por eso el modo sesión mantiene una clave por conversación real y solo el modo completo las une. Si la cuota de una cuenta baja demasiado rápido, cámbiala a dispositivo o desactivada.", "fingerprintTLS": "Huella TLS de Claude Code", "fingerprintTLSHint": "Las conexiones presentan el handshake TLS de Claude Code sobre Node.js 24 (JA3 44f88fca…) en lugar del de Go, por HTTP/1.1 como el cliente real.", "fingerprintInstallation": "ID de instalación", diff --git a/web/src/locales/fr.json b/web/src/locales/fr.json index e1f2200c..aaac414d 100644 --- a/web/src/locales/fr.json +++ b/web/src/locales/fr.json @@ -615,13 +615,13 @@ "fingerprintConvergence": "Convergence des identifiants", "fingerprintModeOff": "Désactivée", "fingerprintModeDevice": "Appareil", - "fingerprintModeSession": "Session", + "fingerprintModeSession": "Session (par défaut)", "fingerprintModeFull": "Complète", "fingerprintModeOffHint": "Chaque client conserve ses propres identifiants d’installation, de session et de fil (SubLane isole toujours les sessions par membre).", "fingerprintModeDeviceHint": "Tous les clients partagent un identifiant d’installation : le fournisseur voit un appareil avec plusieurs sessions.", "fingerprintModeSessionHint": "Un appareil et une session ; chaque conversation réelle garde son propre fil, comme un utilisateur avec plusieurs fenêtres ou sous-agents.", "fingerprintModeFullHint": "Un appareil, une session et un fil pour tous les clients. Le mode le plus agressif.", - "fingerprintConvergenceWarning": "Désactivée par défaut pour une raison : quand sub2api l’a activée par défaut, des utilisateurs ont signalé des quotas réduits. Activez-la d’abord sur un compte et surveillez son quota.", + "fingerprintConvergenceWarning": "La convergence est activée par défaut (mode session). Des utilisateurs de sub2api ont signalé des quotas réduits avec la convergence ; une cause probable est une seule clé de cache de prompt pour toutes les conversations. Le mode session garde donc une clé par conversation réelle et seul le mode complet les fusionne. Si le quota d’un compte baisse anormalement vite, passez-le en appareil ou désactivé.", "fingerprintTLS": "Empreinte TLS de Claude Code", "fingerprintTLSHint": "Les connexions présentent la poignée de main TLS de Claude Code sous Node.js 24 (JA3 44f88fca…) au lieu de celle de Go, en HTTP/1.1 comme le vrai client.", "fingerprintInstallation": "ID d’installation", diff --git a/web/src/locales/ja.json b/web/src/locales/ja.json index 76534c56..465c9e7a 100644 --- a/web/src/locales/ja.json +++ b/web/src/locales/ja.json @@ -615,13 +615,13 @@ "fingerprintConvergence": "識別子の収束", "fingerprintModeOff": "オフ", "fingerprintModeDevice": "デバイス", - "fingerprintModeSession": "セッション", + "fingerprintModeSession": "セッション(既定)", "fingerprintModeFull": "フル", "fingerprintModeOffHint": "各クライアントが自分のインストール ID、セッション ID、スレッド ID を保持します(SubLane はメンバーごとのセッション分離を続けます)。", "fingerprintModeDeviceHint": "全クライアントが 1 つのインストール ID を共有し、上流からは 1 台のデバイスで複数セッションに見えます。", "fingerprintModeSessionHint": "1 台のデバイスと 1 つのセッション。実際の会話ごとに独自のスレッドを持ち、1 人が複数ウィンドウやサブエージェントを使う形に見えます。", "fingerprintModeFullHint": "全クライアントで 1 台のデバイス、1 つのセッション、1 つのスレッド。最も強いモードです。", - "fingerprintConvergenceWarning": "既定でオフなのには理由があります。sub2api が収束を既定で有効にした際、クォータが減ったという報告がありました。まず 1 つのアカウントで有効にしてクォータを確認してください。", + "fingerprintConvergenceWarning": "収束は既定で有効です(セッションモード)。sub2api のユーザーから収束時にクォータが減ったという報告があり、全会話で 1 つのプロンプトキャッシュキーを使うことが原因と考えられます。そのためセッションモードは実際の会話ごとにキャッシュキーを分け、フルモードだけがまとめます。クォータの減りが異常に速い場合はデバイスまたはオフにしてください。", "fingerprintTLS": "Claude Code の TLS フィンガープリント", "fingerprintTLSHint": "接続時に Go ではなく Node.js 24 上の Claude Code の TLS ハンドシェイク(JA3 44f88fca…)を使い、実クライアントと同じく HTTP/1.1 で通信します。", "fingerprintInstallation": "インストール ID", diff --git a/web/src/locales/ko.json b/web/src/locales/ko.json index 52730d1f..8e99926f 100644 --- a/web/src/locales/ko.json +++ b/web/src/locales/ko.json @@ -615,13 +615,13 @@ "fingerprintConvergence": "식별자 수렴", "fingerprintModeOff": "끔", "fingerprintModeDevice": "기기", - "fingerprintModeSession": "세션", + "fingerprintModeSession": "세션(기본)", "fingerprintModeFull": "전체", "fingerprintModeOffHint": "각 클라이언트가 자체 설치 ID, 세션 ID, 스레드 ID를 유지합니다(SubLane은 계속 구성원별로 세션을 분리합니다).", "fingerprintModeDeviceHint": "모든 클라이언트가 설치 ID 하나를 공유하여 업스트림에는 기기 하나에 세션 여러 개로 보입니다.", "fingerprintModeSessionHint": "기기 하나와 세션 하나. 실제 대화마다 고유한 스레드를 유지해, 한 사용자가 여러 창이나 하위 에이전트를 쓰는 것처럼 보입니다.", "fingerprintModeFullHint": "모든 클라이언트가 기기 하나, 세션 하나, 스레드 하나를 씁니다. 가장 강한 모드입니다.", - "fingerprintConvergenceWarning": "기본값이 꺼짐인 데는 이유가 있습니다. sub2api가 수렴을 기본으로 켰을 때 할당량이 줄었다는 보고가 있었습니다. 먼저 계정 하나에서 켜고 할당량을 지켜보세요.", + "fingerprintConvergenceWarning": "수렴은 기본으로 켜져 있습니다(세션 모드). sub2api 사용자들이 수렴 시 할당량이 줄었다고 보고했는데, 모든 대화가 하나의 프롬프트 캐시 키를 쓰는 것이 원인으로 보입니다. 그래서 세션 모드는 실제 대화마다 캐시 키를 따로 유지하고 전체 모드만 합칩니다. 계정 할당량이 비정상적으로 빨리 줄면 기기 또는 끔으로 바꾸세요.", "fingerprintTLS": "Claude Code TLS 지문", "fingerprintTLSHint": "연결할 때 Go 대신 Node.js 24에서 실행되는 Claude Code의 TLS 핸드셰이크(JA3 44f88fca…)를 사용하며, 실제 클라이언트처럼 HTTP/1.1로 통신합니다.", "fingerprintInstallation": "설치 ID", diff --git a/web/src/locales/pt.json b/web/src/locales/pt.json index 6784554b..be8bd47e 100644 --- a/web/src/locales/pt.json +++ b/web/src/locales/pt.json @@ -615,13 +615,13 @@ "fingerprintConvergence": "Convergência de identificadores", "fingerprintModeOff": "Desligada", "fingerprintModeDevice": "Dispositivo", - "fingerprintModeSession": "Sessão", + "fingerprintModeSession": "Sessão (padrão)", "fingerprintModeFull": "Completa", "fingerprintModeOffHint": "Cada cliente mantém seus próprios identificadores de instalação, sessão e thread (o SubLane continua isolando sessões por membro).", "fingerprintModeDeviceHint": "Todos os clientes compartilham um ID de instalação, então o provedor vê um dispositivo com várias sessões.", "fingerprintModeSessionHint": "Um dispositivo e uma sessão; cada conversa real mantém sua própria thread, como um usuário com várias janelas ou subagentes.", "fingerprintModeFullHint": "Um dispositivo, uma sessão e uma thread para todos os clientes. O modo mais agressivo.", - "fingerprintConvergenceWarning": "Desligada por padrão por um motivo: quando o sub2api a ativou por padrão, houve relatos de cotas reduzidas. Ative primeiro em uma conta e acompanhe a cota.", + "fingerprintConvergenceWarning": "A convergência vem ativada por padrão (modo sessão). Usuários do sub2api relataram cotas reduzidas com convergência; uma causa provável é uma única chave de cache de prompt para todas as conversas, então o modo sessão mantém uma chave por conversa real e só o modo completo as junta. Se a cota de uma conta cair rápido demais, mude para dispositivo ou desligada.", "fingerprintTLS": "Impressão TLS do Claude Code", "fingerprintTLSHint": "As conexões usam o handshake TLS do Claude Code no Node.js 24 (JA3 44f88fca…) em vez do Go, via HTTP/1.1 como o cliente real.", "fingerprintInstallation": "ID de instalação", diff --git a/web/src/locales/ru.json b/web/src/locales/ru.json index a7e1b884..d99e26cd 100644 --- a/web/src/locales/ru.json +++ b/web/src/locales/ru.json @@ -615,13 +615,13 @@ "fingerprintConvergence": "Сведение идентификаторов", "fingerprintModeOff": "Выкл.", "fingerprintModeDevice": "Устройство", - "fingerprintModeSession": "Сессия", + "fingerprintModeSession": "Сессия (по умолчанию)", "fingerprintModeFull": "Полное", "fingerprintModeOffHint": "Каждый клиент сохраняет свои идентификаторы установки, сессии и потока (SubLane по-прежнему изолирует сессии по участникам).", "fingerprintModeDeviceHint": "Все клиенты используют один ID установки, и провайдер видит одно устройство с несколькими сессиями.", "fingerprintModeSessionHint": "Одно устройство и одна сессия; каждый реальный диалог получает свой поток, как у пользователя с несколькими окнами или субагентами.", "fingerprintModeFullHint": "Одно устройство, одна сессия и один поток для всех клиентов. Самый агрессивный режим.", - "fingerprintConvergenceWarning": "По умолчанию выключено не случайно: когда sub2api включил сведение по умолчанию, пользователи сообщали об уменьшении квоты. Сначала включите на одном аккаунте и следите за квотой.", + "fingerprintConvergenceWarning": "Сведение включено по умолчанию (режим сессии). Пользователи sub2api сообщали об уменьшении квоты при сведении; вероятная причина — один ключ кэша промптов для всех диалогов. Поэтому режим сессии сохраняет отдельный ключ для каждого реального диалога, и только полный режим их объединяет. Если квота аккаунта тает слишком быстро, переключите его на «Устройство» или «Выкл.».", "fingerprintTLS": "TLS-отпечаток Claude Code", "fingerprintTLSHint": "Соединения используют TLS-рукопожатие Claude Code на Node.js 24 (JA3 44f88fca…) вместо Go и работают по HTTP/1.1, как настоящий клиент.", "fingerprintInstallation": "ID установки", diff --git a/web/src/locales/zh-TW.json b/web/src/locales/zh-TW.json index 11e6eea8..20f4d0d0 100644 --- a/web/src/locales/zh-TW.json +++ b/web/src/locales/zh-TW.json @@ -615,13 +615,13 @@ "fingerprintConvergence": "識別碼收斂", "fingerprintModeOff": "關閉", "fingerprintModeDevice": "裝置", - "fingerprintModeSession": "工作階段", + "fingerprintModeSession": "工作階段(預設)", "fingerprintModeFull": "完全", "fingerprintModeOffHint": "每個用戶端保留自己的安裝 ID、工作階段 ID 和執行緒 ID(SubLane 仍依成員隔離工作階段)。", "fingerprintModeDeviceHint": "所有用戶端共用一個安裝 ID,上游看到一台裝置、多個工作階段。", "fingerprintModeSessionHint": "一台裝置、一個工作階段;每個真實對話保留自己的執行緒,就像一個使用者開了多個視窗或子代理。", "fingerprintModeFullHint": "所有用戶端共用一台裝置、一個工作階段、一個執行緒,最激進的模式。", - "fingerprintConvergenceWarning": "預設關閉是有原因的:sub2api 曾把收斂設為預設開啟,之後有使用者回報額度縮水。建議先在一個帳號上開啟並觀察額度。", + "fingerprintConvergenceWarning": "收斂預設開啟(工作階段模式)。sub2api 使用者曾回報開啟收斂後額度縮水,可能原因是所有對話共用一個提示詞快取鍵;因此工作階段模式為每個真實對話保留獨立的快取鍵,只有完全模式才會合併。若某個帳號額度消耗異常快,可改為「裝置」或「關閉」。", "fingerprintTLS": "Claude Code TLS 指紋", "fingerprintTLSHint": "連線時使用 Node.js 24 上的 Claude Code 的 TLS 握手(JA3 44f88fca…),而非 Go 的握手,並像真實用戶端一樣使用 HTTP/1.1。", "fingerprintInstallation": "安裝 ID", diff --git a/web/src/locales/zh.ts b/web/src/locales/zh.ts index dc2b1def..d9059041 100644 --- a/web/src/locales/zh.ts +++ b/web/src/locales/zh.ts @@ -867,7 +867,7 @@ export const zh: Record = { fingerprintConvergence: '标识收敛', fingerprintModeOff: '关闭', fingerprintModeDevice: '设备', - fingerprintModeSession: '会话', + fingerprintModeSession: '会话(默认)', fingerprintModeFull: '完全', fingerprintModeOffHint: '每个客户端保留自己的安装 ID、会话 ID 和线程 ID(SubLane 仍按成员隔离会话)。', @@ -878,7 +878,7 @@ export const zh: Record = { fingerprintModeFullHint: '所有客户端共用一台设备、一个会话、一个线程,最激进的模式。', fingerprintConvergenceWarning: - '默认关闭是有原因的:sub2api 曾把收敛设为默认开启,随后有用户报告额度缩水。建议先在一个账号上开启并观察额度。', + '收敛默认开启(会话模式)。sub2api 用户曾报告开启收敛后额度缩水,可能原因是所有对话共用一个提示词缓存键;因此会话模式为每个真实对话保留独立的缓存键,只有完全模式才会合并。如果某个账号额度消耗异常快,可改为“设备”或“关闭”。', fingerprintTLS: 'Claude Code TLS 指纹', fingerprintTLSHint: '连接时使用 Node.js 24 上的 Claude Code 的 TLS 握手(JA3 44f88fca…),而不是 Go 的握手,并像真实客户端一样走 HTTP/1.1。',