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:
hkfires
2026-07-04 08:49:59 +08:00
parent 5afc0f1d5e
commit df08038941
7 changed files with 257 additions and 224 deletions

View File

@@ -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...)
}
}
}

View File

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

View File

@@ -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 {

View File

@@ -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"

View File

@@ -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{})

View File

@@ -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()

View File

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