2026-05-24 15:27:45 +08:00
|
|
|
package auth
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"crypto/rand"
|
|
|
|
|
"encoding/hex"
|
2026-05-24 17:14:35 +08:00
|
|
|
"net"
|
2026-05-24 15:27:45 +08:00
|
|
|
"net/http"
|
|
|
|
|
"strings"
|
|
|
|
|
"sync"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"mirror-proxy/internal/config"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type RateLimiter struct {
|
|
|
|
|
visitors map[string]*visitor
|
|
|
|
|
mu sync.RWMutex
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type visitor struct {
|
|
|
|
|
count int
|
|
|
|
|
lastSeen time.Time
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func NewRateLimiter() *RateLimiter {
|
|
|
|
|
rl := &RateLimiter{visitors: make(map[string]*visitor)}
|
|
|
|
|
go rl.cleanup()
|
|
|
|
|
return rl
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (rl *RateLimiter) cleanup() {
|
|
|
|
|
ticker := time.NewTicker(time.Minute)
|
|
|
|
|
for range ticker.C {
|
|
|
|
|
rl.mu.Lock()
|
|
|
|
|
for ip, v := range rl.visitors {
|
|
|
|
|
if time.Since(v.lastSeen) > time.Minute {
|
|
|
|
|
delete(rl.visitors, ip)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
rl.mu.Unlock()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (rl *RateLimiter) Allow(ip string, limit int) bool {
|
|
|
|
|
if limit <= 0 {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
rl.mu.Lock()
|
|
|
|
|
defer rl.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
v, exists := rl.visitors[ip]
|
|
|
|
|
if !exists {
|
|
|
|
|
rl.visitors[ip] = &visitor{count: 1, lastSeen: time.Now()}
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if time.Since(v.lastSeen) > time.Minute {
|
|
|
|
|
v.count = 1
|
|
|
|
|
v.lastSeen = time.Now()
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if v.count >= limit {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
v.count++
|
|
|
|
|
v.lastSeen = time.Now()
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func GenerateToken() string {
|
|
|
|
|
b := make([]byte, 16)
|
|
|
|
|
rand.Read(b)
|
|
|
|
|
return hex.EncodeToString(b)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func getAuthMode(link *config.Link) string {
|
|
|
|
|
if link.AuthMode == "" {
|
|
|
|
|
return "dual"
|
|
|
|
|
}
|
|
|
|
|
return link.AuthMode
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 17:14:35 +08:00
|
|
|
// CheckIPAllowed 检查客户端 IP 是否在链接的白名单中
|
|
|
|
|
func CheckIPAllowed(clientIP string, allowedIPs []string) bool {
|
|
|
|
|
if len(allowedIPs) == 0 {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ip := net.ParseIP(clientIP)
|
|
|
|
|
if ip == nil {
|
|
|
|
|
// 尝试去掉端口
|
|
|
|
|
host, _, err := net.SplitHostPort(clientIP)
|
|
|
|
|
if err == nil {
|
|
|
|
|
ip = net.ParseIP(host)
|
|
|
|
|
}
|
|
|
|
|
if ip == nil {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, allowed := range allowedIPs {
|
|
|
|
|
allowed = strings.TrimSpace(allowed)
|
|
|
|
|
if allowed == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// CIDR 格式
|
|
|
|
|
if strings.Contains(allowed, "/") {
|
|
|
|
|
_, ipNet, err := net.ParseCIDR(allowed)
|
|
|
|
|
if err == nil && ipNet.Contains(ip) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 单个 IP
|
|
|
|
|
allowedIP := net.ParseIP(allowed)
|
|
|
|
|
if allowedIP != nil && allowedIP.Equal(ip) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// extractClientIP 从请求中提取客户端 IP(去掉端口)
|
|
|
|
|
func extractClientIP(r *http.Request) string {
|
|
|
|
|
ip := r.RemoteAddr
|
|
|
|
|
if xf := r.Header.Get("X-Forwarded-For"); xf != "" {
|
|
|
|
|
ip = strings.Split(xf, ",")[0]
|
|
|
|
|
} else if xr := r.Header.Get("X-Real-Ip"); xr != "" {
|
|
|
|
|
ip = xr
|
|
|
|
|
}
|
|
|
|
|
ip = strings.TrimSpace(ip)
|
|
|
|
|
host, _, err := net.SplitHostPort(ip)
|
|
|
|
|
if err == nil {
|
|
|
|
|
ip = host
|
|
|
|
|
}
|
|
|
|
|
return ip
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 15:27:45 +08:00
|
|
|
func ValidateLinkToken(linkID, token string) (*config.Link, bool) {
|
|
|
|
|
cfg := config.Get()
|
|
|
|
|
link, ok := cfg.GetLink(linkID)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, false
|
|
|
|
|
}
|
|
|
|
|
if !link.Enabled {
|
|
|
|
|
return nil, false
|
|
|
|
|
}
|
|
|
|
|
if getAuthMode(link) != "dual" {
|
|
|
|
|
return nil, false
|
|
|
|
|
}
|
|
|
|
|
if link.Token != token {
|
|
|
|
|
return nil, false
|
|
|
|
|
}
|
|
|
|
|
return link, true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func ValidateLinkTokenSingle(token string) (*config.Link, bool) {
|
|
|
|
|
cfg := config.Get()
|
|
|
|
|
link, ok := cfg.GetLinkByToken(token)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, false
|
|
|
|
|
}
|
|
|
|
|
if !link.Enabled {
|
|
|
|
|
return nil, false
|
|
|
|
|
}
|
|
|
|
|
if getAuthMode(link) != "single" {
|
|
|
|
|
return nil, false
|
|
|
|
|
}
|
|
|
|
|
return link, true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type contextKey string
|
|
|
|
|
|
|
|
|
|
const LinkContextKey contextKey = "link"
|
2026-05-24 17:14:35 +08:00
|
|
|
const TokenPrefixContextKey contextKey = "tokenPrefix"
|
2026-05-24 15:27:45 +08:00
|
|
|
|
|
|
|
|
func ProxyAuthMiddleware(rl *RateLimiter) func(http.Handler) http.Handler {
|
|
|
|
|
return func(next http.Handler) http.Handler {
|
|
|
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
path := strings.TrimPrefix(r.URL.Path, "/")
|
|
|
|
|
parts := strings.SplitN(path, "/", 3)
|
|
|
|
|
if len(parts) < 1 {
|
|
|
|
|
http.Error(w, "Unauthorized: missing token", http.StatusUnauthorized)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var link *config.Link
|
|
|
|
|
var ok bool
|
|
|
|
|
var newPath string
|
2026-05-24 17:14:35 +08:00
|
|
|
var tokenPrefix string
|
2026-05-24 15:27:45 +08:00
|
|
|
|
|
|
|
|
// 优先尝试 dual 模式 (/{linkID}/{token}/...)
|
|
|
|
|
if len(parts) >= 2 {
|
|
|
|
|
link, ok = ValidateLinkToken(parts[0], parts[1])
|
|
|
|
|
if ok {
|
2026-05-24 17:14:35 +08:00
|
|
|
tokenPrefix = "/" + parts[0] + "/" + parts[1]
|
2026-05-24 15:27:45 +08:00
|
|
|
if len(parts) == 2 {
|
|
|
|
|
newPath = "/"
|
|
|
|
|
} else {
|
|
|
|
|
newPath = "/" + parts[2]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 尝试 single 模式 (/{token}/...)
|
|
|
|
|
if !ok {
|
|
|
|
|
link, ok = ValidateLinkTokenSingle(parts[0])
|
|
|
|
|
if ok {
|
2026-05-24 17:14:35 +08:00
|
|
|
tokenPrefix = "/" + parts[0]
|
2026-05-24 15:27:45 +08:00
|
|
|
if len(parts) == 1 {
|
|
|
|
|
newPath = "/"
|
|
|
|
|
} else {
|
2026-05-24 16:22:02 +08:00
|
|
|
newPath = "/" + strings.Join(parts[1:], "/")
|
2026-05-24 15:27:45 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !ok {
|
|
|
|
|
http.Error(w, "Unauthorized: invalid link or token", http.StatusUnauthorized)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 17:14:35 +08:00
|
|
|
clientIP := extractClientIP(r)
|
|
|
|
|
|
|
|
|
|
if !CheckIPAllowed(clientIP, link.AllowedIPs) {
|
|
|
|
|
http.Error(w, "Forbidden: IP not allowed", http.StatusForbidden)
|
|
|
|
|
return
|
2026-05-24 15:27:45 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !rl.Allow(clientIP, link.RateLimit) {
|
|
|
|
|
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
r.URL.Path = newPath
|
|
|
|
|
|
2026-05-24 17:14:35 +08:00
|
|
|
// 存储link和token前缀到context
|
2026-05-24 15:27:45 +08:00
|
|
|
ctx := context.WithValue(r.Context(), LinkContextKey, link)
|
2026-05-24 17:14:35 +08:00
|
|
|
ctx = context.WithValue(ctx, TokenPrefixContextKey, tokenPrefix)
|
2026-05-24 15:27:45 +08:00
|
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|