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 detected

Example 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 detected

Example 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) } }