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 detectedExample 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:
`)
+ for _, key := range keys {
+ b.WriteString(``)
+ b.WriteString(html.EscapeString(key))
+ b.WriteString(` `)
+ }
+ 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(``)
+ 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)
+ }
+}