598 lines
16 KiB
Go
598 lines
16 KiB
Go
package management
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/auth/codex"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/credentialweight"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
|
|
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
|
log "github.com/sirupsen/logrus"
|
|
"github.com/tidwall/gjson"
|
|
)
|
|
|
|
var lastRefreshKeys = []string{"last_refresh", "lastRefresh", "last_refreshed_at", "lastRefreshedAt"}
|
|
|
|
var (
|
|
callbackForwardersMu sync.Mutex
|
|
callbackForwarders = make(map[int]*callbackForwarder)
|
|
authFileEntryMu sync.Mutex
|
|
errAuthFileMustBeJSON = errors.New("auth file must be .json")
|
|
errAuthFileNotFound = errors.New("auth file not found")
|
|
errPluginVirtualAuth = errors.New("plugin virtual auth cannot be modified directly; edit or delete the source auth file")
|
|
newCodexOAuthService = func(cfg *config.Config) codexOAuthService { return codex.NewCodexAuth(cfg) }
|
|
)
|
|
|
|
func extractLastRefreshTimestamp(meta map[string]any) (time.Time, bool) {
|
|
if len(meta) == 0 {
|
|
return time.Time{}, false
|
|
}
|
|
for _, key := range lastRefreshKeys {
|
|
if val, ok := meta[key]; ok {
|
|
if ts, ok1 := parseLastRefreshValue(val); ok1 {
|
|
return ts, true
|
|
}
|
|
}
|
|
}
|
|
return time.Time{}, false
|
|
}
|
|
|
|
func parseLastRefreshValue(v any) (time.Time, bool) {
|
|
switch val := v.(type) {
|
|
case string:
|
|
s := strings.TrimSpace(val)
|
|
if s == "" {
|
|
return time.Time{}, false
|
|
}
|
|
layouts := []string{time.RFC3339, time.RFC3339Nano, "2006-01-02 15:04:05", "2006-01-02T15:04:05Z07:00"}
|
|
for _, layout := range layouts {
|
|
if ts, err := time.Parse(layout, s); err == nil {
|
|
return ts.UTC(), true
|
|
}
|
|
}
|
|
if unix, err := strconv.ParseInt(s, 10, 64); err == nil && unix > 0 {
|
|
return time.Unix(unix, 0).UTC(), true
|
|
}
|
|
case float64:
|
|
if val <= 0 {
|
|
return time.Time{}, false
|
|
}
|
|
return time.Unix(int64(val), 0).UTC(), true
|
|
case int64:
|
|
if val <= 0 {
|
|
return time.Time{}, false
|
|
}
|
|
return time.Unix(val, 0).UTC(), true
|
|
case int:
|
|
if val <= 0 {
|
|
return time.Time{}, false
|
|
}
|
|
return time.Unix(int64(val), 0).UTC(), true
|
|
case json.Number:
|
|
if i, err := val.Int64(); err == nil && i > 0 {
|
|
return time.Unix(i, 0).UTC(), true
|
|
}
|
|
}
|
|
return time.Time{}, false
|
|
}
|
|
|
|
func (h *Handler) ListAuthFiles(c *gin.Context) {
|
|
if h == nil {
|
|
c.JSON(500, gin.H{"error": "handler not initialized"})
|
|
return
|
|
}
|
|
if h.authManager == nil {
|
|
h.listAuthFilesFromDisk(c)
|
|
return
|
|
}
|
|
nameFilter := strings.TrimSpace(c.Query("name"))
|
|
authIndexFilter := strings.TrimSpace(c.Query("auth_index"))
|
|
auths := h.authManager.List()
|
|
files := make([]gin.H, 0, len(auths))
|
|
for _, auth := range auths {
|
|
if !matchesAuthFileLookup(auth, nameFilter, authIndexFilter) {
|
|
continue
|
|
}
|
|
if entry := h.buildAuthFileEntry(auth); entry != nil {
|
|
files = append(files, entry)
|
|
}
|
|
}
|
|
sort.Slice(files, func(i, j int) bool {
|
|
nameI, _ := files[i]["name"].(string)
|
|
nameJ, _ := files[j]["name"].(string)
|
|
return strings.ToLower(nameI) < strings.ToLower(nameJ)
|
|
})
|
|
c.JSON(200, gin.H{"files": files})
|
|
}
|
|
|
|
func lockedAuthIndex(auth *coreauth.Auth) string {
|
|
if auth == nil {
|
|
return ""
|
|
}
|
|
authFileEntryMu.Lock()
|
|
defer authFileEntryMu.Unlock()
|
|
return strings.TrimSpace(auth.EnsureIndex())
|
|
}
|
|
|
|
func matchesAuthFileLookup(auth *coreauth.Auth, name string, authIndex string) bool {
|
|
if auth == nil {
|
|
return false
|
|
}
|
|
if name != "" && strings.TrimSpace(auth.ID) != name && strings.TrimSpace(auth.FileName) != name {
|
|
return false
|
|
}
|
|
if authIndex != "" && lockedAuthIndex(auth) != authIndex {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (h *Handler) lookupAuthFile(name string, authIndex string) (*coreauth.Auth, bool) {
|
|
name = strings.TrimSpace(name)
|
|
authIndex = strings.TrimSpace(authIndex)
|
|
if h == nil || h.authManager == nil || name == "" {
|
|
return nil, false
|
|
}
|
|
if authIndex == "" {
|
|
if auth, ok := h.authManager.GetByID(name); ok {
|
|
return auth, true
|
|
}
|
|
auths := h.authManager.List()
|
|
for _, auth := range auths {
|
|
if auth != nil && strings.TrimSpace(auth.FileName) == name {
|
|
return auth, true
|
|
}
|
|
}
|
|
return nil, false
|
|
}
|
|
auths := h.authManager.List()
|
|
for _, auth := range auths {
|
|
if matchesAuthFileLookup(auth, name, authIndex) {
|
|
return auth, true
|
|
}
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
// GetAuthFileModels returns the models supported by a specific auth file
|
|
func (h *Handler) GetAuthFileModels(c *gin.Context) {
|
|
name := c.Query("name")
|
|
if name == "" {
|
|
c.JSON(400, gin.H{"error": "name is required"})
|
|
return
|
|
}
|
|
|
|
// Try to find auth ID via authManager
|
|
var authID string
|
|
if h.authManager != nil {
|
|
auths := h.authManager.List()
|
|
for _, auth := range auths {
|
|
if auth.FileName == name || auth.ID == name {
|
|
authID = auth.ID
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if authID == "" {
|
|
authID = name // fallback to filename as ID
|
|
}
|
|
|
|
// Get models from registry
|
|
reg := registry.GetGlobalRegistry()
|
|
models := reg.GetModelsForClient(authID)
|
|
|
|
result := make([]gin.H, 0, len(models))
|
|
for _, m := range models {
|
|
entry := gin.H{
|
|
"id": m.ID,
|
|
}
|
|
if m.DisplayName != "" {
|
|
entry["display_name"] = m.DisplayName
|
|
}
|
|
if m.Type != "" {
|
|
entry["type"] = m.Type
|
|
}
|
|
if m.OwnedBy != "" {
|
|
entry["owned_by"] = m.OwnedBy
|
|
}
|
|
result = append(result, entry)
|
|
}
|
|
|
|
c.JSON(200, gin.H{"models": result})
|
|
}
|
|
|
|
// List auth files from disk when the auth manager is unavailable.
|
|
func (h *Handler) listAuthFilesFromDisk(c *gin.Context) {
|
|
nameFilter := strings.TrimSpace(c.Query("name"))
|
|
authIndexFilter := strings.TrimSpace(c.Query("auth_index"))
|
|
entries, err := os.ReadDir(h.cfg.AuthDir)
|
|
if err != nil {
|
|
c.JSON(500, gin.H{"error": fmt.Sprintf("failed to read auth dir: %v", err)})
|
|
return
|
|
}
|
|
files := make([]gin.H, 0)
|
|
if authIndexFilter != "" {
|
|
c.JSON(200, gin.H{"files": files})
|
|
return
|
|
}
|
|
for _, e := range entries {
|
|
if e.IsDir() {
|
|
continue
|
|
}
|
|
name := e.Name()
|
|
if nameFilter != "" && name != nameFilter {
|
|
continue
|
|
}
|
|
if !strings.HasSuffix(strings.ToLower(name), ".json") {
|
|
continue
|
|
}
|
|
if info, errInfo := e.Info(); errInfo == nil {
|
|
fileData := gin.H{"name": name, "size": info.Size(), "modtime": info.ModTime()}
|
|
|
|
// Read file to get type field
|
|
full := filepath.Join(h.cfg.AuthDir, name)
|
|
if data, errRead := os.ReadFile(full); errRead == nil {
|
|
typeValue := gjson.GetBytes(data, "type").String()
|
|
emailValue := gjson.GetBytes(data, "email").String()
|
|
fileData["type"] = typeValue
|
|
fileData["email"] = emailValue
|
|
if projectID := strings.TrimSpace(gjson.GetBytes(data, "project_id").String()); projectID != "" {
|
|
fileData["project_id"] = projectID
|
|
}
|
|
if pv := gjson.GetBytes(data, "priority"); pv.Exists() {
|
|
switch pv.Type {
|
|
case gjson.Number:
|
|
fileData["priority"] = int(pv.Int())
|
|
case gjson.String:
|
|
if parsed, errAtoi := strconv.Atoi(strings.TrimSpace(pv.String())); errAtoi == nil {
|
|
fileData["priority"] = parsed
|
|
}
|
|
}
|
|
}
|
|
if wv := gjson.GetBytes(data, coreauth.AttributeWeight); wv.Exists() {
|
|
var rawWeight string
|
|
switch wv.Type {
|
|
case gjson.Number:
|
|
rawWeight = wv.Raw
|
|
case gjson.String:
|
|
rawWeight = wv.String()
|
|
}
|
|
if rawWeight != "" {
|
|
if weight, errWeight := credentialweight.ParseString(rawWeight); errWeight == nil {
|
|
fileData[coreauth.AttributeWeight] = weight
|
|
}
|
|
}
|
|
}
|
|
if nv := gjson.GetBytes(data, "note"); nv.Exists() && nv.Type == gjson.String {
|
|
if trimmed := strings.TrimSpace(nv.String()); trimmed != "" {
|
|
fileData["note"] = trimmed
|
|
}
|
|
}
|
|
if wv := gjson.GetBytes(data, "websockets"); wv.Exists() {
|
|
switch wv.Type {
|
|
case gjson.True:
|
|
fileData["websockets"] = true
|
|
case gjson.False:
|
|
fileData["websockets"] = false
|
|
case gjson.String:
|
|
if parsed, errParse := strconv.ParseBool(strings.TrimSpace(wv.String())); errParse == nil {
|
|
fileData["websockets"] = parsed
|
|
}
|
|
}
|
|
}
|
|
if requestRetry, okRetry := authFileRequestRetryFromJSON(data); okRetry {
|
|
fileData["request_retry"] = requestRetry
|
|
}
|
|
}
|
|
|
|
files = append(files, fileData)
|
|
}
|
|
}
|
|
c.JSON(200, gin.H{"files": files})
|
|
}
|
|
|
|
func (h *Handler) buildAuthFileEntry(auth *coreauth.Auth) gin.H {
|
|
authFileEntryMu.Lock()
|
|
defer authFileEntryMu.Unlock()
|
|
return h.buildAuthFileEntryLocked(auth)
|
|
}
|
|
|
|
func (h *Handler) buildAuthFileEntryLocked(auth *coreauth.Auth) gin.H {
|
|
if auth == nil {
|
|
return nil
|
|
}
|
|
auth.EnsureIndex()
|
|
runtimeOnly := isRuntimeOnlyAuth(auth)
|
|
if runtimeOnly && (auth.Disabled || auth.Status == coreauth.StatusDisabled) {
|
|
return nil
|
|
}
|
|
path := strings.TrimSpace(authAttribute(auth, "path"))
|
|
if path == "" && !runtimeOnly {
|
|
return nil
|
|
}
|
|
name := strings.TrimSpace(auth.FileName)
|
|
if name == "" {
|
|
name = auth.ID
|
|
}
|
|
entry := gin.H{
|
|
"id": auth.ID,
|
|
"auth_index": auth.Index,
|
|
"name": name,
|
|
"type": strings.TrimSpace(auth.Provider),
|
|
"provider": strings.TrimSpace(auth.Provider),
|
|
"label": auth.Label,
|
|
"status": auth.Status,
|
|
"status_message": auth.StatusMessage,
|
|
"disabled": auth.Disabled,
|
|
"unavailable": auth.Unavailable,
|
|
"runtime_only": runtimeOnly,
|
|
"source": "memory",
|
|
"size": int64(0),
|
|
}
|
|
entry["success"] = auth.Success
|
|
entry["failed"] = auth.Failed
|
|
entry["recent_requests"] = auth.RecentRequestsSnapshot(time.Now())
|
|
if email := authEmail(auth); email != "" {
|
|
entry["email"] = email
|
|
}
|
|
if projectID := authProjectID(auth); projectID != "" {
|
|
entry["project_id"] = projectID
|
|
}
|
|
if accountType, account := auth.AccountInfo(); accountType != "" || account != "" {
|
|
if accountType != "" {
|
|
entry["account_type"] = accountType
|
|
}
|
|
if account != "" {
|
|
entry["account"] = account
|
|
}
|
|
}
|
|
if !auth.CreatedAt.IsZero() {
|
|
entry["created_at"] = auth.CreatedAt
|
|
}
|
|
if !auth.UpdatedAt.IsZero() {
|
|
entry["modtime"] = auth.UpdatedAt
|
|
entry["updated_at"] = auth.UpdatedAt
|
|
}
|
|
if !auth.LastRefreshedAt.IsZero() {
|
|
entry["last_refresh"] = auth.LastRefreshedAt
|
|
}
|
|
if !auth.NextRetryAfter.IsZero() {
|
|
entry["next_retry_after"] = auth.NextRetryAfter
|
|
}
|
|
if path != "" {
|
|
entry["path"] = path
|
|
entry["source"] = "file"
|
|
if info, err := os.Stat(path); err == nil {
|
|
entry["size"] = info.Size()
|
|
entry["modtime"] = info.ModTime()
|
|
} else if os.IsNotExist(err) {
|
|
// Hide credentials removed from disk but still lingering in memory.
|
|
if !runtimeOnly && (auth.Disabled || auth.Status == coreauth.StatusDisabled || strings.EqualFold(strings.TrimSpace(auth.StatusMessage), "removed via management api")) {
|
|
return nil
|
|
}
|
|
entry["source"] = "memory"
|
|
} else {
|
|
log.WithError(err).Warnf("failed to stat auth file %s", path)
|
|
}
|
|
}
|
|
if claims := extractCodexIDTokenClaims(auth); claims != nil {
|
|
entry["id_token"] = claims
|
|
}
|
|
// Expose priority from Attributes (set by synthesizer from JSON "priority" field).
|
|
// Fall back to Metadata for auths registered via UploadAuthFile (no synthesizer).
|
|
if p := strings.TrimSpace(authAttribute(auth, "priority")); p != "" {
|
|
if parsed, err := strconv.Atoi(p); err == nil {
|
|
entry["priority"] = parsed
|
|
}
|
|
} else if auth.Metadata != nil {
|
|
if rawPriority, ok := auth.Metadata["priority"]; ok {
|
|
switch v := rawPriority.(type) {
|
|
case float64:
|
|
entry["priority"] = int(v)
|
|
case int:
|
|
entry["priority"] = v
|
|
case string:
|
|
if parsed, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
|
|
entry["priority"] = parsed
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Expose note from Attributes (set by synthesizer from JSON "note" field).
|
|
// Fall back to Metadata for auths registered via UploadAuthFile (no synthesizer).
|
|
if note := strings.TrimSpace(authAttribute(auth, "note")); note != "" {
|
|
entry["note"] = note
|
|
} else if auth.Metadata != nil {
|
|
if rawNote, ok := auth.Metadata["note"].(string); ok {
|
|
if trimmed := strings.TrimSpace(rawNote); trimmed != "" {
|
|
entry["note"] = trimmed
|
|
}
|
|
}
|
|
}
|
|
if weight, ok := authWeightValue(auth); ok {
|
|
entry[coreauth.AttributeWeight] = weight
|
|
}
|
|
if websockets, ok := authWebsocketsValue(auth); ok {
|
|
entry["websockets"] = websockets
|
|
}
|
|
if requestRetry, ok := auth.RequestRetryOverride(); ok {
|
|
entry["request_retry"] = requestRetry
|
|
}
|
|
return entry
|
|
}
|
|
|
|
func authFileRequestRetryFromJSON(data []byte) (int, bool) {
|
|
var metadata map[string]any
|
|
if errUnmarshal := json.Unmarshal(data, &metadata); errUnmarshal != nil {
|
|
return 0, false
|
|
}
|
|
return (&coreauth.Auth{Metadata: metadata}).RequestRetryOverride()
|
|
}
|
|
|
|
func authWeightValue(auth *coreauth.Auth) (int64, bool) {
|
|
if auth == nil {
|
|
return 0, false
|
|
}
|
|
if rawWeight := strings.TrimSpace(authAttribute(auth, coreauth.AttributeWeight)); rawWeight != "" {
|
|
weight, errWeight := credentialweight.ParseString(rawWeight)
|
|
return weight, errWeight == nil
|
|
}
|
|
if auth.Metadata == nil {
|
|
return 0, false
|
|
}
|
|
rawWeight, ok := auth.Metadata[coreauth.AttributeWeight]
|
|
if !ok || rawWeight == nil {
|
|
return 0, false
|
|
}
|
|
weight, errWeight := credentialweight.ParseValue(rawWeight)
|
|
return weight, errWeight == nil
|
|
}
|
|
|
|
func authWebsocketsValue(auth *coreauth.Auth) (bool, bool) {
|
|
if auth == nil {
|
|
return false, false
|
|
}
|
|
if auth.Attributes != nil {
|
|
if raw := strings.TrimSpace(auth.Attributes["websockets"]); raw != "" {
|
|
parsed, errParse := strconv.ParseBool(raw)
|
|
if errParse == nil {
|
|
return parsed, true
|
|
}
|
|
}
|
|
}
|
|
if auth.Metadata == nil {
|
|
return false, false
|
|
}
|
|
raw, ok := auth.Metadata["websockets"]
|
|
if !ok || raw == nil {
|
|
return false, false
|
|
}
|
|
switch v := raw.(type) {
|
|
case bool:
|
|
return v, true
|
|
case string:
|
|
parsed, errParse := strconv.ParseBool(strings.TrimSpace(v))
|
|
if errParse == nil {
|
|
return parsed, true
|
|
}
|
|
}
|
|
return false, false
|
|
}
|
|
|
|
func authProjectID(auth *coreauth.Auth) string {
|
|
if auth == nil {
|
|
return ""
|
|
}
|
|
if auth.Metadata != nil {
|
|
if v, ok := auth.Metadata["project_id"].(string); ok {
|
|
if projectID := strings.TrimSpace(v); projectID != "" {
|
|
return projectID
|
|
}
|
|
}
|
|
}
|
|
if auth.Attributes != nil {
|
|
if projectID := strings.TrimSpace(auth.Attributes["project_id"]); projectID != "" {
|
|
return projectID
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func extractCodexIDTokenClaims(auth *coreauth.Auth) gin.H {
|
|
if auth == nil || auth.Metadata == nil {
|
|
return nil
|
|
}
|
|
if !strings.EqualFold(strings.TrimSpace(auth.Provider), "codex") {
|
|
return nil
|
|
}
|
|
idTokenRaw, ok := auth.Metadata["id_token"].(string)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
idToken := strings.TrimSpace(idTokenRaw)
|
|
if idToken == "" {
|
|
return nil
|
|
}
|
|
claims, err := codex.ParseJWTToken(idToken)
|
|
if err != nil || claims == nil {
|
|
return nil
|
|
}
|
|
|
|
result := gin.H{}
|
|
if v := strings.TrimSpace(claims.CodexAuthInfo.ChatgptAccountID); v != "" {
|
|
result["chatgpt_account_id"] = v
|
|
}
|
|
if v := strings.TrimSpace(claims.CodexAuthInfo.ChatgptPlanType); v != "" {
|
|
result["plan_type"] = v
|
|
}
|
|
if v := claims.CodexAuthInfo.ChatgptSubscriptionActiveStart; v != nil {
|
|
result["chatgpt_subscription_active_start"] = v
|
|
}
|
|
if v := claims.CodexAuthInfo.ChatgptSubscriptionActiveUntil; v != nil {
|
|
result["chatgpt_subscription_active_until"] = v
|
|
}
|
|
|
|
if len(result) == 0 {
|
|
return nil
|
|
}
|
|
return result
|
|
}
|
|
|
|
func authEmail(auth *coreauth.Auth) string {
|
|
if auth == nil {
|
|
return ""
|
|
}
|
|
if auth.Metadata != nil {
|
|
if v, ok := auth.Metadata["email"].(string); ok {
|
|
return strings.TrimSpace(v)
|
|
}
|
|
}
|
|
if auth.Attributes != nil {
|
|
if v := strings.TrimSpace(auth.Attributes["email"]); v != "" {
|
|
return v
|
|
}
|
|
if v := strings.TrimSpace(auth.Attributes["account_email"]); v != "" {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func authAttribute(auth *coreauth.Auth, key string) string {
|
|
if auth == nil || len(auth.Attributes) == 0 {
|
|
return ""
|
|
}
|
|
return auth.Attributes[key]
|
|
}
|
|
|
|
func isRuntimeOnlyAuth(auth *coreauth.Auth) bool {
|
|
if auth == nil || len(auth.Attributes) == 0 {
|
|
return false
|
|
}
|
|
return strings.EqualFold(strings.TrimSpace(auth.Attributes["runtime_only"]), "true")
|
|
}
|
|
|
|
func isUnsafeAuthFileName(name string) bool {
|
|
if strings.TrimSpace(name) == "" {
|
|
return true
|
|
}
|
|
if strings.ContainsAny(name, "/\\") {
|
|
return true
|
|
}
|
|
if filepath.VolumeName(name) != "" {
|
|
return true
|
|
}
|
|
return false
|
|
}
|