Files
nyoungo 86492460b0 feat: 工具调用解析、Responses 状态缓存、幂等重放与模型别名
- 重写工具调用解析:XML、DSML 未加引号键、fence 内跳过、参数外溢等形状,并按请求携带的 tools 过滤工具名
- 新增 GET /v1/responses/{id} 与 previous_response_id 有界状态缓存(进程内、TTL、store=false 不写入)
- /v1/chat/completions 支持 Idempotency-Key 幂等回放(1 MiB 上限、24h TTL、并发去重)
- 模型别名与 default_model 降级;错误体统一输出 param/code
- 补 e2e 修复场景与 docs/compat-audit.md 兼容性审计
2026-10-09 00:50:01 +08:00

979 lines
31 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package config
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/BurntSushi/toml"
)
const DefaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"
// Turnstile tuning defaults. DefaultSolveTimeoutSeconds is the single source
// for how long one solve attempt may run; the warm_* values drive the cookie
// pool (background pre-solving).
const (
DefaultSolveTimeoutSeconds = 90
DefaultWarmRatio = 0.3
DefaultWarmCheckSeconds = 60
)
// Turnstile token providers: api calls a hosted solver, browser drives a
// local Chrome/Edge over CDP, manual only ever uses imported cookies.
const (
ProviderAPI = "api"
ProviderBrowser = "browser"
ProviderManual = "manual"
)
type Turnstile struct {
Enabled bool `toml:"enabled" json:"enabled"`
Provider string `toml:"provider" json:"provider"`
APIURL string `toml:"api_url" json:"api_url"`
APIKey string `toml:"api_key" json:"api_key"`
SiteKey string `toml:"sitekey" json:"sitekey"`
Action string `toml:"action" json:"action"`
APIStyle string `toml:"api_style" json:"api_style"`
TimeoutSeconds int `toml:"timeout_seconds" json:"timeout_seconds"`
CookieTTLSeconds int `toml:"cookie_ttl_seconds" json:"cookie_ttl_seconds"`
Retries int `toml:"retries" json:"retries"`
RetryBackoffSeconds float64 `toml:"retry_backoff_seconds" json:"retry_backoff_seconds"`
// Cookie pool: a background loop re-solves a site × route slot before its
// cached cookie expires, so requests never pay the solve latency.
WarmEnabled bool `toml:"warm_enabled" json:"warm_enabled"`
WarmRatio float64 `toml:"warm_ratio" json:"warm_ratio"`
WarmCheckSeconds int `toml:"warm_check_seconds" json:"warm_check_seconds"`
// provider = "browser"
BrowserPath string `toml:"browser_path" json:"browser_path"`
BrowserHeadless bool `toml:"browser_headless" json:"browser_headless"`
BrowserUserDataDir string `toml:"browser_user_data_dir" json:"browser_user_data_dir"`
BrowserTimezone string `toml:"browser_timezone" json:"browser_timezone"`
BrowserLocale string `toml:"browser_locale" json:"browser_locale"`
}
// ProviderValue normalizes the token provider ("api" when unset).
func (t Turnstile) ProviderValue() string {
p := strings.ToLower(strings.TrimSpace(t.Provider))
if p == "" {
return ProviderAPI
}
return p
}
// APIStyleValue normalizes api_style for provider "api": "sync" (CapSolver-
// shaped single POST) or "ezsolver" (POST {sitekey,siteurl,timeout} → {token}).
func (t Turnstile) APIStyleValue() string {
switch strings.ToLower(strings.TrimSpace(t.APIStyle)) {
case "ezsolver", "ez":
return "ezsolver"
default:
return "sync"
}
}
// SolveTimeoutValue normalizes timeout_seconds: the longest one solve attempt
// may run (default 90s, clamped to 5–600s).
func (t Turnstile) SolveTimeoutValue() int {
n := t.TimeoutSeconds
if n <= 0 {
return DefaultSolveTimeoutSeconds
}
if n < 5 {
return 5
}
if n > 600 {
return 600
}
return n
}
// WarmRatioValue normalizes warm_ratio: a pooled cookie is re-solved once its
// remaining TTL drops below this fraction (default 30%, clamped 5–95%).
func (t Turnstile) WarmRatioValue() float64 {
r := t.WarmRatio
if r <= 0 {
return DefaultWarmRatio
}
if r < 0.05 {
return 0.05
}
if r > 0.95 {
return 0.95
}
return r
}
// WarmCheckValue normalizes warm_check_seconds: how often the pool is
// inspected (default 60s, clamped 5s–1h).
func (t Turnstile) WarmCheckValue() int {
n := t.WarmCheckSeconds
if n <= 0 {
return DefaultWarmCheckSeconds
}
if n < 5 {
return 5
}
if n > 3600 {
return 3600
}
return n
}
// Quota sentinel defaults: polling interval and the remaining-quota warning
// threshold (share of the daily free limit left).
const (
DefaultQuotaCheckSeconds = 300
DefaultQuotaWarnRatio = 0.2
)
// Responses store defaults: an in-process, bounded, TTL cache backs
// previous_response_id and GET /v1/responses/{id}.
const (
DefaultResponsesStoreCapacity = 256
DefaultResponsesStoreTTLSeconds = 3600
)
// ResponsesConfig tunes the Responses API stateful cache.
type ResponsesConfig struct {
StoreCapacity int `toml:"store_capacity" json:"store_capacity"`
StoreTTLSeconds int `toml:"store_ttl_seconds" json:"store_ttl_seconds"`
}
// StoreCapacityValue normalizes store_capacity (default 256, clamped 1–100000).
func (r ResponsesConfig) StoreCapacityValue() int {
n := r.StoreCapacity
if n <= 0 {
return DefaultResponsesStoreCapacity
}
if n > 100000 {
return 100000
}
return n
}
// StoreTTLValue normalizes store_ttl_seconds (default 1h, clamped 1s–7d).
func (r ResponsesConfig) StoreTTLValue() time.Duration {
n := r.StoreTTLSeconds
if n <= 0 {
n = DefaultResponsesStoreTTLSeconds
}
if n < 1 {
n = 1
}
if n > 7*24*3600 {
n = 7 * 24 * 3600
}
return time.Duration(n) * time.Second
}
// QuotaWatch configures the guest-token balance sentinel: it polls each
// site's balance endpoint with the pooled session (same egress and cookies as
// chat) and reports remaining daily quota to the console and logs.
type QuotaWatch struct {
Enabled bool `toml:"enabled" json:"enabled"`
CheckSeconds int `toml:"check_seconds" json:"check_seconds"`
WarnRatio float64 `toml:"warn_ratio" json:"warn_ratio"`
}
// CheckSecondsValue normalizes check_seconds (default 300s, clamped 30–3600).
func (q QuotaWatch) CheckSecondsValue() int {
n := q.CheckSeconds
if n <= 0 {
return DefaultQuotaCheckSeconds
}
if n < 30 {
return 30
}
if n > 3600 {
return 3600
}
return n
}
// WarnRatioValue normalizes warn_ratio: remaining/limit below this fraction
// raises a "quota low" warning (default 20%, clamped 5–95%).
func (q QuotaWatch) WarnRatioValue() float64 {
r := q.WarnRatio
if r <= 0 {
return DefaultQuotaWarnRatio
}
if r < 0.05 {
return 0.05
}
if r > 0.95 {
return 0.95
}
return r
}
type Site struct {
Code string `toml:"-"`
BaseURL string `toml:"base_url" json:"base_url"`
AJAXURL string `toml:"ajax_url" json:"ajax_url"`
SiteKey string `toml:"sitekey" json:"sitekey"`
VerifyAction string `toml:"verify_action" json:"verify_action"`
Language string `toml:"language" json:"language"`
Enabled bool `toml:"enabled" json:"enabled"`
// Proxies binds this site to the proxy pool: an ordered list of entry
// names and/or "sub:<name>" references. Empty = global [proxy] routes.
Proxies []string `toml:"proxies" json:"proxies"`
// WarmPoolOnly keeps the cookie warmer on the pool routes bound via
// Proxies; the global [proxy] primary/fallback lanes are not pre-solved
// (they remain available for request-time failover). Default false.
WarmPoolOnly bool `toml:"warm_pool_only" json:"warm_pool_only"`
}
// ProxyEntry is one manually configured pool node: an Xray share link
// (vless/vmess/trojan/ss) or a plain http/https/socks5 endpoint.
type ProxyEntry struct {
Link string `toml:"link" json:"link"`
Enabled bool `toml:"enabled" json:"enabled"`
}
// ProxySubscription is a remote node list refreshed on an interval.
type ProxySubscription struct {
URL string `toml:"url" json:"url"`
Enabled bool `toml:"enabled" json:"enabled"`
IntervalMinutes int `toml:"interval_minutes" json:"interval_minutes"`
}
// ProxyPool configures the egress pool and its managed Xray core.
type ProxyPool struct {
Enabled bool `toml:"enabled" json:"enabled"`
CheckIntervalSeconds int `toml:"check_interval_seconds" json:"check_interval_seconds"`
CheckTimeoutSeconds int `toml:"check_timeout_seconds" json:"check_timeout_seconds"`
CheckURL string `toml:"check_url" json:"check_url"`
DefaultScheme string `toml:"default_scheme" json:"default_scheme"`
XrayPath string `toml:"xray_path" json:"xray_path"`
XrayVersion string `toml:"xray_version" json:"xray_version"`
XrayAutoDownload bool `toml:"xray_auto_download" json:"xray_auto_download"`
Entries map[string]*ProxyEntry `toml:"entries" json:"entries"`
Subscriptions map[string]*ProxySubscription `toml:"subscriptions" json:"subscriptions"`
}
type Model struct {
ID string `toml:"-"`
Site string `toml:"site" json:"site"`
UpstreamID string `toml:"upstream_id" json:"upstream_id"`
Label string `toml:"label" json:"label"`
PagePath string `toml:"page_path" json:"page_path"`
BotID int `toml:"bot_id" json:"bot_id"`
PostID int `toml:"post_id" json:"post_id"`
Enabled bool `toml:"enabled" json:"enabled"`
}
type Config struct {
Server struct {
Host string `toml:"host" json:"host"`
Port int `toml:"port" json:"port"`
LogLevel string `toml:"log_level" json:"log_level"`
// LogFile: "" = <data_dir>/logs/dsfree2api.log, "-" = stderr only.
LogFile string `toml:"log_file" json:"log_file"`
} `toml:"server" json:"server"`
Security struct {
APIKeys []string `toml:"api_keys" json:"api_keys"`
} `toml:"security" json:"security"`
Admin struct {
Enabled bool `toml:"enabled" json:"enabled"`
Host string `toml:"host" json:"host"`
Port int `toml:"port" json:"port"`
Password string `toml:"password" json:"password"`
} `toml:"admin" json:"admin"`
Limits struct {
MaxConcurrentPerSite int `toml:"max_concurrent_per_site" json:"max_concurrent_per_site"`
RatePerMinute float64 `toml:"rate_per_minute" json:"rate_per_minute"`
} `toml:"limits" json:"limits"`
Proxy struct {
URL string `toml:"url" json:"url"`
FallbackURLs []string `toml:"fallback_urls" json:"fallback_urls"`
SlowStartSeconds float64 `toml:"slow_start_seconds" json:"slow_start_seconds"`
} `toml:"proxy" json:"proxy"`
ProxyPool ProxyPool `toml:"proxypool" json:"proxypool"`
// Quota is the balance sentinel: it polls each site's guest-token balance
// so the console shows how much daily free quota is left.
Quota QuotaWatch `toml:"quota" json:"quota"`
// Responses backs previous_response_id / GET /v1/responses/{id}.
Responses ResponsesConfig `toml:"responses" json:"responses"`
Upstream struct {
Timeout float64 `toml:"timeout" json:"timeout"`
StreamTimeout float64 `toml:"stream_timeout" json:"stream_timeout"`
ConfigTTLSeconds int `toml:"config_ttl_seconds" json:"config_ttl_seconds"`
AutoRefresh bool `toml:"auto_refresh" json:"auto_refresh"`
RefreshRetries int `toml:"refresh_retries" json:"refresh_retries"`
RetryBackoffSeconds float64 `toml:"retry_backoff_seconds" json:"retry_backoff_seconds"`
UserAgent string `toml:"user_agent" json:"user_agent"`
CrossSiteFailover bool `toml:"cross_site_failover" json:"cross_site_failover"`
ContinueRounds int `toml:"continue_rounds" json:"continue_rounds"`
} `toml:"upstream" json:"upstream"`
Runtime struct {
DataDir string `toml:"data_dir" json:"data_dir"`
} `toml:"runtime" json:"runtime"`
Turnstile Turnstile `toml:"turnstile" json:"turnstile"`
Sites map[string]*Site `toml:"sites" json:"sites"`
Models map[string]*Model `toml:"models" json:"models"`
// ModelAliases maps an arbitrary client-supplied model name (e.g. the
// gpt-* / claude-* names Codex CLI or Claude Code send) onto a configured
// model id. Looked up case-insensitively before DefaultModel kicks in.
ModelAliases map[string]string `toml:"model_aliases" json:"model_aliases"`
// DefaultModel is the fallback when neither Models nor ModelAliases
// matches a request. Empty disables the fallback (unknown model = 404).
DefaultModel string `toml:"default_model" json:"default_model"`
mu sync.RWMutex `toml:"-"`
path string `toml:"-"`
}
// DefaultProxyPool returns the built-in pool settings (feature off, Xray
// auto-download on).
func DefaultProxyPool() ProxyPool {
return ProxyPool{
CheckIntervalSeconds: 120,
CheckTimeoutSeconds: 10,
CheckURL: "https://www.gstatic.com/generate_204",
DefaultScheme: "http",
XrayAutoDownload: true,
Entries: map[string]*ProxyEntry{},
Subscriptions: map[string]*ProxySubscription{},
}
}
func DefaultTurnstile() Turnstile {
return Turnstile{
Enabled: false,
Provider: ProviderAPI,
APIURL: "",
APIKey: "",
SiteKey: "0x4AAAAAADlLZ3ljqZP6cQwq",
Action: "chat",
TimeoutSeconds: DefaultSolveTimeoutSeconds,
CookieTTLSeconds: 10800,
Retries: 5,
RetryBackoffSeconds: 1.5,
WarmRatio: DefaultWarmRatio,
WarmCheckSeconds: DefaultWarmCheckSeconds,
}
}
func DefaultSites(sitekey string) map[string]*Site {
return map[string]*Site{
"de": {
Code: "de", BaseURL: "https://deepseek.de", AJAXURL: "https://deepseek.de/wp-admin/admin-ajax.php",
SiteKey: sitekey, VerifyAction: "deepseek_ts_verify",
Language: "de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7", Enabled: true,
},
"es": {
Code: "es", BaseURL: "https://deepseek.es", AJAXURL: "https://deepseek.es/wp-admin/admin-ajax.php",
SiteKey: sitekey, VerifyAction: "deepseek_ts_verify",
Language: "es-ES,es;q=0.9,en-US;q=0.8,en;q=0.7", Enabled: true,
},
"fr": {
Code: "fr", BaseURL: "https://deepseek.fr", AJAXURL: "https://deepseek.fr/wp-admin/admin-ajax.php",
SiteKey: sitekey, VerifyAction: "deepseek_ts_verify",
Language: "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7", Enabled: true,
},
}
}
func DefaultModels() map[string]*Model {
rows := [][7]any{
{"deepseek-v4-flash-de", "de", "deepseek-v4-flash", "DeepSeek V4-Flash DE", "/", 27487, 106},
{"deepseek-v4-pro-de", "de", "deepseek-v4-pro", "DeepSeek V4-Pro DE", "/pro/", 27533, 27177},
{"deepseek-v4-flash-es", "es", "deepseek-v4-flash", "DeepSeek V4-Flash ES", "/", 27623, 27568},
{"deepseek-v4-pro-es", "es", "deepseek-v4-pro", "DeepSeek V4-Pro ES", "/pro/", 27637, 27615},
{"deepseek-v4-flash-fr", "fr", "deepseek-v4-flash", "DeepSeek V4-Flash FR", "/", 27645, 27569},
{"deepseek-v4-pro-fr", "fr", "deepseek-v4-pro", "DeepSeek V4-Pro FR", "/pro/", 27647, 27616},
}
out := make(map[string]*Model, len(rows))
for _, r := range rows {
id := r[0].(string)
out[id] = &Model{
ID: id, Site: r[1].(string), UpstreamID: r[2].(string), Label: r[3].(string),
PagePath: r[4].(string), BotID: r[5].(int), PostID: r[6].(int), Enabled: true,
}
}
return out
}
func newConfig() *Config {
c := &Config{}
c.Server.Host = "0.0.0.0"
c.Server.Port = 8000
c.Server.LogLevel = "INFO"
c.Admin.Enabled = true
c.Admin.Host = "127.0.0.1"
c.Admin.Port = 8001
c.Limits.MaxConcurrentPerSite = 4
c.Limits.RatePerMinute = 0
c.Proxy.SlowStartSeconds = 8
c.Upstream.Timeout = 60
c.Upstream.StreamTimeout = 300
c.Upstream.ConfigTTLSeconds = 60
c.Upstream.AutoRefresh = true
c.Upstream.RefreshRetries = 4
c.Upstream.RetryBackoffSeconds = 1
c.Upstream.UserAgent = DefaultUserAgent
c.Upstream.CrossSiteFailover = true
c.Upstream.ContinueRounds = 20
c.Runtime.DataDir = "./data"
c.ProxyPool = DefaultProxyPool()
c.Turnstile = DefaultTurnstile()
c.Responses.StoreCapacity = DefaultResponsesStoreCapacity
c.Responses.StoreTTLSeconds = DefaultResponsesStoreTTLSeconds
c.Sites = DefaultSites(c.Turnstile.SiteKey)
c.Models = DefaultModels()
return c
}
// Load reads the TOML config (if present), applies defaults, then env overrides.
func Load(path string) (*Config, error) {
cfg := newConfig()
if path == "" {
path = "config.toml"
}
cfg.path = path
var md toml.MetaData
if raw, err := os.ReadFile(path); err == nil {
md, err = toml.Decode(string(raw), cfg)
if err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
cfg.applyDefaults(&md)
} else if !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("read %s: %w", path, err)
} else {
cfg.applyDefaults(nil)
}
if err := cfg.applyEnv(); err != nil {
return nil, err
}
if cfg.Sites == nil {
cfg.Sites = DefaultSites(cfg.Turnstile.SiteKey)
}
if cfg.Models == nil {
cfg.Models = DefaultModels()
}
if err := cfg.Reindex(); err != nil {
return nil, err
}
return cfg, nil
}
// Reindex restores derived fields (site codes / model ids) and validates the
// site-model wiring. It is safe to call after mutating the config at runtime.
func (c *Config) Reindex() error {
if c.Sites == nil {
c.Sites = DefaultSites(c.Turnstile.SiteKey)
}
if c.Models == nil {
c.Models = DefaultModels()
}
for code, s := range c.Sites {
if s == nil {
return fmt.Errorf("site %q is null", code)
}
s.Code = code
if s.SiteKey == "" {
s.SiteKey = c.Turnstile.SiteKey
}
if s.VerifyAction == "" {
s.VerifyAction = "deepseek_ts_verify"
}
if s.AJAXURL == "" {
s.AJAXURL = strings.TrimRight(s.BaseURL, "/") + "/wp-admin/admin-ajax.php"
}
if s.BaseURL == "" {
return fmt.Errorf("site %q: base_url is required", code)
}
}
for id, m := range c.Models {
if m == nil {
return fmt.Errorf("model %q is null", id)
}
m.ID = id
if m.Site == "" {
return fmt.Errorf("model %q: site is required", id)
}
if _, ok := c.Sites[m.Site]; !ok {
return fmt.Errorf("model %q: unknown site %q", id, m.Site)
}
if m.PagePath == "" {
m.PagePath = "/"
}
}
return nil
}
// applyDefaults fills booleans that were absent from the file (TOML decodes
// missing bools as false, but the project defaults are true).
func (c *Config) applyDefaults(md *toml.MetaData) {
def := func(defined bool, dst *bool, want bool) {
if !defined {
*dst = want
}
}
if md == nil {
c.Turnstile.Enabled = true
c.Upstream.AutoRefresh = true
c.Upstream.ContinueRounds = 20
c.ProxyPool.XrayAutoDownload = true
for _, s := range c.Sites {
s.Enabled = true
}
for _, m := range c.Models {
m.Enabled = true
}
for _, e := range c.ProxyPool.Entries {
e.Enabled = true
}
for _, sc := range c.ProxyPool.Subscriptions {
sc.Enabled = true
}
return
}
def(md.IsDefined("turnstile", "enabled"), &c.Turnstile.Enabled, true)
def(md.IsDefined("upstream", "auto_refresh"), &c.Upstream.AutoRefresh, true)
def(md.IsDefined("proxypool", "xray_auto_download"), &c.ProxyPool.XrayAutoDownload, true)
for code, s := range c.Sites {
def(md.IsDefined("sites", code, "enabled"), &s.Enabled, true)
}
for id, m := range c.Models {
def(md.IsDefined("models", id, "enabled"), &m.Enabled, true)
}
for name, e := range c.ProxyPool.Entries {
def(md.IsDefined("proxypool", "entries", name, "enabled"), &e.Enabled, true)
}
for name, sc := range c.ProxyPool.Subscriptions {
def(md.IsDefined("proxypool", "subscriptions", name, "enabled"), &sc.Enabled, true)
}
}
func (c *Config) applyEnv() error {
str := func(key string) *string {
if v, ok := os.LookupEnv(key); ok {
return &v
}
return nil
}
if v := str("HOST"); v != nil {
c.Server.Host = *v
}
if v := str("LOG_LEVEL"); v != nil {
c.Server.LogLevel = *v
}
if v := str("LOG_FILE"); v != nil {
c.Server.LogFile = strings.TrimSpace(*v)
}
if v := str("PORT"); v != nil {
n, err := strconv.Atoi(strings.TrimSpace(*v))
if err != nil || n < 1 || n > 65535 {
return fmt.Errorf("invalid PORT %q (expected 1-65535)", *v)
}
c.Server.Port = n
}
// 只有"变量存在且非空"才覆盖配置文件:compose 的 `API_KEYS: ${API_KEYS:-}`
// 会让变量恒存在,空值必须视为"不覆盖",否则会把 config.toml 的 key 清空
// 并静默关闭鉴权。
if v := str("API_KEYS"); v != nil && strings.TrimSpace(*v) != "" {
c.Security.APIKeys = splitList(*v)
}
if v := str("PROXY_URL"); v != nil && strings.TrimSpace(*v) != "" {
c.Proxy.URL = strings.TrimSpace(*v)
}
if v := str("PROXY_FALLBACK_URLS"); v != nil && strings.TrimSpace(*v) != "" {
c.Proxy.FallbackURLs = splitList(*v)
}
if v := str("PROXY_SLOW_START_SECONDS"); v != nil {
f, err := strconv.ParseFloat(strings.TrimSpace(*v), 64)
if err != nil || f < 0 {
return fmt.Errorf("invalid PROXY_SLOW_START_SECONDS %q", *v)
}
c.Proxy.SlowStartSeconds = f
}
if v := str("TURNSTILE_API_KEY"); v != nil && strings.TrimSpace(*v) != "" {
c.Turnstile.APIKey = strings.TrimSpace(*v)
}
if v := str("TURNSTILE_PROVIDER"); v != nil && strings.TrimSpace(*v) != "" {
c.Turnstile.Provider = strings.ToLower(strings.TrimSpace(*v))
}
if v := str("TURNSTILE_BROWSER_PATH"); v != nil && strings.TrimSpace(*v) != "" {
c.Turnstile.BrowserPath = strings.TrimSpace(*v)
}
if v := str("TURNSTILE_TIMEOUT_SECONDS"); v != nil {
n, err := strconv.Atoi(strings.TrimSpace(*v))
if err != nil || n < 1 {
return fmt.Errorf("invalid TURNSTILE_TIMEOUT_SECONDS %q", *v)
}
c.Turnstile.TimeoutSeconds = n
}
if v := str("TURNSTILE_WARM_ENABLED"); v != nil {
b, err := strconv.ParseBool(strings.TrimSpace(*v))
if err != nil {
return fmt.Errorf("invalid TURNSTILE_WARM_ENABLED %q", *v)
}
c.Turnstile.WarmEnabled = b
}
if v := str("TURNSTILE_WARM_RATIO"); v != nil {
f, err := strconv.ParseFloat(strings.TrimSpace(*v), 64)
if err != nil || f <= 0 || f > 1 {
return fmt.Errorf("invalid TURNSTILE_WARM_RATIO %q (expected 0–1)", *v)
}
c.Turnstile.WarmRatio = f
}
if v := str("TURNSTILE_WARM_CHECK_SECONDS"); v != nil {
n, err := strconv.Atoi(strings.TrimSpace(*v))
if err != nil || n < 1 {
return fmt.Errorf("invalid TURNSTILE_WARM_CHECK_SECONDS %q", *v)
}
c.Turnstile.WarmCheckSeconds = n
}
if v := str("TURNSTILE_ENABLED"); v != nil {
b, err := strconv.ParseBool(strings.TrimSpace(*v))
if err != nil {
return fmt.Errorf("invalid TURNSTILE_ENABLED %q", *v)
}
c.Turnstile.Enabled = b
}
if v := str("ADMIN_PASSWORD"); v != nil {
c.Admin.Password = *v
}
if v := str("ADMIN_HOST"); v != nil {
c.Admin.Host = *v
}
if v := str("ADMIN_PORT"); v != nil {
n, err := strconv.Atoi(strings.TrimSpace(*v))
if err != nil || n < 1 || n > 65535 {
return fmt.Errorf("invalid ADMIN_PORT %q", *v)
}
c.Admin.Port = n
}
if v := str("DATA_DIR"); v != nil && strings.TrimSpace(*v) != "" {
c.Runtime.DataDir = strings.TrimSpace(*v)
}
if v := str("ADMIN_ENABLED"); v != nil {
b, err := strconv.ParseBool(strings.TrimSpace(*v))
if err != nil {
return fmt.Errorf("invalid ADMIN_ENABLED %q", *v)
}
c.Admin.Enabled = b
}
return nil
}
func splitList(v string) []string {
out := []string{}
for _, p := range strings.Split(v, ",") {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
// Validate takes the write lock because it also normalizes fields
// (upstream.continue_rounds), so it must not run concurrently with a mutation.
func (c *Config) Validate() error {
c.mu.Lock()
defer c.mu.Unlock()
return c.validateLocked()
}
func (c *Config) validateLocked() error {
if c.Server.Port < 1 || c.Server.Port > 65535 {
return fmt.Errorf("server.port out of range: %d", c.Server.Port)
}
if c.Admin.Enabled && (c.Admin.Port < 1 || c.Admin.Port > 65535) {
return fmt.Errorf("admin.port out of range: %d", c.Admin.Port)
}
if c.Admin.Enabled && c.Admin.Port == c.Server.Port {
return errors.New("admin.port must differ from server.port")
}
if c.Upstream.Timeout <= 0 {
return errors.New("upstream.timeout must be > 0")
}
if c.Upstream.StreamTimeout <= 0 {
return errors.New("upstream.stream_timeout must be > 0")
}
if c.Upstream.ContinueRounds < 0 {
c.Upstream.ContinueRounds = 0
}
if err := c.validateTurnstile(); err != nil {
return err
}
if len(c.Models) == 0 {
return errors.New("no models configured")
}
seen := map[string]bool{}
for _, k := range c.Security.APIKeys {
if seen[k] {
return fmt.Errorf("duplicate api key %q", k)
}
seen[k] = true
}
for alias, target := range c.ModelAliases {
if alias == "" {
return errors.New("model_aliases: alias name must not be empty")
}
m, ok := c.Models[target]
if !ok {
return fmt.Errorf("model_aliases[%q]: unknown model %q", alias, target)
}
if !m.Enabled {
return fmt.Errorf("model_aliases[%q]: model %q is disabled", alias, target)
}
}
if c.DefaultModel != "" {
m, ok := c.Models[c.DefaultModel]
if !ok {
return fmt.Errorf("default_model: unknown model %q", c.DefaultModel)
}
if !m.Enabled {
return fmt.Errorf("default_model: model %q is disabled", c.DefaultModel)
}
}
return nil
}
// validateTurnstile checks the token provider settings while the solver is on.
// provider = "manual" needs no credentials — cookies are imported by hand.
func (c *Config) validateTurnstile() error {
if !c.Turnstile.Enabled {
return nil
}
switch c.Turnstile.ProviderValue() {
case ProviderAPI:
if c.Turnstile.APIURL == "" {
return errors.New("turnstile.api_url is empty (set config to your solver endpoint, or use provider = \"manual\")")
}
// Local solve services (api_style = "ezsolver") usually need no key.
if c.Turnstile.APIKey == "" && c.Turnstile.APIStyleValue() != "ezsolver" {
return errors.New("turnstile.api_key is empty (set config, or TURNSTILE_API_KEY)")
}
case ProviderBrowser:
path := strings.TrimSpace(c.Turnstile.BrowserPath)
if path == "" {
return errors.New("turnstile.browser_path is empty (path to Chrome/Edge, or use provider = \"manual\")")
}
if _, err := os.Stat(path); err != nil {
return fmt.Errorf("turnstile.browser_path %q: %w", path, err)
}
case ProviderManual:
// nothing to check — cookies arrive via the console import box
default:
return fmt.Errorf("turnstile.provider %q is invalid (api|browser|manual)", c.Turnstile.Provider)
}
return nil
}
func (c *Config) RLock() { c.mu.RLock() }
func (c *Config) RUnlock() { c.mu.RUnlock() }
func (c *Config) Lock() { c.mu.Lock() }
func (c *Config) Unlock() { c.mu.Unlock() }
func (c *Config) Path() string { return c.path }
// Site returns a site by code (must hold at least a read lock).
func (c *Config) Site(code string) (*Site, bool) {
s, ok := c.Sites[code]
return s, ok
}
// Model returns a model by id (must hold at least a read lock).
func (c *Config) Model(id string) (*Model, bool) {
m, ok := c.Models[id]
return m, ok
}
// ResolveModel maps a client-supplied model name onto a configured model.
// Resolution order: exact id → case-insensitive id → model_aliases
// (case-insensitive) → DefaultModel fallback. Must hold at least a read lock.
//
// An exact id that exists but is disabled is never aliased away — the admin
// turned it off on purpose, so the caller gets ok=false (404). ok=false means
// the name is genuinely unknown and the caller should reject the request.
// resolved is the configured id the caller must use for upstream routing and
// metrics; it equals the requested name when no aliasing happened.
func (c *Config) ResolveModel(id string) (m *Model, resolved string, ok bool) {
if m, hit := c.Models[id]; hit {
if m.Enabled {
return m, id, true
}
return nil, "", false
}
for candidate, m := range c.Models {
if strings.EqualFold(candidate, id) && m.Enabled {
return m, candidate, true
}
}
for alias, target := range c.ModelAliases {
if strings.EqualFold(alias, id) {
if m, hit := c.Models[target]; hit && m.Enabled {
return m, target, true
}
return nil, "", false
}
}
if c.DefaultModel != "" {
if m, hit := c.Models[c.DefaultModel]; hit && m.Enabled {
return m, c.DefaultModel, true
}
}
return nil, "", false
}
// Save writes the config back to its file after taking a timestamped backup.
func (c *Config) Save() error {
return c.SaveTo(c.path)
}
// SaveTo encodes the config with a read lock held: toml.Encode iterates the
// Sites/Models/... maps, which admin actions mutate under the write lock, and
// an unlocked iteration racing a map write is a fatal (unrecoverable) error.
func (c *Config) SaveTo(path string) error {
c.mu.RLock()
defer c.mu.RUnlock()
return c.saveToLocked(path)
}
func (c *Config) saveToLocked(path string) error {
if path == "" {
return errors.New("config path is empty")
}
if _, err := os.Stat(path); err == nil {
backup := path + ".bak"
if raw, err := os.ReadFile(path); err == nil {
// .bak 里同样是 api_keys / admin password,收紧到 0600
_ = os.WriteFile(backup, raw, 0o600)
}
}
if dir := filepath.Dir(path); dir != "" && dir != "." {
_ = os.MkdirAll(dir, 0o755)
}
var sb strings.Builder
enc := toml.NewEncoder(&sb)
enc.Indent = ""
if err := enc.Encode(c); err != nil {
return fmt.Errorf("encode config: %w", err)
}
header := "# dsfree2api configuration\n# Managed by the web console; a .bak copy is kept next to this file.\n\n"
data := []byte(header + sb.String())
// 原子替换:先写同目录临时文件再 rename。裸 WriteFile 会先把目标截断,
// 写一半时进程崩溃就留下损坏的 config.toml(下次启动直接 config error),
// 并发读者也会读到空/半截内容。
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return fmt.Errorf("write config temp: %w", err)
}
if err := renameReplace(tmp, path); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("replace config: %w", err)
}
return nil
}
// renameReplace 把 src 重命名覆盖到 dst。Windows 上目标文件被并发读者以
// 不带 FILE_SHARE_DELETE 的方式打开时 MoveFileEx 会报 sharing violation,
// 而读者都是瞬间的 ReadFile,退避重试即可等到空档;其他平台首次即成功。
func renameReplace(src, dst string) error {
var err error
for i := 0; i < 100; i++ {
if err = os.Rename(src, dst); err == nil {
return nil
}
time.Sleep(10 * time.Millisecond)
}
return err
}
// ReplaceJSON applies a console payload in one shot: decode into the live
// config only after the payload itself is known to be well-formed, and roll
// back to the pre-request snapshot when reindexing or validation rejects it,
// so a failed PUT never leaves half-applied state behind in memory.
func (c *Config) ReplaceJSON(raw []byte) error {
var probe map[string]json.RawMessage
if err := json.Unmarshal(raw, &probe); err != nil {
return err
}
c.mu.Lock()
defer c.mu.Unlock()
snapshot, err := json.Marshal(c)
if err != nil {
return err
}
// json 解码对 map 是"合并"语义,待替换的键必须先清空才能真正替换
if _, ok := probe["sites"]; ok {
c.Sites = nil
}
if _, ok := probe["models"]; ok {
c.Models = nil
}
if _, ok := probe["proxypool"]; ok {
c.ProxyPool.Entries = nil
c.ProxyPool.Subscriptions = nil
}
curPassword, curTSKey := c.Admin.Password, c.Turnstile.APIKey
if err := json.Unmarshal(raw, c); err != nil {
c.restoreLocked(snapshot)
return err
}
// 控制台 GET 会把这两个密钥抹成空串再原样回传:空值 = 保持不变
if c.Admin.Password == "" {
c.Admin.Password = curPassword
}
if strings.TrimSpace(c.Turnstile.APIKey) == "" {
c.Turnstile.APIKey = curTSKey
}
if err := c.Reindex(); err != nil {
c.restoreLocked(snapshot)
return err
}
if err := c.validateLocked(); err != nil {
c.restoreLocked(snapshot)
return err
}
return nil
}
// restoreLocked puts back a JSON snapshot taken under the same lock.
func (c *Config) restoreLocked(snapshot []byte) {
c.Sites, c.Models = nil, nil
c.ProxyPool.Entries, c.ProxyPool.Subscriptions = nil, nil
_ = json.Unmarshal(snapshot, c)
}
// DataDir resolves (and creates) the runtime data directory.
func (c *Config) DataDir() (string, error) {
dir := c.Runtime.DataDir
if dir == "" {
dir = "./data"
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
return dir, nil
}