mirror of
https://gh-proxy.org/https://github.com/klinsmaya/CLIProxyAPI.git
synced 2026-11-10 02:16:49 +08:00
Merge pull request #3735 from router-for-me/safemode
feat(safemode): implement example API key warning server and related functionality
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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{}) {
|
||||
|
||||
@@ -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(`<!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>`)
|
||||
if len(keys) > 0 {
|
||||
b.WriteString(`<p>Replace these values before using the proxy:</p><ul class="keys">`)
|
||||
for _, key := range keys {
|
||||
b.WriteString(`<li><code>`)
|
||||
b.WriteString(html.EscapeString(key))
|
||||
b.WriteString(`</code></li>`)
|
||||
}
|
||||
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(`</section></main></body></html>`)
|
||||
return b.String()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user