feat(proxy): add proxy IP management and account routing

Add admin CRUD and connectivity checks for SOCKS5/SOCKS5H proxy nodes.

Allow accounts to bind to a proxy, route DeepSeek requests through the selected node, and expose proxy management in the admin UI.
This commit is contained in:
Jason.li
2026-04-07 02:05:25 +08:00
parent 1c95942e5d
commit 8ae2ea10c8
30 changed files with 1675 additions and 51 deletions

View File

@@ -20,6 +20,9 @@ func (c Config) MarshalJSON() ([]byte, error) {
if len(c.Accounts) > 0 {
m["accounts"] = c.Accounts
}
if len(c.Proxies) > 0 {
m["proxies"] = c.Proxies
}
if len(c.ClaudeMapping) > 0 {
m["claude_mapping"] = c.ClaudeMapping
}
@@ -70,6 +73,10 @@ func (c *Config) UnmarshalJSON(b []byte) error {
if err := json.Unmarshal(v, &c.Accounts); err != nil {
return fmt.Errorf("invalid field %q: %w", k, err)
}
case "proxies":
if err := json.Unmarshal(v, &c.Proxies); err != nil {
return fmt.Errorf("invalid field %q: %w", k, err)
}
case "claude_mapping":
if err := json.Unmarshal(v, &c.ClaudeMapping); err != nil {
return fmt.Errorf("invalid field %q: %w", k, err)
@@ -130,6 +137,7 @@ func (c Config) Clone() Config {
clone := Config{
Keys: slices.Clone(c.Keys),
Accounts: slices.Clone(c.Accounts),
Proxies: slices.Clone(c.Proxies),
ClaudeMapping: cloneStringMap(c.ClaudeMapping),
ClaudeModelMap: cloneStringMap(c.ClaudeModelMap),
ModelAliases: cloneStringMap(c.ModelAliases),

View File

@@ -1,8 +1,16 @@
package config
import (
"crypto/sha1"
"encoding/hex"
"fmt"
"strings"
)
type Config struct {
Keys []string `json:"keys,omitempty"`
Accounts []Account `json:"accounts,omitempty"`
Proxies []Proxy `json:"proxies,omitempty"`
ClaudeMapping map[string]string `json:"claude_mapping,omitempty"`
ClaudeModelMap map[string]string `json:"claude_model_mapping,omitempty"`
ModelAliases map[string]string `json:"model_aliases,omitempty"`
@@ -22,6 +30,38 @@ type Account struct {
Mobile string `json:"mobile,omitempty"`
Password string `json:"password,omitempty"`
Token string `json:"token,omitempty"`
ProxyID string `json:"proxy_id,omitempty"`
}
type Proxy struct {
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Type string `json:"type,omitempty"`
Host string `json:"host,omitempty"`
Port int `json:"port,omitempty"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
}
func NormalizeProxy(p Proxy) Proxy {
p.ID = strings.TrimSpace(p.ID)
p.Name = strings.TrimSpace(p.Name)
p.Type = strings.ToLower(strings.TrimSpace(p.Type))
p.Host = strings.TrimSpace(p.Host)
p.Username = strings.TrimSpace(p.Username)
p.Password = strings.TrimSpace(p.Password)
if p.ID == "" {
p.ID = StableProxyID(p)
}
if p.Name == "" && p.Host != "" && p.Port > 0 {
p.Name = fmt.Sprintf("%s:%d", p.Host, p.Port)
}
return p
}
func StableProxyID(p Proxy) string {
sum := sha1.Sum([]byte(strings.ToLower(strings.TrimSpace(p.Type)) + "|" + strings.ToLower(strings.TrimSpace(p.Host)) + "|" + fmt.Sprintf("%d", p.Port) + "|" + strings.TrimSpace(p.Username)))
return "proxy_" + hex.EncodeToString(sum[:6])
}
func (c *Config) ClearAccountTokens() {

View File

@@ -32,6 +32,47 @@ func TestLoadStoreClearsTokensFromConfigInput(t *testing.T) {
}
}
func TestLoadStorePreservesProxiesAndAccountProxyAssignment(t *testing.T) {
t.Setenv("DS2API_CONFIG_JSON", `{
"proxies":[
{
"id":"proxy-sh-1",
"name":"Shanghai Exit",
"type":"socks5h",
"host":"127.0.0.1",
"port":1080,
"username":"demo",
"password":"secret"
}
],
"accounts":[
{
"email":"u@example.com",
"password":"p",
"proxy_id":"proxy-sh-1"
}
]
}`)
store := LoadStore()
snap := store.Snapshot()
if len(snap.Proxies) != 1 {
t.Fatalf("expected 1 proxy, got %d", len(snap.Proxies))
}
if snap.Proxies[0].ID != "proxy-sh-1" {
t.Fatalf("unexpected proxy id: %#v", snap.Proxies[0])
}
if snap.Proxies[0].Type != "socks5h" {
t.Fatalf("unexpected proxy type: %#v", snap.Proxies[0])
}
if len(snap.Accounts) != 1 {
t.Fatalf("expected 1 account, got %d", len(snap.Accounts))
}
if snap.Accounts[0].ProxyID != "proxy-sh-1" {
t.Fatalf("expected account proxy assignment preserved, got %#v", snap.Accounts[0])
}
}
func TestLoadStoreDropsLegacyTokenOnlyAccounts(t *testing.T) {
t.Setenv("DS2API_CONFIG_JSON", `{
"accounts":[

View File

@@ -6,6 +6,9 @@ import (
)
func ValidateConfig(c Config) error {
if err := ValidateProxyConfig(c.Proxies); err != nil {
return err
}
if err := ValidateAdminConfig(c.Admin); err != nil {
return err
}
@@ -21,6 +24,55 @@ func ValidateConfig(c Config) error {
if err := ValidateAutoDeleteConfig(c.AutoDelete); err != nil {
return err
}
if err := ValidateAccountProxyReferences(c.Accounts, c.Proxies); err != nil {
return err
}
return nil
}
func ValidateProxyConfig(proxies []Proxy) error {
seen := make(map[string]struct{}, len(proxies))
for _, proxy := range proxies {
proxy = NormalizeProxy(proxy)
if err := ValidateTrimmedString("proxies.id", proxy.ID, true); err != nil {
return err
}
switch proxy.Type {
case "socks5", "socks5h":
default:
return fmt.Errorf("proxies.type must be one of socks5, socks5h")
}
if err := ValidateTrimmedString("proxies.host", proxy.Host, true); err != nil {
return err
}
if err := ValidateIntRange("proxies.port", proxy.Port, 1, 65535, true); err != nil {
return err
}
if _, ok := seen[proxy.ID]; ok {
return fmt.Errorf("duplicate proxy id: %s", proxy.ID)
}
seen[proxy.ID] = struct{}{}
}
return nil
}
func ValidateAccountProxyReferences(accounts []Account, proxies []Proxy) error {
if len(accounts) == 0 {
return nil
}
ids := make(map[string]struct{}, len(proxies))
for _, proxy := range proxies {
ids[NormalizeProxy(proxy).ID] = struct{}{}
}
for _, acc := range accounts {
proxyID := strings.TrimSpace(acc.ProxyID)
if proxyID == "" {
continue
}
if _, ok := ids[proxyID]; !ok {
return fmt.Errorf("account proxy_id references unknown proxy: %s", proxyID)
}
}
return nil
}