Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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.

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.
Expand Down
6 changes: 6 additions & 0 deletions THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion internal/accounts/credential.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
148 changes: 129 additions & 19 deletions internal/accounts/fingerprint.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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:
// "<originator>/<version> (<os> <os version>; <arch>) <terminal>[ (<suffix>)]".
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
// ("<originator>/<version> (<os_type> <os_version>; <arch>) <terminal>[ (<suffix>)]"). 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)$`)
Expand Down Expand Up @@ -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 {
Expand All @@ -149,19 +175,54 @@ 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
}
// 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 {
return "", err
}
return stableUUID(raw), nil
}

func (s *Service) changeFingerprint(ctx context.Context, id string, profile *ClientProfile, device bool, action string) (Fingerprint, error) {
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 {
Expand All @@ -179,7 +240,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
}
Expand All @@ -202,7 +263,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()
Expand Down Expand Up @@ -233,3 +319,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)
}
Loading