feat(cache): 新增缓存清理 API
支持通过 HTTP API 主动清理代理缓存,提供精确路径和通配符模式清理。 包含 IP 白名单和 Token 认证支持。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
612208616c
commit
ce43d5c1d6
329
internal/cache/purge.go
vendored
Normal file
329
internal/cache/purge.go
vendored
Normal file
@ -0,0 +1,329 @@
|
|||||||
|
// Package cache 提供文件缓存和代理缓存功能,支持 LRU 淘汰和缓存锁防击穿。
|
||||||
|
//
|
||||||
|
// 该文件实现了缓存清理 API,用于主动清理代理缓存。
|
||||||
|
//
|
||||||
|
// 主要功能:
|
||||||
|
// - 精确路径清理:删除指定路径的缓存条目
|
||||||
|
// - 通配符模式清理:按模式批量删除缓存条目
|
||||||
|
// - IP 白名单访问控制
|
||||||
|
// - Token 认证支持
|
||||||
|
//
|
||||||
|
// 作者:xfy
|
||||||
|
package cache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"hash/fnv"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/valyala/fasthttp"
|
||||||
|
"rua.plus/lolly/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PurgeAPI 缓存清理 API 处理器。
|
||||||
|
//
|
||||||
|
// 提供 HTTP API 用于主动清理代理缓存,支持精确路径和通配符模式清理。
|
||||||
|
//
|
||||||
|
// 注意事项:
|
||||||
|
// - 所有方法均为并发安全
|
||||||
|
// - 支持 IP 白名单和 Token 认证
|
||||||
|
// - 仅处理 POST 请求
|
||||||
|
type PurgeAPI struct {
|
||||||
|
// cache 代理缓存实例
|
||||||
|
cache *ProxyCache
|
||||||
|
|
||||||
|
// allowed 允许访问的 IP 网络列表
|
||||||
|
allowed []net.IPNet
|
||||||
|
|
||||||
|
// auth 认证配置
|
||||||
|
auth config.CacheAPIAuthConfig
|
||||||
|
|
||||||
|
// path API 端点路径
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurgeRequest 清理请求结构。
|
||||||
|
type PurgeRequest struct {
|
||||||
|
// Path 精确路径
|
||||||
|
Path string `json:"path,omitempty"`
|
||||||
|
|
||||||
|
// Pattern 通配符模式(支持 * 通配符)
|
||||||
|
Pattern string `json:"pattern,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurgeResponse 清理响应结构。
|
||||||
|
type PurgeResponse struct {
|
||||||
|
// Deleted 被删除的缓存条目数
|
||||||
|
Deleted int `json:"deleted"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurgeErrorResponse 错误响应结构。
|
||||||
|
type PurgeErrorResponse struct {
|
||||||
|
// Error 错误信息
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPurgeAPI 创建缓存清理 API 处理器。
|
||||||
|
//
|
||||||
|
// 参数:
|
||||||
|
// - cache: 代理缓存实例
|
||||||
|
// - cfg: 缓存 API 配置
|
||||||
|
//
|
||||||
|
// 返回值:
|
||||||
|
// - *PurgeAPI: 配置好的处理器
|
||||||
|
// - error: IP 解析失败时返回非 nil 错误
|
||||||
|
func NewPurgeAPI(cache *ProxyCache, cfg *config.CacheAPIConfig) (*PurgeAPI, error) {
|
||||||
|
p := &PurgeAPI{
|
||||||
|
cache: cache,
|
||||||
|
auth: cfg.Auth,
|
||||||
|
path: cfg.Path,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析允许的 IP 列表
|
||||||
|
for _, cidr := range cfg.Allow {
|
||||||
|
_, network, err := net.ParseCIDR(cidr)
|
||||||
|
if err != nil {
|
||||||
|
// 尝试作为单个 IP 解析
|
||||||
|
ip := net.ParseIP(cidr)
|
||||||
|
if ip == nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// 转换为 CIDR 格式
|
||||||
|
if ip.To4() != nil {
|
||||||
|
_, network, _ = net.ParseCIDR(cidr + "/32")
|
||||||
|
} else {
|
||||||
|
_, network, _ = net.ParseCIDR(cidr + "/128")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if network != nil {
|
||||||
|
p.allowed = append(p.allowed, *network)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path 返回 API 端点路径。
|
||||||
|
func (p *PurgeAPI) Path() string {
|
||||||
|
if p.path == "" {
|
||||||
|
return "/_cache/purge"
|
||||||
|
}
|
||||||
|
return p.path
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeHTTP 处理缓存清理请求。
|
||||||
|
//
|
||||||
|
// 仅处理 POST 请求,支持精确路径和通配符模式清理。
|
||||||
|
// 返回 JSON 格式的响应。
|
||||||
|
func (p *PurgeAPI) ServeHTTP(ctx *fasthttp.RequestCtx) {
|
||||||
|
// 仅允许 POST 方法
|
||||||
|
if string(ctx.Method()) != "POST" {
|
||||||
|
p.sendError(ctx, fasthttp.StatusMethodNotAllowed, "method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 IP 访问权限
|
||||||
|
if !p.checkAccess(ctx) {
|
||||||
|
p.sendError(ctx, fasthttp.StatusForbidden, "forbidden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查认证
|
||||||
|
if !p.checkAuth(ctx) {
|
||||||
|
p.sendError(ctx, fasthttp.StatusUnauthorized, "unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析请求体
|
||||||
|
var req PurgeRequest
|
||||||
|
if err := json.Unmarshal(ctx.PostBody(), &req); err != nil {
|
||||||
|
p.sendError(ctx, fasthttp.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行清理
|
||||||
|
deleted := 0
|
||||||
|
if req.Path != "" {
|
||||||
|
deleted = p.purgeByPath(req.Path)
|
||||||
|
} else if req.Pattern != "" {
|
||||||
|
deleted = p.purgeByPattern(req.Pattern)
|
||||||
|
} else {
|
||||||
|
p.sendError(ctx, fasthttp.StatusBadRequest, "missing path or pattern")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回响应
|
||||||
|
ctx.SetContentType("application/json; charset=utf-8")
|
||||||
|
ctx.SetStatusCode(fasthttp.StatusOK)
|
||||||
|
json.NewEncoder(ctx).Encode(PurgeResponse{Deleted: deleted}) //nolint:errcheck
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkAccess 检查客户端 IP 是否在允许列表中。
|
||||||
|
func (p *PurgeAPI) checkAccess(ctx *fasthttp.RequestCtx) bool {
|
||||||
|
// 如果没有配置允许列表,允许所有访问
|
||||||
|
if len(p.allowed) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
clientIP := p.getClientIP(ctx)
|
||||||
|
if clientIP == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否在允许列表中
|
||||||
|
for _, network := range p.allowed {
|
||||||
|
if network.Contains(clientIP) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkAuth 检查认证。
|
||||||
|
func (p *PurgeAPI) checkAuth(ctx *fasthttp.RequestCtx) bool {
|
||||||
|
// 无需认证
|
||||||
|
if p.auth.Type == "" || p.auth.Type == "none" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token 认证
|
||||||
|
if p.auth.Type == "token" {
|
||||||
|
// 从 Authorization header 获取 token
|
||||||
|
authHeader := ctx.Request.Header.Peek("Authorization")
|
||||||
|
if len(authHeader) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 支持 Bearer token 格式
|
||||||
|
authStr := string(authHeader)
|
||||||
|
if token, ok := strings.CutPrefix(authStr, "Bearer "); ok {
|
||||||
|
return token == p.auth.Token
|
||||||
|
}
|
||||||
|
|
||||||
|
// 也支持直接传递 token
|
||||||
|
return authStr == p.auth.Token
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// getClientIP 从请求上下文提取客户端 IP。
|
||||||
|
func (p *PurgeAPI) getClientIP(ctx *fasthttp.RequestCtx) net.IP {
|
||||||
|
// 检查 X-Forwarded-For 头部
|
||||||
|
if xff := ctx.Request.Header.Peek("X-Forwarded-For"); len(xff) > 0 {
|
||||||
|
ips := strings.Split(string(xff), ",")
|
||||||
|
if len(ips) > 0 {
|
||||||
|
ipStr := strings.TrimSpace(ips[0])
|
||||||
|
ip := net.ParseIP(ipStr)
|
||||||
|
if ip != nil {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 X-Real-IP 头部
|
||||||
|
if xri := ctx.Request.Header.Peek("X-Real-IP"); len(xri) > 0 {
|
||||||
|
ip := net.ParseIP(string(xri))
|
||||||
|
if ip != nil {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 RemoteAddr
|
||||||
|
if addr := ctx.RemoteAddr(); addr != nil {
|
||||||
|
if tcpAddr, ok := addr.(*net.TCPAddr); ok {
|
||||||
|
return tcpAddr.IP
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// purgeByPath 按精确路径清理缓存。
|
||||||
|
func (p *PurgeAPI) purgeByPath(path string) int {
|
||||||
|
if p.cache == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算缓存键的哈希值
|
||||||
|
hashKey := hashPath(path)
|
||||||
|
|
||||||
|
// 尝试删除
|
||||||
|
p.cache.mu.Lock()
|
||||||
|
defer p.cache.mu.Unlock()
|
||||||
|
|
||||||
|
if _, ok := p.cache.entries[hashKey]; ok {
|
||||||
|
delete(p.cache.entries, hashKey)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// purgeByPattern 按通配符模式清理缓存。
|
||||||
|
func (p *PurgeAPI) purgeByPattern(pattern string) int {
|
||||||
|
if p.cache == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
p.cache.mu.Lock()
|
||||||
|
defer p.cache.mu.Unlock()
|
||||||
|
|
||||||
|
deleted := 0
|
||||||
|
for hashKey, entry := range p.cache.entries {
|
||||||
|
if matchPattern(pattern, entry.OrigKey) {
|
||||||
|
delete(p.cache.entries, hashKey)
|
||||||
|
deleted++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return deleted
|
||||||
|
}
|
||||||
|
|
||||||
|
// hashPath 使用 FNV-64a 计算路径的哈希值。
|
||||||
|
// 与代理层 buildCacheKeyHash 使用相同的算法,确保一致性。
|
||||||
|
// 注意:代理层的 key 格式为 "METHOD:URI",purge 时默认使用 GET 方法。
|
||||||
|
func hashPath(path string) uint64 {
|
||||||
|
// 默认使用 GET 方法,与代理层 key 格式一致
|
||||||
|
key := "GET:" + path
|
||||||
|
h := fnv.New64a()
|
||||||
|
h.Write([]byte(key))
|
||||||
|
return h.Sum64()
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchPattern 检查路径是否匹配通配符模式。
|
||||||
|
// 仅支持 * 通配符,匹配任意字符。
|
||||||
|
func matchPattern(pattern, path string) bool {
|
||||||
|
// 特殊情况:* 匹配所有
|
||||||
|
if pattern == "*" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否有通配符
|
||||||
|
if !strings.Contains(pattern, "*") {
|
||||||
|
return path == pattern
|
||||||
|
}
|
||||||
|
|
||||||
|
// 简单的前缀匹配:/api/users/* 匹配 /api/users/123
|
||||||
|
if prefix, ok := strings.CutSuffix(pattern, "*"); ok {
|
||||||
|
return strings.HasPrefix(path, prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 中间通配符:/api/*/users 匹配 /api/v1/users
|
||||||
|
parts := strings.Split(pattern, "*")
|
||||||
|
if len(parts) == 2 {
|
||||||
|
return strings.HasPrefix(path, parts[0]) && strings.HasSuffix(path, parts[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复杂模式不支持,返回 false
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendError 发送错误响应。
|
||||||
|
func (p *PurgeAPI) sendError(ctx *fasthttp.RequestCtx, status int, errMsg string) {
|
||||||
|
ctx.SetContentType("application/json; charset=utf-8")
|
||||||
|
ctx.SetStatusCode(status)
|
||||||
|
json.NewEncoder(ctx).Encode(PurgeErrorResponse{Error: errMsg}) //nolint:errcheck
|
||||||
|
}
|
||||||
@ -271,6 +271,10 @@ type ServerConfig struct {
|
|||||||
// 支持单位:b, kb, mb, gb 或纯数字表示字节
|
// 支持单位:b, kb, mb, gb 或纯数字表示字节
|
||||||
// 默认值为 1MB
|
// 默认值为 1MB
|
||||||
ClientMaxBodySize string `yaml:"client_max_body_size"`
|
ClientMaxBodySize string `yaml:"client_max_body_size"`
|
||||||
|
|
||||||
|
// CacheAPI 缓存 API 配置
|
||||||
|
// 用于主动清理代理缓存
|
||||||
|
CacheAPI *CacheAPIConfig `yaml:"cache_api"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// StaticConfig 静态文件服务配置。
|
// StaticConfig 静态文件服务配置。
|
||||||
@ -1445,6 +1449,54 @@ type StatusConfig struct {
|
|||||||
Format string `yaml:"format"`
|
Format string `yaml:"format"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CacheAPIConfig 缓存 API 配置。
|
||||||
|
//
|
||||||
|
// 配置缓存清理 API 端点,支持主动清理代理缓存。
|
||||||
|
//
|
||||||
|
// 注意事项:
|
||||||
|
// - Enabled 默认为 false,需显式启用
|
||||||
|
// - Allow 限制可访问的 IP 地址列表
|
||||||
|
// - Auth 配置认证方式,推荐使用 token 认证
|
||||||
|
//
|
||||||
|
// 使用示例:
|
||||||
|
//
|
||||||
|
// cache_api:
|
||||||
|
// enabled: true
|
||||||
|
// path: "/_cache/purge"
|
||||||
|
// allow: ["127.0.0.1", "10.0.0.0/8"]
|
||||||
|
// auth:
|
||||||
|
// type: "token"
|
||||||
|
// token: "${CACHE_API_TOKEN}"
|
||||||
|
type CacheAPIConfig struct {
|
||||||
|
// Enabled 是否启用缓存 API
|
||||||
|
// 默认为 false
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
|
||||||
|
// Path API 端点路径
|
||||||
|
// 默认为 "/_cache/purge"
|
||||||
|
Path string `yaml:"path"`
|
||||||
|
|
||||||
|
// Allow 允许访问的 IP 列表
|
||||||
|
// 可访问缓存 API 的 IP 地址或 CIDR
|
||||||
|
Allow []string `yaml:"allow"`
|
||||||
|
|
||||||
|
// Auth 认证配置
|
||||||
|
Auth CacheAPIAuthConfig `yaml:"auth"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CacheAPIAuthConfig 缓存 API 认证配置。
|
||||||
|
type CacheAPIAuthConfig struct {
|
||||||
|
// Type 认证类型
|
||||||
|
// 支持 "none" 和 "token" 两种类型
|
||||||
|
// 默认为 "none"
|
||||||
|
Type string `yaml:"type"`
|
||||||
|
|
||||||
|
// Token 认证令牌
|
||||||
|
// 当 Type 为 "token" 时使用
|
||||||
|
// 支持环境变量替换,如 "${CACHE_API_TOKEN}"
|
||||||
|
Token string `yaml:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
// StreamConfig TCP/UDP Stream 代理配置。
|
// StreamConfig TCP/UDP Stream 代理配置。
|
||||||
//
|
//
|
||||||
// 用于四层网络代理,如数据库、Redis 等 TCP/UDP 服务。
|
// 用于四层网络代理,如数据库、Redis 等 TCP/UDP 服务。
|
||||||
|
|||||||
@ -45,6 +45,15 @@ func DefaultConfig() *Config {
|
|||||||
IdleTimeout: 120 * time.Second,
|
IdleTimeout: 120 * time.Second,
|
||||||
MaxConnsPerIP: 1000,
|
MaxConnsPerIP: 1000,
|
||||||
MaxRequestsPerConn: 10000,
|
MaxRequestsPerConn: 10000,
|
||||||
|
CacheAPI: &CacheAPIConfig{
|
||||||
|
Enabled: false,
|
||||||
|
Path: "/_cache/purge",
|
||||||
|
Allow: []string{"127.0.0.1"},
|
||||||
|
Auth: CacheAPIAuthConfig{
|
||||||
|
Type: "none",
|
||||||
|
Token: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
Static: []StaticConfig{{
|
Static: []StaticConfig{{
|
||||||
Path: "/",
|
Path: "/",
|
||||||
Root: "/var/www/html",
|
Root: "/var/www/html",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user