Skip to content

Commit 5422cb4

Browse files
committed
Merge remote-tracking branch 'upstream/main' into remote-fix
# Conflicts: # README.md # README_CN.md # README_JA.md # internal/registry/model_definitions_test.go # sdk/cliproxy/service.go
2 parents 74bfd37 + 736ae61 commit 5422cb4

35 files changed

Lines changed: 2424 additions & 191 deletions

config.example.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,7 @@ nonstream-keepalive-interval: 0
261261
# OpenAI compatibility providers
262262
# openai-compatibility:
263263
# - name: "openrouter" # The name of the provider; it will be used in the user agent and other places.
264+
# disabled: false # optional: set to true to disable this provider without removing it
264265
# prefix: "test" # optional: require calls like "test/kimi-k2" to target this provider's credentials
265266
# base-url: "https://openrouter.ai/api/v1" # The base URL of the provider.
266267
# headers:

internal/api/buffered_conn.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package api
2+
3+
import (
4+
"bufio"
5+
"crypto/tls"
6+
"net"
7+
)
8+
9+
type bufferedConn struct {
10+
net.Conn
11+
reader *bufio.Reader
12+
}
13+
14+
func (c *bufferedConn) Read(p []byte) (int, error) {
15+
if c == nil {
16+
return 0, net.ErrClosed
17+
}
18+
if c.reader == nil {
19+
return c.Conn.Read(p)
20+
}
21+
return c.reader.Read(p)
22+
}
23+
24+
func (c *bufferedConn) ConnectionState() tls.ConnectionState {
25+
if c == nil || c.Conn == nil {
26+
return tls.ConnectionState{}
27+
}
28+
if stater, ok := c.Conn.(interface{ ConnectionState() tls.ConnectionState }); ok {
29+
return stater.ConnectionState()
30+
}
31+
return tls.ConnectionState{}
32+
}

