package management import ( "bytes" "context" "encoding/json" "errors" "fmt" "net/http" "os" "path/filepath" "runtime" "strconv" "strings" "time" "github.com/gin-gonic/gin" "github.com/router-for-me/CLIProxyAPI/v7/internal/credentialweight" sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth" coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" ) // PatchAuthFileStatus toggles the disabled state of an auth file func (h *Handler) PatchAuthFileStatus(c *gin.Context) { if h.authManager == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "core auth manager unavailable"}) return } var req struct { Name string `json:"name"` AuthIndex string `json:"auth_index"` Disabled *bool `json:"disabled"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) return } name := strings.TrimSpace(req.Name) authIndex := strings.TrimSpace(req.AuthIndex) if name == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) return } if req.Disabled == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "disabled is required"}) return } ctx := c.Request.Context() targetAuth, _ := h.lookupAuthFile(name, authIndex) if targetAuth == nil { c.JSON(http.StatusNotFound, gin.H{"error": "auth file not found"}) return } if coreauth.IsPluginVirtualAuth(targetAuth) { // Allow status changes only when targeting the source auth file name, matching delete semantics. // Expanded virtual project auths still cannot be modified independently. if !isPluginVirtualSourceDelete(name, targetAuth) { c.JSON(http.StatusConflict, gin.H{"error": errPluginVirtualAuth.Error()}) return } if errPatch := h.patchPluginVirtualSourceStatus(ctx, targetAuth, *req.Disabled); errPatch != nil { status := http.StatusInternalServerError if errors.Is(errPatch, errAuthFileNotFound) || os.IsNotExist(errPatch) { status = http.StatusNotFound } c.JSON(status, gin.H{"error": errPatch.Error()}) return } c.JSON(http.StatusOK, gin.H{"status": "ok", "disabled": *req.Disabled}) return } if coreauth.IsConfigAPIKeyAuth(targetAuth) { h.mu.Lock() handled, errToggle := toggleConfigAPIKeyExcludedAll(h.cfg, targetAuth, *req.Disabled) if errToggle != nil { h.mu.Unlock() c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to update config api key: %v", errToggle)}) return } if !handled { h.mu.Unlock() c.JSON(http.StatusNotFound, gin.H{"error": "config api key entry not found"}) return } cfgSnapshot, okSnapshot := h.saveConfigAndSnapshotLocked(c) h.mu.Unlock() if !okSnapshot { return } h.reloadConfigAfterManagementSave(ctx, cfgSnapshot) if h.tokenStore != nil { _ = h.tokenStore.Delete(ctx, targetAuth.ID) } c.JSON(http.StatusOK, gin.H{ "status": "ok", "disabled": *req.Disabled, "via": "config:excluded-models", "excluded_pattern": configAPIKeyDisablePattern, }) return } applyAuthDisabledState(targetAuth, *req.Disabled) if _, err := h.authManager.Update(ctx, targetAuth); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to update auth: %v", err)}) return } c.JSON(http.StatusOK, gin.H{"status": "ok", "disabled": *req.Disabled}) } // patchPluginVirtualSourceStatus toggles disabled on a plugin multi-auth source file and all // runtime auths expanded from it. Virtual project children cannot be toggled independently. func (h *Handler) patchPluginVirtualSourceStatus(ctx context.Context, targetAuth *coreauth.Auth, disabled bool) error { if h == nil || h.authManager == nil || targetAuth == nil { return fmt.Errorf("core auth manager unavailable") } sourcePath := strings.TrimSpace(authAttribute(targetAuth, coreauth.AttributeVirtualSource)) if sourcePath == "" { sourcePath = strings.TrimSpace(authAttribute(targetAuth, "path")) } if sourcePath == "" { return errPluginVirtualAuth } if errWrite := setSourceAuthFileDisabled(sourcePath, disabled); errWrite != nil { if os.IsNotExist(errWrite) { return errAuthFileNotFound } return fmt.Errorf("failed to update source auth file: %w", errWrite) } now := time.Now() for _, auth := range h.authManager.List() { if auth == nil { continue } if !sameAuthFilePath(authAttribute(auth, "path"), sourcePath) && !sameAuthFilePath(authAttribute(auth, coreauth.AttributeVirtualSource), sourcePath) { continue } applyAuthDisabledState(auth, disabled) auth.UpdatedAt = now if _, errUpdate := h.authManager.Update(ctx, auth); errUpdate != nil { return fmt.Errorf("failed to update auth %s: %w", auth.ID, errUpdate) } } return nil } func setSourceAuthFileDisabled(path string, disabled bool) error { path = strings.TrimSpace(path) if path == "" { return fmt.Errorf("source auth path is empty") } data, errRead := os.ReadFile(path) if errRead != nil { return errRead } metadata := make(map[string]any) if len(bytes.TrimSpace(data)) > 0 { if errUnmarshal := json.Unmarshal(data, &metadata); errUnmarshal != nil { return fmt.Errorf("invalid auth file: %w", errUnmarshal) } } if metadata == nil { metadata = make(map[string]any) } coreauth.NormalizeCredentialMetadata(metadata) metadata["disabled"] = disabled raw, errMarshal := json.Marshal(metadata) if errMarshal != nil { return fmt.Errorf("marshal auth file: %w", errMarshal) } if errWrite := os.WriteFile(path, raw, 0o600); errWrite != nil { return errWrite } return nil } func applyAuthDisabledState(auth *coreauth.Auth, disabled bool) { if auth == nil { return } auth.Disabled = disabled if disabled { auth.Status = coreauth.StatusDisabled auth.StatusMessage = "disabled via management API" } else { auth.Status = coreauth.StatusActive auth.StatusMessage = "" } auth.UpdatedAt = time.Now() if auth.Metadata == nil { auth.Metadata = make(map[string]any) } auth.Metadata["disabled"] = disabled } // PatchAuthFileFields updates arbitrary metadata fields of an auth file. func (h *Handler) PatchAuthFileFields(c *gin.Context) { if h.authManager == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "core auth manager unavailable"}) return } var req map[string]json.RawMessage decoder := json.NewDecoder(c.Request.Body) decoder.UseNumber() if err := decoder.Decode(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) return } nameRaw, ok := req["name"] if !ok { c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) return } var nameValue string if err := json.Unmarshal(nameRaw, &nameValue); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) return } name := strings.TrimSpace(nameValue) if name == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) return } delete(req, "name") var errNormalize error req, errNormalize = normalizeAuthFilePatchFields(req) if errNormalize != nil { c.JSON(http.StatusBadRequest, gin.H{"error": errNormalize.Error()}) return } requestRetryPatch, errRequestRetry := decodeAuthFileRequestRetryPatch(req) if errRequestRetry != nil { c.JSON(http.StatusBadRequest, gin.H{"error": errRequestRetry.Error()}) return } for key := range req { if strings.TrimSpace(key) == "request_retry" { delete(req, key) } } ctx := c.Request.Context() // Find auth by name or ID var targetAuth *coreauth.Auth if auth, ok := h.authManager.GetByID(name); ok { targetAuth = auth } else { auths := h.authManager.List() for _, auth := range auths { if auth.FileName == name { targetAuth = auth break } } } if targetAuth == nil { c.JSON(http.StatusNotFound, gin.H{"error": "auth file not found"}) return } if coreauth.IsPluginVirtualAuth(targetAuth) { c.JSON(http.StatusConflict, gin.H{"error": errPluginVirtualAuth.Error()}) return } coreauth.NormalizeCredentialMetadata(targetAuth.Metadata) changed := false touchedRoots := make(map[string]struct{}, len(req)) for key, rawValue := range req { fieldPath := strings.TrimSpace(key) if fieldPath == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "field name is required"}) return } value, errDecode := decodeAuthFileFieldValue(rawValue) if errDecode != nil { c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid field %s", fieldPath)}) return } if targetAuth.Metadata == nil { targetAuth.Metadata = make(map[string]any) } if fieldPath == coreauth.AttributeWeight { if value == nil { delete(targetAuth.Metadata, coreauth.AttributeWeight) } else { if _, okNumber := value.(json.Number); !okNumber { c.JSON(http.StatusBadRequest, gin.H{"error": "weight must be an integer"}) return } weight, errWeight := credentialweight.ParseValue(value) if errWeight != nil { c.JSON(http.StatusBadRequest, gin.H{"error": errWeight.Error()}) return } targetAuth.Metadata[coreauth.AttributeWeight] = weight } } else if rootAuthFileField(fieldPath) == coreauth.AttributeWeight { c.JSON(http.StatusBadRequest, gin.H{"error": "weight does not support nested fields"}) return } else if fieldPath == "headers" { applyAuthFileHeadersPatch(targetAuth, value) } else if errSet := setAuthFileMetadataValue(targetAuth.Metadata, fieldPath, value); errSet != nil { c.JSON(http.StatusBadRequest, gin.H{"error": errSet.Error()}) return } if root := rootAuthFileField(fieldPath); root != "" { touchedRoots[root] = struct{}{} } changed = true } if requestRetryPatch.Set { if targetAuth.Metadata == nil { targetAuth.Metadata = make(map[string]any) } if requestRetryPatch.Value == nil { delete(targetAuth.Metadata, "request_retry") } else { targetAuth.Metadata["request_retry"] = *requestRetryPatch.Value } changed = true } if changed { syncAuthFileMetadataFields(targetAuth, touchedRoots) } if !changed { c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) return } targetAuth.UpdatedAt = time.Now() if _, err := h.authManager.Update(ctx, targetAuth); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to update auth: %v", err)}) return } c.JSON(http.StatusOK, gin.H{"status": "ok"}) } func decodeAuthFileFieldValue(raw json.RawMessage) (any, error) { decoder := json.NewDecoder(bytes.NewReader(raw)) decoder.UseNumber() var value any if err := decoder.Decode(&value); err != nil { return nil, err } return value, nil } type authFileRequestRetryPatch struct { Set bool Value *int } func normalizeAuthFilePatchFields(fields map[string]json.RawMessage) (map[string]json.RawMessage, error) { normalized := make(map[string]json.RawMessage, len(fields)) originalNames := make(map[string]string, len(fields)) canonicalNames := make(map[string]bool, len(fields)) for key, value := range fields { parts := strings.Split(strings.TrimSpace(key), ".") for index := range parts { parts[index] = strings.TrimSpace(parts[index]) } originalRoot := parts[0] parts[0] = coreauth.CanonicalCredentialMetadataKey(originalRoot) canonicalPath := strings.Join(parts, ".") if original, exists := originalNames[canonicalPath]; exists { currentCanonical := originalRoot == parts[0] if canonicalNames[canonicalPath] != currentCanonical { if currentCanonical { normalized[canonicalPath] = value originalNames[canonicalPath] = key canonicalNames[canonicalPath] = true } continue } return nil, fmt.Errorf("auth file fields %q and %q refer to the same field", original, key) } normalized[canonicalPath] = value originalNames[canonicalPath] = key canonicalNames[canonicalPath] = originalRoot == parts[0] } return normalized, nil } func decodeAuthFileRequestRetryPatch(fields map[string]json.RawMessage) (authFileRequestRetryPatch, error) { var raw json.RawMessage found := false for key, value := range fields { fieldPath := strings.TrimSpace(key) fieldRoot := rootAuthFileField(fieldPath) if fieldRoot == "request_retry" && fieldPath != fieldRoot { return authFileRequestRetryPatch{}, fmt.Errorf("request_retry does not support nested fields") } if fieldPath == "request_retry" { found = true raw = value } } if !found { return authFileRequestRetryPatch{}, nil } value, errDecode := decodeAuthFileFieldValue(raw) if errDecode != nil { return authFileRequestRetryPatch{}, fmt.Errorf("request_retry must be an integer or null") } if value == nil { return authFileRequestRetryPatch{Set: true}, nil } number, okNumber := value.(json.Number) if !okNumber { return authFileRequestRetryPatch{}, fmt.Errorf("request_retry must be an integer or null") } parsed, errInt := number.Int64() if errInt != nil { return authFileRequestRetryPatch{}, fmt.Errorf("request_retry must be an integer or null") } normalized := int(parsed) if int64(normalized) != parsed { return authFileRequestRetryPatch{}, fmt.Errorf("request_retry must be an integer or null") } if normalized < 0 { return authFileRequestRetryPatch{Set: true}, nil } return authFileRequestRetryPatch{Set: true, Value: &normalized}, nil } func rootAuthFileField(path string) string { path = strings.TrimSpace(path) if path == "" { return "" } if idx := strings.Index(path, "."); idx >= 0 { return strings.TrimSpace(path[:idx]) } return path } func setAuthFileMetadataValue(metadata map[string]any, path string, value any) error { if metadata == nil { return fmt.Errorf("metadata is nil") } parts := strings.Split(path, ".") current := metadata for i, rawPart := range parts { part := strings.TrimSpace(rawPart) if part == "" { return fmt.Errorf("invalid field path: %s", path) } if i == len(parts)-1 { current[part] = value return nil } next, ok := current[part].(map[string]any) if !ok { next = make(map[string]any) current[part] = next } current = next } return nil } func applyAuthFileHeadersPatch(auth *coreauth.Auth, value any) { if auth == nil { return } if auth.Metadata == nil { auth.Metadata = make(map[string]any) } headersPatch, ok := authFileHeadersStringMap(value) if !ok { auth.Metadata["headers"] = value return } existingHeaders := coreauth.ExtractCustomHeadersFromMetadata(auth.Metadata) nextHeaders := make(map[string]string, len(existingHeaders)) for key, val := range existingHeaders { nextHeaders[key] = val } for key, value := range headersPatch { name := strings.TrimSpace(key) if name == "" { continue } val := strings.TrimSpace(value) if val == "" { delete(nextHeaders, name) continue } nextHeaders[name] = val } if len(nextHeaders) == 0 { delete(auth.Metadata, "headers") return } metaHeaders := make(map[string]any, len(nextHeaders)) for key, value := range nextHeaders { metaHeaders[key] = value } auth.Metadata["headers"] = metaHeaders } func authFileHeadersStringMap(value any) (map[string]string, bool) { switch typed := value.(type) { case map[string]string: return typed, true case map[string]any: out := make(map[string]string, len(typed)) for key, rawValue := range typed { value, ok := rawValue.(string) if !ok { return nil, false } out[key] = value } return out, true default: return nil, false } } func syncAuthFileMetadataFields(auth *coreauth.Auth, touchedRoots map[string]struct{}) { if auth == nil || len(touchedRoots) == 0 { return } if _, ok := touchedRoots["prefix"]; ok { if prefix, okString := auth.Metadata["prefix"].(string); okString { auth.Prefix = strings.TrimSpace(prefix) } } if _, ok := touchedRoots["proxy_url"]; ok { if proxyURL, okString := auth.Metadata["proxy_url"].(string); okString { auth.ProxyURL = strings.TrimSpace(proxyURL) } } if _, ok := touchedRoots["headers"]; ok { syncAuthFileHeaderAttributes(auth) } if _, ok := touchedRoots["priority"]; ok { syncAuthFilePriorityAttribute(auth) } if _, ok := touchedRoots[coreauth.AttributeWeight]; ok { syncAuthFileWeightAttribute(auth) } if _, ok := touchedRoots["note"]; ok { syncAuthFileNoteAttribute(auth) } if _, ok := touchedRoots["websockets"]; ok { syncAuthFileWebsocketsAttribute(auth) } if _, ok := touchedRoots["disabled"]; ok { syncAuthFileDisabledState(auth) } } func syncAuthFileHeaderAttributes(auth *coreauth.Auth) { if auth == nil { return } if auth.Attributes == nil { auth.Attributes = make(map[string]string) } for key := range auth.Attributes { if strings.HasPrefix(key, "header:") { delete(auth.Attributes, key) } } for name, value := range coreauth.ExtractCustomHeadersFromMetadata(auth.Metadata) { auth.Attributes["header:"+name] = value } } func syncAuthFilePriorityAttribute(auth *coreauth.Auth) { if auth == nil { return } if auth.Attributes == nil { auth.Attributes = make(map[string]string) } priority, ok := authFileIntValue(auth.Metadata["priority"]) if !ok { delete(auth.Attributes, "priority") return } if priority == 0 { delete(auth.Attributes, "priority") return } auth.Attributes["priority"] = strconv.Itoa(priority) } func syncAuthFileWeightAttribute(auth *coreauth.Auth) { if auth == nil { return } if auth.Attributes == nil { auth.Attributes = make(map[string]string) } weight, errWeight := credentialweight.ParseValue(auth.Metadata[coreauth.AttributeWeight]) if errWeight != nil { delete(auth.Attributes, coreauth.AttributeWeight) return } auth.Attributes[coreauth.AttributeWeight] = strconv.FormatInt(weight, 10) } func authFileIntValue(value any) (int, bool) { switch typed := value.(type) { case int: return typed, true case int64: return int(typed), true case float64: return int(typed), true case json.Number: if i, err := typed.Int64(); err == nil { return int(i), true } case string: if i, err := strconv.Atoi(strings.TrimSpace(typed)); err == nil { return i, true } } return 0, false } func syncAuthFileNoteAttribute(auth *coreauth.Auth) { if auth == nil { return } if auth.Attributes == nil { auth.Attributes = make(map[string]string) } note, ok := auth.Metadata["note"].(string) if !ok { delete(auth.Attributes, "note") return } note = strings.TrimSpace(note) if note == "" { delete(auth.Attributes, "note") return } auth.Attributes["note"] = note } func syncAuthFileWebsocketsAttribute(auth *coreauth.Auth) { if auth == nil { return } if auth.Attributes == nil { auth.Attributes = make(map[string]string) } websockets, ok := authFileBoolValue(auth.Metadata["websockets"]) if !ok { delete(auth.Attributes, "websockets") return } auth.Attributes["websockets"] = strconv.FormatBool(websockets) } func authFileBoolValue(value any) (bool, bool) { switch typed := value.(type) { case bool: return typed, true case string: parsed, errParse := strconv.ParseBool(strings.TrimSpace(typed)) if errParse == nil { return parsed, true } } return false, false } func syncAuthFileDisabledState(auth *coreauth.Auth) { if auth == nil { return } disabled, ok := authFileBoolValue(auth.Metadata["disabled"]) if !ok { return } auth.Disabled = disabled if disabled { auth.Status = coreauth.StatusDisabled if strings.TrimSpace(auth.StatusMessage) == "" { auth.StatusMessage = "disabled via management API" } return } auth.Status = coreauth.StatusActive auth.StatusMessage = "" } func (h *Handler) removeAuth(ctx context.Context, id string) { if h == nil || h.authManager == nil { return } id = strings.TrimSpace(id) if id == "" { return } if _, ok := h.authManager.GetByID(id); ok { h.authManager.Remove(ctx, id) return } authID := h.authIDForPath(id) if authID == "" { return } h.authManager.Remove(ctx, authID) } func (h *Handler) removeAuthsForPath(ctx context.Context, path string, fallbackID string) { if h == nil || h.authManager == nil { return } removed := false for _, auth := range h.authManager.List() { if auth == nil { continue } if sameAuthFilePath(authAttribute(auth, "path"), path) || sameAuthFilePath(authAttribute(auth, coreauth.AttributeVirtualSource), path) { h.removeAuth(ctx, auth.ID) removed = true } } if removed { return } if strings.TrimSpace(fallbackID) != "" { h.removeAuth(ctx, fallbackID) return } h.removeAuth(ctx, path) } func sameAuthFilePath(left, right string) bool { left = cleanAuthFilePath(left) right = cleanAuthFilePath(right) if left == "" || right == "" { return false } if runtime.GOOS == "windows" { return strings.EqualFold(left, right) } return left == right } func cleanAuthFilePath(path string) string { path = strings.TrimSpace(path) if path == "" { return "" } if abs, errAbs := filepath.Abs(path); errAbs == nil && strings.TrimSpace(abs) != "" { path = abs } return filepath.Clean(path) } func (h *Handler) deleteTokenRecord(ctx context.Context, path string) error { if strings.TrimSpace(path) == "" { return fmt.Errorf("auth path is empty") } store := h.tokenStoreWithBaseDir() if store == nil { return fmt.Errorf("token store unavailable") } return store.Delete(ctx, path) } func (h *Handler) tokenStoreWithBaseDir() coreauth.Store { if h == nil { return nil } store := h.tokenStore if store == nil { store = sdkAuth.GetTokenStore() h.tokenStore = store } if h.cfg != nil { if dirSetter, ok := store.(interface{ SetBaseDir(string) }); ok { dirSetter.SetBaseDir(h.cfg.AuthDir) } } return store } func (h *Handler) mergeExistingAuthFileMetadata(record *coreauth.Auth) { if h == nil || record == nil { return } var existingMap map[string]any if h.cfg != nil && strings.TrimSpace(h.cfg.AuthDir) != "" { targetFile := record.FileName if targetFile == "" { targetFile = record.ID } if targetFile != "" { fullPath := filepath.Join(h.cfg.AuthDir, targetFile) if raw, errRead := os.ReadFile(fullPath); errRead == nil && len(raw) > 0 { _ = json.Unmarshal(raw, &existingMap) } } } if existingMap == nil && h.authManager != nil { if existing, ok := h.authManager.GetByID(record.ID); ok && existing != nil && existing.Metadata != nil { existingMap = existing.Metadata } else { for _, auth := range h.authManager.List() { if auth != nil && auth.FileName == record.FileName && auth.Metadata != nil { existingMap = auth.Metadata break } } } } if len(existingMap) > 0 { coreauth.MergeExistingAuthMetadata(record, existingMap) } } func (h *Handler) saveTokenRecord(ctx context.Context, record *coreauth.Auth) (string, error) { if record == nil { return "", fmt.Errorf("token record is nil") } h.mergeExistingAuthFileMetadata(record) store := h.tokenStoreWithBaseDir() if store == nil { return "", fmt.Errorf("token store unavailable") } if h.postAuthHook != nil { if err := h.postAuthHook(ctx, record); err != nil { return "", fmt.Errorf("post-auth hook failed: %w", err) } } savedPath, errSave := store.Save(ctx, record) if errSave != nil { return savedPath, errSave } if h.postAuthPersistHook != nil { if errHook := h.postAuthPersistHook(ctx, record); errHook != nil { return savedPath, fmt.Errorf("post-auth persist hook failed: %w", errHook) } } return savedPath, nil }