lolly/internal/cache/disk_cache.go
xfy 179090fa34 fix(security): 修复 2 个 CRITICAL + 6 个 HIGH 安全与代码质量问题
安全修复:
- ConnLimiter Acquire() TOCTOU 竞态: atomic.AddInt64 替代 loadInt64+addInt64
- Cache Purge token 时序侧信道: 改用 subtle.ConstantTimeCompare
- Lua Cosocket SSRF: 新增 ip_guard 两层 IP 检查(字面量+解析后),拒绝私有/回环地址
- X-Accel-Redirect 路径遍历: urlpath.Clean + 前缀拒绝(/internal/ /admin/)
- CRLF 注入: containsCRLF 校验变量展开后的 header 值,logging.Warn 可观测
- Proxy URI 注入: bytes.ContainsAny 检查 path 中的 @\r\n 危险字符

代码质量:
- disk_cache.go Set() 7 处静默 return 改为 logging.Error 日志记录
- config.go 从 2392 行拆分为 9 个按域文件(config/server/proxy/security/ssl/cache/performance/monitoring/variable)

验证: go build + vet + golangci-lint(0 issues) + test(83.2% 无回归) + race detector 全部通过

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 10:13:47 +08:00

520 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package cache 提供文件缓存和代理缓存功能。
//
// 该文件实现 DiskCache 磁盘缓存后端,支持:
// - 目录层级配置levels=1:2
// - 原子写入策略(.tmp → .data
// - 懒加载(后台加载元数据,不阻塞启动)
// - CRC32 校验和验证数据完整性
//
// 主要用途:
//
// 作为 L2 缓存层,持久化代理响应到磁盘,支持服务重启后恢复。
//
// 作者xfy
package cache
import (
"encoding/json"
"fmt"
"hash/crc32"
"os"
"path/filepath"
"sync"
"sync/atomic"
"time"
"rua.plus/lolly/internal/logging"
)
// DiskCacheConfig 磁盘缓存配置。
type DiskCacheConfig struct {
// Path 缓存根目录
Path string
// Levels 目录层级,如 "1:2" 表示两级目录
Levels string
// MaxSize 最大缓存大小(字节)
MaxSize int64
// Inactive 未访问淘汰时间
Inactive time.Duration
// StaleIfError 错误时使用过期缓存的窗口
StaleIfError time.Duration
// StaleIfTimeout 超时时使用过期缓存的窗口
StaleIfTimeout time.Duration
}
// DiskCache 磁盘缓存实现。
type DiskCache struct {
basePath string
levels []int
maxSize int64
inactive time.Duration
staleIfError time.Duration
staleIfTimeout time.Duration
currentSize atomic.Int64
entries map[uint64]*DiskCacheMeta
mu sync.RWMutex
stopCh chan struct{}
// 懒加载相关
loaded atomic.Bool
loadCh chan struct{}
// 统计
hitCount atomic.Int64
missCount atomic.Int64
evictions atomic.Int64
}
// DiskCacheMeta 磁盘缓存元数据。
type DiskCacheMeta struct {
HashKey uint64 `json:"hash_key"`
OrigKey string `json:"orig_key"`
Created time.Time `json:"created"`
MaxAge time.Duration `json:"max_age"`
Status int `json:"status"`
Size int64 `json:"size"`
Headers map[string]string `json:"headers,omitempty"`
CRC32 uint32 `json:"crc32"`
}
// DiskCacheEntry 磁盘缓存条目(包含数据)。
type DiskCacheEntry struct {
*DiskCacheMeta
Data []byte
}
// NewDiskCache 创建磁盘缓存实例(懒加载模式)。
func NewDiskCache(cfg *DiskCacheConfig) (*DiskCache, error) {
if cfg.Path == "" {
return nil, fmt.Errorf("disk cache path is required")
}
// 确保目录存在
if err := os.MkdirAll(cfg.Path, 0o755); err != nil {
return nil, fmt.Errorf("create cache directory: %w", err)
}
dc := &DiskCache{
basePath: cfg.Path,
levels: parseLevels(cfg.Levels),
maxSize: cfg.MaxSize,
inactive: cfg.Inactive,
staleIfError: cfg.StaleIfError,
staleIfTimeout: cfg.StaleIfTimeout,
entries: make(map[uint64]*DiskCacheMeta),
loadCh: make(chan struct{}),
stopCh: make(chan struct{}),
}
// 启动后台加载,不阻塞服务启动
go dc.lazyLoad()
return dc, nil
}
// parseLevels 解析目录层级配置。
// 支持格式:""(无层级)、"1"(一级)、"1:2"(两级)
func parseLevels(levels string) []int {
if levels == "" {
return nil
}
var result []int
start := 0
for i := 0; i <= len(levels); i++ {
if i == len(levels) || levels[i] == ':' {
var level int
for j := start; j < i; j++ {
level = level*10 + int(levels[j]-'0')
}
if level > 0 {
result = append(result, level)
}
start = i + 1
}
}
return result
}
// lazyLoad 后台加载缓存元数据。
func (dc *DiskCache) lazyLoad() {
defer close(dc.loadCh)
// 扫描目录加载元数据(不加载实际数据)
_ = filepath.Walk(dc.basePath, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
// 只处理 .meta 文件
if filepath.Ext(path) != ".meta" {
return nil
}
meta := dc.loadMetaFile(path)
if meta != nil {
dc.mu.Lock()
dc.entries[meta.HashKey] = meta
dc.mu.Unlock()
dc.currentSize.Add(meta.Size)
}
return nil
})
dc.loaded.Store(true)
}
// loadMetaFile 加载元数据文件。
func (dc *DiskCache) loadMetaFile(path string) *DiskCacheMeta {
data, err := os.ReadFile(path)
if err != nil {
return nil
}
var meta DiskCacheMeta
if err := json.Unmarshal(data, &meta); err != nil {
return nil
}
return &meta
}
// Get 获取缓存条目(实现 CacheBackend 接口)。
func (dc *DiskCache) Get(hashKey uint64, origKey string) (*ProxyCacheEntry, bool, bool) {
// 如果未完成加载,等待加载完成或超时
if !dc.loaded.Load() {
select {
case <-dc.loadCh:
// 加载完成,继续
case <-time.After(100 * time.Millisecond):
// 超时,返回未命中(避免阻塞请求)
dc.missCount.Add(1)
return nil, false, false
}
}
dc.mu.RLock()
meta, exists := dc.entries[hashKey]
dc.mu.RUnlock()
if !exists {
dc.missCount.Add(1)
return nil, false, false
}
// 双重验证:检查原始 key 是否匹配
if meta.OrigKey != origKey {
dc.missCount.Add(1)
return nil, false, false
}
// 读取数据文件
dataPath := dc.filePathFromHash(hashKey, "data")
data, err := os.ReadFile(dataPath)
if err != nil {
dc.missCount.Add(1)
return nil, false, false
}
// 验证 CRC32
if meta.CRC32 != 0 {
if crc32.ChecksumIEEE(data) != meta.CRC32 {
// 数据损坏,删除条目
_ = dc.Delete(hashKey)
dc.missCount.Add(1)
return nil, false, false
}
}
// 检查是否过期
now := time.Now()
expiresAt := meta.Created.Add(meta.MaxAge)
stale := now.After(expiresAt)
dc.hitCount.Add(1)
// 转换为 ProxyCacheEntry
entry := &ProxyCacheEntry{
Key: meta.OrigKey,
OrigKey: meta.OrigKey,
Data: data,
Headers: meta.Headers,
Status: meta.Status,
Created: meta.Created,
MaxAge: meta.MaxAge,
}
return entry, true, stale
}
// GetStale 在上游错误时获取可用的过期缓存。
//
// 与 Get 不同GetStale 只在错误发生时使用,根据错误类型检查对应的 stale 窗口。
func (dc *DiskCache) GetStale(hashKey uint64, origKey string, isTimeout bool) (*ProxyCacheEntry, bool) {
// 等待懒加载完成
if !dc.loaded.Load() {
select {
case <-dc.loadCh:
case <-time.After(100 * time.Millisecond):
return nil, false
}
}
dc.mu.RLock()
meta, ok := dc.entries[hashKey]
dc.mu.RUnlock()
if !ok {
return nil, false
}
// 双重验证:检查原始 key 是否匹配
if meta.OrigKey != origKey {
return nil, false
}
// 读取数据文件
dataPath := dc.filePathFromHash(hashKey, "data")
data, err := os.ReadFile(dataPath)
if err != nil {
return nil, false
}
// 验证 CRC32
crc := crc32.ChecksumIEEE(data)
if crc != meta.CRC32 {
return nil, false
}
now := time.Now()
expiresAt := meta.Created.Add(meta.MaxAge)
// 未过期,直接返回
if !now.After(expiresAt) {
entry := &ProxyCacheEntry{
Key: meta.OrigKey,
OrigKey: meta.OrigKey,
Data: data,
Headers: meta.Headers,
Status: meta.Status,
Created: meta.Created,
MaxAge: meta.MaxAge,
}
return entry, true
}
// 已过期,检查 stale 窗口
var staleWindow time.Duration
if isTimeout {
staleWindow = dc.staleIfTimeout
} else {
staleWindow = dc.staleIfError
}
if staleWindow <= 0 {
return nil, false
}
// 检查是否在 stale 窗口内
if now.Sub(expiresAt) > staleWindow {
return nil, false
}
entry := &ProxyCacheEntry{
Key: meta.OrigKey,
OrigKey: meta.OrigKey,
Data: data,
Headers: meta.Headers,
Status: meta.Status,
Created: meta.Created,
MaxAge: meta.MaxAge,
}
return entry, true
}
// Set 设置缓存条目(实现 CacheBackend 接口)。
func (dc *DiskCache) Set(hashKey uint64, origKey string, data []byte, headers map[string]string, status int, maxAge time.Duration) {
// 计算文件路径
dataPath := dc.filePathFromHash(hashKey, "data")
metaPath := dc.filePathFromHash(hashKey, "meta")
// 确保目录存在
dir := filepath.Dir(dataPath)
if err := os.MkdirAll(dir, 0o755); err != nil {
logging.Error().Err(err).Str("dir", dir).Msg("disk cache mkdir failed")
return
}
// 计算 CRC32
crc := crc32.ChecksumIEEE(data)
// 创建元数据
meta := &DiskCacheMeta{
HashKey: hashKey,
OrigKey: origKey,
Created: time.Now(),
MaxAge: maxAge,
Status: status,
Size: int64(len(data)),
Headers: headers,
CRC32: crc,
}
// 原子写入数据文件:先写临时文件,再重命名
tmpDataPath := dataPath + ".tmp"
if err := os.WriteFile(tmpDataPath, data, 0o644); err != nil {
logging.Error().Err(err).Str("path", tmpDataPath).Msg("disk cache write failed")
return
}
if err := os.Rename(tmpDataPath, dataPath); err != nil {
_ = os.Remove(tmpDataPath)
logging.Error().Err(err).Str("from", tmpDataPath).Str("to", dataPath).Msg("disk cache rename failed")
return
}
// 写入元数据文件
metaData, err := json.Marshal(meta)
if err != nil {
logging.Error().Err(err).Msg("disk cache json marshal failed")
return
}
tmpMetaPath := metaPath + ".tmp"
if err := os.WriteFile(tmpMetaPath, metaData, 0o644); err != nil {
logging.Error().Err(err).Str("path", tmpMetaPath).Msg("disk cache write meta failed")
return
}
if err := os.Rename(tmpMetaPath, metaPath); err != nil {
_ = os.Remove(tmpMetaPath)
logging.Error().Err(err).Str("from", tmpMetaPath).Str("to", metaPath).Msg("disk cache rename meta failed")
return
}
// 更新内存索引
dc.mu.Lock()
oldMeta, existed := dc.entries[hashKey]
dc.entries[hashKey] = meta
dc.mu.Unlock()
// 更新大小统计
if existed && oldMeta != nil {
dc.currentSize.Add(-oldMeta.Size)
}
dc.currentSize.Add(meta.Size)
// 检查是否需要淘汰
if dc.maxSize > 0 && dc.currentSize.Load() > dc.maxSize {
go dc.evict()
}
}
// Delete 删除缓存条目(实现 CacheBackend 接口)。
func (dc *DiskCache) Delete(hashKey uint64) error {
dc.mu.Lock()
meta, exists := dc.entries[hashKey]
if exists {
delete(dc.entries, hashKey)
dc.currentSize.Add(-meta.Size)
dc.evictions.Add(1)
}
dc.mu.Unlock()
if !exists {
return nil
}
// 删除文件
dataPath := dc.filePathFromHash(hashKey, "data")
metaPath := dc.filePathFromHash(hashKey, "meta")
_ = os.Remove(dataPath)
_ = os.Remove(metaPath)
return nil
}
// CacheStats 返回缓存统计信息(实现 CacheBackend 接口)。
func (dc *DiskCache) CacheStats() CacheStats {
dc.mu.RLock()
entries := int64(len(dc.entries))
dc.mu.RUnlock()
return CacheStats{
Entries: entries,
Size: dc.currentSize.Load(),
HitCount: dc.hitCount.Load(),
MissCount: dc.missCount.Load(),
Evictions: dc.evictions.Load(),
}
}
// Stop 停止磁盘缓存。
func (dc *DiskCache) Stop() {
close(dc.stopCh)
}
// filePathFromHash 根据哈希值计算文件路径。
func (dc *DiskCache) filePathFromHash(hashKey uint64, ext string) string {
// 将哈希值转换为十六进制字符串
hashStr := fmt.Sprintf("%016x", hashKey)
if len(dc.levels) == 0 {
return filepath.Join(dc.basePath, hashStr+"."+ext)
}
// 根据层级构建路径
parts := []string{dc.basePath}
offset := len(hashStr)
for _, level := range dc.levels {
if offset < level {
break
}
offset -= level
parts = append(parts, hashStr[offset:offset+level])
}
parts = append(parts, hashStr+"."+ext)
return filepath.Join(parts...)
}
// evict 淘汰旧条目。
func (dc *DiskCache) evict() {
// 简单策略:删除最旧的条目直到大小低于阈值
targetSize := dc.maxSize * 9 / 10 // 淘汰到 90%
for dc.currentSize.Load() > targetSize {
dc.mu.Lock()
// 找到最旧的条目
var oldestKey uint64
var oldestTime time.Time
for key, meta := range dc.entries {
if oldestKey == 0 || meta.Created.Before(oldestTime) {
oldestKey = key
oldestTime = meta.Created
}
}
if oldestKey == 0 {
dc.mu.Unlock()
break
}
meta := dc.entries[oldestKey]
delete(dc.entries, oldestKey)
dc.currentSize.Add(-meta.Size)
dc.evictions.Add(1)
dc.mu.Unlock()
// 删除文件
dataPath := dc.filePathFromHash(oldestKey, "data")
metaPath := dc.filePathFromHash(oldestKey, "meta")
_ = os.Remove(dataPath)
_ = os.Remove(metaPath)
}
}