Add projects
This commit is contained in:
parent
2d3a9ad623
commit
8b607dd700
1802 changed files with 503346 additions and 2 deletions
397
backend/internal/api/server.go
Normal file
397
backend/internal/api/server.go
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
// Package api provides the HTTP API server implementation for the CLI Proxy API.
|
||||
// It includes the main server struct, routing setup, middleware for CORS and authentication,
|
||||
// and integration with various AI API handlers (OpenAI, Claude, Gemini).
|
||||
// The server supports hot-reloading of clients and configuration.
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
managementHandlers "github.com/router-for-me/CLIProxyAPI/v7/internal/api/handlers/management"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/api/middleware"
|
||||
codexlive "github.com/router-for-me/CLIProxyAPI/v7/internal/client/codex/live"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/pluginhost"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/redisqueue"
|
||||
sdkaccess "github.com/router-for-me/CLIProxyAPI/v7/sdk/access"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/net/http2"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Server represents the main API server.
|
||||
// It encapsulates the Gin engine, HTTP server, handlers, and configuration.
|
||||
type Server struct {
|
||||
// engine is the Gin web framework engine instance.
|
||||
engine *gin.Engine
|
||||
|
||||
// server is the underlying HTTP server.
|
||||
server *http.Server
|
||||
|
||||
// muxBaseListener is the shared TCP listener used to serve both HTTP and Redis protocol traffic.
|
||||
muxBaseListener net.Listener
|
||||
|
||||
// muxHTTPListener receives HTTP connections selected by the multiplexer.
|
||||
muxHTTPListener *muxListener
|
||||
|
||||
// handlers contains the API handlers for processing requests.
|
||||
handlers *handlers.BaseAPIHandler
|
||||
codexLiveHandler *codexlive.Handler
|
||||
|
||||
// cfg holds the current server configuration.
|
||||
cfg *config.Config
|
||||
|
||||
// oldConfigYaml stores a YAML snapshot of the previous configuration for change detection.
|
||||
// This prevents issues when the config object is modified in place by Management API.
|
||||
oldConfigYaml []byte
|
||||
|
||||
// accessManager handles request authentication providers.
|
||||
accessManager *sdkaccess.Manager
|
||||
|
||||
// requestLogger is the request logger instance for dynamic configuration updates.
|
||||
requestLogger logging.RequestLogger
|
||||
loggerToggle func(bool)
|
||||
|
||||
// configFilePath is the absolute path to the YAML config file for persistence.
|
||||
configFilePath string
|
||||
|
||||
// currentPath is the absolute path to the current working directory.
|
||||
currentPath string
|
||||
|
||||
// wsRoutes tracks registered websocket upgrade paths.
|
||||
wsRouteMu sync.Mutex
|
||||
wsRoutes map[string]struct{}
|
||||
wsAuthChanged func(bool, bool)
|
||||
wsAuthEnabled atomic.Bool
|
||||
|
||||
// management handler
|
||||
mgmt *managementHandlers.Handler
|
||||
|
||||
// pluginHost owns dynamic plugin Management API route dispatch.
|
||||
pluginHost *pluginhost.Host
|
||||
|
||||
// managementRoutesRegistered tracks whether the management routes have been attached to the engine.
|
||||
managementRoutesRegistered atomic.Bool
|
||||
// managementRoutesEnabled controls whether management endpoints serve real handlers.
|
||||
managementRoutesEnabled atomic.Bool
|
||||
|
||||
// envManagementSecret indicates whether MANAGEMENT_PASSWORD is configured.
|
||||
envManagementSecret bool
|
||||
|
||||
localPassword string
|
||||
|
||||
keepAliveEnabled bool
|
||||
keepAliveTimeout time.Duration
|
||||
keepAliveOnTimeout func()
|
||||
keepAliveHeartbeat chan struct{}
|
||||
keepAliveStop chan struct{}
|
||||
|
||||
exampleAPIKeySafeModeEnabled bool
|
||||
exampleAPIKeySafeModeActive atomic.Bool
|
||||
}
|
||||
|
||||
// NewServer creates and initializes a new API server instance.
|
||||
// It sets up the Gin engine, middleware, routes, and handlers.
|
||||
//
|
||||
// Parameters:
|
||||
// - cfg: The server configuration
|
||||
// - authManager: core runtime auth manager
|
||||
// - accessManager: request authentication manager
|
||||
//
|
||||
// Returns:
|
||||
// - *Server: A new server instance
|
||||
func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdkaccess.Manager, configFilePath string, opts ...ServerOption) *Server {
|
||||
optionState := &serverOptionConfig{
|
||||
requestLoggerFactory: defaultRequestLoggerFactory,
|
||||
}
|
||||
for i := range opts {
|
||||
opts[i](optionState)
|
||||
}
|
||||
// Set gin mode
|
||||
if !cfg.Debug {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
|
||||
// Create gin engine
|
||||
engine := gin.New()
|
||||
if optionState.engineConfigurator != nil {
|
||||
optionState.engineConfigurator(engine)
|
||||
}
|
||||
|
||||
// Add middleware
|
||||
engine.Use(logging.GinLogrusLogger())
|
||||
engine.Use(logging.GinLogrusRecovery())
|
||||
engine.Use(logging.CPATraceIDMiddleware())
|
||||
for _, mw := range optionState.extraMiddleware {
|
||||
engine.Use(mw)
|
||||
}
|
||||
|
||||
// Add request logging middleware (positioned after recovery, before auth)
|
||||
// Resolve logs directory relative to the configuration file directory.
|
||||
var requestLogger logging.RequestLogger
|
||||
var toggle func(bool)
|
||||
if !cfg.CommercialMode {
|
||||
if optionState.requestLoggerFactory != nil {
|
||||
requestLogger = optionState.requestLoggerFactory(cfg, configFilePath)
|
||||
}
|
||||
if requestLogger != nil {
|
||||
engine.Use(middleware.RequestLoggingMiddleware(requestLogger))
|
||||
if setter, ok := requestLogger.(interface{ SetEnabled(bool) }); ok {
|
||||
toggle = setter.SetEnabled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
engine.Use(corsMiddleware())
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
wd = configFilePath
|
||||
}
|
||||
|
||||
envAdminPassword, envAdminPasswordSet := os.LookupEnv("MANAGEMENT_PASSWORD")
|
||||
envAdminPassword = strings.TrimSpace(envAdminPassword)
|
||||
envManagementSecret := envAdminPasswordSet && envAdminPassword != ""
|
||||
|
||||
// Create server instance
|
||||
s := &Server{
|
||||
engine: engine,
|
||||
handlers: handlers.NewBaseAPIHandlers(effectiveSDKConfig(cfg), authManager),
|
||||
cfg: cfg,
|
||||
accessManager: accessManager,
|
||||
requestLogger: requestLogger,
|
||||
loggerToggle: toggle,
|
||||
configFilePath: configFilePath,
|
||||
currentPath: wd,
|
||||
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)
|
||||
optionState.pluginHost.SetAuthManager(authManager)
|
||||
}
|
||||
// Save initial YAML snapshot
|
||||
s.oldConfigYaml, _ = yaml.Marshal(cfg)
|
||||
s.applyAccessConfig(nil, cfg)
|
||||
if authManager != nil {
|
||||
authManager.SetRetryConfig(cfg.RequestRetry, time.Duration(cfg.MaxRetryInterval)*time.Second, cfg.MaxRetryCredentials)
|
||||
}
|
||||
auth.SetQuotaCooldownDisabled(cfg.DisableCooling)
|
||||
auth.SetTransientErrorCooldownSeconds(cfg.TransientErrorCooldownSeconds)
|
||||
applySignatureCacheConfig(nil, cfg)
|
||||
// Initialize management handler
|
||||
s.mgmt = managementHandlers.NewHandler(cfg, configFilePath, authManager)
|
||||
s.mgmt.SetPluginHost(optionState.pluginHost)
|
||||
s.mgmt.SetConfigReloadHook(optionState.configReloadHook)
|
||||
if optionState.localPassword != "" {
|
||||
s.mgmt.SetLocalPassword(optionState.localPassword)
|
||||
}
|
||||
logDir := logging.ResolveLogDirectory(cfg)
|
||||
s.mgmt.SetLogDirectory(logDir)
|
||||
if optionState.postAuthHook != nil {
|
||||
s.mgmt.SetPostAuthHook(optionState.postAuthHook)
|
||||
}
|
||||
if optionState.postAuthPersistHook != nil {
|
||||
s.mgmt.SetPostAuthPersistHook(optionState.postAuthPersistHook)
|
||||
}
|
||||
s.localPassword = optionState.localPassword
|
||||
|
||||
// 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()
|
||||
|
||||
// Apply additional router configurators from options
|
||||
if optionState.routerConfigurator != nil {
|
||||
optionState.routerConfigurator(engine, s.handlers, cfg)
|
||||
}
|
||||
|
||||
// Register management routes when configuration or environment secrets are available,
|
||||
// or when a local management password is provided (e.g. TUI mode).
|
||||
hasManagementSecret := cfg.RemoteManagement.SecretKey != "" || envManagementSecret || s.localPassword != ""
|
||||
s.managementRoutesEnabled.Store(hasManagementSecret)
|
||||
redisqueue.SetEnabled(hasManagementSecret || (cfg != nil && cfg.Home.Enabled))
|
||||
if hasManagementSecret {
|
||||
s.registerManagementRoutes()
|
||||
}
|
||||
s.refreshPluginManagementRoutes()
|
||||
engine.NoRoute(s.pluginManagementNoRoute)
|
||||
|
||||
if optionState.keepAliveEnabled {
|
||||
s.enableKeepAlive(optionState.keepAliveTimeout, optionState.keepAliveOnTimeout)
|
||||
}
|
||||
|
||||
// Create HTTP server
|
||||
s.server = &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port),
|
||||
Handler: engine,
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// Start begins listening for and serving HTTP or HTTPS requests.
|
||||
// It's a blocking call and will only return on an unrecoverable error.
|
||||
//
|
||||
// Returns:
|
||||
// - error: An error if the server fails to start
|
||||
func (s *Server) Start() error {
|
||||
if s == nil || s.server == nil {
|
||||
return fmt.Errorf("failed to start HTTP server: server not initialized")
|
||||
}
|
||||
|
||||
addr := s.server.Addr
|
||||
listener, errListen := net.Listen("tcp", addr)
|
||||
if errListen != nil {
|
||||
return fmt.Errorf("failed to start HTTP server: %v", errListen)
|
||||
}
|
||||
|
||||
useTLS := s.cfg != nil && s.cfg.TLS.Enable
|
||||
if useTLS {
|
||||
certPath := strings.TrimSpace(s.cfg.TLS.Cert)
|
||||
keyPath := strings.TrimSpace(s.cfg.TLS.Key)
|
||||
if certPath == "" || keyPath == "" {
|
||||
if errClose := listener.Close(); errClose != nil {
|
||||
log.Errorf("failed to close listener after TLS validation failure: %v", errClose)
|
||||
}
|
||||
return fmt.Errorf("failed to start HTTPS server: tls.cert or tls.key is empty")
|
||||
}
|
||||
certPair, errLoad := tls.LoadX509KeyPair(certPath, keyPath)
|
||||
if errLoad != nil {
|
||||
if errClose := listener.Close(); errClose != nil {
|
||||
log.Errorf("failed to close listener after TLS key pair load failure: %v", errClose)
|
||||
}
|
||||
return fmt.Errorf("failed to start HTTPS server: %v", errLoad)
|
||||
}
|
||||
|
||||
tlsConfig := &tls.Config{
|
||||
Certificates: []tls.Certificate{certPair},
|
||||
NextProtos: []string{"h2", "http/1.1"},
|
||||
}
|
||||
s.server.TLSConfig = tlsConfig
|
||||
if errHTTP2 := http2.ConfigureServer(s.server, &http2.Server{}); errHTTP2 != nil {
|
||||
log.Warnf("failed to configure HTTP/2: %v", errHTTP2)
|
||||
}
|
||||
listener = tls.NewListener(listener, tlsConfig)
|
||||
log.Debugf("Starting API server on %s with TLS", addr)
|
||||
} else {
|
||||
log.Debugf("Starting API server on %s", addr)
|
||||
}
|
||||
|
||||
httpListener := newMuxListener(listener.Addr(), 1024)
|
||||
s.muxBaseListener = listener
|
||||
s.muxHTTPListener = httpListener
|
||||
|
||||
httpErrCh := make(chan error, 1)
|
||||
acceptErrCh := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
httpErrCh <- s.server.Serve(httpListener)
|
||||
}()
|
||||
go func() {
|
||||
acceptErrCh <- s.acceptMuxConnections(listener, httpListener)
|
||||
}()
|
||||
|
||||
select {
|
||||
case errServe := <-httpErrCh:
|
||||
if s.muxBaseListener != nil {
|
||||
if errClose := s.muxBaseListener.Close(); errClose != nil && !errors.Is(errClose, net.ErrClosed) {
|
||||
log.Debugf("failed to close shared listener after HTTP serve exit: %v", errClose)
|
||||
}
|
||||
}
|
||||
if s.muxHTTPListener != nil {
|
||||
_ = s.muxHTTPListener.Close()
|
||||
}
|
||||
errAccept := <-acceptErrCh
|
||||
errServe = normalizeHTTPServeError(errServe)
|
||||
errAccept = normalizeListenerError(errAccept)
|
||||
if errServe != nil {
|
||||
return fmt.Errorf("failed to start HTTP server: %v", errServe)
|
||||
}
|
||||
if errAccept != nil {
|
||||
return fmt.Errorf("failed to start HTTP server: %v", errAccept)
|
||||
}
|
||||
return nil
|
||||
case errAccept := <-acceptErrCh:
|
||||
if s.muxHTTPListener != nil {
|
||||
_ = s.muxHTTPListener.Close()
|
||||
}
|
||||
if s.muxBaseListener != nil {
|
||||
if errClose := s.muxBaseListener.Close(); errClose != nil && !errors.Is(errClose, net.ErrClosed) {
|
||||
log.Debugf("failed to close shared listener after accept loop exit: %v", errClose)
|
||||
}
|
||||
}
|
||||
errServe := <-httpErrCh
|
||||
errServe = normalizeHTTPServeError(errServe)
|
||||
errAccept = normalizeListenerError(errAccept)
|
||||
if errAccept != nil {
|
||||
return fmt.Errorf("failed to start HTTP server: %v", errAccept)
|
||||
}
|
||||
if errServe != nil {
|
||||
return fmt.Errorf("failed to start HTTP server: %v", errServe)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Stop gracefully shuts down the API server without interrupting any
|
||||
// active connections.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: The context for graceful shutdown
|
||||
//
|
||||
// Returns:
|
||||
// - error: An error if the server fails to stop
|
||||
func (s *Server) Stop(ctx context.Context) error {
|
||||
log.Debug("Stopping API server...")
|
||||
|
||||
if s.keepAliveEnabled {
|
||||
select {
|
||||
case s.keepAliveStop <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
if s.muxHTTPListener != nil {
|
||||
_ = s.muxHTTPListener.Close()
|
||||
}
|
||||
if s.muxBaseListener != nil {
|
||||
if errClose := s.muxBaseListener.Close(); errClose != nil && !errors.Is(errClose, net.ErrClosed) {
|
||||
log.Debugf("failed to close shared listener: %v", errClose)
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown the HTTP server.
|
||||
errShutdown := s.server.Shutdown(ctx)
|
||||
if s.codexLiveHandler != nil {
|
||||
s.codexLiveHandler.Close()
|
||||
}
|
||||
if errShutdown != nil {
|
||||
return fmt.Errorf("failed to shutdown HTTP server: %v", errShutdown)
|
||||
}
|
||||
|
||||
log.Debug("API server stopped")
|
||||
return nil
|
||||
}
|
||||
Loading…
Reference in a new issue