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>
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user