internal/api/handlers/management/api_tools.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -830,6 +830,9 @@ func resolveOpenAICompatAPIKeyProxyURL(cfg *config.Config, auth *coreauth.Auth,
830830

831831
for i := range cfg.OpenAICompatibility {
832832
compat := &cfg.OpenAICompatibility[i]
833+
if compat.Disabled {
834+
continue
835+
}
833836
for _, candidate := range candidates {
834837
if candidate != "" && strings.EqualFold(strings.TrimSpace(candidate), compat.Name) {
835838
for j := range compat.APIKeyEntries {

internal/api/handlers/management/config_auth_index.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ type openAICompatibilityAPIKeyWithAuthIndex struct {
3636
type openAICompatibilityWithAuthIndex struct {
3737
Name string `json:"name"`
3838
Priority int `json:"priority,omitempty"`
39+
Disabled bool `json:"disabled"`
3940
Prefix string `json:"prefix,omitempty"`
4041
BaseURL string `json:"base-url"`
4142
APIKeyEntries []openAICompatibilityAPIKeyWithAuthIndex `json:"api-key-entries,omitempty"`
@@ -215,6 +216,7 @@ func (h *Handler) openAICompatibilityWithAuthIndex() []openAICompatibilityWithAu
215216
response := openAICompatibilityWithAuthIndex{
216217
Name: entry.Name,
217218
Priority: entry.Priority,
219+
Disabled: entry.Disabled,
218220
Prefix: entry.Prefix,
219221
BaseURL: entry.BaseURL,
220222
Models: entry.Models,

internal/api/handlers/management/config_lists.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,7 @@ func (h *Handler) PatchOpenAICompat(c *gin.Context) {
464464
type openAICompatPatch struct {
465465
Name *string `json:"name"`
466466
Prefix *string `json:"prefix"`
467+
Disabled *bool `json:"disabled"`
467468
BaseURL *string `json:"base-url"`
468469
APIKeyEntries *[]config.OpenAICompatibilityAPIKey `json:"api-key-entries"`
469470
Models *[]config.OpenAICompatibilityModel `json:"models"`
@@ -506,6 +507,9 @@ func (h *Handler) PatchOpenAICompat(c *gin.Context) {
506507
if body.Value.Prefix != nil {
507508
entry.Prefix = strings.TrimSpace(*body.Value.Prefix)
508509
}
510+
if body.Value.Disabled != nil {
511+
entry.Disabled = *body.Value.Disabled
512+
}
509513
if body.Value.BaseURL != nil {
510514
trimmed := strings.TrimSpace(*body.Value.BaseURL)
511515
if trimmed == "" {

internal/api/handlers/management/handler.go

Lines changed: 95 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -152,74 +152,13 @@ func (h *Handler) SetPostAuthHook(hook coreauth.PostAuthHook) {
152152
// All requests (local and remote) require a valid management key.
153153
// Additionally, remote access requires allow-remote-management=true.
154154
func (h *Handler) Middleware() gin.HandlerFunc {
155-
const maxFailures = 5
156-
const banDuration = 30 * time.Minute
157-
158155
return func(c *gin.Context) {
159156
c.Header("X-CPA-VERSION", buildinfo.Version)
160157
c.Header("X-CPA-COMMIT", buildinfo.Commit)
161158
c.Header("X-CPA-BUILD-DATE", buildinfo.BuildDate)
162159

163160
clientIP := c.ClientIP()
164161
localClient := clientIP == "127.0.0.1" || clientIP == "::1"
165-
cfg := h.cfg
166-
var (
167-
allowRemote bool
168-
secretHash string
169-
)
170-
if cfg != nil {
171-
allowRemote = cfg.RemoteManagement.AllowRemote
172-
secretHash = cfg.RemoteManagement.SecretKey
173-
}
174-
if h.allowRemoteOverride {
175-
allowRemote = true
176-
}
177-
envSecret := h.envSecret
178-
179-
fail := func() {}
180-
if !localClient {
181-
h.attemptsMu.Lock()
182-
ai := h.failedAttempts[clientIP]
183-
if ai != nil {
184-
if !ai.blockedUntil.IsZero() {
185-
if time.Now().Before(ai.blockedUntil) {
186-
remaining := time.Until(ai.blockedUntil).Round(time.Second)
187-
h.attemptsMu.Unlock()
188-
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": fmt.Sprintf("IP banned due to too many failed attempts. Try again in %s", remaining)})
189-
return
190-
}
191-
// Ban expired, reset state
192-
ai.blockedUntil = time.Time{}
193-
ai.count = 0
194-
}
195-
}
196-
h.attemptsMu.Unlock()
197-
198-
if !allowRemote {
199-
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "remote management disabled"})
200-
return
201-
}
202-
203-
fail = func() {
204-
h.attemptsMu.Lock()
205-
aip := h.failedAttempts[clientIP]
206-
if aip == nil {
207-
aip = &attemptInfo{}
208-
h.failedAttempts[clientIP] = aip
209-
}
210-
aip.count++
211-
aip.lastActivity = time.Now()
212-
if aip.count >= maxFailures {
213-
aip.blockedUntil = time.Now().Add(banDuration)
214-
aip.count = 0
215-
}
216-
h.attemptsMu.Unlock()
217-
}
218-
}
219-
if secretHash == "" && envSecret == "" {
220-
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "remote management key not set"})
221-
return
222-
}
223162

224163
// Accept either Authorization: Bearer <key> or X-Management-Key
225164
var provided string
@@ -235,55 +174,114 @@ func (h *Handler) Middleware() gin.HandlerFunc {
235174
provided = c.GetHeader("X-Management-Key")
236175
}
237176

238-
if provided == "" {
239-
if !localClient {
240-
fail()
241-
}
242-
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing management key"})
177+
allowed, statusCode, errMsg := h.AuthenticateManagementKey(clientIP, localClient, provided)
178+
if !allowed {
179+
c.AbortWithStatusJSON(statusCode, gin.H{"error": errMsg})
243180
return
244181
}
182+
c.Next()
183+
}
184+
}
245185

246-
if localClient {
247-
if lp := h.localPassword; lp != "" {
248-
if subtle.ConstantTimeCompare([]byte(provided), []byte(lp)) == 1 {
249-
c.Next()
250-
return
251-
}
252-
}
186+
// AuthenticateManagementKey verifies the provided management key for the given client.
187+
// It mirrors the behaviour of Middleware() so non-HTTP callers can reuse the same logic.
188+
func (h *Handler) AuthenticateManagementKey(clientIP string, localClient bool, provided string) (bool, int, string) {
189+
const maxFailures = 5
190+
const banDuration = 30 * time.Minute
191+
192+
if h == nil {
193+
return false, http.StatusForbidden, "remote management disabled"
194+
}
195+
196+
cfg := h.cfg
197+
var (
198+
allowRemote bool
199+
secretHash string
200+
)
201+
if cfg != nil {
202+
allowRemote = cfg.RemoteManagement.AllowRemote
203+
secretHash = cfg.RemoteManagement.SecretKey
204+
}
205+
if h.allowRemoteOverride {
206+
allowRemote = true
207+
}
208+
envSecret := h.envSecret
209+
210+
now := time.Now()
211+
h.attemptsMu.Lock()
212+
ai := h.failedAttempts[clientIP]
213+
if ai != nil && !ai.blockedUntil.IsZero() {
214+
if now.Before(ai.blockedUntil) {
215+
remaining := ai.blockedUntil.Sub(now).Round(time.Second)
216+
h.attemptsMu.Unlock()
217+
return false, http.StatusForbidden, fmt.Sprintf("IP banned due to too many failed attempts. Try again in %s", remaining)
253218
}
219+
// Ban expired, reset state
220+
ai.blockedUntil = time.Time{}
221+
ai.count = 0
222+
}
223+
h.attemptsMu.Unlock()
254224

255-
if envSecret != "" && subtle.ConstantTimeCompare([]byte(provided), []byte(envSecret)) == 1 {
256-
if !localClient {
257-
h.attemptsMu.Lock()
258-
if ai := h.failedAttempts[clientIP]; ai != nil {
259-
ai.count = 0
260-
ai.blockedUntil = time.Time{}
261-
}
262-
h.attemptsMu.Unlock()
263-
}
264-
c.Next()
265-
return
225+
if !localClient && !allowRemote {
226+
return false, http.StatusForbidden, "remote management disabled"
227+
}
228+
229+
fail := func() {
230+
h.attemptsMu.Lock()
231+
aip := h.failedAttempts[clientIP]
232+
if aip == nil {
233+
aip = &attemptInfo{}
234+
h.failedAttempts[clientIP] = aip
266235
}
236+
aip.count++
237+
aip.lastActivity = time.Now()
238+
if aip.count >= maxFailures {
239+
aip.blockedUntil = time.Now().Add(banDuration)
240+
aip.count = 0
241+
}
242+
h.attemptsMu.Unlock()
243+
}
267244

268-
if secretHash == "" || bcrypt.CompareHashAndPassword([]byte(secretHash), []byte(provided)) != nil {
269-
if !localClient {
270-
fail()
271-
}
272-
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid management key"})
273-
return
245+
reset := func() {
246+
h.attemptsMu.Lock()
247+
if ai := h.failedAttempts[clientIP]; ai != nil {
248+
ai.count = 0
249+
ai.blockedUntil = time.Time{}
274250
}
251+
h.attemptsMu.Unlock()
252+
}
253+
254+
if secretHash == "" && envSecret == "" {
255+
return false, http.StatusForbidden, "remote management key not set"
256+
}
275257

276-
if !localClient {
277-
h.attemptsMu.Lock()
278-
if ai := h.failedAttempts[clientIP]; ai != nil {
279-
ai.count = 0
280-
ai.blockedUntil = time.Time{}
258+
if provided == "" {
259+
fail()
260+
return false, http.StatusUnauthorized, "missing management key"
261+
}
262+
263+
if localClient {
264+
if lp := h.localPassword; lp != "" {
265+
if subtle.ConstantTimeCompare([]byte(provided), []byte(lp)) == 1 {
266+
reset()
267+
return true, 0, ""
281268
}
282-
h.attemptsMu.Unlock()
283269
}
270+
}
284271

285-
c.Next()
272+
if envSecret != "" && subtle.ConstantTimeCompare([]byte(provided), []byte(envSecret)) == 1 {
273+
reset()
274+
return true, 0, ""
275+
}
276+
277+
if secretHash == "" || bcrypt.CompareHashAndPassword([]byte(secretHash), []byte(provided)) != nil {
278+
fail()
279+
return false, http.StatusUnauthorized, "invalid management key"
286280
}
281+
282+
reset()
283+
284+
return true, 0, ""
287285
}
288286

289287
// persist saves the current in-memory config to disk.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package management
2+
3+
import (
4+
"net/http"
5+
"strings"
6+
"testing"
7+
8+
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
9+
)
10+
11+
func TestAuthenticateManagementKey_LocalhostIPBan_BlocksCorrectKeyDuringBan(t *testing.T) {
12+
h := &Handler{
13+
cfg: &config.Config{},
14+
failedAttempts: make(map[string]*attemptInfo),
15+
envSecret: "test-secret",
16+
}
17+
18+
for i := 0; i < 5; i++ {
19+
allowed, statusCode, errMsg := h.AuthenticateManagementKey("127.0.0.1", true, "wrong-secret")
20+
if allowed {
21+
t.Fatalf("expected auth to be denied at attempt %d", i+1)
22+
}
23+
if statusCode != http.StatusUnauthorized || errMsg != "invalid management key" {
24+
t.Fatalf("unexpected auth failure at attempt %d: status=%d msg=%q", i+1, statusCode, errMsg)
25+
}
26+
}
27+
28+
allowed, statusCode, errMsg := h.AuthenticateManagementKey("127.0.0.1", true, "test-secret")
29+
if allowed {
30+
t.Fatalf("expected correct key to be denied while banned")
31+
}
32+
if statusCode != http.StatusForbidden {
33+
t.Fatalf("expected forbidden status while banned, got %d", statusCode)
34+
}
35+
if !strings.HasPrefix(errMsg, "IP banned due to too many failed attempts. Try again in") {
36+
t.Fatalf("unexpected banned message: %q", errMsg)
37+
}
38+
}

0 commit comments

Comments
 (0)