diff --git a/cmd/server/main.go b/cmd/server/main.go
index 80b029f3d..facc85661 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -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...)
}
}
}
diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go
index f5ec3b318..02c779674 100644
--- a/cmd/server/main_test.go
+++ b/cmd/server/main_test.go
@@ -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)
}
})
}
diff --git a/internal/api/server.go b/internal/api/server.go
index 01875fd69..6e8b6ae20 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -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 {
diff --git a/internal/api/server_test.go b/internal/api/server_test.go
index 011c1f1e9..3a93870fd 100644
--- a/internal/api/server_test.go
+++ b/internal/api/server_test.go
@@ -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("management app"), 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"
diff --git a/internal/cmd/run.go b/internal/cmd/run.go
index c55784258..bd690975b 100644
--- a/internal/cmd/run.go
+++ b/internal/cmd/run.go
@@ -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{})
diff --git a/internal/safemode/example_api_keys.go b/internal/safemode/example_api_keys.go
index 8e8997557..2c95efc38 100644
--- a/internal/safemode/example_api_keys.go
+++ b/internal/safemode/example_api_keys.go
@@ -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(`
Example API key detectedExample API key detected
The normal API server was not started because the top-level api-keys configuration still contains template values.
`)
+ b.WriteString(`Example API key detectedExample API key detected
Proxy API endpoints are disabled because the top-level api-keys configuration still contains template values.
`)
if len(keys) > 0 {
b.WriteString(`Replace these values before using the proxy:
`)
for _, key := range keys {
@@ -172,12 +54,11 @@ func warningPageHTML(configPath string, keys []string) string {
}
b.WriteString(`
`)
}
- if strings.TrimSpace(configPath) != "" {
- b.WriteString(`Edit `)
- b.WriteString(html.EscapeString(configPath))
- b.WriteString(`, set strong random API keys, then restart CLIProxyAPI.
`)
- } else {
- b.WriteString(`Edit your config file, set strong random API keys, then restart CLIProxyAPI.
`)
+ b.WriteString(`Set strong random API keys, then retry the proxy endpoint.
`)
+ if trimmed := strings.TrimSpace(managementPath); trimmed != "" {
+ b.WriteString(``)
}
b.WriteString(``)
return b.String()
diff --git a/internal/safemode/example_api_keys_test.go b/internal/safemode/example_api_keys_test.go
index 6f37b04b1..7aaa5e8fe 100644
--- a/internal/safemode/example_api_keys_test.go
+++ b/internal/safemode/example_api_keys_test.go
@@ -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)
}
}