From e13d4033e52999da1516609950aec3ab7b8c4a63 Mon Sep 17 00:00:00 2001 From: yyhhyyyyyy Date: Mon, 20 Jul 2026 18:11:22 +0800 Subject: [PATCH] fix(channel): improve proxy client compatibility and cache lifecycle (#6157) * fix(channel): improve proxy client compatibility and cache lifecycle * test(controller): use non-fatal assertions for channel tests --- common/proxy_url.go | 77 +++++ controller/channel-billing.go | 2 +- controller/channel.go | 42 ++- controller/channel_test_internal_test.go | 127 +++++++ controller/channel_upstream_update.go | 1 - controller/codex_usage.go | 3 +- docs/channel/other_setting.md | 18 +- model/channel.go | 23 +- relay/channel/api_request.go | 2 +- relay/channel/aws/relay-aws.go | 2 +- relay/channel/coze/relay-coze.go | 2 +- relay/channel/vertex/service_account.go | 4 +- relay/mjproxy_handler.go | 2 +- service/codex_credential_refresh.go | 1 - service/codex_credential_refresh_task.go | 1 - service/http_client.go | 313 ++++++++++++------ .../drawers/channel-mutate-drawer.tsx | 2 +- web/src/features/channels/constants.ts | 2 + web/src/features/channels/lib/channel-form.ts | 39 ++- web/src/i18n/locales/en.json | 3 +- web/src/i18n/locales/fr.json | 3 +- web/src/i18n/locales/ja.json | 3 +- web/src/i18n/locales/ru.json | 3 +- web/src/i18n/locales/vi.json | 3 +- web/src/i18n/locales/zh-TW.json | 3 +- web/src/i18n/locales/zh.json | 3 +- 26 files changed, 537 insertions(+), 147 deletions(-) create mode 100644 common/proxy_url.go diff --git a/common/proxy_url.go b/common/proxy_url.go new file mode 100644 index 00000000..b295bd71 --- /dev/null +++ b/common/proxy_url.go @@ -0,0 +1,77 @@ +package common + +import ( + "fmt" + "net" + "net/url" + "strconv" + "strings" +) + +// ParseProxyURLStrict validates and normalizes a proxy URL for persistence. +func ParseProxyURLStrict(rawProxyURL string) (*url.URL, error) { + parsedURL, _, err := parseProxyURL(rawProxyURL, false) + return parsedURL, err +} + +// ParseProxyURLRuntime validates and normalizes a proxy URL for runtime use. +// The boolean result reports whether a legacy path, query, or fragment was removed. +func ParseProxyURLRuntime(rawProxyURL string) (*url.URL, bool, error) { + return parseProxyURL(rawProxyURL, true) +} + +func parseProxyURL(rawProxyURL string, allowLegacySuffix bool) (*url.URL, bool, error) { + trimmedProxyURL := strings.TrimSpace(rawProxyURL) + if trimmedProxyURL == "" { + return nil, false, nil + } + + parsedURL, err := url.Parse(trimmedProxyURL) + if err != nil { + return nil, false, fmt.Errorf("invalid proxy URL") + } + parsedURL.Scheme = strings.ToLower(parsedURL.Scheme) + switch parsedURL.Scheme { + case "http", "https", "socks5", "socks5h": + default: + return nil, false, fmt.Errorf("proxy URL must use http, https, socks5, or socks5h") + } + if parsedURL.Hostname() == "" { + return nil, false, fmt.Errorf("proxy URL must include a host") + } + if portText := parsedURL.Port(); portText != "" { + port, err := strconv.Atoi(portText) + if err != nil || port < 1 || port > 65535 { + return nil, false, fmt.Errorf("proxy URL must include a valid port") + } + } + + hasQuery := parsedURL.RawQuery != "" || parsedURL.ForceQuery + hasFragment := strings.Contains(trimmedProxyURL, "#") + escapedPath := parsedURL.EscapedPath() + hasNonRootPath := escapedPath != "" && escapedPath != "/" + legacySuffixStripped := hasQuery || hasFragment || hasNonRootPath + if !allowLegacySuffix { + switch { + case hasQuery: + return nil, false, fmt.Errorf("proxy URL must not include a query") + case hasFragment: + return nil, false, fmt.Errorf("proxy URL must not include a fragment") + case hasNonRootPath: + return nil, false, fmt.Errorf("proxy URL must not include a path") + } + } + + parsedURL.Path = "" + parsedURL.RawPath = "" + parsedURL.RawQuery = "" + parsedURL.ForceQuery = false + parsedURL.Fragment = "" + parsedURL.RawFragment = "" + + if (parsedURL.Scheme == "socks5" || parsedURL.Scheme == "socks5h") && parsedURL.Port() == "" { + parsedURL.Host = net.JoinHostPort(parsedURL.Hostname(), "1080") + } + + return parsedURL, legacySuffixStripped, nil +} diff --git a/controller/channel-billing.go b/controller/channel-billing.go index 751ee360..2d2b4f6e 100644 --- a/controller/channel-billing.go +++ b/controller/channel-billing.go @@ -144,7 +144,7 @@ func GetResponseBody(method, url string, channel *model.Channel, headers http.He for k := range headers { req.Header.Add(k, headers.Get(k)) } - client, err := service.NewProxyHttpClient(channel.GetSetting().Proxy) + client, err := service.GetHttpClientWithProxy(channel.GetSetting().Proxy) if err != nil { return nil, err } diff --git a/controller/channel.go b/controller/channel.go index a00011a9..af4c6eec 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -697,7 +697,6 @@ func AddChannel(c *gin.Context) { common.ApiError(c, err) return } - service.ResetProxyClientCache() recordManageAudit(c, "channel.create", map[string]interface{}{ "name": addChannelRequest.Channel.Name, "type": addChannelRequest.Channel.Type, @@ -713,8 +712,13 @@ func AddChannel(c *gin.Context) { func DeleteChannel(c *gin.Context) { id, _ := strconv.Atoi(c.Param("id")) channelName := "" + channelProxy := "" + channelLookupFailed := false if existing, err := model.GetChannelById(id, false); err == nil && existing != nil { channelName = existing.Name + channelProxy = existing.GetSetting().Proxy + } else { + channelLookupFailed = true } channel := model.Channel{Id: id} err := channel.Delete() @@ -723,6 +727,11 @@ func DeleteChannel(c *gin.Context) { return } model.InitChannelCache() + if channelLookupFailed { + service.ResetProxyClientCache() + } else { + service.InvalidateProxyClient(channelProxy) + } recordManageAudit(c, "channel.delete", map[string]interface{}{ "id": id, "name": channelName, @@ -741,6 +750,9 @@ func DeleteDisabledChannel(c *gin.Context) { return } model.InitChannelCache() + if rows > 0 { + service.ResetProxyClientCache() + } recordManageAudit(c, "channel.delete_disabled", map[string]interface{}{ "count": rows, }) @@ -891,19 +903,22 @@ func DeleteChannelBatch(c *gin.Context) { }) return } - err = model.BatchDeleteChannels(channelBatch.Ids) + deletedCount, err := model.BatchDeleteChannels(channelBatch.Ids) if err != nil { common.ApiError(c, err) return } model.InitChannelCache() + if deletedCount > 0 { + service.ResetProxyClientCache() + } recordManageAudit(c, "channel.delete_batch", map[string]interface{}{ - "count": len(channelBatch.Ids), + "count": deletedCount, }) c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", - "data": len(channelBatch.Ids), + "data": deletedCount, }) return } @@ -962,6 +977,13 @@ func UpdateChannel(c *gin.Context) { }) return } + originProxy := originChannel.GetSetting().Proxy + proxyChanged := false + if _, settingProvided := requestData["setting"]; settingProvided { + newProxy, _ := service.NormalizeProxyURL(channel.GetSetting().Proxy) + normalizedOriginProxy, originProxyErr := service.NormalizeProxyURL(originProxy) + proxyChanged = originProxyErr != nil || normalizedOriginProxy != newProxy + } // Always copy the original ChannelInfo so that fields like IsMultiKey and MultiKeySize are retained. channel.ChannelInfo = originChannel.ChannelInfo @@ -1063,7 +1085,9 @@ func UpdateChannel(c *gin.Context) { return } model.InitChannelCache() - service.ResetProxyClientCache() + if proxyChanged { + service.InvalidateProxyClient(originProxy) + } // 记录变更的字段名(语言无关的字段标识),密钥仅记录"已更换"绝不记录内容。 changedFields := make([]string, 0) if channel.Models != originChannel.Models { @@ -1110,7 +1134,6 @@ func UpdateChannelStatus(c *gin.Context) { changed := model.UpdateChannelStatus(id, "", req.Status, "manual operation") if changed { model.InitChannelCache() - service.ResetProxyClientCache() } recordManageAudit(c, "channel.status_update", map[string]interface{}{ "id": id, @@ -1138,7 +1161,6 @@ func BatchUpdateChannelStatus(c *gin.Context) { } if changedCount > 0 { model.InitChannelCache() - service.ResetProxyClientCache() } recordManageAudit(c, "channel.status_update_batch", map[string]interface{}{ "count": changedCount, @@ -1411,6 +1433,12 @@ func CopyChannel(c *gin.Context) { clone.UsedQuota = 0 } + if err := clone.ValidateSettings(); err != nil { + common.SysError("failed to validate cloned channel: " + err.Error()) + c.JSON(http.StatusOK, gin.H{"success": false, "message": "Failed to copy channel: invalid channel settings"}) + return + } + // insert if err := clone.Insert(); err != nil { common.SysError("failed to clone channel: " + err.Error()) diff --git a/controller/channel_test_internal_test.go b/controller/channel_test_internal_test.go index aa98ab83..56810fdc 100644 --- a/controller/channel_test_internal_test.go +++ b/controller/channel_test_internal_test.go @@ -1,21 +1,148 @@ package controller import ( + "bytes" + "fmt" "net/http" "net/http/httptest" "testing" "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/pkg/billingexpr" relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/setting/operation_setting" "github.com/QuantumNous/new-api/types" "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +func TestValidateChannelProxy(t *testing.T) { + tests := []struct { + name string + proxy string + wantErr bool + }{ + {name: "empty"}, + {name: "http", proxy: "http://proxy.example:8080"}, + {name: "https", proxy: "https://proxy.example:8443"}, + {name: "socks5", proxy: "socks5://proxy.example"}, + {name: "socks5h", proxy: "socks5h://proxy.example:1080/"}, + {name: "unsupported", proxy: "ftp://proxy.example", wantErr: true}, + {name: "path", proxy: "socks5://proxy.example:1080/path", wantErr: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + setting, err := common.Marshal(dto.ChannelSettings{Proxy: test.proxy}) + require.NoError(t, err) + channel := &model.Channel{ + Type: constant.ChannelTypeOpenAI, + Setting: common.GetPointer(string(setting)), + } + + err = validateChannel(channel, false) + + if test.wantErr { + require.ErrorContains(t, err, "invalid channel proxy") + return + } + require.NoError(t, err) + }) + } +} + +func TestCopyChannelRejectsInvalidLegacyProxySettings(t *testing.T) { + db := setupModelListControllerTestDB(t) + settingBytes, err := common.Marshal(dto.ChannelSettings{ + Proxy: "socks5://proxy.example/legacy-path", + }) + require.NoError(t, err) + setting := string(settingBytes) + origin := &model.Channel{ + Type: constant.ChannelTypeOpenAI, + Name: "legacy proxy channel", + Key: "test-key", + Models: "gpt-test", + Group: "default", + Setting: &setting, + } + require.NoError(t, db.Create(origin).Error) + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", origin.Id)}} + ctx.Request = httptest.NewRequest(http.MethodPost, "/api/channel/copy", nil) + + CopyChannel(ctx) + + assert.Contains(t, recorder.Body.String(), "invalid channel settings") + var channelCount int64 + require.NoError(t, db.Model(&model.Channel{}).Count(&channelCount).Error) + assert.Equal(t, int64(1), channelCount) +} + +func TestDeleteChannelResetsProxyCacheWhenPreReadFails(t *testing.T) { + db := setupModelListControllerTestDB(t) + require.NoError(t, db.AutoMigrate(&model.Log{})) + service.ResetProxyClientCache() + t.Cleanup(service.ResetProxyClientCache) + + proxyURL := "http://proxy.example:8080" + beforeDelete, err := service.GetHttpClientWithProxy(proxyURL) + require.NoError(t, err) + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Params = gin.Params{{Key: "id", Value: "999999"}} + ctx.Request = httptest.NewRequest(http.MethodDelete, "/api/channel/999999", nil) + + DeleteChannel(ctx) + + assert.Contains(t, recorder.Body.String(), `"success":true`) + afterDelete, err := service.GetHttpClientWithProxy(proxyURL) + require.NoError(t, err) + assert.NotSame(t, beforeDelete, afterDelete) +} + +func TestDeleteChannelBatchReportsAndAuditsActualDeletedCount(t *testing.T) { + db := setupModelListControllerTestDB(t) + require.NoError(t, db.AutoMigrate(&model.Log{})) + channel := &model.Channel{Name: "existing", Key: "test-key"} + require.NoError(t, db.Create(channel).Error) + + requestBody, err := common.Marshal(ChannelBatch{Ids: []int{channel.Id, 999999}}) + require.NoError(t, err) + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(http.MethodDelete, "/api/channel/batch", bytes.NewReader(requestBody)) + ctx.Request.Header.Set("Content-Type", "application/json") + + DeleteChannelBatch(ctx) + + var response struct { + Success bool `json:"success"` + Data int64 `json:"data"` + } + require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response)) + assert.True(t, response.Success) + assert.Equal(t, int64(1), response.Data) + + var auditLog model.Log + require.NoError(t, db.Order("id desc").First(&auditLog).Error) + var auditData struct { + Operation struct { + Params map[string]any `json:"params"` + } `json:"op"` + } + require.NoError(t, common.UnmarshalJsonStr(auditLog.Other, &auditData)) + assert.Equal(t, float64(1), auditData.Operation.Params["count"]) +} + func TestSettleTestQuotaUsesTieredBilling(t *testing.T) { info := &relaycommon.RelayInfo{ TieredBillingSnapshot: &billingexpr.BillingSnapshot{ diff --git a/controller/channel_upstream_update.go b/controller/channel_upstream_update.go index 2c63a691..19db4345 100644 --- a/controller/channel_upstream_update.go +++ b/controller/channel_upstream_update.go @@ -531,7 +531,6 @@ func refreshChannelRuntimeCache() { model.InitChannelCache() }() } - service.ResetProxyClientCache() } func shouldSendUpstreamModelUpdateNotification(now int64, changedChannels int, failedChannels int) bool { diff --git a/controller/codex_usage.go b/controller/codex_usage.go index 10e5abe2..608823ea 100644 --- a/controller/codex_usage.go +++ b/controller/codex_usage.go @@ -99,7 +99,7 @@ func fetchCodexChannelWhamData( return } - client, err := service.NewProxyHttpClient(ch.GetSetting().Proxy) + client, err := service.GetHttpClientWithProxy(ch.GetSetting().Proxy) if err != nil { common.ApiError(c, err) return @@ -133,7 +133,6 @@ func fetchCodexChannelWhamData( if encErr == nil { _ = model.DB.Model(&model.Channel{}).Where("id = ?", ch.Id).Update("key", string(encoded)).Error model.InitChannelCache() - service.ResetProxyClientCache() } ctx2, cancel2 := context.WithTimeout(c.Request.Context(), 15*time.Second) diff --git a/docs/channel/other_setting.md b/docs/channel/other_setting.md index 43341660..4eb8e775 100644 --- a/docs/channel/other_setting.md +++ b/docs/channel/other_setting.md @@ -1,6 +1,6 @@ -# 渠道而外设置说明 +# 渠道额外设置说明 -该配置用于设置一些额外的渠道参数,可以通过 JSON 对象进行配置。主要包含以下两个设置项: +该配置用于设置一些额外的渠道参数,可以通过 JSON 对象进行配置。主要包含以下三个设置项: 1. force_format - 用于标识是否对数据进行强制格式化为 OpenAI 格式 @@ -8,7 +8,9 @@ 2. proxy - 用于配置网络代理 - - 类型为字符串,填写代理地址(例如 socks5 协议的代理地址) + - 类型为字符串,支持 `http`、`https`、`socks5` 和 `socks5h` 协议 + - 保存时必须包含协议和主机;仅允许空路径或根路径 `/`,不允许 query 或 fragment + - SOCKS 代理未填写端口时,运行时使用默认端口 `1080` 3. thinking_to_content - 用于标识是否将思考内容`reasoning_content`转换为``标签拼接到内容中返回 @@ -23,11 +25,17 @@ ```json { "force_format": true, - "thinking_to_content": true, - "proxy": "socks5://xxxxxxx" + "thinking_to_content": true, + "proxy": "socks5://proxy.example:1080" } ``` -------------------------------------------------------------- 通过调整上述 JSON 配置中的值,可以灵活控制渠道的额外行为,比如是否进行格式化以及使用特定的网络代理。 + +## 升级兼容性 + +旧版本会忽略代理地址中的 path、query 和 fragment。为避免升级后中断已有渠道流量,运行时会继续剥离这些遗留后缀,并对同一代理地址每个进程记录一次不含凭证和后缀的警告。该兼容逻辑不会改写数据库;再次保存渠道时必须按上述严格规则修正代理地址。 + +代理连接使用 30 秒 TCP 拨号超时和 30 秒 KeepAlive;TLS 握手超时为 10 秒。这些超时同样适用于未配置渠道代理的中转请求。 diff --git a/model/channel.go b/model/channel.go index 7326f28c..1de87687 100644 --- a/model/channel.go +++ b/model/channel.go @@ -452,26 +452,32 @@ func BatchInsertChannels(channels []Channel) error { return tx.Commit().Error } -func BatchDeleteChannels(ids []int) error { +func BatchDeleteChannels(ids []int) (int64, error) { if len(ids) == 0 { - return nil + return 0, nil } // 使用事务 分批删除channel表和abilities表 tx := DB.Begin() if tx.Error != nil { - return tx.Error + return 0, tx.Error } + var deletedCount int64 for _, chunk := range lo.Chunk(ids, 200) { - if err := tx.Where("id in (?)", chunk).Delete(&Channel{}).Error; err != nil { + result := tx.Where("id in (?)", chunk).Delete(&Channel{}) + if result.Error != nil { tx.Rollback() - return err + return 0, result.Error } + deletedCount += result.RowsAffected if err := tx.Where("channel_id in (?)", chunk).Delete(&Ability{}).Error; err != nil { tx.Rollback() - return err + return 0, err } } - return tx.Commit().Error + if err := tx.Commit().Error; err != nil { + return 0, err + } + return deletedCount, nil } func (channel *Channel) GetPriority() int64 { @@ -945,6 +951,9 @@ func (channel *Channel) ValidateSettings() error { return err } } + if _, err := common.ParseProxyURLStrict(channelParams.Proxy); err != nil { + return fmt.Errorf("invalid channel proxy: %w", err) + } channelOtherSettings := &dto.ChannelOtherSettings{} if channel.OtherSettings != "" { err := common.UnmarshalJsonStr(channel.OtherSettings, channelOtherSettings) diff --git a/relay/channel/api_request.go b/relay/channel/api_request.go index 9fae7df0..62de6a32 100644 --- a/relay/channel/api_request.go +++ b/relay/channel/api_request.go @@ -478,7 +478,7 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http var client *http.Client var err error if info.ChannelSetting.Proxy != "" { - client, err = service.NewProxyHttpClient(info.ChannelSetting.Proxy) + client, err = service.GetHttpClientWithProxy(info.ChannelSetting.Proxy) if err != nil { return nil, fmt.Errorf("new proxy http client failed: %w", err) } diff --git a/relay/channel/aws/relay-aws.go b/relay/channel/aws/relay-aws.go index 1f6ff7e6..a0d9923e 100644 --- a/relay/channel/aws/relay-aws.go +++ b/relay/channel/aws/relay-aws.go @@ -53,7 +53,7 @@ func newAwsClient(c *gin.Context, info *relaycommon.RelayInfo) (*bedrockruntime. err error ) if info.ChannelSetting.Proxy != "" { - httpClient, err = service.NewProxyHttpClient(info.ChannelSetting.Proxy) + httpClient, err = service.GetHttpClientWithProxy(info.ChannelSetting.Proxy) if err != nil { return nil, fmt.Errorf("new proxy http client failed: %w", err) } diff --git a/relay/channel/coze/relay-coze.go b/relay/channel/coze/relay-coze.go index c2db5c60..1337a6bd 100644 --- a/relay/channel/coze/relay-coze.go +++ b/relay/channel/coze/relay-coze.go @@ -282,7 +282,7 @@ func doRequest(req *http.Request, info *relaycommon.RelayInfo) (*http.Response, var client *http.Client var err error // 声明 err 变量 if info.ChannelSetting.Proxy != "" { - client, err = service.NewProxyHttpClient(info.ChannelSetting.Proxy) + client, err = service.GetHttpClientWithProxy(info.ChannelSetting.Proxy) if err != nil { return nil, fmt.Errorf("new proxy http client failed: %w", err) } diff --git a/relay/channel/vertex/service_account.go b/relay/channel/vertex/service_account.go index 96ec6b28..6b077f55 100644 --- a/relay/channel/vertex/service_account.go +++ b/relay/channel/vertex/service_account.go @@ -114,7 +114,7 @@ func exchangeJwtForAccessToken(signedJWT string, info *relaycommon.RelayInfo) (s var client *http.Client var err error if info.ChannelSetting.Proxy != "" { - client, err = service.NewProxyHttpClient(info.ChannelSetting.Proxy) + client, err = service.GetHttpClientWithProxy(info.ChannelSetting.Proxy) if err != nil { return "", fmt.Errorf("new proxy http client failed: %w", err) } @@ -157,7 +157,7 @@ func exchangeJwtForAccessTokenWithProxy(signedJWT string, proxy string) (string, var client *http.Client var err error if proxy != "" { - client, err = service.NewProxyHttpClient(proxy) + client, err = service.GetHttpClientWithProxy(proxy) if err != nil { return "", fmt.Errorf("new proxy http client failed: %w", err) } diff --git a/relay/mjproxy_handler.go b/relay/mjproxy_handler.go index 2bde2505..daa0585a 100644 --- a/relay/mjproxy_handler.go +++ b/relay/mjproxy_handler.go @@ -40,7 +40,7 @@ func RelayMidjourneyImage(c *gin.Context) { if channel, err := model.CacheGetChannel(midjourneyTask.ChannelId); err == nil { proxy = channel.GetSetting().Proxy if proxy != "" { - if httpClient, err = service.NewProxyHttpClient(proxy); err != nil { + if httpClient, err = service.GetHttpClientWithProxy(proxy); err != nil { c.JSON(400, gin.H{ "error": "proxy_url_invalid", }) diff --git a/service/codex_credential_refresh.go b/service/codex_credential_refresh.go index 2e681ee6..9599ba0f 100644 --- a/service/codex_credential_refresh.go +++ b/service/codex_credential_refresh.go @@ -97,7 +97,6 @@ func RefreshCodexChannelCredential(ctx context.Context, channelID int, opts Code if opts.ResetCaches { model.InitChannelCache() - ResetProxyClientCache() } return oauthKey, ch, nil diff --git a/service/codex_credential_refresh_task.go b/service/codex_credential_refresh_task.go index 5cc53fea..5d87c823 100644 --- a/service/codex_credential_refresh_task.go +++ b/service/codex_credential_refresh_task.go @@ -139,7 +139,6 @@ func runCodexCredentialAutoRefreshOnce() { }() model.InitChannelCache() }() - ResetProxyClientCache() } if common.DebugEnabled { diff --git a/service/http_client.go b/service/http_client.go index dd36db6c..c6febe06 100644 --- a/service/http_client.go +++ b/service/http_client.go @@ -6,10 +6,12 @@ import ( "net" "net/http" "net/url" + "strings" "sync" "time" "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/setting/system_setting" "golang.org/x/net/proxy" @@ -18,10 +20,24 @@ import ( var ( httpClient *http.Client ssrfProtectedHTTPClient *http.Client - proxyClientLock sync.Mutex - proxyClients = make(map[string]*http.Client) + proxyClients = proxyHTTPClientCache{ + clients: make(map[string]*http.Client), + aliases: make(map[string]string), + } + legacyProxyURLWarnings sync.Map ) +type proxyHTTPClientCache struct { + mutex sync.RWMutex + clients map[string]*http.Client + aliases map[string]string +} + +type proxyURLConfig struct { + parsedURL *url.URL + cacheKey string +} + func checkRedirect(req *http.Request, via []*http.Request) error { urlStr := req.URL.String() if err := validateURLWithCurrentFetchSetting(urlStr, true); err != nil { @@ -53,30 +69,48 @@ func ValidateSSRFProtectedFetchURL(urlStr string) error { return validateURLWithCurrentFetchSetting(urlStr, true) } -func InitHttpClient() { - transport := &http.Transport{ - MaxIdleConns: common.RelayMaxIdleConns, - MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost, - IdleConnTimeout: time.Duration(common.RelayIdleConnTimeout) * time.Second, - ForceAttemptHTTP2: true, - Proxy: http.ProxyFromEnvironment, // Support HTTP_PROXY, HTTPS_PROXY, NO_PROXY env vars +func newRelayHTTPTransport() *http.Transport { + var transport *http.Transport + if defaultTransport, ok := http.DefaultTransport.(*http.Transport); ok && defaultTransport != nil { + transport = defaultTransport.Clone() + } else { + dialer := &net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + } + transport = &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: dialer.DialContext, + ForceAttemptHTTP2: true, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: time.Second, + } } + transport.MaxIdleConns = common.RelayMaxIdleConns + transport.MaxIdleConnsPerHost = common.RelayMaxIdleConnsPerHost + transport.IdleConnTimeout = time.Duration(common.RelayIdleConnTimeout) * time.Second + transport.ForceAttemptHTTP2 = true if common.TLSInsecureSkipVerify { transport.TLSClientConfig = common.InsecureTLSConfig } + return transport +} - if common.RelayTimeout == 0 { - httpClient = &http.Client{ - Transport: transport, - CheckRedirect: checkRedirect, - } - } else { - httpClient = &http.Client{ - Transport: transport, - Timeout: time.Duration(common.RelayTimeout) * time.Second, - CheckRedirect: checkRedirect, - } +func newRelayHTTPClient(transport *http.Transport) *http.Client { + client := &http.Client{ + Transport: transport, + CheckRedirect: checkRedirect, } + if common.RelayTimeout != 0 { + client.Timeout = time.Duration(common.RelayTimeout) * time.Second + } + return client +} + +func InitHttpClient() { + transport := newRelayHTTPTransport() + transport.Proxy = http.ProxyFromEnvironment + httpClient = newRelayHTTPClient(transport) ssrfProtectedHTTPClient = newProtectedFetchHTTPClient() } @@ -100,110 +134,177 @@ func GetSSRFProtectedHTTPClient() *http.Client { return ssrfProtectedHTTPClient } -// GetHttpClientWithProxy returns the default client or a proxy-enabled one when proxyURL is provided. -func GetHttpClientWithProxy(proxyURL string) (*http.Client, error) { - if proxyURL == "" { - return GetHttpClient(), nil +func newProxyURLConfig(parsedURL *url.URL) *proxyURLConfig { + return &proxyURLConfig{ + parsedURL: parsedURL, + cacheKey: parsedURL.String(), } - return NewProxyHttpClient(proxyURL) } -// ResetProxyClientCache 清空代理客户端缓存,确保下次使用时重新初始化 -func ResetProxyClientCache() { - proxyClientLock.Lock() - defer proxyClientLock.Unlock() - for _, client := range proxyClients { - if transport, ok := client.Transport.(*http.Transport); ok && transport != nil { - transport.CloseIdleConnections() +func warnLegacyProxyURLOnce(config *proxyURLConfig) { + if _, loaded := legacyProxyURLWarnings.LoadOrStore(config.cacheKey, struct{}{}); loaded { + return + } + logger.LogWarn( + context.Background(), + fmt.Sprintf( + "legacy proxy URL suffix ignored at runtime: scheme=%s host=%s; update the channel proxy setting", + config.parsedURL.Scheme, + config.parsedURL.Host, + ), + ) +} + +// NormalizeProxyURL validates a proxy URL using runtime-compatible rules and returns its canonical cache key. +func NormalizeProxyURL(rawProxyURL string) (string, error) { + parsedURL, legacySuffixStripped, err := common.ParseProxyURLRuntime(rawProxyURL) + if err != nil { + return "", err + } + if parsedURL == nil { + return "", nil + } + config := newProxyURLConfig(parsedURL) + if legacySuffixStripped { + warnLegacyProxyURLOnce(config) + } + return config.cacheKey, nil +} + +// ValidateProxyURL validates a channel proxy URL without connecting to it. +func ValidateProxyURL(rawProxyURL string) error { + _, err := common.ParseProxyURLStrict(rawProxyURL) + return err +} + +func (cache *proxyHTTPClientCache) get(rawCacheKey string) (*http.Client, bool) { + cache.mutex.RLock() + defer cache.mutex.RUnlock() + cacheKey := rawCacheKey + if canonicalKey, ok := cache.aliases[rawCacheKey]; ok { + cacheKey = canonicalKey + } + client, ok := cache.clients[cacheKey] + return client, ok +} + +func (cache *proxyHTTPClientCache) getOrCreate(rawCacheKey string, config *proxyURLConfig) (*http.Client, error) { + cache.mutex.Lock() + defer cache.mutex.Unlock() + if client, ok := cache.clients[config.cacheKey]; ok { + cache.aliases[rawCacheKey] = config.cacheKey + return client, nil + } + + client, err := newProxyHTTPClient(config.parsedURL) + if err != nil { + return nil, err + } + cache.clients[config.cacheKey] = client + cache.aliases[rawCacheKey] = config.cacheKey + return client, nil +} + +func (cache *proxyHTTPClientCache) remove(cacheKey string) *http.Client { + cache.mutex.Lock() + defer cache.mutex.Unlock() + client := cache.clients[cacheKey] + delete(cache.clients, cacheKey) + for alias, canonicalKey := range cache.aliases { + if canonicalKey == cacheKey { + delete(cache.aliases, alias) } } - proxyClients = make(map[string]*http.Client) + return client } -// NewProxyHttpClient 创建支持代理的 HTTP 客户端 -func NewProxyHttpClient(proxyURL string) (*http.Client, error) { - if proxyURL == "" { +func (cache *proxyHTTPClientCache) reset() map[string]*http.Client { + cache.mutex.Lock() + defer cache.mutex.Unlock() + oldClients := cache.clients + cache.clients = make(map[string]*http.Client) + cache.aliases = make(map[string]string) + return oldClients +} + +func newProxyHTTPClient(proxyURL *url.URL) (*http.Client, error) { + transport := newRelayHTTPTransport() + + switch proxyURL.Scheme { + case "http", "https": + transport.Proxy = http.ProxyURL(proxyURL) + + case "socks5", "socks5h": + transport.Proxy = nil + forwardDialer := &net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + } + dialer, err := proxy.FromURL(proxyURL, forwardDialer) + if err != nil { + return nil, err + } + contextDialer, ok := dialer.(proxy.ContextDialer) + if !ok { + return nil, fmt.Errorf("SOCKS proxy dialer does not support context cancellation") + } + transport.DialContext = contextDialer.DialContext + + default: + return nil, fmt.Errorf("unsupported proxy scheme") + } + + return newRelayHTTPClient(transport), nil +} + +// GetHttpClientWithProxy returns the default client or a cached proxy-enabled client. +func GetHttpClientWithProxy(rawProxyURL string) (*http.Client, error) { + trimmedProxyURL := strings.TrimSpace(rawProxyURL) + if trimmedProxyURL == "" { if client := GetHttpClient(); client != nil { return client, nil } return http.DefaultClient, nil } - - proxyClientLock.Lock() - if client, ok := proxyClients[proxyURL]; ok { - proxyClientLock.Unlock() + if client, ok := proxyClients.get(trimmedProxyURL); ok { return client, nil } - proxyClientLock.Unlock() - parsedURL, err := url.Parse(proxyURL) + parsedURL, legacySuffixStripped, err := common.ParseProxyURLRuntime(trimmedProxyURL) if err != nil { return nil, err } + config := newProxyURLConfig(parsedURL) + if legacySuffixStripped { + warnLegacyProxyURLOnce(config) + } + return proxyClients.getOrCreate(trimmedProxyURL, config) +} - switch parsedURL.Scheme { - case "http", "https": - transport := &http.Transport{ - MaxIdleConns: common.RelayMaxIdleConns, - MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost, - IdleConnTimeout: time.Duration(common.RelayIdleConnTimeout) * time.Second, - ForceAttemptHTTP2: true, - Proxy: http.ProxyURL(parsedURL), - } - if common.TLSInsecureSkipVerify { - transport.TLSClientConfig = common.InsecureTLSConfig - } - client := &http.Client{ - Transport: transport, - CheckRedirect: checkRedirect, - } - client.Timeout = time.Duration(common.RelayTimeout) * time.Second - proxyClientLock.Lock() - proxyClients[proxyURL] = client - proxyClientLock.Unlock() - return client, nil - - case "socks5", "socks5h": - // 获取认证信息 - var auth *proxy.Auth - if parsedURL.User != nil { - auth = &proxy.Auth{ - User: parsedURL.User.Username(), - Password: "", - } - if password, ok := parsedURL.User.Password(); ok { - auth.Password = password - } - } - - // 创建 SOCKS5 代理拨号器 - // proxy.SOCKS5 使用 tcp 参数,所有 TCP 连接包括 DNS 查询都将通过代理进行。行为与 socks5h 相同 - dialer, err := proxy.SOCKS5("tcp", parsedURL.Host, auth, proxy.Direct) - if err != nil { - return nil, err - } - - transport := &http.Transport{ - MaxIdleConns: common.RelayMaxIdleConns, - MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost, - IdleConnTimeout: time.Duration(common.RelayIdleConnTimeout) * time.Second, - ForceAttemptHTTP2: true, - DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { - return dialer.Dial(network, addr) - }, - } - if common.TLSInsecureSkipVerify { - transport.TLSClientConfig = common.InsecureTLSConfig - } - - client := &http.Client{Transport: transport, CheckRedirect: checkRedirect} - client.Timeout = time.Duration(common.RelayTimeout) * time.Second - proxyClientLock.Lock() - proxyClients[proxyURL] = client - proxyClientLock.Unlock() - return client, nil - - default: - return nil, fmt.Errorf("unsupported proxy scheme: %s, must be http, https, socks5 or socks5h", parsedURL.Scheme) +// InvalidateProxyClient removes one proxy client and closes its idle connections. +func InvalidateProxyClient(rawProxyURL string) { + parsedURL, legacySuffixStripped, err := common.ParseProxyURLRuntime(rawProxyURL) + if err != nil || parsedURL == nil { + return + } + config := newProxyURLConfig(parsedURL) + if legacySuffixStripped { + warnLegacyProxyURLOnce(config) + } + if client := proxyClients.remove(config.cacheKey); client != nil { + client.CloseIdleConnections() } } + +// ResetProxyClientCache clears all cached proxy clients. +func ResetProxyClientCache() { + for _, client := range proxyClients.reset() { + client.CloseIdleConnections() + } +} + +// NewProxyHttpClient is kept for compatibility. +// Deprecated: use GetHttpClientWithProxy. +func NewProxyHttpClient(proxyURL string) (*http.Client, error) { + return GetHttpClientWithProxy(proxyURL) +} diff --git a/web/src/features/channels/components/drawers/channel-mutate-drawer.tsx b/web/src/features/channels/components/drawers/channel-mutate-drawer.tsx index f1bef8d6..fc872579 100644 --- a/web/src/features/channels/components/drawers/channel-mutate-drawer.tsx +++ b/web/src/features/channels/components/drawers/channel-mutate-drawer.tsx @@ -4192,7 +4192,7 @@ export function ChannelMutateDrawer({ {t( - 'Network proxy for this channel (supports socks5 protocol)' + 'Network proxy for this channel (supports HTTP, HTTPS, SOCKS5, and SOCKS5H)' )} diff --git a/web/src/features/channels/constants.ts b/web/src/features/channels/constants.ts index f584df0b..c218a5fe 100644 --- a/web/src/features/channels/constants.ts +++ b/web/src/features/channels/constants.ts @@ -239,6 +239,8 @@ export const ERROR_MESSAGES = { REQUIRED_GROUP: 'Group is required', INVALID_JSON: 'Invalid JSON format', INVALID_MODEL_MAPPING: 'Invalid model mapping format', + INVALID_PROXY: + 'Proxy address must use HTTP, HTTPS, SOCKS5, or SOCKS5H and include a valid host', CREATE_FAILED: 'Failed to create channel', UPDATE_FAILED: 'Failed to update channel', DELETE_FAILED: 'Failed to delete channel', diff --git a/web/src/features/channels/lib/channel-form.ts b/web/src/features/channels/lib/channel-form.ts index 772b6be4..e7e36827 100644 --- a/web/src/features/channels/lib/channel-form.ts +++ b/web/src/features/channels/lib/channel-form.ts @@ -37,6 +37,38 @@ import { // Form Validation Schema // ============================================================================ +const SUPPORTED_PROXY_PROTOCOLS = new Set([ + 'http:', + 'https:', + 'socks5:', + 'socks5h:', +]) + +function isOptionalProxyURL(value: string | undefined): boolean { + const trimmedValue = value?.trim() || '' + if (!trimmedValue) return true + + const schemeSeparatorIndex = trimmedValue.indexOf('://') + if (schemeSeparatorIndex <= 0) return false + + const authorityAndSuffix = trimmedValue.slice(schemeSeparatorIndex + 3) + const suffixIndex = authorityAndSuffix.search(/[/?#]/) + if (suffixIndex >= 0 && authorityAndSuffix.slice(suffixIndex) !== '/') { + return false + } + + try { + const parsedURL = new URL(trimmedValue) + return ( + SUPPORTED_PROXY_PROTOCOLS.has(parsedURL.protocol) && + Boolean(parsedURL.hostname) && + parsedURL.port !== '0' + ) + } catch { + return false + } +} + function parseOptionalJson(value: string | undefined): unknown { if (!value?.trim()) return undefined return JSON.parse(value) @@ -188,7 +220,10 @@ export const channelFormSchema = z // Channel extra settings (stored in setting JSON, not sent directly) force_format: z.boolean().optional(), thinking_to_content: z.boolean().optional(), - proxy: z.string().optional(), + proxy: z + .string() + .optional() + .refine(isOptionalProxyURL, ERROR_MESSAGES.INVALID_PROXY), pass_through_body_enabled: z.boolean().optional(), system_prompt: z.string().optional(), system_prompt_override: z.boolean().optional(), @@ -505,7 +540,7 @@ function buildSettingJSON(formData: ChannelFormValues): string { const settingObj = { force_format: formData.force_format || false, thinking_to_content: formData.thinking_to_content || false, - proxy: formData.proxy || '', + proxy: formData.proxy?.trim() || '', pass_through_body_enabled: formData.pass_through_body_enabled || false, system_prompt: formData.system_prompt || '', system_prompt_override: formData.system_prompt_override || false, diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 0bda9d68..609e72eb 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -2804,7 +2804,7 @@ "Nested JSON defining per-group rules for adding (+:), removing (-:), or appending usable groups.": "Nested JSON defining per-group rules for adding (+:), removing (-:), or appending usable groups.", "Nested JSON: source group →": "Nested JSON: source group →", "Network connection failed or server not responding": "Network connection failed or server not responding", - "Network proxy for this channel (supports socks5 protocol)": "Network proxy for this channel (supports socks5 protocol)", + "Network proxy for this channel (supports HTTP, HTTPS, SOCKS5, and SOCKS5H)": "Network proxy for this channel (supports HTTP, HTTPS, SOCKS5, and SOCKS5H)", "Never": "Never", "Never expires": "Never expires", "Never used an API Gateway?": "Never used an API Gateway?", @@ -3545,6 +3545,7 @@ "Provider updated successfully": "Provider updated successfully", "Provider-specific endpoint, account, and compatibility settings.": "Provider-specific endpoint, account, and compatibility settings.", "Proxy Address": "Proxy Address", + "Proxy address must use HTTP, HTTPS, SOCKS5, or SOCKS5H and include a valid host": "Proxy address must use HTTP, HTTPS, SOCKS5, or SOCKS5H and include a valid host", "Prune Object Items": "Prune Object Items", "Prune object items by conditions": "Prune object items by conditions", "Prune Rule (string or JSON object)": "Prune Rule (string or JSON object)", diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index 668d3922..443b3fa1 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -2804,7 +2804,7 @@ "Nested JSON defining per-group rules for adding (+:), removing (-:), or appending usable groups.": "JSON imbriqué définissant des règles par groupe pour ajouter (+:), supprimer (-:), ou ajouter des groupes utilisables.", "Nested JSON: source group →": "JSON imbriqué : groupe source →", "Network connection failed or server not responding": "La connexion réseau a échoué ou le serveur ne répond pas", - "Network proxy for this channel (supports socks5 protocol)": "Proxy réseau pour ce canal (supporte le protocole socks5)", + "Network proxy for this channel (supports HTTP, HTTPS, SOCKS5, and SOCKS5H)": "Proxy réseau de ce canal (prend en charge HTTP, HTTPS, SOCKS5 et SOCKS5H)", "Never": "Jamais", "Never expires": "N'expire jamais", "Never used an API Gateway?": "Vous n'avez jamais utilisé de passerelle API ?", @@ -3545,6 +3545,7 @@ "Provider updated successfully": "Fournisseur mis à jour avec succès", "Provider-specific endpoint, account, and compatibility settings.": "Paramètres de point d’accès, de compte et de compatibilité propres au fournisseur.", "Proxy Address": "Adresse du proxy", + "Proxy address must use HTTP, HTTPS, SOCKS5, or SOCKS5H and include a valid host": "L’adresse du proxy doit utiliser HTTP, HTTPS, SOCKS5 ou SOCKS5H et contenir un hôte valide", "Prune Object Items": "Nettoyer les éléments objet", "Prune object items by conditions": "Nettoyer les éléments d'objets par conditions", "Prune Rule (string or JSON object)": "Règle de nettoyage (chaîne ou objet JSON)", diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index ffb10733..c15de553 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -2804,7 +2804,7 @@ "Nested JSON defining per-group rules for adding (+:), removing (-:), or appending usable groups.": "追加 (+:)、削除 (-:)、または使用可能なグループの追加を行うグループごとのルールを定義するネストされたJSON。", "Nested JSON: source group →": "ネストされたJSON: ソースグループ →", "Network connection failed or server not responding": "ネットワーク接続に失敗したか、サーバーが応答していません", - "Network proxy for this channel (supports socks5 protocol)": "このチャネルのネットワークプロキシ (socks5プロトコルをサポート)", + "Network proxy for this channel (supports HTTP, HTTPS, SOCKS5, and SOCKS5H)": "このチャネルのネットワークプロキシ(HTTP、HTTPS、SOCKS5、SOCKS5H に対応)", "Never": "しない", "Never expires": "無期限", "Never used an API Gateway?": "APIゲートウェイを一度も使用したことがありませんか?", @@ -3545,6 +3545,7 @@ "Provider updated successfully": "プロバイダーが正常に更新されました", "Provider-specific endpoint, account, and compatibility settings.": "プロバイダー固有のエンドポイント、アカウント、互換性設定です。", "Proxy Address": "プロキシアドレス", + "Proxy address must use HTTP, HTTPS, SOCKS5, or SOCKS5H and include a valid host": "プロキシアドレスは HTTP、HTTPS、SOCKS5、SOCKS5H のいずれかを使用し、有効なホストを含める必要があります", "Prune Object Items": "オブジェクト項目を整理", "Prune object items by conditions": "条件に基づいてオブジェクト項目を削除", "Prune Rule (string or JSON object)": "削除ルール(文字列またはJSONオブジェクト)", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 70252a1c..a1c18a35 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -2804,7 +2804,7 @@ "Nested JSON defining per-group rules for adding (+:), removing (-:), or appending usable groups.": "Вложенный JSON, определяющий правила для каждой группы для добавления (+:), удаления (-:) или добавления используемых групп.", "Nested JSON: source group →": "Вложенный JSON: исходная группа →", "Network connection failed or server not responding": "Сбой сетевого подключения или сервер не отвечает", - "Network proxy for this channel (supports socks5 protocol)": "Сетевой прокси для этого канала (поддерживает протокол socks5)", + "Network proxy for this channel (supports HTTP, HTTPS, SOCKS5, and SOCKS5H)": "Сетевой прокси для этого канала (поддерживает HTTP, HTTPS, SOCKS5 и SOCKS5H)", "Never": "Никогда", "Never expires": "Никогда не истекает", "Never used an API Gateway?": "Никогда не пользовались API-шлюзом?", @@ -3545,6 +3545,7 @@ "Provider updated successfully": "Поставщик успешно обновлен", "Provider-specific endpoint, account, and compatibility settings.": "Настройки endpoint, аккаунта и совместимости для конкретного провайдера.", "Proxy Address": "Адрес прокси", + "Proxy address must use HTTP, HTTPS, SOCKS5, or SOCKS5H and include a valid host": "Адрес прокси должен использовать HTTP, HTTPS, SOCKS5 или SOCKS5H и содержать допустимый узел", "Prune Object Items": "Очистить элементы объекта", "Prune object items by conditions": "Удалить элементы объекта по условиям", "Prune Rule (string or JSON object)": "Правило очистки (строка или JSON-объект)", diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json index 89849046..19cdddb1 100644 --- a/web/src/i18n/locales/vi.json +++ b/web/src/i18n/locales/vi.json @@ -2804,7 +2804,7 @@ "Nested JSON defining per-group rules for adding (+:), removing (-:), or appending usable groups.": "JSON lồng nhau xác định quy tắc theo nhóm để thêm (+:), xóa (-:), hoặc nối các nhóm có thể sử dụng.", "Nested JSON: source group →": "JSON lồng nhau: nhóm nguồn →", "Network connection failed or server not responding": "Kết nối mạng thất bại hoặc máy chủ không phản hồi", - "Network proxy for this channel (supports socks5 protocol)": "Proxy mạng cho kênh này (hỗ trợ giao thức socks5)", + "Network proxy for this channel (supports HTTP, HTTPS, SOCKS5, and SOCKS5H)": "Proxy mạng cho kênh này (hỗ trợ HTTP, HTTPS, SOCKS5 và SOCKS5H)", "Never": "Không bao giờ", "Never expires": "Không hết hạn", "Never used an API Gateway?": "Chưa bao giờ sử dụng API Gateway?", @@ -3545,6 +3545,7 @@ "Provider updated successfully": "Nhà cung cấp đã được cập nhật thành công", "Provider-specific endpoint, account, and compatibility settings.": "Thiết lập endpoint, tài khoản và tương thích riêng cho nhà cung cấp.", "Proxy Address": "Địa chỉ Proxy", + "Proxy address must use HTTP, HTTPS, SOCKS5, or SOCKS5H and include a valid host": "Địa chỉ proxy phải dùng HTTP, HTTPS, SOCKS5 hoặc SOCKS5H và chứa máy chủ hợp lệ", "Prune Object Items": "Dọn mục đối tượng", "Prune object items by conditions": "Dọn dẹp các mục đối tượng theo điều kiện", "Prune Rule (string or JSON object)": "Quy tắc dọn dẹp (chuỗi hoặc đối tượng JSON)", diff --git a/web/src/i18n/locales/zh-TW.json b/web/src/i18n/locales/zh-TW.json index e9b6c9e7..d895a2b3 100644 --- a/web/src/i18n/locales/zh-TW.json +++ b/web/src/i18n/locales/zh-TW.json @@ -2804,7 +2804,7 @@ "Nested JSON defining per-group rules for adding (+:), removing (-:), or appending usable groups.": "嵌套 JSON,定義按分組新增(+:)、移除(-:)或追加可用分組的規則。", "Nested JSON: source group →": "嵌套 JSON:源分組 →", "Network connection failed or server not responding": "網絡連接失敗或伺服器未回應", - "Network proxy for this channel (supports socks5 protocol)": "此渠道的網絡代理(支援 socks5 協定)", + "Network proxy for this channel (supports HTTP, HTTPS, SOCKS5, and SOCKS5H)": "此渠道的網路代理(支援 HTTP、HTTPS、SOCKS5 和 SOCKS5H)", "Never": "永不", "Never expires": "永不過期", "Never used an API Gateway?": "從未使用過 API 閘道/中轉 API?", @@ -3545,6 +3545,7 @@ "Provider updated successfully": "供應商更新成功", "Provider-specific endpoint, account, and compatibility settings.": "設定供應商專屬的端點、用戶和兼容性選項。", "Proxy Address": "代理地址", + "Proxy address must use HTTP, HTTPS, SOCKS5, or SOCKS5H and include a valid host": "代理位址必須使用 HTTP、HTTPS、SOCKS5 或 SOCKS5H,並包含有效的主機", "Prune Object Items": "清理物件項", "Prune object items by conditions": "按條件清理物件中的子項", "Prune Rule (string or JSON object)": "清理規則(字串或 JSON 物件)", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index b0309e68..ee80b960 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -2804,7 +2804,7 @@ "Nested JSON defining per-group rules for adding (+:), removing (-:), or appending usable groups.": "嵌套 JSON,定义按分组添加(+:)、移除(-:)或追加可用分组的规则。", "Nested JSON: source group →": "嵌套 JSON:源分组 →", "Network connection failed or server not responding": "网络连接失败或服务器未响应", - "Network proxy for this channel (supports socks5 protocol)": "此渠道的网络代理(支持 socks5 协议)", + "Network proxy for this channel (supports HTTP, HTTPS, SOCKS5, and SOCKS5H)": "此渠道的网络代理(支持 HTTP、HTTPS、SOCKS5 和 SOCKS5H)", "Never": "永不", "Never expires": "永不过期", "Never used an API Gateway?": "从未使用过 API 网关/中转 API?", @@ -3545,6 +3545,7 @@ "Provider updated successfully": "提供商更新成功", "Provider-specific endpoint, account, and compatibility settings.": "配置供应商专属的端点、账户和兼容性选项。", "Proxy Address": "代理地址", + "Proxy address must use HTTP, HTTPS, SOCKS5, or SOCKS5H and include a valid host": "代理地址必须使用 HTTP、HTTPS、SOCKS5 或 SOCKS5H,并包含有效的主机", "Prune Object Items": "清理对象项", "Prune object items by conditions": "按条件清理对象中的子项", "Prune Rule (string or JSON object)": "清理规则(字符串或 JSON 对象)",