a4274be444
CI / docker (push) Successful in 4m52s
Text keys use OpenAI routes; image keys use predictions with ingress Model ID, owner/name or short name resolution, OpenAPI input schemas, editable workflows with per-field defaults, protocol-aware admin whitelist, and in-app API docs. Co-authored-by: Cursor <cursoragent@cursor.com>
205 lines
5.2 KiB
Go
205 lines
5.2 KiB
Go
package middleware
|
|
|
|
import (
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/rose_cat707/luminary/internal/model"
|
|
"github.com/rose_cat707/luminary/internal/store/cache"
|
|
)
|
|
|
|
func IPFilter(c *cache.Store, scope model.IPRuleScope) gin.HandlerFunc {
|
|
return func(ctx *gin.Context) {
|
|
rules := c.ListIPRules()
|
|
if len(rules) == 0 {
|
|
ctx.Next()
|
|
return
|
|
}
|
|
clientIP := clientIP(ctx)
|
|
ip := net.ParseIP(clientIP)
|
|
if ip == nil {
|
|
ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "invalid client ip"})
|
|
return
|
|
}
|
|
var allowRules, denyRules []model.IPRule
|
|
for _, r := range rules {
|
|
if !r.Enabled {
|
|
continue
|
|
}
|
|
if r.Scope != scope && r.Scope != model.IPScopeAll {
|
|
continue
|
|
}
|
|
if r.Type == model.IPRuleAllow {
|
|
allowRules = append(allowRules, r)
|
|
} else {
|
|
denyRules = append(denyRules, r)
|
|
}
|
|
}
|
|
for _, r := range denyRules {
|
|
if ipMatchesRule(r.CIDR, ip) {
|
|
ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "ip denied"})
|
|
return
|
|
}
|
|
}
|
|
if len(allowRules) > 0 {
|
|
allowed := false
|
|
for _, r := range allowRules {
|
|
if ipMatchesRule(r.CIDR, ip) {
|
|
allowed = true
|
|
break
|
|
}
|
|
}
|
|
if !allowed {
|
|
ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "ip not allowed"})
|
|
return
|
|
}
|
|
}
|
|
ctx.Next()
|
|
}
|
|
}
|
|
|
|
func cidrContains(cidr string, ip net.IP) bool {
|
|
if !strings.Contains(cidr, "/") {
|
|
return ip.String() == cidr
|
|
}
|
|
_, network, err := net.ParseCIDR(cidr)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return network.Contains(ip)
|
|
}
|
|
|
|
// ipMatchesRule checks CIDR/IP match, treating IPv4/IPv6 loopback as equivalent.
|
|
func ipMatchesRule(cidr string, ip net.IP) bool {
|
|
if cidrContains(cidr, ip) {
|
|
return true
|
|
}
|
|
if !ip.IsLoopback() {
|
|
return false
|
|
}
|
|
alt := loopbackAlt(ip)
|
|
return alt != nil && cidrContains(cidr, alt)
|
|
}
|
|
|
|
func loopbackAlt(ip net.IP) net.IP {
|
|
if v4 := ip.To4(); v4 != nil && v4.IsLoopback() {
|
|
return net.ParseIP("::1")
|
|
}
|
|
if ip.Equal(net.ParseIP("::1")) {
|
|
return net.ParseIP("127.0.0.1")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func clientIP(ctx *gin.Context) string {
|
|
return ClientIP(ctx)
|
|
}
|
|
|
|
const sessionCookie = "luminary_session"
|
|
|
|
type SessionValidator func(token string) (bool, time.Time)
|
|
|
|
func AdminAuth(validate SessionValidator) gin.HandlerFunc {
|
|
return func(ctx *gin.Context) {
|
|
token := extractToken(ctx)
|
|
if token == "" {
|
|
ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
|
return
|
|
}
|
|
ok, expires := validate(token)
|
|
if !ok || time.Now().After(expires) {
|
|
ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "session expired"})
|
|
return
|
|
}
|
|
ctx.Set("session_token", token)
|
|
ctx.Next()
|
|
}
|
|
}
|
|
|
|
func extractToken(ctx *gin.Context) string {
|
|
if auth := ctx.GetHeader("Authorization"); strings.HasPrefix(auth, "Bearer ") {
|
|
return strings.TrimPrefix(auth, "Bearer ")
|
|
}
|
|
if cookie, err := ctx.Cookie(sessionCookie); err == nil {
|
|
return cookie
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func SessionCookieName() string { return sessionCookie }
|
|
|
|
func IngressAuth(gw IngressKeyResolver) gin.HandlerFunc {
|
|
return func(ctx *gin.Context) {
|
|
key := extractIngressKey(ctx)
|
|
if key == "" {
|
|
ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing api key"})
|
|
return
|
|
}
|
|
ingress, err := gw.ResolveIngressKey(key)
|
|
if err != nil {
|
|
ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid api key"})
|
|
return
|
|
}
|
|
ctx.Set("ingress_key", ingress)
|
|
ctx.Next()
|
|
}
|
|
}
|
|
|
|
func extractIngressKey(ctx *gin.Context) string {
|
|
if auth := ctx.GetHeader("Authorization"); strings.HasPrefix(auth, "Bearer ") {
|
|
return strings.TrimPrefix(auth, "Bearer ")
|
|
}
|
|
return ctx.GetHeader("x-api-key")
|
|
}
|
|
|
|
type IngressKeyResolver interface {
|
|
ResolveIngressKey(key string) (*model.IngressKey, error)
|
|
}
|
|
|
|
// RequireIngressProtocol restricts routes to ingress keys of the given protocol family.
|
|
func RequireIngressProtocol(want model.IngressKeyProtocol) gin.HandlerFunc {
|
|
return func(ctx *gin.Context) {
|
|
v, ok := ctx.Get("ingress_key")
|
|
if !ok {
|
|
ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing api key"})
|
|
return
|
|
}
|
|
k, ok := v.(*model.IngressKey)
|
|
if !ok {
|
|
ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid api key"})
|
|
return
|
|
}
|
|
if k.EffectiveProtocol() != want {
|
|
abortWrongProtocol(ctx, want, protocolMismatchMessage(k.EffectiveProtocol(), want))
|
|
return
|
|
}
|
|
ctx.Next()
|
|
}
|
|
}
|
|
|
|
func protocolMismatchMessage(got, want model.IngressKeyProtocol) string {
|
|
if want == model.IngressProtocolImage {
|
|
return "this api key is for text (OpenAI) API only; use an image (Replicate) ingress key"
|
|
}
|
|
return "this api key is for image (Replicate) API only; use a text (OpenAI) ingress key"
|
|
}
|
|
|
|
func abortWrongProtocol(ctx *gin.Context, want model.IngressKeyProtocol, msg string) {
|
|
if want == model.IngressProtocolImage {
|
|
ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"detail": msg})
|
|
return
|
|
}
|
|
ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
|
"error": gin.H{"message": msg, "type": "forbidden"},
|
|
})
|
|
}
|
|
|
|
func Logger() gin.HandlerFunc {
|
|
return gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {
|
|
return param.TimeStamp.Format(time.RFC3339) + " " + param.Method + " " + param.Path + " " + param.ClientIP + "\n"
|
|
})
|
|
}
|