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
77 changes: 77 additions & 0 deletions common/proxy_url.go
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 1 addition & 1 deletion controller/channel-billing.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
42 changes: 35 additions & 7 deletions controller/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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()
Expand All @@ -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,
Expand All @@ -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,
})
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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())
Expand Down
127 changes: 127 additions & 0 deletions controller/channel_test_internal_test.go
Original file line number Diff line number Diff line change
@@ -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{
Expand Down
1 change: 0 additions & 1 deletion controller/channel_upstream_update.go
Original file line number Diff line number Diff line change
Expand Up @@ -531,7 +531,6 @@ func refreshChannelRuntimeCache() {
model.InitChannelCache()
}()
}
service.ResetProxyClientCache()
}

func shouldSendUpstreamModelUpdateNotification(now int64, changedChannels int, failedChannels int) bool {
Expand Down
3 changes: 1 addition & 2 deletions controller/codex_usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading