76ba500417
CI / docker (push) Successful in 2m4s
OpenAI-compatible AI gateway with Vue admin UI, multi-provider egress, ingress key governance, monitoring, and security controls. Co-authored-by: Cursor <cursoragent@cursor.com>
274 lines
9.3 KiB
Go
274 lines
9.3 KiB
Go
package admin
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/rose_cat707/luminary/internal/auth"
|
|
"github.com/rose_cat707/luminary/internal/auth/loginlimit"
|
|
"github.com/rose_cat707/luminary/internal/gateway"
|
|
"github.com/rose_cat707/luminary/internal/middleware"
|
|
"github.com/rose_cat707/luminary/internal/model"
|
|
"github.com/rose_cat707/luminary/internal/monitor"
|
|
"github.com/rose_cat707/luminary/internal/prediction"
|
|
"github.com/rose_cat707/luminary/internal/settings"
|
|
"github.com/rose_cat707/luminary/internal/storage/imagestore"
|
|
"github.com/rose_cat707/luminary/internal/store/cache"
|
|
sqlitestore "github.com/rose_cat707/luminary/internal/store/sqlite"
|
|
)
|
|
|
|
type Handler struct {
|
|
repo *sqlitestore.Repository
|
|
db *sqlitestore.Store
|
|
cache *cache.Store
|
|
gateway *gateway.Service
|
|
settings *settings.Runtime
|
|
monitor *monitor.Service
|
|
loginLimit *loginlimit.Limiter
|
|
predictions *prediction.Service
|
|
images *imagestore.Store
|
|
publicURL func() string
|
|
}
|
|
|
|
func New(repo *sqlitestore.Repository, db *sqlitestore.Store, c *cache.Store, gw *gateway.Service, rt *settings.Runtime, mon *monitor.Service, pred *prediction.Service, imgs *imagestore.Store, publicURL func() string) *Handler {
|
|
return &Handler{
|
|
repo: repo,
|
|
db: db,
|
|
cache: c,
|
|
gateway: gw,
|
|
settings: rt,
|
|
monitor: mon,
|
|
loginLimit: loginlimit.New(rt.LoginMaxAttempts(), rt.LoginLockout()),
|
|
predictions: pred,
|
|
images: imgs,
|
|
publicURL: publicURL,
|
|
}
|
|
}
|
|
|
|
func (h *Handler) publicBaseURL() string {
|
|
if h.publicURL != nil {
|
|
return h.publicURL()
|
|
}
|
|
return "http://localhost:8293"
|
|
}
|
|
|
|
func (h *Handler) Register(r *gin.RouterGroup) {
|
|
r.POST("/login", h.Login)
|
|
r.GET("/auth/status", h.AuthStatus)
|
|
|
|
authGroup := r.Group("", middleware.AdminAuth(h.validateSession))
|
|
authGroup.POST("/logout", h.Logout)
|
|
authGroup.PUT("/password", h.ChangePassword)
|
|
|
|
authGroup.GET("/endpoint-meta", h.ListEndpointMeta)
|
|
authGroup.GET("/providers", h.ListProviders)
|
|
authGroup.POST("/providers", h.CreateProvider)
|
|
authGroup.GET("/providers/:id", h.GetProvider)
|
|
authGroup.PUT("/providers/:id", h.UpdateProvider)
|
|
authGroup.DELETE("/providers/:id", h.DeleteProvider)
|
|
authGroup.POST("/providers/:id/sync-models", h.SyncModels)
|
|
|
|
authGroup.GET("/providers/:id/keys", h.ListProviderKeys)
|
|
authGroup.POST("/providers/:id/keys", h.CreateProviderKey)
|
|
authGroup.PUT("/provider-keys/:keyId", h.UpdateProviderKey)
|
|
authGroup.DELETE("/provider-keys/:keyId", h.DeleteProviderKey)
|
|
|
|
authGroup.GET("/providers/:id/models", h.ListModels)
|
|
authGroup.POST("/providers/:id/models", h.CreateModel)
|
|
authGroup.PUT("/providers/:id/models/:modelId", h.UpdateModel)
|
|
authGroup.DELETE("/providers/:id/models/:modelId", h.DeleteModel)
|
|
authGroup.PUT("/models/:modelId", h.UpdateModel)
|
|
authGroup.DELETE("/models/:modelId", h.DeleteModel)
|
|
|
|
authGroup.GET("/ingress-keys", h.ListIngressKeys)
|
|
authGroup.POST("/ingress-keys", h.CreateIngressKey)
|
|
authGroup.GET("/ingress-keys/:id", h.GetIngressKey)
|
|
authGroup.PUT("/ingress-keys/:id", h.UpdateIngressKey)
|
|
authGroup.DELETE("/ingress-keys/:id", h.DeleteIngressKey)
|
|
authGroup.GET("/ingress-keys/:id/usage", h.IngressUsage)
|
|
|
|
authGroup.GET("/ingress-keys/:id/routing-rules", h.ListRoutingRules)
|
|
authGroup.POST("/ingress-keys/:id/routing-rules", h.CreateRoutingRule)
|
|
authGroup.PUT("/routing-rules/:ruleId", h.UpdateRoutingRule)
|
|
authGroup.DELETE("/routing-rules/:ruleId", h.DeleteRoutingRule)
|
|
|
|
authGroup.GET("/workflows/param-meta", h.GetWorkflowParamMeta)
|
|
authGroup.POST("/workflows/analyze", h.AnalyzeWorkflow)
|
|
authGroup.GET("/predictions/:id", h.GetPrediction)
|
|
authGroup.GET("/images/:id", h.ServeAdminImage)
|
|
|
|
authGroup.GET("/request-logs", h.ListRequestLogs)
|
|
authGroup.GET("/request-logs/:id", h.GetRequestLog)
|
|
authGroup.GET("/pricing/catalog", h.PricingCatalog)
|
|
|
|
authGroup.GET("/ip-rules", h.ListIPRules)
|
|
authGroup.POST("/ip-rules", h.CreateIPRule)
|
|
authGroup.PUT("/ip-rules/:id", h.UpdateIPRule)
|
|
authGroup.DELETE("/ip-rules/:id", h.DeleteIPRule)
|
|
|
|
authGroup.GET("/settings", h.GetSettings)
|
|
authGroup.PUT("/settings", h.UpdateSettings)
|
|
|
|
authGroup.GET("/dashboard/overview", h.DashboardOverview)
|
|
authGroup.GET("/dashboard/providers", h.DashboardProviders)
|
|
authGroup.GET("/dashboard/ingress-usage", h.DashboardIngressUsage)
|
|
authGroup.GET("/dashboard/egress-usage", h.DashboardEgressUsage)
|
|
authGroup.GET("/dashboard/provider-model-usage", h.DashboardProviderModelUsage)
|
|
authGroup.GET("/dashboard/client-ip-usage", h.DashboardClientIPUsage)
|
|
authGroup.GET("/dashboard/request-trend", h.DashboardRequestTrend)
|
|
}
|
|
|
|
func (h *Handler) validateSession(token string) (bool, time.Time) {
|
|
hash := auth.HashToken(token)
|
|
var session model.AdminSession
|
|
if err := h.db.DB().Where("token_hash = ?", hash).First(&session).Error; err != nil {
|
|
return false, time.Time{}
|
|
}
|
|
return session.ExpiresAt.After(time.Now()), session.ExpiresAt
|
|
}
|
|
|
|
func (h *Handler) Login(c *gin.Context) {
|
|
ip := middleware.ClientIP(c)
|
|
if locked, retryAfter := h.loginLimit.Check(ip); locked {
|
|
sec := int(retryAfter.Seconds()) + 1
|
|
c.Header("Retry-After", strconv.Itoa(sec))
|
|
h.recordLoginLog(ip, "", "error", "登录尝试次数过多", "", fmt.Sprintf(`{"retry_after_sec":%d}`, sec))
|
|
c.JSON(http.StatusTooManyRequests, gin.H{
|
|
"error": "登录尝试次数过多,请稍后再试",
|
|
"retry_after_sec": sec,
|
|
})
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
h.recordLoginLog(ip, "", "error", "invalid request", "", "")
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
|
return
|
|
}
|
|
reqBody := loginRequestBody(req.Username)
|
|
|
|
var user model.AdminUser
|
|
if err := h.db.DB().Where("username = ?", req.Username).First(&user).Error; err != nil {
|
|
h.loginLimit.RecordFailure(ip)
|
|
h.recordLoginLog(ip, req.Username, "error", "invalid credentials", reqBody, "")
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
|
return
|
|
}
|
|
if !auth.CheckPassword(user.PasswordHash, req.Password) {
|
|
h.loginLimit.RecordFailure(ip)
|
|
h.recordLoginLog(ip, req.Username, "error", "invalid credentials", reqBody, "")
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
|
return
|
|
}
|
|
h.loginLimit.Reset(ip)
|
|
token, err := generateSessionToken()
|
|
if err != nil {
|
|
h.recordLoginLog(ip, req.Username, "error", "session error", reqBody, "")
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "session error"})
|
|
return
|
|
}
|
|
session := model.AdminSession{
|
|
TokenHash: auth.HashToken(token),
|
|
ExpiresAt: time.Now().Add(h.settings.SessionTTL()),
|
|
}
|
|
if err := h.db.DB().Create(&session).Error; err != nil {
|
|
h.recordLoginLog(ip, req.Username, "error", "session error", reqBody, "")
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "session error"})
|
|
return
|
|
}
|
|
respBody := fmt.Sprintf(`{"expires_at":%q}`, session.ExpiresAt.Format(time.RFC3339))
|
|
h.recordLoginLog(ip, req.Username, "success", "", reqBody, respBody)
|
|
c.SetSameSite(http.SameSiteStrictMode)
|
|
c.SetCookie(middleware.SessionCookieName(), token, int(h.settings.SessionTTL().Seconds()), "/", "", false, true)
|
|
c.JSON(http.StatusOK, gin.H{"token": token, "expires_at": session.ExpiresAt})
|
|
}
|
|
|
|
func loginRequestBody(username string) string {
|
|
b, _ := json.Marshal(map[string]string{"username": username})
|
|
return string(b)
|
|
}
|
|
|
|
func (h *Handler) recordLoginLog(ip, username, status, errMsg, requestBody, responseBody string) {
|
|
h.repo.RecordRequestLog(model.UsageRecord{
|
|
ClientIP: ip,
|
|
Endpoint: model.EndpointAdminLogin,
|
|
Model: username,
|
|
Status: status,
|
|
ErrorMsg: errMsg,
|
|
RequestBody: requestBody,
|
|
ResponseBody: responseBody,
|
|
})
|
|
}
|
|
|
|
func (h *Handler) Logout(c *gin.Context) {
|
|
token, _ := c.Get("session_token")
|
|
if t, ok := token.(string); ok {
|
|
h.db.DB().Where("token_hash = ?", auth.HashToken(t)).Delete(&model.AdminSession{})
|
|
}
|
|
c.SetCookie(middleware.SessionCookieName(), "", -1, "/", "", false, true)
|
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
|
}
|
|
|
|
func (h *Handler) AuthStatus(c *gin.Context) {
|
|
token := c.GetHeader("Authorization")
|
|
if len(token) > 7 {
|
|
token = token[7:]
|
|
}
|
|
if token == "" {
|
|
if cookie, err := c.Cookie(middleware.SessionCookieName()); err == nil {
|
|
token = cookie
|
|
}
|
|
}
|
|
valid := false
|
|
if token != "" {
|
|
valid, _ = h.validateSession(token)
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"authenticated": valid})
|
|
}
|
|
|
|
func (h *Handler) ChangePassword(c *gin.Context) {
|
|
var req struct {
|
|
OldPassword string `json:"old_password"`
|
|
NewPassword string `json:"new_password"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil || req.NewPassword == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
|
return
|
|
}
|
|
var user model.AdminUser
|
|
if err := h.db.DB().First(&user).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "user not found"})
|
|
return
|
|
}
|
|
if !auth.CheckPassword(user.PasswordHash, req.OldPassword) {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid old password"})
|
|
return
|
|
}
|
|
hash, err := auth.HashPassword(req.NewPassword)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "hash error"})
|
|
return
|
|
}
|
|
user.PasswordHash = hash
|
|
h.db.DB().Save(&user)
|
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
|
}
|
|
|
|
func generateSessionToken() (string, error) {
|
|
b := make([]byte, 32)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(b), nil
|
|
}
|