mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-03 06:35:00 +08:00
fix: allow management access in example API key safe mode
Start the normal server when template api-keys are detected, but keep proxy API endpoints disabled until the keys are updated. Show the warning page on / and /management.html, add a button to open the management panel via /management.html?safe-mode=configure, and remove the old warning-only server path. Fixes #4063
This commit is contained in:
@@ -18,6 +18,7 @@ import (
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
configaccess "github.com/router-for-me/CLIProxyAPI/v7/internal/access/config_access"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/api"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/buildinfo"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/cmd"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
@@ -54,7 +55,7 @@ func init() {
|
||||
buildinfo.BuildDate = BuildDate
|
||||
}
|
||||
|
||||
func shouldStartExampleAPIKeyWarningServer(cfg *config.Config, commandMode, tuiMode, standalone, cloudConfigMissing, homeMode bool) bool {
|
||||
func shouldEnableExampleAPIKeySafeMode(cfg *config.Config, commandMode, tuiMode, standalone, cloudConfigMissing, homeMode bool) bool {
|
||||
if cfg == nil || commandMode || homeMode || cloudConfigMissing {
|
||||
return false
|
||||
}
|
||||
@@ -547,11 +548,12 @@ func main() {
|
||||
commandMode := vertexImport != "" || antigravityLogin || codexLogin || codexDeviceLogin || claudeLogin || kimiLogin || xaiLogin
|
||||
cloudConfigMissing := isCloudDeploy && !configFileExists
|
||||
homeMode := configLoadedFromHome || (cfg != nil && cfg.Home.Enabled)
|
||||
if shouldStartExampleAPIKeyWarningServer(cfg, commandMode, tuiMode, standalone, cloudConfigMissing, homeMode) {
|
||||
exampleAPIKeySafeMode := shouldEnableExampleAPIKeySafeMode(cfg, commandMode, tuiMode, standalone, cloudConfigMissing, homeMode)
|
||||
serverOptions := []api.ServerOption(nil)
|
||||
if exampleAPIKeySafeMode {
|
||||
matches := safemode.ExampleAPIKeys(cfg.APIKeys)
|
||||
log.WithField("api_keys", strings.Join(matches, ",")).Error("unsafe example API key configured; starting warning-only server")
|
||||
cmd.StartExampleAPIKeyWarningServer(cfg, configFilePath, matches)
|
||||
return
|
||||
log.WithField("api_keys", strings.Join(matches, ",")).Error("unsafe example API key configured; proxy API endpoints disabled until api-keys is updated")
|
||||
serverOptions = append(serverOptions, api.WithExampleAPIKeySafeMode())
|
||||
}
|
||||
|
||||
// Register the shared token store once so all components use the same persistence backend.
|
||||
@@ -660,7 +662,7 @@ func main() {
|
||||
password = localMgmtPassword
|
||||
}
|
||||
|
||||
cancel, done := cmd.StartServiceBackgroundWithPluginHost(cfg, configFilePath, password, pluginHost)
|
||||
cancel, done := cmd.StartServiceBackgroundWithPluginHost(cfg, configFilePath, password, pluginHost, serverOptions...)
|
||||
|
||||
client := tui.NewClient(cfg.Port, password)
|
||||
ready := false
|
||||
@@ -709,7 +711,7 @@ func main() {
|
||||
} else if cfg.Home.Enabled {
|
||||
log.Info("Home mode: remote model updates disabled")
|
||||
}
|
||||
cmd.StartServiceWithPluginHost(cfg, configFilePath, password, pluginHost)
|
||||
cmd.StartServiceWithPluginHost(cfg, configFilePath, password, pluginHost, serverOptions...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
)
|
||||
|
||||
func TestShouldStartExampleAPIKeyWarningServer(t *testing.T) {
|
||||
func TestShouldEnableExampleAPIKeySafeMode(t *testing.T) {
|
||||
cfgWithExampleKey := &config.Config{
|
||||
SDKConfig: config.SDKConfig{
|
||||
APIKeys: []string{"real-key", " your-api-key-1 "},
|
||||
@@ -80,9 +80,9 @@ func TestShouldStartExampleAPIKeyWarningServer(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := shouldStartExampleAPIKeyWarningServer(tt.cfg, tt.commandMode, tt.tuiMode, tt.standalone, tt.cloudConfigMissing, tt.homeMode)
|
||||
got := shouldEnableExampleAPIKeySafeMode(tt.cfg, tt.commandMode, tt.tuiMode, tt.standalone, tt.cloudConfigMissing, tt.homeMode)
|
||||
if got != tt.want {
|
||||
t.Fatalf("shouldStartExampleAPIKeyWarningServer() = %t, want %t", got, tt.want)
|
||||
t.Fatalf("shouldEnableExampleAPIKeySafeMode() = %t, want %t", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import (
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/pluginhost"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/redisqueue"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/safemode"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
||||
sdkaccess "github.com/router-for-me/CLIProxyAPI/v7/sdk/access"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
|
||||
@@ -62,19 +63,25 @@ var corsExposedResponseHeaders = []string{
|
||||
|
||||
var corsExposedResponseHeadersJoined = strings.Join(corsExposedResponseHeaders, ", ")
|
||||
|
||||
const (
|
||||
exampleAPIKeyManagementPath = "/management.html"
|
||||
exampleAPIKeyManagementURL = "/management.html?safe-mode=configure"
|
||||
)
|
||||
|
||||
type serverOptionConfig struct {
|
||||
extraMiddleware []gin.HandlerFunc
|
||||
engineConfigurator func(*gin.Engine)
|
||||
routerConfigurator func(*gin.Engine, *handlers.BaseAPIHandler, *config.Config)
|
||||
requestLoggerFactory func(*config.Config, string) logging.RequestLogger
|
||||
localPassword string
|
||||
keepAliveEnabled bool
|
||||
keepAliveTimeout time.Duration
|
||||
keepAliveOnTimeout func()
|
||||
postAuthHook auth.PostAuthHook
|
||||
postAuthPersistHook auth.PostAuthHook
|
||||
pluginHost *pluginhost.Host
|
||||
configReloadHook func(context.Context, *config.Config)
|
||||
extraMiddleware []gin.HandlerFunc
|
||||
engineConfigurator func(*gin.Engine)
|
||||
routerConfigurator func(*gin.Engine, *handlers.BaseAPIHandler, *config.Config)
|
||||
requestLoggerFactory func(*config.Config, string) logging.RequestLogger
|
||||
localPassword string
|
||||
keepAliveEnabled bool
|
||||
keepAliveTimeout time.Duration
|
||||
keepAliveOnTimeout func()
|
||||
postAuthHook auth.PostAuthHook
|
||||
postAuthPersistHook auth.PostAuthHook
|
||||
pluginHost *pluginhost.Host
|
||||
configReloadHook func(context.Context, *config.Config)
|
||||
exampleAPIKeySafeMode bool
|
||||
}
|
||||
|
||||
// ServerOption customises HTTP server construction.
|
||||
@@ -174,6 +181,13 @@ func WithConfigReloadHook(hook func(context.Context, *config.Config)) ServerOpti
|
||||
}
|
||||
}
|
||||
|
||||
// WithExampleAPIKeySafeMode blocks proxy API endpoints while template API keys remain configured.
|
||||
func WithExampleAPIKeySafeMode() ServerOption {
|
||||
return func(cfg *serverOptionConfig) {
|
||||
cfg.exampleAPIKeySafeMode = true
|
||||
}
|
||||
}
|
||||
|
||||
// Server represents the main API server.
|
||||
// It encapsulates the Gin engine, HTTP server, handlers, and configuration.
|
||||
type Server struct {
|
||||
@@ -239,6 +253,9 @@ type Server struct {
|
||||
keepAliveOnTimeout func()
|
||||
keepAliveHeartbeat chan struct{}
|
||||
keepAliveStop chan struct{}
|
||||
|
||||
exampleAPIKeySafeModeEnabled bool
|
||||
exampleAPIKeySafeModeActive atomic.Bool
|
||||
}
|
||||
|
||||
// NewServer creates and initializes a new API server instance.
|
||||
@@ -315,8 +332,11 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
|
||||
envManagementSecret: envManagementSecret,
|
||||
wsRoutes: make(map[string]struct{}),
|
||||
pluginHost: optionState.pluginHost,
|
||||
|
||||
exampleAPIKeySafeModeEnabled: optionState.exampleAPIKeySafeMode,
|
||||
}
|
||||
s.wsAuthEnabled.Store(cfg.WebsocketAuth)
|
||||
s.exampleAPIKeySafeModeActive.Store(s.exampleAPIKeySafeModeRequired(cfg))
|
||||
s.handlers.SetPluginHost(optionState.pluginHost)
|
||||
if optionState.pluginHost != nil {
|
||||
optionState.pluginHost.SetModelExecutor(s.handlers)
|
||||
@@ -352,6 +372,7 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
|
||||
// Home heartbeat gate: when home is enabled, block all endpoints with 503 until the
|
||||
// subscribe-config heartbeat connection is healthy.
|
||||
engine.Use(s.homeHeartbeatMiddleware())
|
||||
engine.Use(s.exampleAPIKeySafeModeMiddleware())
|
||||
|
||||
// Setup routes
|
||||
s.setupRoutes()
|
||||
@@ -407,6 +428,71 @@ func (s *Server) homeHeartbeatMiddleware() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) exampleAPIKeySafeModeRequired(cfg *config.Config) bool {
|
||||
return s != nil && s.exampleAPIKeySafeModeEnabled && cfg != nil && safemode.HasExampleAPIKeys(cfg.APIKeys)
|
||||
}
|
||||
|
||||
func (s *Server) exampleAPIKeySafeModeMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if s == nil || !s.exampleAPIKeySafeModeActive.Load() || c == nil || c.Request == nil || c.Request.URL == nil {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
path := c.Request.URL.Path
|
||||
if path == exampleAPIKeyManagementPath && c.Query("safe-mode") == "configure" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
if (path == "/" || path == exampleAPIKeyManagementPath) && (c.Request.Method == http.MethodGet || c.Request.Method == http.MethodHead) {
|
||||
s.serveExampleAPIKeyWarningPage(c)
|
||||
return
|
||||
}
|
||||
if !isExampleAPIKeySafeModeProxyPath(path) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("X-CPA-SAFE-MODE", "example-api-key")
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||
"error": "unsafe_example_api_key",
|
||||
"message": "Proxy API endpoints are disabled because api-keys contains template values. Open /management.html?safe-mode=configure, update api-keys in Management, then retry.",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) serveExampleAPIKeyWarningPage(c *gin.Context) {
|
||||
cfg := s.cfg
|
||||
var keys []string
|
||||
if cfg != nil {
|
||||
keys = safemode.ExampleAPIKeys(cfg.APIKeys)
|
||||
}
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
c.Header("Cache-Control", "no-store")
|
||||
if c.Request.Method == http.MethodHead {
|
||||
c.Status(http.StatusOK)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.String(http.StatusOK, safemode.ExampleAPIKeyWarningPageHTML(keys, exampleAPIKeyManagementURL))
|
||||
c.Abort()
|
||||
}
|
||||
|
||||
func isExampleAPIKeySafeModeProxyPath(path string) bool {
|
||||
switch {
|
||||
case path == "/v1" || strings.HasPrefix(path, "/v1/"):
|
||||
return true
|
||||
case path == "/v1beta" || strings.HasPrefix(path, "/v1beta/"):
|
||||
return true
|
||||
case path == "/openai/v1" || strings.HasPrefix(path, "/openai/v1/"):
|
||||
return true
|
||||
case path == "/backend-api/codex" || strings.HasPrefix(path, "/backend-api/codex/"):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// setupRoutes configures the API routes for the server.
|
||||
// It defines the endpoints and associates them with their respective handlers.
|
||||
func (s *Server) setupRoutes() {
|
||||
@@ -1560,13 +1646,14 @@ func corsMiddleware() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) applyAccessConfig(oldCfg, newCfg *config.Config) {
|
||||
func (s *Server) applyAccessConfig(oldCfg, newCfg *config.Config) bool {
|
||||
if s == nil || s.accessManager == nil || newCfg == nil {
|
||||
return
|
||||
return false
|
||||
}
|
||||
if _, err := access.ApplyAccessProviders(s.accessManager, oldCfg, newCfg); err != nil {
|
||||
return
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// UpdateClients updates the server's client list and configuration.
|
||||
@@ -1676,7 +1763,14 @@ func (s *Server) UpdateClients(cfg *config.Config) {
|
||||
}
|
||||
redisqueue.SetEnabled(s.managementRoutesEnabled.Load() || (cfg != nil && cfg.Home.Enabled))
|
||||
|
||||
s.applyAccessConfig(oldCfg, cfg)
|
||||
exampleAPIKeySafeModeRequired := s.exampleAPIKeySafeModeRequired(cfg)
|
||||
if exampleAPIKeySafeModeRequired {
|
||||
s.exampleAPIKeySafeModeActive.Store(true)
|
||||
}
|
||||
accessConfigApplied := s.applyAccessConfig(oldCfg, cfg)
|
||||
if accessConfigApplied || exampleAPIKeySafeModeRequired {
|
||||
s.exampleAPIKeySafeModeActive.Store(exampleAPIKeySafeModeRequired)
|
||||
}
|
||||
s.cfg = cfg
|
||||
s.wsAuthEnabled.Store(cfg.WebsocketAuth)
|
||||
if oldCfg != nil && s.wsAuthChanged != nil && oldCfg.WebsocketAuth != cfg.WebsocketAuth {
|
||||
|
||||
@@ -357,6 +357,119 @@ func TestHomeEnabledHidesManagementEndpointsAndControlPanel(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestExampleAPIKeySafeModeShowsWarningAndKeepsManagement(t *testing.T) {
|
||||
t.Setenv("MANAGEMENT_PASSWORD", "test-management-key")
|
||||
staticDir := t.TempDir()
|
||||
t.Setenv("MANAGEMENT_STATIC_PATH", staticDir)
|
||||
if err := os.WriteFile(filepath.Join(staticDir, "management.html"), []byte("<html>management app</html>"), 0o600); err != nil {
|
||||
t.Fatalf("failed to write management asset: %v", err)
|
||||
}
|
||||
|
||||
server := newTestServerWithOptions(t, WithExampleAPIKeySafeMode())
|
||||
cfg := *server.cfg
|
||||
cfg.APIKeys = []string{"your-api-key-1"}
|
||||
server.UpdateClients(&cfg)
|
||||
|
||||
t.Run("root warning page includes management link", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
server.engine.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d body=%s", rr.Code, http.StatusOK, rr.Body.String())
|
||||
}
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{"Example API key detected", "Open Management", `href="/management.html?safe-mode=configure"`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("warning page missing %q: %s", want, body)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("management html defaults to warning page", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/management.html", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
server.engine.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d body=%s", rr.Code, http.StatusOK, rr.Body.String())
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), "Example API key detected") {
|
||||
t.Fatalf("management.html did not show warning page: %s", rr.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("management html head stops at warning page", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodHead, "/management.html", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
server.engine.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d body=%s", rr.Code, http.StatusOK, rr.Body.String())
|
||||
}
|
||||
if rr.Body.Len() != 0 {
|
||||
t.Fatalf("HEAD body length = %d, want 0", rr.Body.Len())
|
||||
}
|
||||
if got := rr.Header().Get("Cache-Control"); got != "no-store" {
|
||||
t.Fatalf("Cache-Control = %q, want no-store", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("management button query opens control panel", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/management.html?safe-mode=configure", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
server.engine.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d body=%s", rr.Code, http.StatusOK, rr.Body.String())
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), "management app") {
|
||||
t.Fatalf("management panel body missing: %s", rr.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("proxy endpoints are blocked", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
server.engine.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want %d body=%s", rr.Code, http.StatusForbidden, rr.Body.String())
|
||||
}
|
||||
if got := rr.Header().Get("X-CPA-SAFE-MODE"); got != "example-api-key" {
|
||||
t.Fatalf("X-CPA-SAFE-MODE = %q, want example-api-key", got)
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), "unsafe_example_api_key") {
|
||||
t.Fatalf("body missing safe-mode error: %s", rr.Body.String())
|
||||
}
|
||||
if strings.Contains(rr.Body.String(), "management_url") {
|
||||
t.Fatalf("body should not include management_url field: %s", rr.Body.String())
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), "/management.html?safe-mode=configure") {
|
||||
t.Fatalf("body missing management link in message: %s", rr.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("management endpoints still work", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/v0/management/config", nil)
|
||||
req.Header.Set("Authorization", "Bearer test-management-key")
|
||||
rr := httptest.NewRecorder()
|
||||
server.engine.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d body=%s", rr.Code, http.StatusOK, rr.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("safe mode clears after key update", func(t *testing.T) {
|
||||
nextCfg := cfg
|
||||
nextCfg.APIKeys = []string{"real-key"}
|
||||
server.UpdateClients(&nextCfg)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
||||
req.Header.Set("Authorization", "Bearer real-key")
|
||||
rr := httptest.NewRecorder()
|
||||
server.engine.ServeHTTP(rr, req)
|
||||
if rr.Code == http.StatusForbidden && strings.Contains(rr.Body.String(), "unsafe_example_api_key") {
|
||||
t.Fatalf("proxy endpoint still blocked after key update: %s", rr.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestModelsDispatchByAnthropicVersionHeader(t *testing.T) {
|
||||
modelRegistry := registry.GetGlobalRegistry()
|
||||
clientID := "test-anthropic-version-dispatch"
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/api"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/pluginhost"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/safemode"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
@@ -31,7 +30,7 @@ func StartService(cfg *config.Config, configPath string, localPassword string) {
|
||||
}
|
||||
|
||||
// StartServiceWithPluginHost builds and runs the proxy service with a shared plugin host.
|
||||
func StartServiceWithPluginHost(cfg *config.Config, configPath string, localPassword string, host *pluginhost.Host) {
|
||||
func StartServiceWithPluginHost(cfg *config.Config, configPath string, localPassword string, host *pluginhost.Host, serverOptions ...api.ServerOption) {
|
||||
builder := cliproxy.NewBuilder().
|
||||
WithConfig(cfg).
|
||||
WithConfigPath(configPath).
|
||||
@@ -39,6 +38,9 @@ func StartServiceWithPluginHost(cfg *config.Config, configPath string, localPass
|
||||
if host != nil {
|
||||
builder = builder.WithPluginHost(host)
|
||||
}
|
||||
if len(serverOptions) > 0 {
|
||||
builder = builder.WithServerOptions(serverOptions...)
|
||||
}
|
||||
|
||||
ctxSignal, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
@@ -65,18 +67,6 @@ func StartServiceWithPluginHost(cfg *config.Config, configPath string, localPass
|
||||
}
|
||||
}
|
||||
|
||||
// StartExampleAPIKeyWarningServer starts a warning-only server for unsafe template API keys.
|
||||
func StartExampleAPIKeyWarningServer(cfg *config.Config, configPath string, keys []string) {
|
||||
ctxSignal, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
log.Errorf("normal API server disabled: example API key values are configured in %s", configPath)
|
||||
log.Errorf("example API key warning page listening on: %s", safemode.WarningServerURL(cfg))
|
||||
if err := safemode.StartExampleAPIKeyWarningServer(ctxSignal, cfg, configPath, keys); err != nil && !errors.Is(err, context.Canceled) {
|
||||
log.Errorf("example API key warning server exited with error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// StartServiceBackground starts the proxy service in a background goroutine
|
||||
// and returns a cancel function for shutdown and a done channel.
|
||||
func StartServiceBackground(cfg *config.Config, configPath string, localPassword string) (cancel func(), done <-chan struct{}) {
|
||||
@@ -84,7 +74,7 @@ func StartServiceBackground(cfg *config.Config, configPath string, localPassword
|
||||
}
|
||||
|
||||
// StartServiceBackgroundWithPluginHost starts the proxy service with a shared plugin host.
|
||||
func StartServiceBackgroundWithPluginHost(cfg *config.Config, configPath string, localPassword string, host *pluginhost.Host) (cancel func(), done <-chan struct{}) {
|
||||
func StartServiceBackgroundWithPluginHost(cfg *config.Config, configPath string, localPassword string, host *pluginhost.Host, serverOptions ...api.ServerOption) (cancel func(), done <-chan struct{}) {
|
||||
builder := cliproxy.NewBuilder().
|
||||
WithConfig(cfg).
|
||||
WithConfigPath(configPath).
|
||||
@@ -92,6 +82,9 @@ func StartServiceBackgroundWithPluginHost(cfg *config.Config, configPath string,
|
||||
if host != nil {
|
||||
builder = builder.WithPluginHost(host)
|
||||
}
|
||||
if len(serverOptions) > 0 {
|
||||
builder = builder.WithServerOptions(serverOptions...)
|
||||
}
|
||||
|
||||
ctx, cancelFn := context.WithCancel(context.Background())
|
||||
doneCh := make(chan struct{})
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
package safemode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"html"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
)
|
||||
|
||||
var exampleAPIKeys = map[string]struct{}{
|
||||
@@ -49,120 +41,10 @@ func HasExampleAPIKeys(keys []string) bool {
|
||||
return len(ExampleAPIKeys(keys)) > 0
|
||||
}
|
||||
|
||||
// WarningServerURL returns a local-friendly URL for the warning-only server.
|
||||
func WarningServerURL(cfg *config.Config) string {
|
||||
scheme := "http"
|
||||
host := "127.0.0.1"
|
||||
port := 0
|
||||
if cfg != nil {
|
||||
port = cfg.Port
|
||||
if cfg.TLS.Enable {
|
||||
scheme = "https"
|
||||
}
|
||||
if trimmed := strings.TrimSpace(cfg.Host); trimmed != "" {
|
||||
host = trimmed
|
||||
}
|
||||
}
|
||||
if strings.Contains(host, ":") && !strings.HasPrefix(host, "[") {
|
||||
host = "[" + host + "]"
|
||||
}
|
||||
return fmt.Sprintf("%s://%s:%d/", scheme, host, port)
|
||||
}
|
||||
|
||||
// NewExampleAPIKeyWarningHandler serves a setup warning page and leaves all other routes unregistered.
|
||||
func NewExampleAPIKeyWarningHandler(configPath string, keys []string) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL == nil || (r.URL.Path != "/" && r.URL.Path != "/management.html") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
w.Header().Set("Allow", "GET, HEAD")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if r.Method == http.MethodHead {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprint(w, warningPageHTML(configPath, keys))
|
||||
})
|
||||
return mux
|
||||
}
|
||||
|
||||
// StartExampleAPIKeyWarningServer starts the warning-only HTTP(S) server and blocks until it stops.
|
||||
func StartExampleAPIKeyWarningServer(ctx context.Context, cfg *config.Config, configPath string, keys []string) error {
|
||||
if cfg == nil {
|
||||
cfg = &config.Config{}
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
var tlsConfig *tls.Config
|
||||
if cfg.TLS.Enable {
|
||||
certPath := strings.TrimSpace(cfg.TLS.Cert)
|
||||
keyPath := strings.TrimSpace(cfg.TLS.Key)
|
||||
if certPath == "" || keyPath == "" {
|
||||
return fmt.Errorf("failed to start HTTPS warning server: tls.cert or tls.key is empty")
|
||||
}
|
||||
certPair, errLoad := tls.LoadX509KeyPair(certPath, keyPath)
|
||||
if errLoad != nil {
|
||||
return fmt.Errorf("failed to start HTTPS warning server: %w", errLoad)
|
||||
}
|
||||
tlsConfig = &tls.Config{
|
||||
Certificates: []tls.Certificate{certPair},
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
|
||||
listener, errListen := net.Listen("tcp", addr)
|
||||
if errListen != nil {
|
||||
return fmt.Errorf("failed to start warning server: %w", errListen)
|
||||
}
|
||||
if tlsConfig != nil {
|
||||
listener = tls.NewListener(listener, tlsConfig)
|
||||
}
|
||||
|
||||
server := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: NewExampleAPIKeyWarningHandler(configPath, keys),
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- server.Serve(listener)
|
||||
}()
|
||||
|
||||
select {
|
||||
case errServe := <-errCh:
|
||||
if errServe == nil || errServe == http.ErrServerClosed {
|
||||
return nil
|
||||
}
|
||||
return errServe
|
||||
case <-ctx.Done():
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
errShutdown := server.Shutdown(shutdownCtx)
|
||||
errServe := <-errCh
|
||||
if errShutdown != nil {
|
||||
return errShutdown
|
||||
}
|
||||
if errServe != nil && errServe != http.ErrServerClosed {
|
||||
return errServe
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func warningPageHTML(configPath string, keys []string) string {
|
||||
// ExampleAPIKeyWarningPageHTML returns the setup warning page HTML.
|
||||
func ExampleAPIKeyWarningPageHTML(keys []string, managementPath string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Example API key detected</title><style>body{margin:0;font-family:Arial,sans-serif;background:#f6f8fa;color:#1f2328}.wrap{max-width:760px;margin:12vh auto;padding:0 24px}.panel{background:#fff;border:1px solid #d0d7de;border-radius:8px;padding:28px;box-shadow:0 8px 24px rgba(140,149,159,.2)}h1{margin:0 0 12px;font-size:28px;line-height:1.25}p{font-size:16px;line-height:1.55}code{background:#f6f8fa;border:1px solid #d0d7de;border-radius:4px;padding:2px 5px}.keys{margin:16px 0;padding-left:22px}.path{word-break:break-all}</style></head><body><main class="wrap"><section class="panel"><h1>Example API key detected</h1><p>The normal API server was not started because the top-level <code>api-keys</code> configuration still contains template values.</p>`)
|
||||
b.WriteString(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Example API key detected</title><style>body{margin:0;font-family:Arial,sans-serif;background:#f6f8fa;color:#1f2328}.wrap{max-width:760px;margin:12vh auto;padding:0 24px}.panel{background:#fff;border:1px solid #d0d7de;border-radius:8px;padding:28px;box-shadow:0 8px 24px rgba(140,149,159,.2)}h1{margin:0 0 12px;font-size:28px;line-height:1.25}p{font-size:16px;line-height:1.55}code{background:#f6f8fa;border:1px solid #d0d7de;border-radius:4px;padding:2px 5px}.keys{margin:16px 0;padding-left:22px}.actions{margin-top:24px}.button{display:inline-block;border-radius:6px;background:#0969da;color:#fff;text-decoration:none;font-weight:600;padding:10px 16px}.button:hover{background:#0759b8}</style></head><body><main class="wrap"><section class="panel"><h1>Example API key detected</h1><p>Proxy API endpoints are disabled because the top-level <code>api-keys</code> configuration still contains template values.</p>`)
|
||||
if len(keys) > 0 {
|
||||
b.WriteString(`<p>Replace these values before using the proxy:</p><ul class="keys">`)
|
||||
for _, key := range keys {
|
||||
@@ -172,12 +54,11 @@ func warningPageHTML(configPath string, keys []string) string {
|
||||
}
|
||||
b.WriteString(`</ul>`)
|
||||
}
|
||||
if strings.TrimSpace(configPath) != "" {
|
||||
b.WriteString(`<p>Edit <code class="path">`)
|
||||
b.WriteString(html.EscapeString(configPath))
|
||||
b.WriteString(`</code>, set strong random API keys, then restart CLIProxyAPI.</p>`)
|
||||
} else {
|
||||
b.WriteString(`<p>Edit your config file, set strong random API keys, then restart CLIProxyAPI.</p>`)
|
||||
b.WriteString(`<p>Set strong random API keys, then retry the proxy endpoint.</p>`)
|
||||
if trimmed := strings.TrimSpace(managementPath); trimmed != "" {
|
||||
b.WriteString(`<div class="actions"><a class="button" href="`)
|
||||
b.WriteString(html.EscapeString(trimmed))
|
||||
b.WriteString(`">Open Management</a></div>`)
|
||||
}
|
||||
b.WriteString(`</section></main></body></html>`)
|
||||
return b.String()
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
package safemode
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
)
|
||||
|
||||
func TestExampleAPIKeysDetectsOnlyTemplateValues(t *testing.T) {
|
||||
@@ -42,60 +38,14 @@ func TestExampleAPIKeysIgnoresSimilarValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExampleAPIKeyWarningHandler(t *testing.T) {
|
||||
handler := NewExampleAPIKeyWarningHandler("C:\\config.yaml", []string{"your-api-key-1"})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET / status = %d, want %d", w.Code, http.StatusOK)
|
||||
}
|
||||
body := w.Body.String()
|
||||
for _, want := range []string{"Example API key detected", "your-api-key-1", "C:\\config.yaml"} {
|
||||
func TestExampleAPIKeyWarningPageIncludesManagementButton(t *testing.T) {
|
||||
body := ExampleAPIKeyWarningPageHTML([]string{"your-api-key-1"}, "/management.html?safe-mode=configure")
|
||||
for _, want := range []string{"Example API key detected", "your-api-key-1", "Open Management", `href="/management.html?safe-mode=configure"`, "Proxy API endpoints are disabled"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("GET / body missing %q: %s", want, body)
|
||||
t.Fatalf("warning page missing %q: %s", want, body)
|
||||
}
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/management.html", nil)
|
||||
w = httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET /management.html status = %d, want %d", w.Code, http.StatusOK)
|
||||
}
|
||||
if body := w.Body.String(); !strings.Contains(body, "Example API key detected") {
|
||||
t.Fatalf("GET /management.html body missing warning: %s", body)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodHead, "/", nil)
|
||||
w = httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("HEAD / status = %d, want %d", w.Code, http.StatusOK)
|
||||
}
|
||||
if w.Body.Len() != 0 {
|
||||
t.Fatalf("HEAD / body length = %d, want 0", w.Body.Len())
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
||||
w = httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("GET /v1/models status = %d, want %d", w.Code, http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarningServerURL(t *testing.T) {
|
||||
cfg := &config.Config{Port: 8317}
|
||||
if got := WarningServerURL(cfg); got != "http://127.0.0.1:8317/" {
|
||||
t.Fatalf("WarningServerURL() = %q", got)
|
||||
}
|
||||
|
||||
cfg.Host = "::1"
|
||||
cfg.TLS.Enable = true
|
||||
if got := WarningServerURL(cfg); got != "https://[::1]:8317/" {
|
||||
t.Fatalf("WarningServerURL() = %q", got)
|
||||
if strings.Contains(body, `class="path"`) {
|
||||
t.Fatalf("warning page should not include a local config path: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user