From a4767fdd8e01db860fe6ca434b8b5775ce87d166 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Thu, 25 Sep 2025 11:32:14 +0800 Subject: [PATCH] feat(auth, docs): add SDK guides and local password support for management - Added extensive SDK usage guides for `cliproxy`, `sdk/access`, and watcher integration. - Introduced `--password` flag for specifying local management access passwords. - Enhanced management API with local password checks to secure localhost requests. - Updated documentation to reflect the new password functionality. --- cmd/server/main.go | 33 +++- docs/sdk-access.md | 176 ++++++++++++++++++++ docs/sdk-access_CN.md | 176 ++++++++++++++++++++ docs/sdk-advanced.md | 138 +++++++++++++++ docs/sdk-advanced_CN.md | 131 +++++++++++++++ docs/sdk-usage.md | 163 ++++++++++++++++++ docs/sdk-usage_CN.md | 164 ++++++++++++++++++ docs/sdk-watcher.md | 32 ++++ docs/sdk-watcher_CN.md | 32 ++++ internal/api/handlers/management/handler.go | 74 ++++---- internal/api/server.go | 11 ++ internal/cmd/run.go | 4 +- sdk/cliproxy/builder.go | 9 + 13 files changed, 1111 insertions(+), 32 deletions(-) create mode 100644 docs/sdk-access.md create mode 100644 docs/sdk-access_CN.md create mode 100644 docs/sdk-advanced.md create mode 100644 docs/sdk-advanced_CN.md create mode 100644 docs/sdk-usage.md create mode 100644 docs/sdk-usage_CN.md create mode 100644 docs/sdk-watcher.md create mode 100644 docs/sdk-watcher_CN.md diff --git a/cmd/server/main.go b/cmd/server/main.go index 85bd2c61..22b43d0a 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -64,7 +64,7 @@ func (m *LogFormatter) Format(entry *log.Entry) ([]byte, error) { func init() { logDir := "logs" if err := os.MkdirAll(logDir, 0755); err != nil { - fmt.Fprintf(os.Stderr, "failed to create log directory: %v\n", err) + _, _ = fmt.Fprintf(os.Stderr, "failed to create log directory: %v\n", err) os.Exit(1) } @@ -122,6 +122,7 @@ func main() { var noBrowser bool var projectID string var configPath string + var password string // Define command-line flags for different operation modes. flag.BoolVar(&login, "login", false, "Login Google Account") @@ -132,6 +133,34 @@ func main() { flag.BoolVar(&noBrowser, "no-browser", false, "Don't open browser automatically for OAuth") flag.StringVar(&projectID, "project_id", "", "Project ID (Gemini only, not required)") flag.StringVar(&configPath, "config", "", "Configure File Path") + flag.StringVar(&password, "password", "", "") + + flag.CommandLine.Usage = func() { + out := flag.CommandLine.Output() + _, _ = fmt.Fprintf(out, "Usage of %s\n", os.Args[0]) + flag.CommandLine.VisitAll(func(f *flag.Flag) { + if f.Name == "password" { + return + } + s := fmt.Sprintf(" -%s", f.Name) + name, usage := flag.UnquoteUsage(f) + if name != "" { + s += " " + name + } + if len(s) <= 4 { + s += " " + } else { + s += "\n " + } + if usage != "" { + s += usage + } + if f.DefValue != "" && f.DefValue != "false" && f.DefValue != "0" { + s += fmt.Sprintf(" (default %s)", f.DefValue) + } + _, _ = fmt.Fprint(out, s+"\n") + }) + } // Parse the command-line flags. flag.Parse() @@ -206,6 +235,6 @@ func main() { cmd.DoGeminiWebAuth(cfg) } else { // Start the main proxy service - cmd.StartService(cfg, configFilePath) + cmd.StartService(cfg, configFilePath, password) } } diff --git a/docs/sdk-access.md b/docs/sdk-access.md new file mode 100644 index 00000000..e4e69629 --- /dev/null +++ b/docs/sdk-access.md @@ -0,0 +1,176 @@ +# @sdk/access SDK Reference + +The `github.com/router-for-me/CLIProxyAPI/v6/sdk/access` package centralizes inbound request authentication for the proxy. It offers a lightweight manager that chains credential providers, so servers can reuse the same access control logic inside or outside the CLI runtime. + +## Importing + +```go +import ( + sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access" + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" +) +``` + +Add the module with `go get github.com/router-for-me/CLIProxyAPI/v6/sdk/access`. + +## Manager Lifecycle + +```go +manager := sdkaccess.NewManager() +providers, err := sdkaccess.BuildProviders(cfg) +if err != nil { + return err +} +manager.SetProviders(providers) +``` + +* `NewManager` constructs an empty manager. +* `SetProviders` replaces the provider slice using a defensive copy. +* `Providers` retrieves a snapshot that can be iterated safely from other goroutines. +* `BuildProviders` translates `config.Config` access declarations into runnable providers. When the config omits explicit providers but defines inline API keys, the helper auto-installs the built-in `config-api-key` provider. + +## Authenticating Requests + +```go +result, err := manager.Authenticate(ctx, req) +switch { +case err == nil: + // Authentication succeeded; result describes the provider and principal. +case errors.Is(err, sdkaccess.ErrNoCredentials): + // No recognizable credentials were supplied. +case errors.Is(err, sdkaccess.ErrInvalidCredential): + // Supplied credentials were present but rejected. +default: + // Transport-level failure was returned by a provider. +} +``` + +`Manager.Authenticate` walks the configured providers in order. It returns on the first success, skips providers that surface `ErrNotHandled`, and tracks whether any provider reported `ErrNoCredentials` or `ErrInvalidCredential` for downstream error reporting. + +If the manager itself is `nil` or no providers are registered, the call returns `nil, nil`, allowing callers to treat access control as disabled without branching on errors. + +Each `Result` includes the provider identifier, the resolved principal, and optional metadata (for example, which header carried the credential). + +## Configuration Layout + +The manager expects access providers under the `auth.providers` key inside `config.yaml`: + +```yaml +auth: + providers: + - name: inline-api + type: config-api-key + api-keys: + - sk-test-123 + - sk-prod-456 +``` + +Fields map directly to `config.AccessProvider`: `name` labels the provider, `type` selects the registered factory, `sdk` can name an external module, `api-keys` seeds inline credentials, and `config` passes provider-specific options. + +### Loading providers from external SDK modules + +To consume a provider shipped in another Go module, point the `sdk` field at the module path and import it for its registration side effect: + +```yaml +auth: + providers: + - name: partner-auth + type: partner-token + sdk: github.com/acme/xplatform/sdk/access/providers/partner + config: + region: us-west-2 + audience: cli-proxy +``` + +```go +import ( + _ "github.com/acme/xplatform/sdk/access/providers/partner" // registers partner-token + sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access" +) +``` + +The blank identifier import ensures `init` runs so `sdkaccess.RegisterProvider` executes before `BuildProviders` is called. + +## Built-in Providers + +The SDK ships with one provider out of the box: + +- `config-api-key`: Validates API keys declared inline or under top-level `api-keys`. It accepts the key from `Authorization: Bearer`, `X-Goog-Api-Key`, `X-Api-Key`, or the `?key=` query string and reports `ErrInvalidCredential` when no match is found. + +Additional providers can be delivered by third-party packages. When a provider package is imported, it registers itself with `sdkaccess.RegisterProvider`. + +### Metadata and auditing + +`Result.Metadata` carries provider-specific context. The built-in `config-api-key` provider, for example, stores the credential source (`authorization`, `x-goog-api-key`, `x-api-key`, or `query-key`). Populate this map in custom providers to enrich logs and downstream auditing. + +## Writing Custom Providers + +```go +type customProvider struct{} + +func (p *customProvider) Identifier() string { return "my-provider" } + +func (p *customProvider) Authenticate(ctx context.Context, r *http.Request) (*sdkaccess.Result, error) { + token := r.Header.Get("X-Custom") + if token == "" { + return nil, sdkaccess.ErrNoCredentials + } + if token != "expected" { + return nil, sdkaccess.ErrInvalidCredential + } + return &sdkaccess.Result{ + Provider: p.Identifier(), + Principal: "service-user", + Metadata: map[string]string{"source": "x-custom"}, + }, nil +} + +func init() { + sdkaccess.RegisterProvider("custom", func(cfg *config.AccessProvider, root *config.Config) (sdkaccess.Provider, error) { + return &customProvider{}, nil + }) +} +``` + +A provider must implement `Identifier()` and `Authenticate()`. To expose it to configuration, call `RegisterProvider` inside `init`. Provider factories receive the specific `AccessProvider` block plus the full root configuration for contextual needs. + +## Error Semantics + +- `ErrNoCredentials`: no credentials were present or recognized by any provider. +- `ErrInvalidCredential`: at least one provider processed the credentials but rejected them. +- `ErrNotHandled`: instructs the manager to fall through to the next provider without affecting aggregate error reporting. + +Return custom errors to surface transport failures; they propagate immediately to the caller instead of being masked. + +## Integration with cliproxy Service + +`sdk/cliproxy` wires `@sdk/access` automatically when you build a CLI service via `cliproxy.NewBuilder`. Supplying a preconfigured manager allows you to extend or override the default providers: + +```go +coreCfg, _ := config.LoadConfig("config.yaml") +providers, _ := sdkaccess.BuildProviders(coreCfg) +manager := sdkaccess.NewManager() +manager.SetProviders(providers) + +svc, _ := cliproxy.NewBuilder(). + WithConfig(coreCfg). + WithAccessManager(manager). + Build() +``` + +The service reuses the manager for every inbound request, ensuring consistent authentication across embedded deployments and the canonical CLI binary. + +### Hot reloading providers + +When configuration changes, rebuild providers and swap them into the manager: + +```go +providers, err := sdkaccess.BuildProviders(newCfg) +if err != nil { + log.Errorf("reload auth providers failed: %v", err) + return +} +accessManager.SetProviders(providers) +``` + +This mirrors the behaviour in `cliproxy.Service.refreshAccessProviders` and `api.Server.applyAccessConfig`, enabling runtime updates without restarting the process. diff --git a/docs/sdk-access_CN.md b/docs/sdk-access_CN.md new file mode 100644 index 00000000..b3f26497 --- /dev/null +++ b/docs/sdk-access_CN.md @@ -0,0 +1,176 @@ +# @sdk/access 开发指引 + +`github.com/router-for-me/CLIProxyAPI/v6/sdk/access` 包负责代理的入站访问认证。它提供一个轻量的管理器,用于按顺序链接多种凭证校验实现,让服务器在 CLI 运行时内外都能复用相同的访问控制逻辑。 + +## 引用方式 + +```go +import ( + sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access" + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" +) +``` + +通过 `go get github.com/router-for-me/CLIProxyAPI/v6/sdk/access` 添加依赖。 + +## 管理器生命周期 + +```go +manager := sdkaccess.NewManager() +providers, err := sdkaccess.BuildProviders(cfg) +if err != nil { + return err +} +manager.SetProviders(providers) +``` + +- `NewManager` 创建空管理器。 +- `SetProviders` 替换提供者切片并做防御性拷贝。 +- `Providers` 返回适合并发读取的快照。 +- `BuildProviders` 将 `config.Config` 中的访问配置转换成可运行的提供者。当配置没有显式声明但包含顶层 `api-keys` 时,会自动挂载内建的 `config-api-key` 提供者。 + +## 认证请求 + +```go +result, err := manager.Authenticate(ctx, req) +switch { +case err == nil: + // Authentication succeeded; result carries provider and principal. +case errors.Is(err, sdkaccess.ErrNoCredentials): + // No recognizable credentials were supplied. +case errors.Is(err, sdkaccess.ErrInvalidCredential): + // Credentials were present but rejected. +default: + // Provider surfaced a transport-level failure. +} +``` + +`Manager.Authenticate` 按配置顺序遍历提供者。遇到成功立即返回,`ErrNotHandled` 会继续尝试下一个;若发现 `ErrNoCredentials` 或 `ErrInvalidCredential`,会在遍历结束后汇总给调用方。 + +若管理器本身为 `nil` 或尚未注册提供者,调用会返回 `nil, nil`,让调用方无需针对错误做额外分支即可关闭访问控制。 + +`Result` 提供认证提供者标识、解析出的主体以及可选元数据(例如凭证来源)。 + +## 配置结构 + +在 `config.yaml` 的 `auth.providers` 下定义访问提供者: + +```yaml +auth: + providers: + - name: inline-api + type: config-api-key + api-keys: + - sk-test-123 + - sk-prod-456 +``` + +条目映射到 `config.AccessProvider`:`name` 指定实例名,`type` 选择注册的工厂,`sdk` 可引用第三方模块,`api-keys` 提供内联凭证,`config` 用于传递特定选项。 + +### 引入外部 SDK 提供者 + +若要消费其它 Go 模块输出的访问提供者,可在配置里填写 `sdk` 字段并在代码中引入该包,利用其 `init` 注册过程: + +```yaml +auth: + providers: + - name: partner-auth + type: partner-token + sdk: github.com/acme/xplatform/sdk/access/providers/partner + config: + region: us-west-2 + audience: cli-proxy +``` + +```go +import ( + _ "github.com/acme/xplatform/sdk/access/providers/partner" // registers partner-token + sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access" +) +``` + +通过空白标识符导入即可确保 `init` 调用,先于 `BuildProviders` 完成 `sdkaccess.RegisterProvider`。 + +## 内建提供者 + +当前 SDK 默认内置: + +- `config-api-key`:校验配置中的 API Key。它从 `Authorization: Bearer`、`X-Goog-Api-Key`、`X-Api-Key` 以及查询参数 `?key=` 提取凭证,不匹配时抛出 `ErrInvalidCredential`。 + +导入第三方包即可通过 `sdkaccess.RegisterProvider` 注册更多类型。 + +### 元数据与审计 + +`Result.Metadata` 用于携带提供者特定的上下文信息。内建的 `config-api-key` 会记录凭证来源(`authorization`、`x-goog-api-key`、`x-api-key` 或 `query-key`)。自定义提供者同样可以填充该 Map,以便丰富日志与审计场景。 + +## 编写自定义提供者 + +```go +type customProvider struct{} + +func (p *customProvider) Identifier() string { return "my-provider" } + +func (p *customProvider) Authenticate(ctx context.Context, r *http.Request) (*sdkaccess.Result, error) { + token := r.Header.Get("X-Custom") + if token == "" { + return nil, sdkaccess.ErrNoCredentials + } + if token != "expected" { + return nil, sdkaccess.ErrInvalidCredential + } + return &sdkaccess.Result{ + Provider: p.Identifier(), + Principal: "service-user", + Metadata: map[string]string{"source": "x-custom"}, + }, nil +} + +func init() { + sdkaccess.RegisterProvider("custom", func(cfg *config.AccessProvider, root *config.Config) (sdkaccess.Provider, error) { + return &customProvider{}, nil + }) +} +``` + +自定义提供者需要实现 `Identifier()` 与 `Authenticate()`。在 `init` 中调用 `RegisterProvider` 暴露给配置层,工厂函数既能读取当前条目,也能访问完整根配置。 + +## 错误语义 + +- `ErrNoCredentials`:任何提供者都未识别到凭证。 +- `ErrInvalidCredential`:至少一个提供者处理了凭证但判定无效。 +- `ErrNotHandled`:告诉管理器跳到下一个提供者,不影响最终错误统计。 + +自定义错误(例如网络异常)会马上冒泡返回。 + +## 与 cliproxy 集成 + +使用 `sdk/cliproxy` 构建服务时会自动接入 `@sdk/access`。如果需要扩展内置行为,可传入自定义管理器: + +```go +coreCfg, _ := config.LoadConfig("config.yaml") +providers, _ := sdkaccess.BuildProviders(coreCfg) +manager := sdkaccess.NewManager() +manager.SetProviders(providers) + +svc, _ := cliproxy.NewBuilder(). + WithConfig(coreCfg). + WithAccessManager(manager). + Build() +``` + +服务会复用该管理器处理每一个入站请求,实现与 CLI 二进制一致的访问控制体验。 + +### 动态热更新提供者 + +当配置发生变化时,可以重新构建提供者并替换当前列表: + +```go +providers, err := sdkaccess.BuildProviders(newCfg) +if err != nil { + log.Errorf("reload auth providers failed: %v", err) + return +} +accessManager.SetProviders(providers) +``` + +这一流程与 `cliproxy.Service.refreshAccessProviders` 和 `api.Server.applyAccessConfig` 保持一致,避免为更新访问策略而重启进程。 diff --git a/docs/sdk-advanced.md b/docs/sdk-advanced.md new file mode 100644 index 00000000..3a9d3e50 --- /dev/null +++ b/docs/sdk-advanced.md @@ -0,0 +1,138 @@ +# SDK Advanced: Executors & Translators + +This guide explains how to extend the embedded proxy with custom providers and schemas using the SDK. You will: +- Implement a provider executor that talks to your upstream API +- Register request/response translators for schema conversion +- Register models so they appear in `/v1/models` + +The examples use Go 1.24+ and the v6 module path. + +## Concepts + +- Provider executor: a runtime component implementing `auth.ProviderExecutor` that performs outbound calls for a given provider key (e.g., `gemini`, `claude`, `codex`). Executors can also implement `RequestPreparer` to inject credentials on raw HTTP requests. +- Translator registry: schema conversion functions routed by `sdk/translator`. The built‑in handlers translate between OpenAI/Gemini/Claude/Codex formats; you can register new ones. +- Model registry: publishes the list of available models per client/provider to power `/v1/models` and routing hints. + +## 1) Implement a Provider Executor + +Create a type that satisfies `auth.ProviderExecutor`. + +```go +package myprov + +import ( + "context" + "net/http" + + coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + clipexec "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" +) + +type Executor struct{} + +func (Executor) Identifier() string { return "myprov" } + +// Optional: mutate outbound HTTP requests with credentials +func (Executor) PrepareRequest(req *http.Request, a *coreauth.Auth) error { + // Example: req.Header.Set("Authorization", "Bearer "+a.APIKey) + return nil +} + +func (Executor) Execute(ctx context.Context, a *coreauth.Auth, req clipexec.Request, opts clipexec.Options) (clipexec.Response, error) { + // Build HTTP request based on req.Payload (already translated into provider format) + // Use per‑auth transport if provided: transport := a.RoundTripper // via RoundTripperProvider + // Perform call and return provider JSON payload + return clipexec.Response{Payload: []byte(`{"ok":true}`)}, nil +} + +func (Executor) ExecuteStream(ctx context.Context, a *coreauth.Auth, req clipexec.Request, opts clipexec.Options) (<-chan clipexec.StreamChunk, error) { + ch := make(chan clipexec.StreamChunk, 1) + go func() { defer close(ch); ch <- clipexec.StreamChunk{Payload: []byte("data: {\"done\":true}\n\n")} }() + return ch, nil +} + +func (Executor) Refresh(ctx context.Context, a *coreauth.Auth) (*coreauth.Auth, error) { + // Optionally refresh tokens and return updated auth + return a, nil +} +``` + +Register the executor with the core manager before starting the service: + +```go +core := coreauth.NewManager(coreauth.NewFileStore(cfg.AuthDir), nil, nil) +core.RegisterExecutor(myprov.Executor{}) +svc, _ := cliproxy.NewBuilder().WithConfig(cfg).WithConfigPath(cfgPath).WithCoreAuthManager(core).Build() +``` + +If your auth entries use provider `"myprov"`, the manager routes requests to your executor. + +## 2) Register Translators + +The handlers accept OpenAI/Gemini/Claude/Codex inputs. To support a new provider format, register translation functions in `sdk/translator`’s default registry. + +Direction matters: +- Request: register from inbound schema to provider schema +- Response: register from provider schema back to inbound schema + +Example: Convert OpenAI Chat → MyProv Chat and back. + +```go +package myprov + +import ( + "context" + sdktr "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" +) + +const ( + FOpenAI = sdktr.Format("openai.chat") + FMyProv = sdktr.Format("myprov.chat") +) + +func init() { + sdktr.Register(FOpenAI, FMyProv, + // Request transform (model, rawJSON, stream) + func(model string, raw []byte, stream bool) []byte { return convertOpenAIToMyProv(model, raw, stream) }, + // Response transform (stream & non‑stream) + sdktr.ResponseTransform{ + Stream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) []string { + return convertStreamMyProvToOpenAI(model, originalReq, translatedReq, raw) + }, + NonStream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) string { + return convertMyProvToOpenAI(model, originalReq, translatedReq, raw) + }, + }, + ) +} +``` + +When the OpenAI handler receives a request that should route to `myprov`, the pipeline uses the registered transforms automatically. + +## 3) Register Models + +Expose models under `/v1/models` by registering them in the global model registry using the auth ID (client ID) and provider name. + +```go +models := []*cliproxy.ModelInfo{ + { ID: "myprov-pro-1", Object: "model", Type: "myprov", DisplayName: "MyProv Pro 1" }, +} +cliproxy.GlobalModelRegistry().RegisterClient(authID, "myprov", models) +``` + +The embedded server calls this automatically for built‑in providers; for custom providers, register during startup (e.g., after loading auths) or upon auth registration hooks. + +## Credentials & Transports + +- Use `Manager.SetRoundTripperProvider` to inject per‑auth `*http.Transport` (e.g., proxy): + ```go + core.SetRoundTripperProvider(myProvider) // returns transport per auth + ``` +- For raw HTTP flows, implement `PrepareRequest` and/or call `Manager.InjectCredentials(req, authID)` to set headers. + +## Testing Tips + +- Enable request logging: Management API GET/PUT `/v0/management/request-log` +- Toggle debug logs: Management API GET/PUT `/v0/management/debug` +- Hot reload changes in `config.yaml` and `auths/` are picked up automatically by the watcher + diff --git a/docs/sdk-advanced_CN.md b/docs/sdk-advanced_CN.md new file mode 100644 index 00000000..25e6e83c --- /dev/null +++ b/docs/sdk-advanced_CN.md @@ -0,0 +1,131 @@ +# SDK 高级指南:执行器与翻译器 + +本文介绍如何使用 SDK 扩展内嵌代理: +- 实现自定义 Provider 执行器以调用你的上游 API +- 注册请求/响应翻译器进行协议转换 +- 注册模型以出现在 `/v1/models` + +示例基于 Go 1.24+ 与 v6 模块路径。 + +## 概念 + +- Provider 执行器:实现 `auth.ProviderExecutor` 的运行时组件,负责某个 provider key(如 `gemini`、`claude`、`codex`)的真正出站调用。若实现 `RequestPreparer` 接口,可在原始 HTTP 请求上注入凭据。 +- 翻译器注册表:由 `sdk/translator` 驱动的协议转换函数。内置了 OpenAI/Gemini/Claude/Codex 的互转;你也可以注册新的格式转换。 +- 模型注册表:对外发布可用模型列表,供 `/v1/models` 与路由参考。 + +## 1) 实现 Provider 执行器 + +创建类型满足 `auth.ProviderExecutor` 接口。 + +```go +package myprov + +import ( + "context" + "net/http" + + coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + clipexec "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" +) + +type Executor struct{} + +func (Executor) Identifier() string { return "myprov" } + +// 可选:在原始 HTTP 请求上注入凭据 +func (Executor) PrepareRequest(req *http.Request, a *coreauth.Auth) error { + // 例如:req.Header.Set("Authorization", "Bearer "+a.Attributes["api_key"]) + return nil +} + +func (Executor) Execute(ctx context.Context, a *coreauth.Auth, req clipexec.Request, opts clipexec.Options) (clipexec.Response, error) { + // 基于 req.Payload 构造上游请求,返回上游 JSON 负载 + return clipexec.Response{Payload: []byte(`{"ok":true}`)}, nil +} + +func (Executor) ExecuteStream(ctx context.Context, a *coreauth.Auth, req clipexec.Request, opts clipexec.Options) (<-chan clipexec.StreamChunk, error) { + ch := make(chan clipexec.StreamChunk, 1) + go func() { defer close(ch); ch <- clipexec.StreamChunk{Payload: []byte("data: {\\"done\\":true}\\n\\n")} }() + return ch, nil +} + +func (Executor) Refresh(ctx context.Context, a *coreauth.Auth) (*coreauth.Auth, error) { return a, nil } +``` + +在启动服务前将执行器注册到核心管理器: + +```go +core := coreauth.NewManager(coreauth.NewFileStore(cfg.AuthDir), nil, nil) +core.RegisterExecutor(myprov.Executor{}) +svc, _ := cliproxy.NewBuilder().WithConfig(cfg).WithConfigPath(cfgPath).WithCoreAuthManager(core).Build() +``` + +当凭据的 `Provider` 为 `"myprov"` 时,管理器会将请求路由到你的执行器。 + +## 2) 注册翻译器 + +内置处理器接受 OpenAI/Gemini/Claude/Codex 的入站格式。要支持新的 provider 协议,需要在 `sdk/translator` 的默认注册表中注册转换函数。 + +方向很重要: +- 请求:从“入站格式”转换为“provider 格式” +- 响应:从“provider 格式”转换回“入站格式” + +示例:OpenAI Chat → MyProv Chat 及其反向。 + +```go +package myprov + +import ( + "context" + sdktr "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" +) + +const ( + FOpenAI = sdktr.Format("openai.chat") + FMyProv = sdktr.Format("myprov.chat") +) + +func init() { + sdktr.Register(FOpenAI, FMyProv, + func(model string, raw []byte, stream bool) []byte { return convertOpenAIToMyProv(model, raw, stream) }, + sdktr.ResponseTransform{ + Stream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) []string { + return convertStreamMyProvToOpenAI(model, originalReq, translatedReq, raw) + }, + NonStream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) string { + return convertMyProvToOpenAI(model, originalReq, translatedReq, raw) + }, + }, + ) +} +``` + +当 OpenAI 处理器接到需要路由到 `myprov` 的请求时,流水线会自动应用已注册的转换。 + +## 3) 注册模型 + +通过全局模型注册表将模型暴露到 `/v1/models`: + +```go +models := []*cliproxy.ModelInfo{ + { ID: "myprov-pro-1", Object: "model", Type: "myprov", DisplayName: "MyProv Pro 1" }, +} +cliproxy.GlobalModelRegistry().RegisterClient(authID, "myprov", models) +``` + +内置 Provider 会自动注册;自定义 Provider 建议在启动时(例如加载到 Auth 后)或在 Auth 注册钩子中调用。 + +## 凭据与传输 + +- 使用 `Manager.SetRoundTripperProvider` 注入按账户的 `*http.Transport`(例如代理): + ```go + core.SetRoundTripperProvider(myProvider) // 按账户返回 transport + ``` +- 对于原始 HTTP 请求,若实现了 `PrepareRequest`,或通过 `Manager.InjectCredentials(req, authID)` 进行头部注入。 + +## 测试建议 + +- 启用请求日志:管理 API GET/PUT `/v0/management/request-log` +- 切换调试日志:管理 API GET/PUT `/v0/management/debug` +- 热更新:`config.yaml` 与 `auths/` 变化会自动被侦测并应用 + diff --git a/docs/sdk-usage.md b/docs/sdk-usage.md new file mode 100644 index 00000000..55e7d5f9 --- /dev/null +++ b/docs/sdk-usage.md @@ -0,0 +1,163 @@ +# CLI Proxy SDK Guide + +The `sdk/cliproxy` module exposes the proxy as a reusable Go library so external programs can embed the routing, authentication, hot‑reload, and translation layers without depending on the CLI binary. + +## Install & Import + +```bash +go get github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy +``` + +```go +import ( + "context" + "errors" + "time" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy" +) +``` + +Note the `/v6` module path. + +## Minimal Embed + +```go +cfg, err := config.LoadConfig("config.yaml") +if err != nil { panic(err) } + +svc, err := cliproxy.NewBuilder(). + WithConfig(cfg). + WithConfigPath("config.yaml"). // absolute or working-dir relative + Build() +if err != nil { panic(err) } + +ctx, cancel := context.WithCancel(context.Background()) +defer cancel() + +if err := svc.Run(ctx); err != nil && !errors.Is(err, context.Canceled) { + panic(err) +} +``` + +The service manages config/auth watching, background token refresh, and graceful shutdown. Cancel the context to stop it. + +## Server Options (middleware, routes, logs) + +The server accepts options via `WithServerOptions`: + +```go +svc, _ := cliproxy.NewBuilder(). + WithConfig(cfg). + WithConfigPath("config.yaml"). + WithServerOptions( + // Add global middleware + cliproxy.WithMiddleware(func(c *gin.Context) { c.Header("X-Embed", "1"); c.Next() }), + // Tweak gin engine early (CORS, trusted proxies, etc.) + cliproxy.WithEngineConfigurator(func(e *gin.Engine) { e.ForwardedByClientIP = true }), + // Add your own routes after defaults + cliproxy.WithRouterConfigurator(func(e *gin.Engine, _ *handlers.BaseAPIHandler, _ *config.Config) { + e.GET("/healthz", func(c *gin.Context) { c.String(200, "ok") }) + }), + // Override request log writer/dir + cliproxy.WithRequestLoggerFactory(func(cfg *config.Config, cfgPath string) logging.RequestLogger { + return logging.NewFileRequestLogger(true, "logs", filepath.Dir(cfgPath)) + }), + ). + Build() +``` + +These options mirror the internals used by the CLI server. + +## Management API (when embedded) + +- Management endpoints are mounted only when `remote-management.secret-key` is set in `config.yaml`. +- Remote access additionally requires `remote-management.allow-remote: true`. +- See MANAGEMENT_API.md for endpoints. Your embedded server exposes them under `/v0/management` on the configured port. + +## Using the Core Auth Manager + +The service uses a core `auth.Manager` for selection, execution, and auto‑refresh. When embedding, you can provide your own manager to customize transports or hooks: + +```go +core := coreauth.NewManager(coreauth.NewFileStore(cfg.AuthDir), nil, nil) +core.SetRoundTripperProvider(myRTProvider) // per‑auth *http.Transport + +svc, _ := cliproxy.NewBuilder(). + WithConfig(cfg). + WithConfigPath("config.yaml"). + WithCoreAuthManager(core). + Build() +``` + +Implement a custom per‑auth transport: + +```go +type myRTProvider struct{} +func (myRTProvider) RoundTripperFor(a *coreauth.Auth) http.RoundTripper { + if a == nil || a.ProxyURL == "" { return nil } + u, _ := url.Parse(a.ProxyURL) + return &http.Transport{ Proxy: http.ProxyURL(u) } +} +``` + +Programmatic execution is available on the manager: + +```go +// Non‑streaming +resp, err := core.Execute(ctx, []string{"gemini"}, req, opts) + +// Streaming +chunks, err := core.ExecuteStream(ctx, []string{"gemini"}, req, opts) +for ch := range chunks { /* ... */ } +``` + +Note: Built‑in provider executors are wired automatically when you run the `Service`. If you want to use `Manager` stand‑alone without the HTTP server, you must register your own executors that implement `auth.ProviderExecutor`. + +## Custom Client Sources + +Replace the default loaders if your creds live outside the local filesystem: + +```go +type memoryTokenProvider struct{} +func (p *memoryTokenProvider) Load(ctx context.Context, cfg *config.Config) (*cliproxy.TokenClientResult, error) { + // Populate from memory/remote store and return counts + return &cliproxy.TokenClientResult{}, nil +} + +svc, _ := cliproxy.NewBuilder(). + WithConfig(cfg). + WithConfigPath("config.yaml"). + WithTokenClientProvider(&memoryTokenProvider{}). + WithAPIKeyClientProvider(cliproxy.NewAPIKeyClientProvider()). + Build() +``` + +## Hooks + +Observe lifecycle without patching internals: + +```go +hooks := cliproxy.Hooks{ + OnBeforeStart: func(cfg *config.Config) { log.Infof("starting on :%d", cfg.Port) }, + OnAfterStart: func(s *cliproxy.Service) { log.Info("ready") }, +} +svc, _ := cliproxy.NewBuilder().WithConfig(cfg).WithConfigPath("config.yaml").WithHooks(hooks).Build() +``` + +## Shutdown + +`Run` defers `Shutdown`, so cancelling the parent context is enough. To stop manually: + +```go +ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) +defer cancel() +_ = svc.Shutdown(ctx) +``` + +## Notes + +- Hot reload: changes to `config.yaml` and `auths/` are picked up automatically. +- Request logging can be toggled at runtime via the Management API. +- Gemini Web features (`gemini-web.*`) are honored in the embedded server. diff --git a/docs/sdk-usage_CN.md b/docs/sdk-usage_CN.md new file mode 100644 index 00000000..b87f9aa1 --- /dev/null +++ b/docs/sdk-usage_CN.md @@ -0,0 +1,164 @@ +# CLI Proxy SDK 使用指南 + +`sdk/cliproxy` 模块将代理能力以 Go 库的形式对外暴露,方便在其它服务中内嵌路由、鉴权、热更新与翻译层,而无需依赖可执行的 CLI 程序。 + +## 安装与导入 + +```bash +go get github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy +``` + +```go +import ( + "context" + "errors" + "time" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy" +) +``` + +注意模块路径包含 `/v6`。 + +## 最小可用示例 + +```go +cfg, err := config.LoadConfig("config.yaml") +if err != nil { panic(err) } + +svc, err := cliproxy.NewBuilder(). + WithConfig(cfg). + WithConfigPath("config.yaml"). // 绝对路径或工作目录相对路径 + Build() +if err != nil { panic(err) } + +ctx, cancel := context.WithCancel(context.Background()) +defer cancel() + +if err := svc.Run(ctx); err != nil && !errors.Is(err, context.Canceled) { + panic(err) +} +``` + +服务内部会管理配置与认证文件的监听、后台令牌刷新与优雅关闭。取消上下文即可停止服务。 + +## 服务器可选项(中间件、路由、日志) + +通过 `WithServerOptions` 自定义: + +```go +svc, _ := cliproxy.NewBuilder(). + WithConfig(cfg). + WithConfigPath("config.yaml"). + WithServerOptions( + // 追加全局中间件 + cliproxy.WithMiddleware(func(c *gin.Context) { c.Header("X-Embed", "1"); c.Next() }), + // 提前调整 gin 引擎(如 CORS、trusted proxies) + cliproxy.WithEngineConfigurator(func(e *gin.Engine) { e.ForwardedByClientIP = true }), + // 在默认路由之后追加自定义路由 + cliproxy.WithRouterConfigurator(func(e *gin.Engine, _ *handlers.BaseAPIHandler, _ *config.Config) { + e.GET("/healthz", func(c *gin.Context) { c.String(200, "ok") }) + }), + // 覆盖请求日志的创建(启用/目录) + cliproxy.WithRequestLoggerFactory(func(cfg *config.Config, cfgPath string) logging.RequestLogger { + return logging.NewFileRequestLogger(true, "logs", filepath.Dir(cfgPath)) + }), + ). + Build() +``` + +这些选项与 CLI 服务器内部用法保持一致。 + +## 管理 API(内嵌时) + +- 仅当 `config.yaml` 中设置了 `remote-management.secret-key` 时才会挂载管理端点。 +- 远程访问还需要 `remote-management.allow-remote: true`。 +- 具体端点见 MANAGEMENT_API_CN.md。内嵌服务器会在配置端口下暴露 `/v0/management`。 + +## 使用核心鉴权管理器 + +服务内部使用核心 `auth.Manager` 负责选择、执行、自动刷新。内嵌时可自定义其传输或钩子: + +```go +core := coreauth.NewManager(coreauth.NewFileStore(cfg.AuthDir), nil, nil) +core.SetRoundTripperProvider(myRTProvider) // 按账户返回 *http.Transport + +svc, _ := cliproxy.NewBuilder(). + WithConfig(cfg). + WithConfigPath("config.yaml"). + WithCoreAuthManager(core). + Build() +``` + +实现每个账户的自定义传输: + +```go +type myRTProvider struct{} +func (myRTProvider) RoundTripperFor(a *coreauth.Auth) http.RoundTripper { + if a == nil || a.ProxyURL == "" { return nil } + u, _ := url.Parse(a.ProxyURL) + return &http.Transport{ Proxy: http.ProxyURL(u) } +} +``` + +管理器提供编程式执行接口: + +```go +// 非流式 +resp, err := core.Execute(ctx, []string{"gemini"}, req, opts) + +// 流式 +chunks, err := core.ExecuteStream(ctx, []string{"gemini"}, req, opts) +for ch := range chunks { /* ... */ } +``` + +说明:运行 `Service` 时会自动注册内置的提供商执行器;若仅单独使用 `Manager` 而不启动 HTTP 服务器,则需要自行实现并注册满足 `auth.ProviderExecutor` 的执行器。 + +## 自定义凭据来源 + +当凭据不在本地文件系统时,替换默认加载器: + +```go +type memoryTokenProvider struct{} +func (p *memoryTokenProvider) Load(ctx context.Context, cfg *config.Config) (*cliproxy.TokenClientResult, error) { + // 从内存/远端加载并返回数量统计 + return &cliproxy.TokenClientResult{}, nil +} + +svc, _ := cliproxy.NewBuilder(). + WithConfig(cfg). + WithConfigPath("config.yaml"). + WithTokenClientProvider(&memoryTokenProvider{}). + WithAPIKeyClientProvider(cliproxy.NewAPIKeyClientProvider()). + Build() +``` + +## 启动钩子 + +无需修改内部代码即可观察生命周期: + +```go +hooks := cliproxy.Hooks{ + OnBeforeStart: func(cfg *config.Config) { log.Infof("starting on :%d", cfg.Port) }, + OnAfterStart: func(s *cliproxy.Service) { log.Info("ready") }, +} +svc, _ := cliproxy.NewBuilder().WithConfig(cfg).WithConfigPath("config.yaml").WithHooks(hooks).Build() +``` + +## 关闭 + +`Run` 内部会延迟调用 `Shutdown`,因此只需取消父上下文即可。若需手动停止: + +```go +ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) +defer cancel() +_ = svc.Shutdown(ctx) +``` + +## 说明 + +- 热更新:`config.yaml` 与 `auths/` 变化会被自动侦测并应用。 +- 请求日志可通过管理 API 在运行时开关。 +- `gemini-web.*` 相关配置在内嵌服务器中会被遵循。 + diff --git a/docs/sdk-watcher.md b/docs/sdk-watcher.md new file mode 100644 index 00000000..c455448b --- /dev/null +++ b/docs/sdk-watcher.md @@ -0,0 +1,32 @@ +# SDK Watcher Integration + +The SDK service exposes a watcher integration that surfaces granular auth updates without forcing a full reload. This document explains the queue contract, how the service consumes updates, and how high-frequency change bursts are handled. + +## Update Queue Contract + +- `watcher.AuthUpdate` represents a single credential change. `Action` may be `add`, `modify`, or `delete`, and `ID` carries the credential identifier. For `add`/`modify` the `Auth` payload contains a fully populated clone of the credential; `delete` may omit `Auth`. +- `WatcherWrapper.SetAuthUpdateQueue(chan<- watcher.AuthUpdate)` wires the queue produced by the SDK service into the watcher. The queue must be created before the watcher starts. +- The service builds the queue via `ensureAuthUpdateQueue`, using a buffered channel (`capacity=256`) and a dedicated consumer goroutine (`consumeAuthUpdates`). The consumer drains bursts by looping through the backlog before reacquiring the select loop. + +## Watcher Behaviour + +- `internal/watcher/watcher.go` keeps a shadow snapshot of auth state (`currentAuths`). Each filesystem or configuration event triggers a recomputation and a diff against the previous snapshot to produce minimal `AuthUpdate` entries that mirror adds, edits, and removals. +- Updates are coalesced per credential identifier. If multiple changes occur before dispatch (e.g., write followed by delete), only the final action is sent downstream. +- The watcher runs an internal dispatch loop that buffers pending updates in memory and forwards them asynchronously to the queue. Producers never block on channel capacity; they just enqueue into the in-memory buffer and signal the dispatcher. Dispatch cancellation happens when the watcher stops, guaranteeing goroutines exit cleanly. + +## High-Frequency Change Handling + +- The dispatch loop and service consumer run independently, preventing filesystem watchers from blocking even when many updates arrive at once. +- Back-pressure is absorbed in two places: + - The dispatch buffer (map + order slice) coalesces repeated updates for the same credential until the consumer catches up. + - The service channel capacity (256) combined with the consumer drain loop ensures several bursts can be processed without oscillation. +- If the queue is saturated for an extended period, updates continue to be merged, so the latest state is eventually applied without replaying redundant intermediate states. + +## Usage Checklist + +1. Instantiate the SDK service (builder or manual construction). +2. Call `ensureAuthUpdateQueue` before starting the watcher to allocate the shared channel. +3. When the `WatcherWrapper` is created, call `SetAuthUpdateQueue` with the service queue, then start the watcher. +4. Provide a reload callback that handles configuration updates; auth deltas will arrive via the queue and are applied by the service automatically through `handleAuthUpdate`. + +Following this flow keeps auth changes responsive while avoiding full reloads for every edit. diff --git a/docs/sdk-watcher_CN.md b/docs/sdk-watcher_CN.md new file mode 100644 index 00000000..0373a45d --- /dev/null +++ b/docs/sdk-watcher_CN.md @@ -0,0 +1,32 @@ +# SDK Watcher集成说明 + +本文档介绍SDK服务与文件监控器之间的增量更新队列,包括接口契约、高频变更下的处理策略以及接入步骤。 + +## 更新队列契约 + +- `watcher.AuthUpdate`描述单条凭据变更,`Action`可能为`add`、`modify`或`delete`,`ID`是凭据标识。对于`add`/`modify`会携带完整的`Auth`克隆,`delete`可以省略`Auth`。 +- `WatcherWrapper.SetAuthUpdateQueue(chan<- watcher.AuthUpdate)`用于将服务侧创建的队列注入watcher,必须在watcher启动前完成。 +- 服务通过`ensureAuthUpdateQueue`创建容量为256的缓冲通道,并在`consumeAuthUpdates`中使用专职goroutine消费;消费侧会主动“抽干”积压事件,降低切换开销。 + +## Watcher行为 + +- `internal/watcher/watcher.go`维护`currentAuths`快照,文件或配置事件触发后会重建快照并与旧快照对比,生成最小化的`AuthUpdate`列表。 +- 以凭据ID为维度对更新进行合并,同一凭据在短时间内的多次变更只会保留最新状态(例如先写后删只会下发`delete`)。 +- watcher内部运行异步分发循环:生产者只向内存缓冲追加事件并唤醒分发协程,即使通道暂时写满也不会阻塞文件事件线程。watcher停止时会取消分发循环,确保协程正常退出。 + +## 高频变更处理 + +- 分发循环与服务消费协程相互独立,因此即便短时间内出现大量变更也不会阻塞watcher事件处理。 +- 背压通过两级缓冲吸收: + - 分发缓冲(map + 顺序切片)会合并同一凭据的重复事件,直到消费者完成处理。 + - 服务端通道的256容量加上消费侧的“抽干”逻辑,可平稳处理多个突发批次。 +- 当通道长时间处于高压状态时,缓冲仍持续合并事件,从而在消费者恢复后一次性应用最新状态,避免重复处理无意义的中间状态。 + +## 接入步骤 + +1. 实例化SDK Service(构建器或手工创建)。 +2. 在启动watcher之前调用`ensureAuthUpdateQueue`创建共享通道。 +3. watcher通过工厂函数创建后立刻调用`SetAuthUpdateQueue`注入通道,然后再启动watcher。 +4. Reload回调专注于配置更新;认证增量会通过队列送达,并由`handleAuthUpdate`自动应用。 + +遵循上述流程即可在避免全量重载的同时保持凭据变更的实时性。 diff --git a/internal/api/handlers/management/handler.go b/internal/api/handlers/management/handler.go index fcb71920..f36aaa3d 100644 --- a/internal/api/handlers/management/handler.go +++ b/internal/api/handlers/management/handler.go @@ -3,6 +3,7 @@ package management import ( + "crypto/subtle" "fmt" "net/http" "strings" @@ -33,6 +34,8 @@ type Handler struct { authManager *coreauth.Manager usageStats *usage.RequestStatistics tokenStore sdkAuth.TokenStore + + localPassword string } // NewHandler creates a new management handler instance. @@ -56,6 +59,9 @@ func (h *Handler) SetAuthManager(manager *coreauth.Manager) { h.authManager = ma // SetUsageStatistics allows replacing the usage statistics reference. func (h *Handler) SetUsageStatistics(stats *usage.RequestStatistics) { h.usageStats = stats } +// SetLocalPassword configures the runtime-local password accepted for localhost requests. +func (h *Handler) SetLocalPassword(password string) { h.localPassword = password } + // Middleware enforces access control for management endpoints. // All requests (local and remote) require a valid management key. // Additionally, remote access requires allow-remote-management=true. @@ -65,10 +71,10 @@ func (h *Handler) Middleware() gin.HandlerFunc { return func(c *gin.Context) { clientIP := c.ClientIP() + localClient := clientIP == "127.0.0.1" || clientIP == "::1" - // For remote IPs, enforce allow-remote-management and ban checks - if !(clientIP == "127.0.0.1" || clientIP == "::1") { - // Check if IP is currently blocked + fail := func() {} + if !localClient { h.attemptsMu.Lock() ai := h.failedAttempts[clientIP] if ai != nil { @@ -86,11 +92,25 @@ func (h *Handler) Middleware() gin.HandlerFunc { } h.attemptsMu.Unlock() - allowRemote := h.cfg.RemoteManagement.AllowRemote - if !allowRemote { + if !h.cfg.RemoteManagement.AllowRemote { c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "remote management disabled"}) return } + + fail = func() { + h.attemptsMu.Lock() + aip := h.failedAttempts[clientIP] + if aip == nil { + aip = &attemptInfo{} + h.failedAttempts[clientIP] = aip + } + aip.count++ + if aip.count >= maxFailures { + aip.blockedUntil = time.Now().Add(banDuration) + aip.count = 0 + } + h.attemptsMu.Unlock() + } } secret := h.cfg.RemoteManagement.SecretKey if secret == "" { @@ -112,36 +132,32 @@ func (h *Handler) Middleware() gin.HandlerFunc { provided = c.GetHeader("X-Management-Key") } - if !(clientIP == "127.0.0.1" || clientIP == "::1") { - // For remote IPs, enforce key and track failures - fail := func() { - h.attemptsMu.Lock() - ai := h.failedAttempts[clientIP] - if ai == nil { - ai = &attemptInfo{} - h.failedAttempts[clientIP] = ai - } - ai.count++ - if ai.count >= maxFailures { - ai.blockedUntil = time.Now().Add(banDuration) - ai.count = 0 - } - h.attemptsMu.Unlock() - } - - if provided == "" { + if provided == "" { + if !localClient { fail() - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing management key"}) - return } + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing management key"}) + return + } - if err := bcrypt.CompareHashAndPassword([]byte(secret), []byte(provided)); err != nil { + if localClient { + if lp := h.localPassword; lp != "" { + if subtle.ConstantTimeCompare([]byte(provided), []byte(lp)) == 1 { + c.Next() + return + } + } + } + + if err := bcrypt.CompareHashAndPassword([]byte(secret), []byte(provided)); err != nil { + if !localClient { fail() - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid management key"}) - return } + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid management key"}) + return + } - // Success: reset failed count for this IP + if !localClient { h.attemptsMu.Lock() if ai := h.failedAttempts[clientIP]; ai != nil { ai.count = 0 diff --git a/internal/api/server.go b/internal/api/server.go index e01fb385..4cb54eac 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -33,6 +33,7 @@ type serverOptionConfig struct { engineConfigurator func(*gin.Engine) routerConfigurator func(*gin.Engine, *handlers.BaseAPIHandler, *config.Config) requestLoggerFactory func(*config.Config, string) logging.RequestLogger + localPassword string } // ServerOption customises HTTP server construction. @@ -63,6 +64,13 @@ func WithRouterConfigurator(fn func(*gin.Engine, *handlers.BaseAPIHandler, *conf } } +// WithLocalManagementPassword stores a runtime-only management password accepted for localhost requests. +func WithLocalManagementPassword(password string) ServerOption { + return func(cfg *serverOptionConfig) { + cfg.localPassword = password + } +} + // WithRequestLoggerFactory customises request logger creation. func WithRequestLoggerFactory(factory func(*config.Config, string) logging.RequestLogger) ServerOption { return func(cfg *serverOptionConfig) { @@ -163,6 +171,9 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk s.applyAccessConfig(cfg) // Initialize management handler s.mgmt = managementHandlers.NewHandler(cfg, configFilePath, authManager) + if optionState.localPassword != "" { + s.mgmt.SetLocalPassword(optionState.localPassword) + } // Setup routes s.setupRoutes() diff --git a/internal/cmd/run.go b/internal/cmd/run.go index e063e474..94b01592 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -21,10 +21,12 @@ import ( // Parameters: // - cfg: The application configuration // - configPath: The path to the configuration file -func StartService(cfg *config.Config, configPath string) { +// - localPassword: Optional password accepted for local management requests +func StartService(cfg *config.Config, configPath string, localPassword string) { service, err := cliproxy.NewBuilder(). WithConfig(cfg). WithConfigPath(configPath). + WithLocalManagementPassword(localPassword). Build() if err != nil { log.Fatalf("failed to build proxy service: %v", err) diff --git a/sdk/cliproxy/builder.go b/sdk/cliproxy/builder.go index 091aa010..f0fcf4a0 100644 --- a/sdk/cliproxy/builder.go +++ b/sdk/cliproxy/builder.go @@ -142,6 +142,15 @@ func (b *Builder) WithServerOptions(opts ...api.ServerOption) *Builder { return b } +// WithLocalManagementPassword configures a password that is only accepted from localhost management requests. +func (b *Builder) WithLocalManagementPassword(password string) *Builder { + if password == "" { + return b + } + b.serverOptions = append(b.serverOptions, api.WithLocalManagementPassword(password)) + return b +} + // Build validates inputs, applies defaults, and returns a ready-to-run service. func (b *Builder) Build() (*Service, error) { if b.cfg == nil {