diff --git a/cmd/server/main.go b/cmd/server/main.go index 4181faec..95c646fd 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -27,6 +27,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v7/internal/misc" "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/store" _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator" "github.com/router-for-me/CLIProxyAPI/v7/internal/tui" @@ -51,6 +52,16 @@ func init() { buildinfo.BuildDate = BuildDate } +func shouldStartExampleAPIKeyWarningServer(cfg *config.Config, commandMode, tuiMode, standalone, cloudConfigMissing, homeMode bool) bool { + if cfg == nil || commandMode || homeMode || cloudConfigMissing { + return false + } + if tuiMode && !standalone { + return false + } + return safemode.HasExampleAPIKeys(cfg.APIKeys) +} + // main is the entry point of the application. // It parses command-line flags, loads configuration, and starts the appropriate // service based on the provided flags (login, codex-login, or server mode). @@ -512,6 +523,16 @@ func main() { CallbackPort: oauthCallbackPort, } + commandMode := vertexImport != "" || login || antigravityLogin || codexLogin || codexDeviceLogin || claudeLogin || kimiLogin || xaiLogin + cloudConfigMissing := isCloudDeploy && !configFileExists + homeMode := configLoadedFromHome || (cfg != nil && cfg.Home.Enabled) + if shouldStartExampleAPIKeyWarningServer(cfg, commandMode, tuiMode, standalone, cloudConfigMissing, homeMode) { + 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 + } + // Register the shared token store once so all components use the same persistence backend. if usePostgresStore { sdkAuth.RegisterTokenStore(pgStoreInst) diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go new file mode 100644 index 00000000..f5ec3b31 --- /dev/null +++ b/cmd/server/main_test.go @@ -0,0 +1,89 @@ +package main + +import ( + "testing" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" +) + +func TestShouldStartExampleAPIKeyWarningServer(t *testing.T) { + cfgWithExampleKey := &config.Config{ + SDKConfig: config.SDKConfig{ + APIKeys: []string{"real-key", " your-api-key-1 "}, + }, + } + cfgWithRealKey := &config.Config{ + SDKConfig: config.SDKConfig{ + APIKeys: []string{"real-key"}, + }, + } + + tests := []struct { + name string + cfg *config.Config + commandMode bool + tuiMode bool + standalone bool + cloudConfigMissing bool + homeMode bool + want bool + }{ + { + name: "normal server with example key", + cfg: cfgWithExampleKey, + want: true, + }, + { + name: "standalone tui with example key", + cfg: cfgWithExampleKey, + tuiMode: true, + standalone: true, + want: true, + }, + { + name: "pure tui client is not blocked", + cfg: cfgWithExampleKey, + tuiMode: true, + standalone: false, + commandMode: false, + want: false, + }, + { + name: "one-shot command is not blocked", + cfg: cfgWithExampleKey, + commandMode: true, + want: false, + }, + { + name: "home mode is not blocked", + cfg: cfgWithExampleKey, + homeMode: true, + want: false, + }, + { + name: "cloud standby without config is not blocked", + cfg: cfgWithExampleKey, + cloudConfigMissing: true, + want: false, + }, + { + name: "normal server with real key", + cfg: cfgWithRealKey, + want: false, + }, + { + name: "nil config", + cfg: nil, + want: false, + }, + } + + 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) + if got != tt.want { + t.Fatalf("shouldStartExampleAPIKeyWarningServer() = %t, want %t", got, tt.want) + } + }) + } +} diff --git a/internal/cmd/run.go b/internal/cmd/run.go index 38f189b4..9d699bcf 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -12,6 +12,7 @@ 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/safemode" "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy" log "github.com/sirupsen/logrus" ) @@ -55,6 +56,18 @@ func StartService(cfg *config.Config, configPath string, localPassword string) { } } +// 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{}) { diff --git a/internal/safemode/example_api_keys.go b/internal/safemode/example_api_keys.go new file mode 100644 index 00000000..066c02d9 --- /dev/null +++ b/internal/safemode/example_api_keys.go @@ -0,0 +1,184 @@ +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{}{ + "your-api-key-1": {}, + "your-api-key-2": {}, + "your-api-key-3": {}, +} + +// ExampleAPIKeys returns configured top-level API keys that still use template values. +func ExampleAPIKeys(keys []string) []string { + if len(keys) == 0 { + return nil + } + + matches := make([]string, 0, len(keys)) + seen := make(map[string]struct{}, len(exampleAPIKeys)) + for _, key := range keys { + trimmed := strings.TrimSpace(key) + if _, ok := exampleAPIKeys[trimmed]; !ok { + continue + } + if _, exists := seen[trimmed]; exists { + continue + } + seen[trimmed] = struct{}{} + matches = append(matches, trimmed) + } + if len(matches) == 0 { + return nil + } + return matches +} + +// HasExampleAPIKeys reports whether any configured top-level API key is a template value. +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 != "/" { + 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 { + 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.

`) + if len(keys) > 0 { + b.WriteString(`

Replace these values before using the proxy:

`) + } + 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(`
`) + return b.String() +} diff --git a/internal/safemode/example_api_keys_test.go b/internal/safemode/example_api_keys_test.go new file mode 100644 index 00000000..2aaf5471 --- /dev/null +++ b/internal/safemode/example_api_keys_test.go @@ -0,0 +1,91 @@ +package safemode + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" +) + +func TestExampleAPIKeysDetectsOnlyTemplateValues(t *testing.T) { + keys := []string{ + " real-key ", + " your-api-key-1 ", + "your-api-key", + "change-me", + "your-api-key-2", + "your-api-key-2", + "your-api-key-3", + } + + got := ExampleAPIKeys(keys) + want := []string{"your-api-key-1", "your-api-key-2", "your-api-key-3"} + if len(got) != len(want) { + t.Fatalf("ExampleAPIKeys() = %#v, want %#v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("ExampleAPIKeys()[%d] = %q, want %q (all: %#v)", i, got[i], want[i], got) + } + } +} + +func TestExampleAPIKeysIgnoresSimilarValues(t *testing.T) { + keys := []string{"your-api-key", "change-me", "changeme", "your-api-key-4", "my-your-api-key-1"} + if got := ExampleAPIKeys(keys); len(got) != 0 { + t.Fatalf("ExampleAPIKeys() = %#v, want empty", got) + } + if HasExampleAPIKeys(keys) { + t.Fatal("HasExampleAPIKeys() = true, want false") + } +} + +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"} { + if !strings.Contains(body, want) { + t.Fatalf("GET / body missing %q: %s", want, 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) + } +}