diff --git a/README.md b/README.md index 76ad67d..3856009 100644 --- a/README.md +++ b/README.md @@ -288,6 +288,30 @@ REMOTE_PASSWORD='your-password' \ The script uploads `cmd/`, `internal/`, `vendor/`, `go.mod`, and `go.sum`, prepares a Docker build context on the server, then rebuilds the runtime image there with a multi-stage Docker build. The remote host needs `docker`, `tar`, and `curl`; it does not need a host Go installation, but it does need network access to pull the base Docker images if they are not already cached. +### API Authentication + +Set one or more API keys with `LINGMA_PROXY_API_KEYS` or the JSON config field `api_keys`: + +```bash +export LINGMA_PROXY_API_KEYS="key-one,key-two" +go run ./cmd/lingma-ipc-proxy --host 127.0.0.1 --port 8095 +``` + +```json +{ + "api_keys": ["key-one", "key-two"] +} +``` + +The deployment script also passes through `LINGMA_PROXY_API_KEYS`, so the same key set can be enabled on the remote server during deploy. + +When keys are configured, all API endpoints except `/`, `/health`, `/runtime/status`, and `/v1/runtime/status` require authentication. Clients can send either: + +- `Authorization: Bearer ` +- `x-api-key: ` + +This works well with OpenAI-compatible clients that already expose an API key field. + ## Client Configuration ### Claude Code diff --git a/README.zh-CN.md b/README.zh-CN.md index 559108a..1fb4371 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -363,6 +363,30 @@ REMOTE_PASSWORD='your-password' \ 脚本会上传 `cmd/`、`internal/`、`vendor/`、`go.mod` 和 `go.sum`,在服务器上准备 Docker 构建上下文,然后用多阶段 Docker build 重建运行时镜像。远端主机需要预装 `docker`、`tar`、`curl`;不需要额外安装宿主机 Go,但如果相关基础镜像未缓存,仍需要能拉取 Docker 基础镜像。 +### API 鉴权 + +可以通过环境变量 `LINGMA_PROXY_API_KEYS` 或 JSON 配置里的 `api_keys` 设置一个或多个 API Key: + +```bash +export LINGMA_PROXY_API_KEYS="key-one,key-two" +go run ./cmd/lingma-ipc-proxy --host 127.0.0.1 --port 8095 +``` + +```json +{ + "api_keys": ["key-one", "key-two"] +} +``` + +部署脚本也会透传 `LINGMA_PROXY_API_KEYS`,所以可以在远端部署时一起启用同一组 key。 + +配置了 key 之后,除 `/`、`/health`、`/runtime/status`、`/v1/runtime/status` 之外的接口都会要求鉴权。客户端可以使用: + +- `Authorization: Bearer ` +- `x-api-key: ` + +这也兼容大多数 OpenAI 兼容客户端自带的 API Key 配置方式。 + ## 客户端配置 ### Claude Code diff --git a/cmd/lingma-ipc-proxy/main.go b/cmd/lingma-ipc-proxy/main.go index a9d30ae..f88d3a2 100644 --- a/cmd/lingma-ipc-proxy/main.go +++ b/cmd/lingma-ipc-proxy/main.go @@ -31,6 +31,7 @@ type fileConfig struct { RemoteBaseURL string `json:"remote_base_url"` RemoteAuthFile string `json:"remote_auth_file"` RemoteVersion string `json:"remote_version"` + APIKeys []string `json:"api_keys"` Cwd string `json:"cwd"` CurrentFilePath string `json:"current_file_path"` Mode string `json:"mode"` @@ -144,6 +145,7 @@ func loadConfig() (service.Config, string) { remoteBaseURL := flag.String("remote-base-url", cfg.RemoteBaseURL, "Remote Lingma API base URL") remoteAuthFile := flag.String("remote-auth-file", cfg.RemoteAuthFile, "Remote Lingma credentials.json path; empty reads ~/.lingma cache") remoteVersion := flag.String("remote-version", cfg.RemoteVersion, "Remote Lingma cosy version") + apiKeys := flag.String("api-keys", strings.Join(cfg.APIKeys, ","), "Comma-separated API keys accepted via Authorization Bearer or x-api-key") cwd := flag.String("cwd", cfg.Cwd, "Working directory used when creating Lingma sessions") currentFilePath := flag.String("current-file-path", cfg.CurrentFilePath, "Current file path sent through ACP meta") mode := flag.String("mode", cfg.Mode, "Lingma ACP mode value") @@ -181,6 +183,7 @@ func loadConfig() (service.Config, string) { cfg.RemoteBaseURL = strings.TrimSpace(*remoteBaseURL) cfg.RemoteAuthFile = strings.TrimSpace(*remoteAuthFile) cfg.RemoteVersion = strings.TrimSpace(*remoteVersion) + cfg.APIKeys = splitCSV(*apiKeys) cfg.Cwd = strings.TrimSpace(*cwd) cfg.CurrentFilePath = strings.TrimSpace(*currentFilePath) cfg.Mode = strings.TrimSpace(*mode) @@ -268,6 +271,9 @@ func overlayFileConfig(dst *service.Config, src fileConfig) { if strings.TrimSpace(src.RemoteVersion) != "" { dst.RemoteVersion = strings.TrimSpace(src.RemoteVersion) } + if len(src.APIKeys) > 0 { + dst.APIKeys = cleanStringSlice(src.APIKeys) + } if strings.TrimSpace(src.Cwd) != "" { dst.Cwd = strings.TrimSpace(src.Cwd) } @@ -361,6 +367,9 @@ func overlayEnvConfig(dst *service.Config) { if value := strings.TrimSpace(os.Getenv("LINGMA_REMOTE_VERSION")); value != "" { dst.RemoteVersion = value } + if value := strings.TrimSpace(os.Getenv("LINGMA_PROXY_API_KEYS")); value != "" { + dst.APIKeys = splitCSV(value) + } if value := strings.TrimSpace(os.Getenv("LINGMA_PROXY_CWD")); value != "" { dst.Cwd = value } diff --git a/config.example.json b/config.example.json index 903541f..c12639a 100644 --- a/config.example.json +++ b/config.example.json @@ -22,6 +22,10 @@ "pipe": "", "websocket_url": "", "remote_auth_file": "/secrets/credentials.json", + "api_keys": [ + "replace-with-long-random-key-1", + "replace-with-long-random-key-2" + ], "lingma_bootstrap_enabled": true, "lingma_source_type": "marketplace", "lingma_vsix_url": "", diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 794d557..3656136 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -126,7 +126,7 @@ func NewServer(addr string, svc *service.Service) *Server { s.http = &http.Server{ Addr: addr, - Handler: s.withRecorder(withCORS(mux)), + Handler: s.withRecorder(withCORS(s.withAuth(mux))), ReadHeaderTimeout: 10 * time.Second, } return s @@ -1686,6 +1686,65 @@ func writeOpenAIError(w http.ResponseWriter, status int, kind string, message st }) } +func isPublicPath(path string) bool { + switch path { + case "/", "/health", "/runtime/status", "/v1/runtime/status": + return true + default: + return false + } +} + +func isAnthropicPath(path string) bool { + switch path { + case "/v1/messages", "/v1/messages/count_tokens": + return true + default: + return false + } +} + +func extractAPIKey(r *http.Request) string { + if value := strings.TrimSpace(r.Header.Get("x-api-key")); value != "" { + return value + } + auth := strings.TrimSpace(r.Header.Get("Authorization")) + if auth == "" { + return "" + } + parts := strings.SplitN(auth, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { + return "" + } + return strings.TrimSpace(parts[1]) +} + +func (s *Server) withAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if isPublicPath(r.URL.Path) { + next.ServeHTTP(w, r) + return + } + keys := s.svc.APIKeys() + if len(keys) == 0 { + next.ServeHTTP(w, r) + return + } + provided := extractAPIKey(r) + for _, key := range keys { + if provided == key { + next.ServeHTTP(w, r) + return + } + } + if isAnthropicPath(r.URL.Path) { + writeAnthropicError(w, http.StatusUnauthorized, "authentication_error", "invalid or missing API key") + return + } + writeOpenAIError(w, http.StatusUnauthorized, "authentication_error", "invalid or missing API key") + }) +} + func streamingHeaders(w http.ResponseWriter) { w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") diff --git a/internal/httpapi/server_test.go b/internal/httpapi/server_test.go index efc60fd..0cea98b 100644 --- a/internal/httpapi/server_test.go +++ b/internal/httpapi/server_test.go @@ -81,6 +81,83 @@ func TestCapabilitiesAdvertiseAgentCompatibility(t *testing.T) { } } +func TestAuthAllowsPublicHealthWithoutAPIKey(t *testing.T) { + server := NewServer("", service.New(service.Config{ + Model: "Qwen3-Coder", + Timeout: time.Second, + APIKeys: []string{"key-1", "key-2"}, + })) + + req := httptest.NewRequest(http.MethodGet, "/health", nil) + rec := httptest.NewRecorder() + server.http.Handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) + } +} + +func TestAuthRejectsProtectedEndpointWithoutAPIKey(t *testing.T) { + server := NewServer("", service.New(service.Config{ + Model: "Qwen3-Coder", + Timeout: time.Second, + APIKeys: []string{"key-1", "key-2"}, + })) + + req := httptest.NewRequest(http.MethodGet, "/capabilities", nil) + rec := httptest.NewRecorder() + server.http.Handler.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "authentication_error") { + t.Fatalf("body = %s", rec.Body.String()) + } +} + +func TestAuthAcceptsBearerAndXAPIKey(t *testing.T) { + server := NewServer("", service.New(service.Config{ + Model: "Qwen3-Coder", + Timeout: time.Second, + APIKeys: []string{"key-1", "key-2"}, + })) + + bearerReq := httptest.NewRequest(http.MethodGet, "/capabilities", nil) + bearerReq.Header.Set("Authorization", "Bearer key-2") + bearerRec := httptest.NewRecorder() + server.http.Handler.ServeHTTP(bearerRec, bearerReq) + if bearerRec.Code != http.StatusOK { + t.Fatalf("bearer status = %d body = %s", bearerRec.Code, bearerRec.Body.String()) + } + + xReq := httptest.NewRequest(http.MethodGet, "/capabilities", nil) + xReq.Header.Set("x-api-key", "key-1") + xRec := httptest.NewRecorder() + server.http.Handler.ServeHTTP(xRec, xReq) + if xRec.Code != http.StatusOK { + t.Fatalf("x-api-key status = %d body = %s", xRec.Code, xRec.Body.String()) + } +} + +func TestAnthropicAuthErrorShape(t *testing.T) { + server := NewServer("", service.New(service.Config{ + Model: "Qwen3-Coder", + Timeout: time.Second, + APIKeys: []string{"key-1"}, + })) + + req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(`{"model":"kmodel","messages":[{"role":"user","content":"hello"}],"max_tokens":16}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + server.http.Handler.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), `"type":"error"`) { + t.Fatalf("body = %s", rec.Body.String()) + } +} + + func TestNormalizeOpenAIRequestRejectsMissingUserAndAssistantMessages(t *testing.T) { req := openAIChatRequest{ Model: "test-model", diff --git a/internal/service/service.go b/internal/service/service.go index 6ed0b1a..8dc0d2b 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -53,6 +53,7 @@ type Config struct { ShellType string SessionMode SessionMode Timeout time.Duration + APIKeys []string RemoteFallbackEnabled bool RemoteFallbackModels []string LingmaBootstrapEnabled bool @@ -185,6 +186,7 @@ func New(cfg Config) *Service { cfg.Mode = "agent" } cfg.Model = strings.TrimSpace(cfg.Model) + cfg.APIKeys = cleanStringSlice(cfg.APIKeys) if strings.TrimSpace(cfg.ShellType) == "" { cfg.ShellType = lingmaipc.DefaultShellType() } @@ -247,6 +249,26 @@ func (s *Service) DefaultModel() string { return strings.TrimSpace(s.cfg.Model) } +func (s *Service) APIKeys() []string { + s.mu.Lock() + defer s.mu.Unlock() + return append([]string(nil), s.cfg.APIKeys...) +} + +func cleanStringSlice(values []string) []string { + out := make([]string, 0, len(values)) + seen := map[string]bool{} + for _, value := range values { + item := strings.TrimSpace(value) + if item == "" || seen[item] { + continue + } + seen[item] = true + out = append(out, item) + } + return out +} + func (s *Service) PrepareRuntime() error { s.mu.Lock() cfg := s.cfg diff --git a/scripts/deploy-remote.sh b/scripts/deploy-remote.sh index afe4a39..78ecdeb 100755 --- a/scripts/deploy-remote.sh +++ b/scripts/deploy-remote.sh @@ -23,6 +23,7 @@ Optional environment variables: LINGMA_MARKETPLACE_PUBLISHER=Alibaba-Cloud LINGMA_MARKETPLACE_EXTENSION=tongyi-lingma LINGMA_PROXY_MODEL=org_auto + LINGMA_PROXY_API_KEYS= VERIFY_PUBLIC=false EOF } @@ -74,7 +75,9 @@ LINGMA_VSIX_URL="${LINGMA_VSIX_URL:-https://tongyi-code.oss-cn-hangzhou.aliyuncs LINGMA_MARKETPLACE_PUBLISHER="${LINGMA_MARKETPLACE_PUBLISHER:-Alibaba-Cloud}" LINGMA_MARKETPLACE_EXTENSION="${LINGMA_MARKETPLACE_EXTENSION:-tongyi-lingma}" LINGMA_PROXY_MODEL="${LINGMA_PROXY_MODEL:-org_auto}" +LINGMA_PROXY_API_KEYS="${LINGMA_PROXY_API_KEYS:-}" VERIFY_PUBLIC="${VERIFY_PUBLIC:-false}" +VERIFY_API_KEY="${LINGMA_PROXY_API_KEYS%%,*}" if [ "$REMOTE_BUILD_DIR" = "$REMOTE_DIR" ]; then echo "REMOTE_BUILD_DIR must be different from REMOTE_DIR" >&2 @@ -151,6 +154,7 @@ LINGMA_SESSION_BUNDLE_FILE=/secrets/lingma-session.b64 LINGMA_PROXY_BACKEND=remote LINGMA_PROXY_SESSION_MODE=auto LINGMA_PROXY_MODEL=$LINGMA_PROXY_MODEL +LINGMA_PROXY_API_KEYS=$LINGMA_PROXY_API_KEYS LINGMA_PROXY_TIMEOUT_SECONDS=0 LINGMA_REMOTE_FALLBACK_ENABLED=true EOF @@ -183,7 +187,11 @@ echo "==> Waiting for remote health endpoint" echo echo "==> Remote models" -"${SSH_BASE[@]}" "curl -fsS http://127.0.0.1:$REMOTE_PUBLIC_PORT/v1/models" +if [ -n "$VERIFY_API_KEY" ]; then + "${SSH_BASE[@]}" "curl -fsS -H 'Authorization: Bearer $VERIFY_API_KEY' http://127.0.0.1:$REMOTE_PUBLIC_PORT/v1/models" +else + "${SSH_BASE[@]}" "curl -fsS http://127.0.0.1:$REMOTE_PUBLIC_PORT/v1/models" +fi if [ "$VERIFY_PUBLIC" = "true" ]; then echo