Compare commits

..

67 Commits
v1.0.3 ... main

Author SHA1 Message Date
4ac2c1c43c 为图片请求添加 Vary 头部支持,以便 CDN 根据 Accept 头部进行缓存,优化响应处理逻辑。 2025-07-15 11:51:56 +08:00
8e484f29e9 添加安全头部过滤功能,优化请求头复制逻辑,确保跳过不必要的 hop-by-hop 头部和安全头部,提高安全性和性能。 2025-07-13 04:50:09 +08:00
775814eb24 新增当前会话请求数统计,优化请求窗口逻辑,更新仪表板以展示缓存管理和智能缓存统计,添加工具提示功能以增强用户体验。 2025-07-13 04:31:00 +08:00
19c25b8aca 新增会话请求统计功能,优化请求窗口统计逻辑,支持近5分钟平均每秒请求数的计算,更新仪表板以展示当前会话请求数和近5分钟请求数。 2025-07-13 04:06:06 +08:00
1e77085e10 移除缓存管理器的测试文件,更新缓存管理器以支持图片请求的智能格式回退和统计功能,优化缓存命中率的记录逻辑,增强仪表板的缓存统计展示。 2025-07-13 03:57:25 +08:00
6fd69ba870 添加LRU缓存和内存池优化,提升缓存管理性能。实现缓存预热功能,支持热点数据的快速访问。优化连接池配置,增强镜像代理和代理处理的性能。更新指标收集逻辑,使用分片哈希表提升引用来源统计效率。 2025-07-12 01:19:28 +08:00
5750062168 优化缓存管理日志记录,确保在请求为空时不会导致错误,并在日志中正确显示请求方法。 2025-07-12 00:49:36 +08:00
818dd11dda 在 PathMetrics 的 ToJSON 方法中添加 LastAccessTime 字段,确保 JSON 输出包含最后访问时间信息。 2025-07-11 20:45:40 +08:00
7e81e90113 添加异步请求指标收集功能,优化请求记录逻辑,支持批量更新状态码、字节数和延迟统计,提升性能和可扩展性。 2025-07-11 20:38:24 +08:00
ef2ab55fe6 优化引用来源统计逻辑,移除持久化存储,添加最后访问时间字段,提升仪表板展示信息 2025-07-11 20:08:00 +08:00
4d9162f5e8 移除对基本指标的持久化,仅保留状态码和引用来源统计的持久化,简化指标存储逻辑。 2025-07-11 19:56:09 +08:00
cc677bcf72 移除对基本指标的持久化和加载,简化指标存储逻辑,仅保留状态码和引用来源统计的持久化。仪表板页面状态码统计标题中添加总请求数信息,提升用户体验。 2025-07-11 19:55:05 +08:00
febe460baa 修复文件大小缓存项注释格式,确保代码风格一致性。 2025-06-27 02:04:20 +08:00
30e2f1360e 更新文件大小缓存项注释,调整CSS全局样式变量以改善主题配色和阴影效果,提升界面美观性和可读性。 2025-06-27 02:03:03 +08:00
52fec424ae 更新readme.md中的使用方法链接,修改为新的地址。 2025-06-24 19:44:03 +08:00
ceb92d663e 在仪表板页面中,为客户端IP地址添加了样式,使其在鼠标悬停时显示为蓝色并带下划线,提升了链接的可见性和用户体验。 2025-06-24 12:50:48 +08:00
f07b05e61a 在仪表板页面中,将客户端IP地址链接化,点击后可在新标签页中打开IP信息,提升用户体验和可用性。 2025-06-24 11:55:40 +08:00
c04f600332 添加favicon支持,更新docker-compose配置以挂载favicon目录,并在主程序中实现favicon.ico处理器,提供自定义favicon功能。 2025-06-22 12:53:27 +08:00
f31c601c20 更新Go版本至1.24,添加对github.com/woodchen-ink/go-web-utils的依赖,优化IP获取逻辑,统一使用iputil包获取客户端IP,提升代码一致性和可读性。 2025-06-21 22:39:04 +08:00
da3200c605 添加IP封禁功能,增强安全管理,支持配置IP封禁策略,包括错误阈值、统计窗口、封禁时长和清理间隔。更新配置页面以支持安全设置的可视化管理。 2025-06-21 21:33:12 +08:00
f126dbb9dc 添加域名过滤功能,支持根据请求域名配置不同的扩展规则,增强代理服务的灵活性和可控性。 2025-06-18 16:47:30 +08:00
aed0f755c8 Merge branch 'main' of https://github.com/woodchen-ink/proxy-go 2025-06-03 15:34:21 +08:00
4e3cc382e1 优化规则选择逻辑,严格模式下不再使用扩展名规则作为回退,确保在获取文件大小失败时返回默认目标,提升代码逻辑清晰度和可读性。 2025-06-03 15:34:19 +08:00
wood chen
f54454a6e0
Merge pull request #8 from woodchen-ink/dependabot/go_modules/golang.org/x/net-0.40.0
chore(deps): bump golang.org/x/net from 0.39.0 to 0.40.0
2025-06-02 08:39:36 +08:00
0db0b1f6b1 在配置页面中添加302跳转状态的可视化标识,增强用户界面的交互性和可读性。 2025-06-02 08:37:11 +08:00
35db35e4ce 更新扩展名规则编辑逻辑,添加RedirectMode字段的支持,确保在配置中正确读取和显示302跳转状态,增强用户界面的可读性和交互性。 2025-06-02 08:20:50 +08:00
ef03d71375 优化规则选择逻辑,优先检查扩展名规则,简化302跳转处理,确保在无匹配规则时不强制使用扩展名规则,提升代码可读性和逻辑清晰度。 2025-06-02 08:10:42 +08:00
5790b41a03 优化配置更新逻辑,添加清理缓存功能以确保使用新配置,增强代理处理器的性能和准确性。 2025-06-02 07:54:25 +08:00
83c544bd5b 添加动态压缩管理器支持,优化配置更新逻辑,确保压缩配置可动态调整。更新配置处理器以使用ConfigManager,简化配置保存和加载流程。 2025-06-02 07:52:33 +08:00
370bd1b74f 移除配置迁移功能,简化初始化逻辑,确保应用程序启动时不再执行配置文件迁移。 2025-06-02 07:34:04 +08:00
605b26b883 添加ExtensionMatcher缓存机制,优化缓存管理器和302跳转处理逻辑,增强规则服务集成,提升代码可读性和性能。 2025-06-02 07:18:40 +08:00
1c9d5bc326 移除扩展名匹配器的缓存机制,简化相关逻辑,优化配置更新时的处理流程,提升代码可读性和维护性。 2025-06-02 06:50:37 +08:00
9e45b3e38a 添加扩展名匹配器和缓存机制,优化302跳转规则选择逻辑,增强缓存统计功能,确保配置更新时清理缓存以提高性能和准确性。 2025-06-02 06:33:50 +08:00
8dd410fad4 优化302跳转逻辑,确保在默认目标未配置时仍能进行扩展名匹配,增强代码可读性和逻辑清晰度。 2025-06-02 06:18:28 +08:00
4447e690db 更新302跳转处理逻辑,添加客户端参数以优化规则选择,增强扩展名匹配和文件大小判断,确保代理请求的准确性和稳定性。 2025-06-02 06:12:36 +08:00
f229455db9 添加302跳转选项到路径和扩展名规则配置,更新相关状态管理和UI组件 2025-05-27 08:32:52 +08:00
1a2c7bd06d 添加302跳转支持,更新相关配置和处理逻辑 2025-05-27 08:18:40 +08:00
6bdcaf6f83 优化代理响应头设置,将"Proxy-Go-Cache"和"Proxy-Go-AltTarget"的值调整为更具语义的格式,以提高可读性和一致性。 2025-05-10 18:10:49 +08:00
dependabot[bot]
83ed8dffaa
chore(deps): bump golang.org/x/net from 0.39.0 to 0.40.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.39.0 to 0.40.0.
- [Commits](https://github.com/golang/net/compare/v0.39.0...v0.40.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.40.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-06 12:42:14 +00:00
18a22e2792 Merge branch 'main' of https://github.com/woodchen-ink/proxy-go 2025-05-05 21:51:53 +08:00
d1db2835b4 添加原始请求的查询参数到目标URL,以确保代理请求能够正确传递查询信息。 2025-05-05 21:51:51 +08:00
wood chen
38955fa9c7
Update readme.md 2025-05-05 21:47:01 +08:00
wood chen
87ca33755e
Merge pull request #7 from woodchen-ink/dependabot/go_modules/golang.org/x/net-0.39.0
chore(deps): bump golang.org/x/net from 0.37.0 to 0.39.0
2025-05-05 21:46:30 +08:00
0335640df5 优化Accept-Encoding请求头的设置逻辑,确保在存在该头时使用其值,否则删除该头,以提高代理请求的兼容性和稳定性。 2025-05-05 21:34:15 +08:00
4156b64ac6 优化配置管理逻辑,确保路径配置的扩展名规则在更新时得到正确处理,移除冗余代码以简化回调逻辑。 2025-05-01 08:42:26 +08:00
1d84c0c614 增强路径匹配逻辑,添加前缀匹配器以提高性能,同时优化请求头设置和扩展名处理,确保代理请求的兼容性和稳定性。 2025-04-17 22:11:15 +08:00
dependabot[bot]
964a9672c6
chore(deps): bump golang.org/x/net from 0.37.0 to 0.39.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.37.0 to 0.39.0.
- [Commits](https://github.com/golang/net/compare/v0.37.0...v0.39.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.39.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-08 12:20:51 +00:00
c2266a60d6 增强请求头设置,添加Origin和Accept-Encoding头,确保代理请求的兼容性和稳定性。同时更新User-Agent和Referer的设置逻辑,以支持更灵活的请求处理。 2025-03-24 12:00:04 +08:00
5418e89e3b 移除路径统计相关代码和数据存储,更新指标处理逻辑,调整引用来源统计的加载数量限制,以简化代码和提高性能。 2025-03-23 12:28:15 +08:00
ef1bec7710 移除不再使用的godotenv依赖和相关代码,简化主程序逻辑。 2025-03-22 19:57:16 +08:00
a141672243 移除不再使用的配置文件和信号处理模块,更新扩展名规则中的阈值注释以简化理解。 2025-03-22 19:35:07 +08:00
11378a7e0c 增强请求头设置,添加浏览器User-Agent和Referer,以提高目标URL的可访问性和兼容性。 2025-03-22 18:48:43 +08:00
1aed50444e 优化目标可访问性检查的上下文超时时间,将超时从5秒增加到15秒,以提高请求的稳定性和成功率。 2025-03-22 18:29:38 +08:00
cc45cac622 feat(config): 更新配置管理和扩展名规则处理
- 在配置中添加新的扩展名规则支持,允许用户定义文件扩展名与目标URL的映射
- 优化配置加载逻辑,确保路径配置的扩展名规则在初始化时得到处理
- 更新前端配置页面,支持添加、编辑和删除扩展名规则
- 增强错误处理和用户提示,确保用户体验流畅
2025-03-22 18:17:30 +08:00
c85d08d7a4 移除错误处理模块并更新目标URL获取逻辑,调整返回值以支持备用目标标记。 2025-03-22 16:03:51 +08:00
9c2bc25bfa 优化仪表板页面的指标获取频率,将定时器间隔从1000毫秒调整为3000毫秒,以减少请求频率并提高性能。 2025-03-16 11:54:32 +08:00
e98b2c3efe feat(config): 更新配置处理逻辑并添加日志记录
- 确保在更新配置时调用 ProcessExtensionMap 方法,以更新路径配置的 processedExtMap
- 在配置更新和加载时添加日志记录,便于调试和监控
- 优化回调触发逻辑,确保所有路径配置在触发回调前已更新
2025-03-13 13:23:06 +08:00
50021c1a09 feat(config): 增强配置页面的状态管理
- 添加标志以跟踪配置来源,区分API加载和用户修改
- 优化自动保存逻辑,确保在用户修改时不触发自动保存
- 更新配置时使用包装的setConfig函数,提升代码可读性和维护性
2025-03-13 01:01:51 +08:00
de2209d177 优化指标处理逻辑,移除错误统计部分并更新前端展示。更新.gitignore以忽略.cursor文件。 2025-03-13 00:54:29 +08:00
64423b00e2 feat(config): 添加默认配置文件创建功能
- 实现 createDefaultConfig 方法,在配置文件不存在时自动创建默认配置
- 移除 config.json 中的固定路径配置
- 支持自动创建配置文件目录
- 提供默认的压缩和路由配置
2025-03-12 20:50:19 +08:00
7f4a964163 feat(auth): 增强OAuth认证状态管理和安全性
- 新增 state 状态管理机制,增加 10 分钟有效期
- 实现 generateState 和 validateState 方法
- 优化 LoginHandler 和 OAuthCallbackHandler 中的状态验证逻辑
- 添加更详细的调试和错误日志记录
- 完善回调地址生成逻辑,支持更多网络环境
- 在 OAuth 授权请求中添加 scope 参数
2025-03-12 20:27:20 +08:00
2626f63770 refactor(dashboard): 优化配置页面布局和交互
- 移除 AlertDialog 中的 Info 图标和触发器
- 在卡片标题下添加配置保存提示文本
- 调整配置页面按钮和布局结构
- 简化页面组件和图标导入
2025-03-12 19:19:12 +08:00
07e63eea5f 配置页添加提示 2025-03-12 19:11:58 +08:00
26af4b2b07 添加版本镜像和持续稳定版镜像 2025-03-12 19:05:15 +08:00
512ec6707d feat(auth): 支持自定义OAuth回调地址配置
- 在 docker-compose.yml 中新增 OAUTH_REDIRECT_URI 环境变量配置
- 修改 getCallbackURL 方法,优先使用环境变量指定的回调地址
- 保留原有的自动获取回调地址逻辑作为备选方案
- 增加配置灵活性,方便在不同部署环境中自定义回调地址
2025-03-12 18:54:01 +08:00
a4067a6c66 feat(auth): 增强OAuth用户信息解析和处理逻辑
- 重构用户信息解析方法,支持更多JSON字段和灵活的用户名提取
- 添加调试日志记录用户信息响应内容
- 优化用户名提取策略,支持多种备用字段
- 增加头像URL的多字段兼容处理
- 改进用户信息验证和错误处理机制
- 扩展 OAuthUserInfo 结构体,支持更多可选字段
2025-03-12 15:43:42 +08:00
0d10e89a0b feat(auth): 增强OAuth认证流程的错误处理和日志记录
- 添加详细的OAuth认证流程错误日志
- 增加对OAuth请求各个阶段的参数和状态验证
- 完善错误处理,提供更具体的错误信息和状态码
- 记录认证过程中的关键步骤和错误信息
- 新增客户端IP和请求来源的日志记录
- 优化OAuth令牌和用户信息的验证逻辑
2025-03-12 15:14:01 +08:00
45 changed files with 5992 additions and 2054 deletions

View File

@ -0,0 +1,109 @@
name: Build and Push Docker Image
on:
push:
tags:
- 'v*'
paths-ignore: [ '**.md','docker-compose.yml' ]
jobs:
build-web:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Install dependencies
working-directory: web
run: npm ci
- name: Build web
working-directory: web
run: npm run build
- name: Upload web artifact
uses: actions/upload-artifact@v4
with:
name: web-out
path: web/out
build-backend:
runs-on: ubuntu-latest
strategy:
matrix:
arch: [amd64, arm64]
include:
- arch: amd64
goarch: amd64
- arch: arm64
goarch: arm64
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
- name: Build binary
env:
GOOS: linux
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: 0
run: |
go build -o proxy-go-${{ matrix.arch }}
- name: Upload binary artifact
uses: actions/upload-artifact@v4
with:
name: proxy-go-${{ matrix.arch }}
path: proxy-go-${{ matrix.arch }}
docker:
needs: [build-web, build-backend]
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Download all artifacts
uses: actions/download-artifact@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: woodchen
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create Docker build context
run: |
mkdir -p docker-context
cp Dockerfile docker-context/
cp proxy-go-amd64/proxy-go-amd64 docker-context/proxy-go.amd64
cp proxy-go-arm64/proxy-go-arm64 docker-context/proxy-go.arm64
mkdir -p docker-context/web/out
cp -r web-out/* docker-context/web/out/
- name: Build and push Docker images
uses: docker/build-push-action@v6
with:
context: docker-context
platforms: linux/amd64,linux/arm64
push: true
tags: |
woodchen/proxy-go:${{ github.ref_name }}
woodchen/proxy-go:stable

12
.gitignore vendored
View File

@ -24,5 +24,15 @@ vendor/
web/node_modules/ web/node_modules/
web/dist/ web/dist/
data/config.json data/config.json
data/config.json
kaifa.md kaifa.md
.cursor
data/cache/config.json
data/mirror_cache/config.json
data/metrics/latency_distribution.json
data/metrics/metrics.json
data/metrics/path_stats.json
data/metrics/referer_stats.json
data/metrics/status_codes.json
data/config.json
.env
data/cache

70
data/config.example.json Normal file
View File

@ -0,0 +1,70 @@
{
"MAP": {
"/path1": {
"DefaultTarget": "https://path1.com/path/path/path",
"ExtensionMap": [
{
"Extensions": "jpg,png,avif",
"Target": "https://path1-img.com/path/path/path",
"SizeThreshold": 204800,
"MaxSize": 5242880
},
{
"Extensions": "mp4,webm",
"Target": "https://path1-video.com/path/path/path",
"SizeThreshold": 204800,
"MaxSize": 5242880
},
{
"Extensions": "*",
"Target": "https://path1-wildcard.com/path/path/path",
"SizeThreshold": 204800,
"MaxSize": 5242880
}
]
},
"/path2": "https://path2.com",
"/path3": {
"DefaultTarget": "https://path3.com",
"ExtensionMap": [
{
"Extensions": "*",
"Target": "https://path3-wildcard.com",
"SizeThreshold": 512000,
"MaxSize": 10485760
}
],
"SizeThreshold": 512000
},
"/wildcard-no-limits": {
"DefaultTarget": "https://default.example.com",
"ExtensionMap": [
{
"Extensions": "*",
"Target": "https://unlimited.example.com",
"SizeThreshold": 0,
"MaxSize": 0
}
]
}
},
"Compression": {
"Gzip": {
"Enabled": false,
"Level": 6
},
"Brotli": {
"Enabled": false,
"Level": 4
}
},
"Security": {
"IPBan": {
"Enabled": true,
"ErrorThreshold": 10,
"WindowMinutes": 5,
"BanDurationMinutes": 5,
"CleanupIntervalMinutes": 1
}
}
}

View File

@ -1,39 +0,0 @@
{
"MAP": {
"/path1": {
"DefaultTarget": "https://path1.com/path/path/path",
"ExtensionMap": {
"jpg,png,avif": "https://path1-img.com/path/path/path",
"mp4,webm": "https://path1-video.com/path/path/path"
},
"SizeThreshold": 204800
},
"/path2": "https://path2.com",
"/path3": {
"DefaultTarget": "https://path3.com",
"SizeThreshold": 512000
}
},
"Compression": {
"Gzip": {
"Enabled": false,
"Level": 6
},
"Brotli": {
"Enabled": false,
"Level": 4
}
},
"FixedPaths": [
{
"Path": "/cdnjs",
"TargetHost": "cdnjs.cloudflare.com",
"TargetURL": "https://cdnjs.cloudflare.com"
},
{
"Path": "/jsdelivr",
"TargetHost": "cdn.jsdelivr.net",
"TargetURL": "https://cdn.jsdelivr.net"
}
]
}

View File

@ -6,7 +6,12 @@ services:
- "3336:3336" - "3336:3336"
volumes: volumes:
- ./data:/app/data - ./data:/app/data
- ./favicon:/app/favicon
environment: environment:
- TZ=Asia/Shanghai - TZ=Asia/Shanghai
- OAUTH_CLIENT_ID=your_client_id - OAUTH_CLIENT_ID=your_client_id
- OAUTH_CLIENT_SECRET=your_client_secret
#填写公网访问的地址, 需要跟CZL Connect保持一致.
#选填, 不填为自动获取
- OAUTH_REDIRECT_URI=https://localhost:3336/admin/api/oauth/callback
restart: always restart: always

2
favicon/.gitkeep Normal file
View File

@ -0,0 +1,2 @@
# 这个文件确保 favicon 目录被 git 跟踪
# 用户可以在这个目录中放置自定义的 favicon.ico 文件

32
favicon/README.md Normal file
View File

@ -0,0 +1,32 @@
# Favicon 自定义设置
## 使用方法
1. 将你的 favicon 文件重命名为 `favicon.ico`
2. 放置在这个 `favicon` 目录中
3. 重启 proxy-go 服务
## 支持的文件格式
- `.ico` 文件(推荐)
- `.png` 文件(需要重命名为 favicon.ico
- `.jpg/.jpeg` 文件(需要重命名为 favicon.ico
- `.svg` 文件(需要重命名为 favicon.ico
## 注意事项
- 文件必须命名为 `favicon.ico`
- 推荐尺寸16x16, 32x32, 48x48 像素
- 如果没有放置文件,将返回 404浏览器会使用默认图标
## 示例
```bash
# 将你的 favicon 文件复制到这个目录
cp your-favicon.ico ./favicon/favicon.ico
# 重启服务
docker-compose restart
```
现在访问 `http://your-domain.com/favicon.ico` 就会显示你的自定义 favicon 了!

9
go.mod
View File

@ -1,10 +1,13 @@
module proxy-go module proxy-go
go 1.23.1 go 1.23.0
toolchain go1.23.1
require ( require (
github.com/andybalholm/brotli v1.1.1 github.com/andybalholm/brotli v1.1.1
golang.org/x/net v0.37.0 github.com/woodchen-ink/go-web-utils v1.0.0
golang.org/x/net v0.40.0
) )
require golang.org/x/text v0.23.0 // indirect require golang.org/x/text v0.25.0 // indirect

10
go.sum
View File

@ -1,8 +1,10 @@
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/woodchen-ink/go-web-utils v1.0.0 h1:Kybe0ZPhRI4w5FJ4bZdPcepNEKTmbw3to3xLR31e+ws=
github.com/woodchen-ink/go-web-utils v1.0.0/go.mod h1:hpiT30rd5Egj2LqRwYBqbEtUXjhjh/Qary0S14KCZgw=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=

216
internal/cache/extension_matcher.go vendored Normal file
View File

@ -0,0 +1,216 @@
package cache
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"log"
"proxy-go/internal/config"
"proxy-go/internal/utils"
"sync"
"time"
)
// ExtensionMatcherCacheItem 扩展名匹配器缓存项
type ExtensionMatcherCacheItem struct {
Matcher *utils.ExtensionMatcher
Hash string // 配置的哈希值,用于检测配置变化
CreatedAt time.Time
LastUsed time.Time
UseCount int64
}
// ExtensionMatcherCache 扩展名匹配器缓存管理器
type ExtensionMatcherCache struct {
cache sync.Map
maxAge time.Duration
cleanupTick time.Duration
stopCleanup chan struct{}
mu sync.RWMutex
}
// NewExtensionMatcherCache 创建新的扩展名匹配器缓存
func NewExtensionMatcherCache() *ExtensionMatcherCache {
emc := &ExtensionMatcherCache{
maxAge: 10 * time.Minute, // 缓存10分钟
cleanupTick: 2 * time.Minute, // 每2分钟清理一次
stopCleanup: make(chan struct{}),
}
// 启动清理协程
go emc.startCleanup()
return emc
}
// generateConfigHash 生成配置的哈希值
func (emc *ExtensionMatcherCache) generateConfigHash(rules []config.ExtensionRule) string {
// 将规则序列化为JSON
data, err := json.Marshal(rules)
if err != nil {
// 如果序列化失败,使用时间戳作为哈希
return hex.EncodeToString([]byte(time.Now().String()))
}
// 计算SHA256哈希
hash := sha256.Sum256(data)
return hex.EncodeToString(hash[:])
}
// GetOrCreate 获取或创建扩展名匹配器
func (emc *ExtensionMatcherCache) GetOrCreate(pathKey string, rules []config.ExtensionRule) *utils.ExtensionMatcher {
// 如果没有规则,直接创建新的匹配器
if len(rules) == 0 {
return utils.NewExtensionMatcher(rules)
}
// 生成配置哈希
configHash := emc.generateConfigHash(rules)
// 尝试从缓存获取
if value, ok := emc.cache.Load(pathKey); ok {
item := value.(*ExtensionMatcherCacheItem)
// 检查配置是否变化
if item.Hash == configHash {
// 配置未变化,更新使用信息
emc.mu.Lock()
item.LastUsed = time.Now()
item.UseCount++
emc.mu.Unlock()
log.Printf("[ExtensionMatcherCache] HIT %s (使用次数: %d)", pathKey, item.UseCount)
return item.Matcher
} else {
// 配置已变化,删除旧缓存
emc.cache.Delete(pathKey)
log.Printf("[ExtensionMatcherCache] CONFIG_CHANGED %s", pathKey)
}
}
// 创建新的匹配器
matcher := utils.NewExtensionMatcher(rules)
// 创建缓存项
item := &ExtensionMatcherCacheItem{
Matcher: matcher,
Hash: configHash,
CreatedAt: time.Now(),
LastUsed: time.Now(),
UseCount: 1,
}
// 存储到缓存
emc.cache.Store(pathKey, item)
log.Printf("[ExtensionMatcherCache] NEW %s (规则数量: %d)", pathKey, len(rules))
return matcher
}
// InvalidatePath 使指定路径的缓存失效
func (emc *ExtensionMatcherCache) InvalidatePath(pathKey string) {
if _, ok := emc.cache.LoadAndDelete(pathKey); ok {
log.Printf("[ExtensionMatcherCache] INVALIDATED %s", pathKey)
}
}
// InvalidateAll 清空所有缓存
func (emc *ExtensionMatcherCache) InvalidateAll() {
count := 0
emc.cache.Range(func(key, value interface{}) bool {
emc.cache.Delete(key)
count++
return true
})
log.Printf("[ExtensionMatcherCache] INVALIDATED_ALL (清理了 %d 个缓存项)", count)
}
// GetStats 获取缓存统计信息
func (emc *ExtensionMatcherCache) GetStats() ExtensionMatcherCacheStats {
stats := ExtensionMatcherCacheStats{
MaxAge: int64(emc.maxAge.Minutes()),
CleanupTick: int64(emc.cleanupTick.Minutes()),
}
emc.cache.Range(func(key, value interface{}) bool {
item := value.(*ExtensionMatcherCacheItem)
stats.TotalItems++
stats.TotalUseCount += item.UseCount
// 计算平均年龄
age := time.Since(item.CreatedAt)
stats.AverageAge += int64(age.Minutes())
return true
})
if stats.TotalItems > 0 {
stats.AverageAge /= int64(stats.TotalItems)
}
return stats
}
// ExtensionMatcherCacheStats 扩展名匹配器缓存统计信息
type ExtensionMatcherCacheStats struct {
TotalItems int `json:"total_items"` // 缓存项数量
TotalUseCount int64 `json:"total_use_count"` // 总使用次数
AverageAge int64 `json:"average_age"` // 平均年龄(分钟)
MaxAge int64 `json:"max_age"` // 最大缓存时间(分钟)
CleanupTick int64 `json:"cleanup_tick"` // 清理间隔(分钟)
}
// startCleanup 启动清理协程
func (emc *ExtensionMatcherCache) startCleanup() {
ticker := time.NewTicker(emc.cleanupTick)
defer ticker.Stop()
for {
select {
case <-ticker.C:
emc.cleanup()
case <-emc.stopCleanup:
return
}
}
}
// cleanup 清理过期的缓存项
func (emc *ExtensionMatcherCache) cleanup() {
now := time.Now()
expiredKeys := make([]interface{}, 0)
// 收集过期的键
emc.cache.Range(func(key, value interface{}) bool {
item := value.(*ExtensionMatcherCacheItem)
if now.Sub(item.LastUsed) > emc.maxAge {
expiredKeys = append(expiredKeys, key)
}
return true
})
// 删除过期的缓存项
for _, key := range expiredKeys {
emc.cache.Delete(key)
}
if len(expiredKeys) > 0 {
log.Printf("[ExtensionMatcherCache] CLEANUP 清理了 %d 个过期缓存项", len(expiredKeys))
}
}
// Stop 停止缓存管理器
func (emc *ExtensionMatcherCache) Stop() {
close(emc.stopCleanup)
}
// UpdateConfig 更新缓存配置
func (emc *ExtensionMatcherCache) UpdateConfig(maxAge, cleanupTick time.Duration) {
emc.mu.Lock()
defer emc.mu.Unlock()
emc.maxAge = maxAge
emc.cleanupTick = cleanupTick
log.Printf("[ExtensionMatcherCache] CONFIG_UPDATED maxAge=%v cleanupTick=%v", maxAge, cleanupTick)
}

View File

@ -10,6 +10,7 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"proxy-go/internal/config"
"proxy-go/internal/utils" "proxy-go/internal/utils"
"sort" "sort"
"strings" "strings"
@ -18,6 +19,174 @@ import (
"time" "time"
) )
// 内存池用于复用缓冲区
var (
bufferPool = sync.Pool{
New: func() interface{} {
return make([]byte, 32*1024) // 32KB 缓冲区
},
}
// 大缓冲区池(用于大文件)
largeBufPool = sync.Pool{
New: func() interface{} {
return make([]byte, 1024*1024) // 1MB 缓冲区
},
}
)
// GetBuffer 从池中获取缓冲区
func GetBuffer(size int) []byte {
if size <= 32*1024 {
buf := bufferPool.Get().([]byte)
if cap(buf) >= size {
return buf[:size]
}
bufferPool.Put(buf)
} else if size <= 1024*1024 {
buf := largeBufPool.Get().([]byte)
if cap(buf) >= size {
return buf[:size]
}
largeBufPool.Put(buf)
}
// 如果池中的缓冲区不够大,创建新的
return make([]byte, size)
}
// PutBuffer 将缓冲区放回池中
func PutBuffer(buf []byte) {
if cap(buf) == 32*1024 {
bufferPool.Put(buf)
} else if cap(buf) == 1024*1024 {
largeBufPool.Put(buf)
}
// 其他大小的缓冲区让GC处理
}
// LRU 缓存节点
type LRUNode struct {
key CacheKey
value *CacheItem
prev *LRUNode
next *LRUNode
}
// LRU 缓存实现
type LRUCache struct {
capacity int
size int
head *LRUNode
tail *LRUNode
cache map[CacheKey]*LRUNode
mu sync.RWMutex
}
// NewLRUCache 创建LRU缓存
func NewLRUCache(capacity int) *LRUCache {
lru := &LRUCache{
capacity: capacity,
cache: make(map[CacheKey]*LRUNode),
head: &LRUNode{},
tail: &LRUNode{},
}
lru.head.next = lru.tail
lru.tail.prev = lru.head
return lru
}
// Get 从LRU缓存中获取
func (lru *LRUCache) Get(key CacheKey) (*CacheItem, bool) {
lru.mu.Lock()
defer lru.mu.Unlock()
if node, exists := lru.cache[key]; exists {
lru.moveToHead(node)
return node.value, true
}
return nil, false
}
// Put 向LRU缓存中添加
func (lru *LRUCache) Put(key CacheKey, value *CacheItem) {
lru.mu.Lock()
defer lru.mu.Unlock()
if node, exists := lru.cache[key]; exists {
node.value = value
lru.moveToHead(node)
} else {
newNode := &LRUNode{key: key, value: value}
lru.cache[key] = newNode
lru.addToHead(newNode)
lru.size++
if lru.size > lru.capacity {
tail := lru.removeTail()
delete(lru.cache, tail.key)
lru.size--
}
}
}
// Delete 从LRU缓存中删除
func (lru *LRUCache) Delete(key CacheKey) {
lru.mu.Lock()
defer lru.mu.Unlock()
if node, exists := lru.cache[key]; exists {
lru.removeNode(node)
delete(lru.cache, key)
lru.size--
}
}
// moveToHead 将节点移到头部
func (lru *LRUCache) moveToHead(node *LRUNode) {
lru.removeNode(node)
lru.addToHead(node)
}
// addToHead 添加到头部
func (lru *LRUCache) addToHead(node *LRUNode) {
node.prev = lru.head
node.next = lru.head.next
lru.head.next.prev = node
lru.head.next = node
}
// removeNode 移除节点
func (lru *LRUCache) removeNode(node *LRUNode) {
node.prev.next = node.next
node.next.prev = node.prev
}
// removeTail 移除尾部节点
func (lru *LRUCache) removeTail() *LRUNode {
lastNode := lru.tail.prev
lru.removeNode(lastNode)
return lastNode
}
// Range 遍历所有缓存项
func (lru *LRUCache) Range(fn func(key CacheKey, value *CacheItem) bool) {
lru.mu.RLock()
defer lru.mu.RUnlock()
for key, node := range lru.cache {
if !fn(key, node.value) {
break
}
}
}
// Size 返回缓存大小
func (lru *LRUCache) Size() int {
lru.mu.RLock()
defer lru.mu.RUnlock()
return lru.size
}
// CacheKey 用于标识缓存项的唯一键 // CacheKey 用于标识缓存项的唯一键
type CacheKey struct { type CacheKey struct {
URL string URL string
@ -54,6 +223,7 @@ type CacheItem struct {
Hash string Hash string
CreatedAt time.Time CreatedAt time.Time
AccessCount int64 AccessCount int64
Priority int // 缓存优先级
} }
// CacheStats 缓存统计信息 // CacheStats 缓存统计信息
@ -65,12 +235,16 @@ type CacheStats struct {
HitRate float64 `json:"hit_rate"` // 命中率 HitRate float64 `json:"hit_rate"` // 命中率
BytesSaved int64 `json:"bytes_saved"` // 节省的带宽 BytesSaved int64 `json:"bytes_saved"` // 节省的带宽
Enabled bool `json:"enabled"` // 缓存开关状态 Enabled bool `json:"enabled"` // 缓存开关状态
FormatFallbackHit int64 `json:"format_fallback_hit"` // 格式回退命中次数
ImageCacheHit int64 `json:"image_cache_hit"` // 图片缓存命中次数
RegularCacheHit int64 `json:"regular_cache_hit"` // 常规缓存命中次数
} }
// CacheManager 缓存管理器 // CacheManager 缓存管理器
type CacheManager struct { type CacheManager struct {
cacheDir string cacheDir string
items sync.Map items sync.Map // 保持原有的 sync.Map 用于文件缓存
lruCache *LRUCache // 新增LRU缓存用于热点数据
maxAge time.Duration maxAge time.Duration
cleanupTick time.Duration cleanupTick time.Duration
maxCacheSize int64 maxCacheSize int64
@ -80,6 +254,14 @@ type CacheManager struct {
bytesSaved atomic.Int64 // 节省的带宽 bytesSaved atomic.Int64 // 节省的带宽
cleanupTimer *time.Ticker // 添加清理定时器 cleanupTimer *time.Ticker // 添加清理定时器
stopCleanup chan struct{} // 添加停止信号通道 stopCleanup chan struct{} // 添加停止信号通道
// 新增:格式回退统计
formatFallbackHit atomic.Int64 // 格式回退命中次数
imageCacheHit atomic.Int64 // 图片缓存命中次数
regularCacheHit atomic.Int64 // 常规缓存命中次数
// ExtensionMatcher缓存
extensionMatcherCache *ExtensionMatcherCache
} }
// NewCacheManager 创建新的缓存管理器 // NewCacheManager 创建新的缓存管理器
@ -90,10 +272,14 @@ func NewCacheManager(cacheDir string) (*CacheManager, error) {
cm := &CacheManager{ cm := &CacheManager{
cacheDir: cacheDir, cacheDir: cacheDir,
lruCache: NewLRUCache(10000), // 10000个热点缓存项
maxAge: 30 * time.Minute, maxAge: 30 * time.Minute,
cleanupTick: 5 * time.Minute, cleanupTick: 5 * time.Minute,
maxCacheSize: 10 * 1024 * 1024 * 1024, // 10GB maxCacheSize: 10 * 1024 * 1024 * 1024, // 10GB
stopCleanup: make(chan struct{}), stopCleanup: make(chan struct{}),
// 初始化ExtensionMatcher缓存
extensionMatcherCache: NewExtensionMatcherCache(),
} }
cm.enabled.Store(true) // 默认启用缓存 cm.enabled.Store(true) // 默认启用缓存
@ -127,10 +313,79 @@ func (cm *CacheManager) GenerateCacheKey(r *http.Request) CacheKey {
} }
sort.Strings(varyHeaders) sort.Strings(varyHeaders)
url := r.URL.String()
acceptHeaders := r.Header.Get("Accept")
userAgent := r.Header.Get("User-Agent")
// 🎯 针对图片请求进行智能缓存键优化
if utils.IsImageRequest(r.URL.Path) {
// 解析Accept头中的图片格式偏好
imageFormat := cm.parseImageFormatPreference(acceptHeaders)
// 为图片请求生成格式感知的缓存键
return CacheKey{ return CacheKey{
URL: r.URL.String(), URL: url,
AcceptHeaders: r.Header.Get("Accept"), AcceptHeaders: imageFormat, // 使用标准化的图片格式
UserAgent: r.Header.Get("User-Agent"), UserAgent: cm.normalizeUserAgent(userAgent), // 标准化UserAgent
}
}
return CacheKey{
URL: url,
AcceptHeaders: acceptHeaders,
UserAgent: userAgent,
}
}
// parseImageFormatPreference 解析图片格式偏好,返回标准化的格式标识
func (cm *CacheManager) parseImageFormatPreference(accept string) string {
if accept == "" {
return "image/jpeg" // 默认格式
}
accept = strings.ToLower(accept)
// 按优先级检查现代图片格式
switch {
case strings.Contains(accept, "image/avif"):
return "image/avif"
case strings.Contains(accept, "image/webp"):
return "image/webp"
case strings.Contains(accept, "image/jpeg") || strings.Contains(accept, "image/jpg"):
return "image/jpeg"
case strings.Contains(accept, "image/png"):
return "image/png"
case strings.Contains(accept, "image/gif"):
return "image/gif"
case strings.Contains(accept, "image/*"):
return "image/auto" // 自动格式
default:
return "image/jpeg" // 默认格式
}
}
// normalizeUserAgent 标准化UserAgent减少缓存键的变化
func (cm *CacheManager) normalizeUserAgent(ua string) string {
if ua == "" {
return "default"
}
ua = strings.ToLower(ua)
// 根据主要浏览器类型进行分类
switch {
case strings.Contains(ua, "chrome") && !strings.Contains(ua, "edge"):
return "chrome"
case strings.Contains(ua, "firefox"):
return "firefox"
case strings.Contains(ua, "safari") && !strings.Contains(ua, "chrome"):
return "safari"
case strings.Contains(ua, "edge"):
return "edge"
case strings.Contains(ua, "bot") || strings.Contains(ua, "crawler"):
return "bot"
default:
return "other"
} }
} }
@ -140,7 +395,117 @@ func (cm *CacheManager) Get(key CacheKey, r *http.Request) (*CacheItem, bool, bo
return nil, false, false return nil, false, false
} }
// 检查缓存项是否存在 // 🎯 针对图片请求实现智能格式回退
if utils.IsImageRequest(r.URL.Path) {
return cm.getImageWithFallback(key, r)
}
return cm.getRegularItem(key)
}
// getImageWithFallback 获取图片缓存项,支持格式回退
func (cm *CacheManager) getImageWithFallback(key CacheKey, r *http.Request) (*CacheItem, bool, bool) {
// 首先尝试精确匹配
if item, found, notModified := cm.getRegularItem(key); found {
cm.imageCacheHit.Add(1)
return item, found, notModified
}
// 如果精确匹配失败,尝试格式回退
if item, found, notModified := cm.tryFormatFallback(key, r); found {
cm.formatFallbackHit.Add(1)
return item, found, notModified
}
return nil, false, false
}
// tryFormatFallback 尝试格式回退
func (cm *CacheManager) tryFormatFallback(originalKey CacheKey, r *http.Request) (*CacheItem, bool, bool) {
requestedFormat := originalKey.AcceptHeaders
// 定义格式回退顺序
fallbackFormats := cm.getFormatFallbackOrder(requestedFormat)
for _, format := range fallbackFormats {
fallbackKey := CacheKey{
URL: originalKey.URL,
AcceptHeaders: format,
UserAgent: originalKey.UserAgent,
}
if item, found, notModified := cm.getRegularItem(fallbackKey); found {
// 找到了兼容格式,检查是否真的兼容
if cm.isFormatCompatible(requestedFormat, format, item.ContentType) {
log.Printf("[Cache] 格式回退: %s -> %s (%s)", requestedFormat, format, originalKey.URL)
return item, found, notModified
}
}
}
return nil, false, false
}
// getFormatFallbackOrder 获取格式回退顺序
func (cm *CacheManager) getFormatFallbackOrder(requestedFormat string) []string {
switch requestedFormat {
case "image/avif":
return []string{"image/webp", "image/jpeg", "image/png"}
case "image/webp":
return []string{"image/jpeg", "image/png", "image/avif"}
case "image/jpeg":
return []string{"image/webp", "image/png", "image/avif"}
case "image/png":
return []string{"image/webp", "image/jpeg", "image/avif"}
case "image/auto":
return []string{"image/webp", "image/avif", "image/jpeg", "image/png"}
default:
return []string{"image/jpeg", "image/webp", "image/png"}
}
}
// isFormatCompatible 检查格式是否兼容
func (cm *CacheManager) isFormatCompatible(requestedFormat, cachedFormat, actualContentType string) bool {
// 如果是自动格式,接受任何现代格式
if requestedFormat == "image/auto" {
return true
}
// 现代浏览器通常可以处理多种格式
modernFormats := map[string]bool{
"image/webp": true,
"image/avif": true,
"image/jpeg": true,
"image/png": true,
}
// 检查实际内容类型是否为现代格式
if actualContentType != "" {
return modernFormats[strings.ToLower(actualContentType)]
}
return modernFormats[cachedFormat]
}
// getRegularItem 获取常规缓存项(原有逻辑)
func (cm *CacheManager) getRegularItem(key CacheKey) (*CacheItem, bool, bool) {
// 检查LRU缓存
if item, found := cm.lruCache.Get(key); found {
// 检查LRU缓存项是否过期
if time.Since(item.LastAccess) > cm.maxAge {
cm.lruCache.Delete(key)
cm.missCount.Add(1)
return nil, false, false
}
// 更新访问时间
item.LastAccess = time.Now()
atomic.AddInt64(&item.AccessCount, 1)
cm.hitCount.Add(1)
cm.regularCacheHit.Add(1)
return item, true, false
}
// 检查文件缓存
value, ok := cm.items.Load(key) value, ok := cm.items.Load(key)
if !ok { if !ok {
cm.missCount.Add(1) cm.missCount.Add(1)
@ -168,8 +533,12 @@ func (cm *CacheManager) Get(key CacheKey, r *http.Request) (*CacheItem, bool, bo
item.LastAccess = time.Now() item.LastAccess = time.Now()
atomic.AddInt64(&item.AccessCount, 1) atomic.AddInt64(&item.AccessCount, 1)
cm.hitCount.Add(1) cm.hitCount.Add(1)
cm.regularCacheHit.Add(1)
cm.bytesSaved.Add(item.Size) cm.bytesSaved.Add(item.Size)
// 将缓存项添加到LRU缓存
cm.lruCache.Put(key, item)
return item, true, false return item, true, false
} }
@ -223,7 +592,11 @@ func (cm *CacheManager) Put(key CacheKey, resp *http.Response, body []byte) (*Ca
} }
cm.items.Store(key, item) cm.items.Store(key, item)
log.Printf("[Cache] NEW %s %s (%s) from %s", resp.Request.Method, key.URL, formatBytes(item.Size), utils.GetRequestSource(resp.Request)) method := "GET"
if resp.Request != nil {
method = resp.Request.Method
}
log.Printf("[Cache] NEW %s %s (%s) from %s", method, key.URL, formatBytes(item.Size), utils.GetRequestSource(resp.Request))
return item, nil return item, nil
} }
@ -332,6 +705,9 @@ func (cm *CacheManager) GetStats() CacheStats {
HitRate: hitRate, HitRate: hitRate,
BytesSaved: cm.bytesSaved.Load(), BytesSaved: cm.bytesSaved.Load(),
Enabled: cm.enabled.Load(), Enabled: cm.enabled.Load(),
FormatFallbackHit: cm.formatFallbackHit.Load(),
ImageCacheHit: cm.imageCacheHit.Load(),
RegularCacheHit: cm.regularCacheHit.Load(),
} }
} }
@ -374,6 +750,9 @@ func (cm *CacheManager) ClearCache() error {
cm.hitCount.Store(0) cm.hitCount.Store(0)
cm.missCount.Store(0) cm.missCount.Store(0)
cm.bytesSaved.Store(0) cm.bytesSaved.Store(0)
cm.formatFallbackHit.Store(0)
cm.imageCacheHit.Store(0)
cm.regularCacheHit.Store(0)
return nil return nil
} }
@ -444,15 +823,41 @@ func (cm *CacheManager) Commit(key CacheKey, tempPath string, resp *http.Respons
return fmt.Errorf("cache is disabled") return fmt.Errorf("cache is disabled")
} }
// 生成最终的缓存文件名 // 读取临时文件内容以计算哈希
h := sha256.New() tempData, err := os.ReadFile(tempPath)
h.Write([]byte(key.String())) if err != nil {
hashStr := hex.EncodeToString(h.Sum(nil)) os.Remove(tempPath)
ext := filepath.Ext(key.URL) return fmt.Errorf("failed to read temp file: %v", err)
if ext == "" {
ext = ".bin"
} }
filePath := filepath.Join(cm.cacheDir, hashStr+ext)
// 计算内容哈希与Put方法保持一致
contentHash := sha256.Sum256(tempData)
hashStr := hex.EncodeToString(contentHash[:])
// 检查是否存在相同哈希的缓存项
var existingItem *CacheItem
cm.items.Range(func(k, v interface{}) bool {
if item := v.(*CacheItem); item.Hash == hashStr {
if _, err := os.Stat(item.FilePath); err == nil {
existingItem = item
return false
}
cm.items.Delete(k)
}
return true
})
if existingItem != nil {
// 删除临时文件,使用现有缓存
os.Remove(tempPath)
cm.items.Store(key, existingItem)
log.Printf("[Cache] HIT %s %s (%s) from %s", resp.Request.Method, key.URL, formatBytes(existingItem.Size), utils.GetRequestSource(resp.Request))
return nil
}
// 生成最终的缓存文件名(使用内容哈希)
fileName := hashStr
filePath := filepath.Join(cm.cacheDir, fileName)
// 重命名临时文件 // 重命名临时文件
if err := os.Rename(tempPath, filePath); err != nil { if err := os.Rename(tempPath, filePath); err != nil {
@ -591,3 +996,54 @@ func (cm *CacheManager) loadConfig() error {
return nil return nil
} }
// GetExtensionMatcher 获取缓存的ExtensionMatcher
func (cm *CacheManager) GetExtensionMatcher(pathKey string, rules []config.ExtensionRule) *utils.ExtensionMatcher {
if cm.extensionMatcherCache == nil {
return utils.NewExtensionMatcher(rules)
}
return cm.extensionMatcherCache.GetOrCreate(pathKey, rules)
}
// InvalidateExtensionMatcherPath 使指定路径的ExtensionMatcher缓存失效
func (cm *CacheManager) InvalidateExtensionMatcherPath(pathKey string) {
if cm.extensionMatcherCache != nil {
cm.extensionMatcherCache.InvalidatePath(pathKey)
}
}
// InvalidateAllExtensionMatchers 清空所有ExtensionMatcher缓存
func (cm *CacheManager) InvalidateAllExtensionMatchers() {
if cm.extensionMatcherCache != nil {
cm.extensionMatcherCache.InvalidateAll()
}
}
// GetExtensionMatcherStats 获取ExtensionMatcher缓存统计信息
func (cm *CacheManager) GetExtensionMatcherStats() ExtensionMatcherCacheStats {
if cm.extensionMatcherCache != nil {
return cm.extensionMatcherCache.GetStats()
}
return ExtensionMatcherCacheStats{}
}
// UpdateExtensionMatcherConfig 更新ExtensionMatcher缓存配置
func (cm *CacheManager) UpdateExtensionMatcherConfig(maxAge, cleanupTick time.Duration) {
if cm.extensionMatcherCache != nil {
cm.extensionMatcherCache.UpdateConfig(maxAge, cleanupTick)
}
}
// Stop 停止缓存管理器包括ExtensionMatcher缓存
func (cm *CacheManager) Stop() {
// 停止主缓存清理
if cm.cleanupTimer != nil {
cm.cleanupTimer.Stop()
}
close(cm.stopCleanup)
// 停止ExtensionMatcher缓存
if cm.extensionMatcherCache != nil {
cm.extensionMatcherCache.Stop()
}
}

View File

@ -1,64 +0,0 @@
package cache
import (
"net/http"
"os"
"testing"
"time"
)
func TestCacheExpiry(t *testing.T) {
// 创建临时目录用于测试
tempDir, err := os.MkdirTemp("", "cache-test-*")
if err != nil {
t.Fatal("Failed to create temp dir:", err)
}
defer os.RemoveAll(tempDir)
// 创建缓存管理器设置较短的过期时间5秒用于测试
cm, err := NewCacheManager(tempDir)
if err != nil {
t.Fatal("Failed to create cache manager:", err)
}
cm.maxAge = 5 * time.Second
// 创建测试请求和响应
req, _ := http.NewRequest("GET", "http://example.com/test", nil)
resp := &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
}
testData := []byte("test data")
// 生成缓存键
key := cm.GenerateCacheKey(req)
// 1. 首先放入缓存
_, err = cm.Put(key, resp, testData)
if err != nil {
t.Fatal("Failed to put item in cache:", err)
}
// 2. 立即获取,应该能命中
if _, hit, _ := cm.Get(key, req); !hit {
t.Error("Cache should hit immediately after putting")
}
// 3. 等待3秒未过期再次访问
time.Sleep(3 * time.Second)
if _, hit, _ := cm.Get(key, req); !hit {
t.Error("Cache should hit after 3 seconds")
}
// 4. 再等待3秒总共6秒但因为上次访问重置了时间所以应该还在有效期内
time.Sleep(3 * time.Second)
if _, hit, _ := cm.Get(key, req); !hit {
t.Error("Cache should hit after 6 seconds because last access reset the timer")
}
// 5. 等待6秒超过过期时间且无访问这次应该过期
time.Sleep(6 * time.Second)
if _, hit, _ := cm.Get(key, req); hit {
t.Error("Cache should expire after 6 seconds of no access")
}
}

View File

@ -2,23 +2,14 @@ package config
import ( import (
"encoding/json" "encoding/json"
"log"
"os" "os"
"strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time"
) )
// Config 配置结构体
type configImpl struct {
sync.RWMutex
Config
// 配置更新回调函数
onConfigUpdate []func(*Config)
}
var ( var (
instance *configImpl
once sync.Once
configCallbacks []func(*Config) configCallbacks []func(*Config)
callbackMutex sync.RWMutex callbackMutex sync.RWMutex
) )
@ -26,30 +17,185 @@ var (
type ConfigManager struct { type ConfigManager struct {
config atomic.Value config atomic.Value
configPath string configPath string
mu sync.RWMutex
} }
func NewConfigManager(path string) *ConfigManager { func NewConfigManager(configPath string) (*ConfigManager, error) {
cm := &ConfigManager{configPath: path} cm := &ConfigManager{
cm.loadConfig() configPath: configPath,
go cm.watchConfig()
return cm
} }
func (cm *ConfigManager) watchConfig() { // 加载配置
ticker := time.NewTicker(30 * time.Second) config, err := cm.loadConfigFromFile()
for range ticker.C { if err != nil {
cm.loadConfig() return nil, err
}
} }
// Load 加载配置 // 确保所有路径配置的扩展名规则都已更新
func Load(path string) (*Config, error) { for path, pc := range config.MAP {
var err error pc.ProcessExtensionMap()
once.Do(func() { config.MAP[path] = pc // 更新回原始map
instance = &configImpl{} }
err = instance.reload(path)
}) cm.config.Store(config)
return &instance.Config, err log.Printf("[ConfigManager] 配置已加载: %d 个路径映射", len(config.MAP))
return cm, nil
}
// loadConfigFromFile 从文件加载配置
func (cm *ConfigManager) loadConfigFromFile() (*Config, error) {
data, err := os.ReadFile(cm.configPath)
if err != nil {
// 如果文件不存在,创建默认配置
if os.IsNotExist(err) {
if createErr := cm.createDefaultConfig(); createErr == nil {
return cm.loadConfigFromFile() // 重新加载
} else {
return nil, createErr
}
}
return nil, err
}
var config Config
if err := json.Unmarshal(data, &config); err != nil {
return nil, err
}
return &config, nil
}
// createDefaultConfig 创建默认配置文件
func (cm *ConfigManager) createDefaultConfig() error {
// 创建目录(如果不存在)
dir := cm.configPath[:strings.LastIndex(cm.configPath, "/")]
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
// 创建默认配置
defaultConfig := Config{
MAP: map[string]PathConfig{
"/": {
DefaultTarget: "http://localhost:8080",
// 添加新式扩展名规则映射示例
ExtensionMap: []ExtRuleConfig{
{
Extensions: "jpg,png,webp",
Target: "https://img1.example.com",
SizeThreshold: 500 * 1024, // 500KB
MaxSize: 2 * 1024 * 1024, // 2MB
Domains: "a.com,b.com", // 只对a.com和b.com域名生效
},
{
Extensions: "jpg,png,webp",
Target: "https://img2.example.com",
SizeThreshold: 2 * 1024 * 1024, // 2MB
MaxSize: 5 * 1024 * 1024, // 5MB
Domains: "b.com", // 只对b.com域名生效
},
{
Extensions: "mp4,avi",
Target: "https://video.example.com",
SizeThreshold: 1024 * 1024, // 1MB
MaxSize: 50 * 1024 * 1024, // 50MB
// 不指定Domains对所有域名生效
},
},
},
},
Compression: CompressionConfig{
Gzip: CompressorConfig{
Enabled: true,
Level: 6,
},
Brotli: CompressorConfig{
Enabled: true,
Level: 6,
},
},
}
// 序列化为JSON
data, err := json.MarshalIndent(defaultConfig, "", " ")
if err != nil {
return err
}
// 写入文件
return os.WriteFile(cm.configPath, data, 0644)
}
// GetConfig 获取当前配置
func (cm *ConfigManager) GetConfig() *Config {
return cm.config.Load().(*Config)
}
// UpdateConfig 更新配置
func (cm *ConfigManager) UpdateConfig(newConfig *Config) error {
cm.mu.Lock()
defer cm.mu.Unlock()
// 确保所有路径配置的扩展名规则都已更新
for path, pc := range newConfig.MAP {
pc.ProcessExtensionMap()
newConfig.MAP[path] = pc // 更新回原始map
}
// 保存到文件
if err := cm.saveConfigToFile(newConfig); err != nil {
return err
}
// 更新内存中的配置
cm.config.Store(newConfig)
// 触发回调
TriggerCallbacks(newConfig)
log.Printf("[ConfigManager] 配置已更新: %d 个路径映射", len(newConfig.MAP))
return nil
}
// saveConfigToFile 保存配置到文件
func (cm *ConfigManager) saveConfigToFile(config *Config) error {
// 将新配置格式化为JSON
configData, err := json.MarshalIndent(config, "", " ")
if err != nil {
return err
}
// 保存到临时文件
tempFile := cm.configPath + ".tmp"
if err := os.WriteFile(tempFile, configData, 0644); err != nil {
return err
}
// 重命名临时文件为正式文件
return os.Rename(tempFile, cm.configPath)
}
// ReloadConfig 重新加载配置文件
func (cm *ConfigManager) ReloadConfig() error {
config, err := cm.loadConfigFromFile()
if err != nil {
return err
}
// 确保所有路径配置的扩展名规则都已更新
for path, pc := range config.MAP {
pc.ProcessExtensionMap()
config.MAP[path] = pc // 更新回原始map
}
cm.config.Store(config)
// 触发回调
TriggerCallbacks(config)
log.Printf("[ConfigManager] 配置已重新加载: %d 个路径映射", len(config.MAP))
return nil
} }
// RegisterUpdateCallback 注册配置更新回调函数 // RegisterUpdateCallback 注册配置更新回调函数
@ -61,53 +207,33 @@ func RegisterUpdateCallback(callback func(*Config)) {
// TriggerCallbacks 触发所有回调 // TriggerCallbacks 触发所有回调
func TriggerCallbacks(cfg *Config) { func TriggerCallbacks(cfg *Config) {
// 确保所有路径配置的扩展名规则都已更新
for path, pc := range cfg.MAP {
pc.ProcessExtensionMap()
cfg.MAP[path] = pc // 更新回原始map
}
callbackMutex.RLock() callbackMutex.RLock()
defer callbackMutex.RUnlock() defer callbackMutex.RUnlock()
for _, callback := range configCallbacks { for _, callback := range configCallbacks {
callback(cfg) callback(cfg)
} }
// 添加日志
log.Printf("[Config] 触发了 %d 个配置更新回调", len(configCallbacks))
} }
// Update 更新配置并触发回调 // 为了向后兼容保留Load函数但现在它使用ConfigManager
func (c *configImpl) Update(newConfig *Config) { var globalConfigManager *ConfigManager
c.Lock()
defer c.Unlock()
// 更新配置 // Load 加载配置(向后兼容)
c.MAP = newConfig.MAP func Load(path string) (*Config, error) {
c.Compression = newConfig.Compression if globalConfigManager == nil {
var err error
// 触发回调 globalConfigManager, err = NewConfigManager(path)
for _, callback := range c.onConfigUpdate {
callback(newConfig)
}
}
// reload 重新加载配置文件
func (c *configImpl) reload(path string) error {
data, err := os.ReadFile(path)
if err != nil { if err != nil {
return err return nil, err
} }
var newConfig Config
if err := json.Unmarshal(data, &newConfig); err != nil {
return err
} }
return globalConfigManager.GetConfig(), nil
c.Update(&newConfig)
return nil
}
func (cm *ConfigManager) loadConfig() error {
config, err := Load(cm.configPath)
if err != nil {
return err
}
cm.config.Store(config)
return nil
}
func (cm *ConfigManager) GetConfig() *Config {
return cm.config.Load().(*Config)
} }

16
internal/config/init.go Normal file
View File

@ -0,0 +1,16 @@
package config
import "log"
func Init(configPath string) (*ConfigManager, error) {
log.Printf("[Config] 初始化配置管理器...")
configManager, err := NewConfigManager(configPath)
if err != nil {
log.Printf("[Config] 初始化配置管理器失败: %v", err)
return nil, err
}
log.Printf("[Config] 配置管理器初始化成功")
return configManager, nil
}

View File

@ -1,22 +1,30 @@
package config package config
import ( import (
"encoding/json"
"strings" "strings"
) )
type Config struct { type Config struct {
MAP map[string]PathConfig `json:"MAP"` // 改为使用PathConfig MAP map[string]PathConfig `json:"MAP"` // 路径映射配置
Compression CompressionConfig `json:"Compression"` Compression CompressionConfig `json:"Compression"`
Security SecurityConfig `json:"Security"` // 安全配置
} }
type PathConfig struct { type PathConfig struct {
Path string `json:"Path"` DefaultTarget string `json:"DefaultTarget"` // 默认目标URL
DefaultTarget string `json:"DefaultTarget"` ExtensionMap []ExtRuleConfig `json:"ExtensionMap"` // 扩展名映射规则
ExtensionMap map[string]string `json:"ExtensionMap"` ExtRules []ExtensionRule `json:"-"` // 内部使用,存储处理后的扩展名规则
SizeThreshold int64 `json:"SizeThreshold"` // 最小文件大小阈值 RedirectMode bool `json:"RedirectMode"` // 是否使用302跳转模式
MaxSize int64 `json:"MaxSize"` // 最大文件大小阈值 }
processedExtMap map[string]string // 内部使用,存储拆分后的映射
// ExtensionRule 表示一个扩展名映射规则(内部使用)
type ExtensionRule struct {
Extensions []string // 支持的扩展名列表
Target string // 目标服务器
SizeThreshold int64 // 最小阈值
MaxSize int64 // 最大阈值
RedirectMode bool // 是否使用302跳转模式
Domains []string // 支持的域名列表,为空表示匹配所有域名
} }
type CompressionConfig struct { type CompressionConfig struct {
@ -29,84 +37,99 @@ type CompressorConfig struct {
Level int `json:"Level"` Level int `json:"Level"`
} }
// 添加一个辅助方法来处理字符串到 PathConfig 的转换 type SecurityConfig struct {
func (c *Config) UnmarshalJSON(data []byte) error { IPBan IPBanConfig `json:"IPBan"` // IP封禁配置
// 创建一个临时结构来解析原始JSON
type TempConfig struct {
MAP map[string]json.RawMessage `json:"MAP"`
Compression CompressionConfig `json:"Compression"`
} }
var temp TempConfig type IPBanConfig struct {
if err := json.Unmarshal(data, &temp); err != nil { Enabled bool `json:"Enabled"` // 是否启用IP封禁
return err ErrorThreshold int `json:"ErrorThreshold"` // 404错误阈值
WindowMinutes int `json:"WindowMinutes"` // 统计窗口时间(分钟)
BanDurationMinutes int `json:"BanDurationMinutes"` // 封禁时长(分钟)
CleanupIntervalMinutes int `json:"CleanupIntervalMinutes"` // 清理间隔(分钟)
} }
// 初始化 MAP // 扩展名映射配置结构
c.MAP = make(map[string]PathConfig) type ExtRuleConfig struct {
Extensions string `json:"Extensions"` // 逗号分隔的扩展名
// 处理每个路径配置 Target string `json:"Target"` // 目标服务器
for key, raw := range temp.MAP { SizeThreshold int64 `json:"SizeThreshold"` // 最小阈值
// 尝试作为字符串解析 MaxSize int64 `json:"MaxSize"` // 最大阈值
var strValue string RedirectMode bool `json:"RedirectMode"` // 是否使用302跳转模式
if err := json.Unmarshal(raw, &strValue); err == nil { Domains string `json:"Domains"` // 逗号分隔的域名列表,为空表示匹配所有域名
pathConfig := PathConfig{
DefaultTarget: strValue,
}
pathConfig.ProcessExtensionMap() // 处理扩展名映射
c.MAP[key] = pathConfig
continue
} }
// 如果不是字符串尝试作为PathConfig解析 // 处理扩展名映射的方法
var pathConfig PathConfig
if err := json.Unmarshal(raw, &pathConfig); err != nil {
return err
}
pathConfig.ProcessExtensionMap() // 处理扩展名映射
c.MAP[key] = pathConfig
}
// 复制其他字段
c.Compression = temp.Compression
return nil
}
// 添加处理扩展名映射的方法
func (p *PathConfig) ProcessExtensionMap() { func (p *PathConfig) ProcessExtensionMap() {
p.ExtRules = nil
if p.ExtensionMap == nil { if p.ExtensionMap == nil {
return return
} }
p.processedExtMap = make(map[string]string) // 处理扩展名规则
for exts, target := range p.ExtensionMap { for _, rule := range p.ExtensionMap {
// 分割扩展名 extRule := ExtensionRule{
for _, ext := range strings.Split(exts, ",") { Target: rule.Target,
ext = strings.TrimSpace(ext) // 移除可能的空格 SizeThreshold: rule.SizeThreshold,
MaxSize: rule.MaxSize,
RedirectMode: rule.RedirectMode,
}
// 处理扩展名列表
for _, ext := range strings.Split(rule.Extensions, ",") {
ext = strings.TrimSpace(ext)
if ext != "" { if ext != "" {
p.processedExtMap[ext] = target extRule.Extensions = append(extRule.Extensions, ext)
} }
} }
// 处理域名列表
if rule.Domains != "" {
for _, domain := range strings.Split(rule.Domains, ",") {
domain = strings.TrimSpace(domain)
if domain != "" {
extRule.Domains = append(extRule.Domains, domain)
}
} }
} }
// 添加获取目标URL的方法 if len(extRule.Extensions) > 0 {
func (p *PathConfig) GetTargetForExt(ext string) string { p.ExtRules = append(p.ExtRules, extRule)
if p.processedExtMap == nil {
p.ProcessExtensionMap()
} }
if target, exists := p.processedExtMap[ext]; exists {
return target
} }
return p.DefaultTarget
} }
// 添加检查扩展名是否存在的方法 // GetProcessedExtTarget 快速获取扩展名对应的目标URL如果存在返回true
func (p *PathConfig) GetExtensionTarget(ext string) (string, bool) { func (p *PathConfig) GetProcessedExtTarget(ext string) (string, bool) {
if p.processedExtMap == nil { if p.ExtRules == nil {
p.ProcessExtensionMap() return "", false
} }
target, exists := p.processedExtMap[ext]
return target, exists for _, rule := range p.ExtRules {
for _, e := range rule.Extensions {
if e == ext {
return rule.Target, true
}
}
}
return "", false
}
// GetProcessedExtRule 获取扩展名对应的完整规则信息包括RedirectMode
func (p *PathConfig) GetProcessedExtRule(ext string) (*ExtensionRule, bool) {
if p.ExtRules == nil {
return nil, false
}
for _, rule := range p.ExtRules {
for _, e := range rule.Extensions {
if e == ext {
return &rule, true
}
}
}
return nil, false
} }

View File

@ -1,22 +0,0 @@
package errors
type ErrorCode int
const (
ErrInvalidConfig ErrorCode = iota + 1
ErrRateLimit
ErrMetricsCollection
)
type MetricsError struct {
Code ErrorCode
Message string
Err error
}
func (e *MetricsError) Error() string {
if e.Err != nil {
return e.Message + ": " + e.Err.Error()
}
return e.Message
}

View File

@ -5,6 +5,7 @@ import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"log" "log"
"net/http" "net/http"
"net/url" "net/url"
@ -13,27 +14,40 @@ import (
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/woodchen-ink/go-web-utils/iputil"
) )
const ( const (
tokenExpiry = 30 * 24 * time.Hour // Token 过期时间为 30 天 tokenExpiry = 30 * 24 * time.Hour // Token 过期时间为 30 天
stateExpiry = 10 * time.Minute // State 过期时间为 10 分钟
) )
type OAuthUserInfo struct { type OAuthUserInfo struct {
ID string `json:"id"` ID interface{} `json:"id,omitempty"` // 使用interface{}以接受数字或字符串
Email string `json:"email"` Email string `json:"email,omitempty"`
Username string `json:"username"` Username string `json:"username,omitempty"`
Name string `json:"name"` Name string `json:"name,omitempty"`
AvatarURL string `json:"avatar_url"` AvatarURL string `json:"avatar_url,omitempty"`
Admin bool `json:"admin"` Avatar string `json:"avatar,omitempty"` // 添加avatar字段
Moderator bool `json:"moderator"` Admin bool `json:"admin,omitempty"`
Groups []string `json:"groups"` Moderator bool `json:"moderator,omitempty"`
Groups []string `json:"groups,omitempty"`
Upstreams interface{} `json:"upstreams,omitempty"` // 添加upstreams字段
// 添加可能的替代字段名
Sub string `json:"sub,omitempty"`
PreferredName string `json:"preferred_username,omitempty"`
GivenName string `json:"given_name,omitempty"`
FamilyName string `json:"family_name,omitempty"`
Picture string `json:"picture,omitempty"`
} }
type OAuthToken struct { type OAuthToken struct {
AccessToken string `json:"access_token"` AccessToken string `json:"access_token"`
TokenType string `json:"token_type"` TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"` ExpiresIn int `json:"expires_in"`
RefreshToken string `json:"refresh_token,omitempty"`
} }
type tokenInfo struct { type tokenInfo struct {
@ -42,6 +56,11 @@ type tokenInfo struct {
username string username string
} }
type stateInfo struct {
createdAt time.Time
expiresAt time.Time
}
type authManager struct { type authManager struct {
tokens sync.Map tokens sync.Map
states sync.Map states sync.Map
@ -50,6 +69,7 @@ type authManager struct {
func newAuthManager() *authManager { func newAuthManager() *authManager {
am := &authManager{} am := &authManager{}
go am.cleanExpiredTokens() go am.cleanExpiredTokens()
go am.cleanExpiredStates()
return am return am
} }
@ -59,6 +79,27 @@ func (am *authManager) generateToken() string {
return base64.URLEncoding.EncodeToString(b) return base64.URLEncoding.EncodeToString(b)
} }
func (am *authManager) generateState() string {
state := am.generateToken()
am.states.Store(state, stateInfo{
createdAt: time.Now(),
expiresAt: time.Now().Add(stateExpiry),
})
return state
}
func (am *authManager) validateState(state string) bool {
if info, ok := am.states.Load(state); ok {
stateInfo := info.(stateInfo)
if time.Now().Before(stateInfo.expiresAt) {
am.states.Delete(state) // 使用后立即删除
return true
}
am.states.Delete(state) // 过期也删除
}
return false
}
func (am *authManager) addToken(token string, username string, expiry time.Duration) { func (am *authManager) addToken(token string, username string, expiry time.Duration) {
am.tokens.Store(token, tokenInfo{ am.tokens.Store(token, tokenInfo{
createdAt: time.Now(), createdAt: time.Now(),
@ -92,6 +133,20 @@ func (am *authManager) cleanExpiredTokens() {
} }
} }
func (am *authManager) cleanExpiredStates() {
ticker := time.NewTicker(time.Minute)
for range ticker.C {
am.states.Range(func(key, value interface{}) bool {
state := key.(string)
info := value.(stateInfo)
if time.Now().After(info.expiresAt) {
am.states.Delete(state)
}
return true
})
}
}
// CheckAuth 检查认证令牌是否有效 // CheckAuth 检查认证令牌是否有效
func (h *ProxyHandler) CheckAuth(token string) bool { func (h *ProxyHandler) CheckAuth(token string) bool {
return h.auth.validateToken(token) return h.auth.validateToken(token)
@ -101,7 +156,7 @@ func (h *ProxyHandler) CheckAuth(token string) bool {
func (h *ProxyHandler) LogoutHandler(w http.ResponseWriter, r *http.Request) { func (h *ProxyHandler) LogoutHandler(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization") auth := r.Header.Get("Authorization")
if auth == "" || !strings.HasPrefix(auth, "Bearer ") { if auth == "" || !strings.HasPrefix(auth, "Bearer ") {
log.Printf("[Auth] ERR %s %s -> 401 (%s) no token from %s", r.Method, r.URL.Path, utils.GetClientIP(r), utils.GetRequestSource(r)) log.Printf("[Auth] ERR %s %s -> 401 (%s) no token from %s", r.Method, r.URL.Path, iputil.GetClientIP(r), utils.GetRequestSource(r))
http.Error(w, "Unauthorized", http.StatusUnauthorized) http.Error(w, "Unauthorized", http.StatusUnauthorized)
return return
} }
@ -109,7 +164,7 @@ func (h *ProxyHandler) LogoutHandler(w http.ResponseWriter, r *http.Request) {
token := strings.TrimPrefix(auth, "Bearer ") token := strings.TrimPrefix(auth, "Bearer ")
h.auth.tokens.Delete(token) h.auth.tokens.Delete(token)
log.Printf("[Auth] %s %s -> 200 (%s) logout success from %s", r.Method, r.URL.Path, utils.GetClientIP(r), utils.GetRequestSource(r)) log.Printf("[Auth] %s %s -> 200 (%s) logout success from %s", r.Method, r.URL.Path, iputil.GetClientIP(r), utils.GetRequestSource(r))
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{ json.NewEncoder(w).Encode(map[string]string{
@ -122,14 +177,14 @@ func (h *ProxyHandler) AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization") auth := r.Header.Get("Authorization")
if auth == "" || !strings.HasPrefix(auth, "Bearer ") { if auth == "" || !strings.HasPrefix(auth, "Bearer ") {
log.Printf("[Auth] ERR %s %s -> 401 (%s) no token from %s", r.Method, r.URL.Path, utils.GetClientIP(r), utils.GetRequestSource(r)) log.Printf("[Auth] ERR %s %s -> 401 (%s) no token from %s", r.Method, r.URL.Path, iputil.GetClientIP(r), utils.GetRequestSource(r))
http.Error(w, "Unauthorized", http.StatusUnauthorized) http.Error(w, "Unauthorized", http.StatusUnauthorized)
return return
} }
token := strings.TrimPrefix(auth, "Bearer ") token := strings.TrimPrefix(auth, "Bearer ")
if !h.auth.validateToken(token) { if !h.auth.validateToken(token) {
log.Printf("[Auth] ERR %s %s -> 401 (%s) invalid token from %s", r.Method, r.URL.Path, utils.GetClientIP(r), utils.GetRequestSource(r)) log.Printf("[Auth] ERR %s %s -> 401 (%s) invalid token from %s", r.Method, r.URL.Path, iputil.GetClientIP(r), utils.GetRequestSource(r))
http.Error(w, "Invalid token", http.StatusUnauthorized) http.Error(w, "Invalid token", http.StatusUnauthorized)
return return
} }
@ -140,26 +195,48 @@ func (h *ProxyHandler) AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
// getCallbackURL 从请求中获取回调地址 // getCallbackURL 从请求中获取回调地址
func getCallbackURL(r *http.Request) string { func getCallbackURL(r *http.Request) string {
if redirectURI := os.Getenv("OAUTH_REDIRECT_URI"); redirectURI != "" {
// 验证URI格式
if _, err := url.Parse(redirectURI); err == nil {
log.Printf("[Auth] DEBUG Using configured OAUTH_REDIRECT_URI: %s", redirectURI)
return redirectURI
}
log.Printf("[Auth] WARNING Invalid OAUTH_REDIRECT_URI format: %s", redirectURI)
}
// 更可靠地检测协议
scheme := "http" scheme := "http"
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
scheme = "https" scheme = "https"
} }
return fmt.Sprintf("%s://%s/admin/api/oauth/callback", scheme, r.Host)
// 考虑X-Forwarded-Host头
host := r.Host
if forwardedHost := r.Header.Get("X-Forwarded-Host"); forwardedHost != "" {
host = forwardedHost
}
callbackURL := fmt.Sprintf("%s://%s/admin/api/oauth/callback", scheme, host)
log.Printf("[Auth] DEBUG Generated callback URL: %s", callbackURL)
return callbackURL
} }
// LoginHandler 处理登录请求,重定向到 OAuth 授权页面 // LoginHandler 处理登录请求,重定向到 OAuth 授权页面
func (h *ProxyHandler) LoginHandler(w http.ResponseWriter, r *http.Request) { func (h *ProxyHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
state := h.auth.generateToken() state := h.auth.generateState()
h.auth.states.Store(state, time.Now())
clientID := os.Getenv("OAUTH_CLIENT_ID") clientID := os.Getenv("OAUTH_CLIENT_ID")
redirectURI := getCallbackURL(r) redirectURI := getCallbackURL(r)
// 记录生成的state和重定向URI
log.Printf("[Auth] DEBUG %s %s -> Generated state=%s, redirect_uri=%s",
r.Method, r.URL.Path, state, redirectURI)
authURL := fmt.Sprintf("https://connect.czl.net/oauth2/authorize?%s", authURL := fmt.Sprintf("https://connect.czl.net/oauth2/authorize?%s",
url.Values{ url.Values{
"response_type": {"code"}, "response_type": {"code"},
"client_id": {clientID}, "client_id": {clientID},
"redirect_uri": {redirectURI}, "redirect_uri": {redirectURI},
"scope": {"read write"}, // 添加scope参数
"state": {state}, "state": {state},
}.Encode()) }.Encode())
@ -171,53 +248,162 @@ func (h *ProxyHandler) OAuthCallbackHandler(w http.ResponseWriter, r *http.Reque
code := r.URL.Query().Get("code") code := r.URL.Query().Get("code")
state := r.URL.Query().Get("state") state := r.URL.Query().Get("state")
// 记录完整请求信息
log.Printf("[Auth] DEBUG %s %s -> Callback received with state=%s, code=%s, full URL: %s",
r.Method, r.URL.Path, state, code, r.URL.String())
// 验证 state // 验证 state
if _, ok := h.auth.states.Load(state); !ok { if !h.auth.validateState(state) {
log.Printf("[Auth] ERR %s %s -> 400 (%s) invalid state '%s' from %s",
r.Method, r.URL.Path, iputil.GetClientIP(r), state, utils.GetRequestSource(r))
http.Error(w, "Invalid state", http.StatusBadRequest) http.Error(w, "Invalid state", http.StatusBadRequest)
return return
} }
h.auth.states.Delete(state)
// 验证code参数
if code == "" {
log.Printf("[Auth] ERR %s %s -> 400 (%s) missing code parameter from %s", r.Method, r.URL.Path, iputil.GetClientIP(r), utils.GetRequestSource(r))
http.Error(w, "Missing code parameter", http.StatusBadRequest)
return
}
// 获取访问令牌 // 获取访问令牌
redirectURI := getCallbackURL(r) redirectURI := getCallbackURL(r)
clientID := os.Getenv("OAUTH_CLIENT_ID")
clientSecret := os.Getenv("OAUTH_CLIENT_SECRET")
// 验证OAuth配置
if clientID == "" || clientSecret == "" {
log.Printf("[Auth] ERR %s %s -> 500 (%s) missing OAuth credentials from %s", r.Method, r.URL.Path, iputil.GetClientIP(r), utils.GetRequestSource(r))
http.Error(w, "Server configuration error", http.StatusInternalServerError)
return
}
// 记录令牌交换请求信息
log.Printf("[Auth] DEBUG %s %s -> Exchanging code for token with redirect_uri=%s",
r.Method, r.URL.Path, redirectURI)
resp, err := http.PostForm("https://connect.czl.net/api/oauth2/token", resp, err := http.PostForm("https://connect.czl.net/api/oauth2/token",
url.Values{ url.Values{
"grant_type": {"authorization_code"},
"code": {code}, "code": {code},
"redirect_uri": {redirectURI}, "redirect_uri": {redirectURI},
"client_id": {clientID},
"client_secret": {clientSecret},
}) })
if err != nil { if err != nil {
log.Printf("[Auth] ERR %s %s -> 500 (%s) failed to get access token: %v from %s", r.Method, r.URL.Path, iputil.GetClientIP(r), err, utils.GetRequestSource(r))
http.Error(w, "Failed to get access token", http.StatusInternalServerError) http.Error(w, "Failed to get access token", http.StatusInternalServerError)
return return
} }
defer resp.Body.Close() defer resp.Body.Close()
// 检查响应状态码
if resp.StatusCode != http.StatusOK {
// 读取错误响应内容
bodyBytes, _ := io.ReadAll(resp.Body)
log.Printf("[Auth] ERR %s %s -> %d (%s) OAuth server returned error: %s, response: %s",
r.Method, r.URL.Path, resp.StatusCode, iputil.GetClientIP(r), resp.Status, string(bodyBytes))
http.Error(w, "OAuth server error: "+resp.Status, http.StatusInternalServerError)
return
}
var token OAuthToken var token OAuthToken
if err := json.NewDecoder(resp.Body).Decode(&token); err != nil { if err := json.NewDecoder(resp.Body).Decode(&token); err != nil {
log.Printf("[Auth] ERR %s %s -> 500 (%s) failed to parse token response: %v from %s", r.Method, r.URL.Path, iputil.GetClientIP(r), err, utils.GetRequestSource(r))
http.Error(w, "Failed to parse token response", http.StatusInternalServerError) http.Error(w, "Failed to parse token response", http.StatusInternalServerError)
return return
} }
// 验证访问令牌
if token.AccessToken == "" {
log.Printf("[Auth] ERR %s %s -> 500 (%s) received empty access token from %s", r.Method, r.URL.Path, iputil.GetClientIP(r), utils.GetRequestSource(r))
http.Error(w, "Received invalid token", http.StatusInternalServerError)
return
}
// 获取用户信息 // 获取用户信息
req, _ := http.NewRequest("GET", "https://connect.czl.net/api/oauth2/userinfo", nil) req, _ := http.NewRequest("GET", "https://connect.czl.net/api/oauth2/userinfo", nil)
req.Header.Set("Authorization", "Bearer "+token.AccessToken) req.Header.Set("Authorization", "Bearer "+token.AccessToken)
client := &http.Client{} client := &http.Client{Timeout: 10 * time.Second}
userResp, err := client.Do(req) userResp, err := client.Do(req)
if err != nil { if err != nil {
log.Printf("[Auth] ERR %s %s -> 500 (%s) failed to get user info: %v from %s", r.Method, r.URL.Path, iputil.GetClientIP(r), err, utils.GetRequestSource(r))
http.Error(w, "Failed to get user info", http.StatusInternalServerError) http.Error(w, "Failed to get user info", http.StatusInternalServerError)
return return
} }
defer userResp.Body.Close() defer userResp.Body.Close()
var userInfo OAuthUserInfo // 检查用户信息响应状态码
if err := json.NewDecoder(userResp.Body).Decode(&userInfo); err != nil { if userResp.StatusCode != http.StatusOK {
log.Printf("[Auth] ERR %s %s -> %d (%s) userinfo endpoint returned error status: %s from %s",
r.Method, r.URL.Path, userResp.StatusCode, iputil.GetClientIP(r), userResp.Status, utils.GetRequestSource(r))
http.Error(w, "Failed to get user info: "+userResp.Status, http.StatusInternalServerError)
return
}
// 读取响应体内容并记录
bodyBytes, err := io.ReadAll(userResp.Body)
if err != nil {
log.Printf("[Auth] ERR %s %s -> 500 (%s) failed to read user info response body: %v from %s",
r.Method, r.URL.Path, iputil.GetClientIP(r), err, utils.GetRequestSource(r))
http.Error(w, "Failed to read user info response", http.StatusInternalServerError)
return
}
// 记录响应内容(小心敏感信息)
log.Printf("[Auth] DEBUG %s %s -> user info response: %s", r.Method, r.URL.Path, string(bodyBytes))
// 使用更灵活的方式解析JSON
var rawUserInfo map[string]interface{}
if err := json.Unmarshal(bodyBytes, &rawUserInfo); err != nil {
log.Printf("[Auth] ERR %s %s -> 500 (%s) failed to parse raw user info: %v from %s",
r.Method, r.URL.Path, iputil.GetClientIP(r), err, utils.GetRequestSource(r))
http.Error(w, "Failed to parse user info", http.StatusInternalServerError) http.Error(w, "Failed to parse user info", http.StatusInternalServerError)
return return
} }
// 创建用户信息对象
userInfo := OAuthUserInfo{}
// 填充用户名优先级username > preferred_username > sub > email
if username, ok := rawUserInfo["username"].(string); ok && username != "" {
userInfo.Username = username
} else if preferred, ok := rawUserInfo["preferred_username"].(string); ok && preferred != "" {
userInfo.Username = preferred
} else if sub, ok := rawUserInfo["sub"].(string); ok && sub != "" {
userInfo.Username = sub
} else if email, ok := rawUserInfo["email"].(string); ok && email != "" {
// 从邮箱中提取用户名
parts := strings.Split(email, "@")
if len(parts) > 0 {
userInfo.Username = parts[0]
}
}
// 填充头像URL
if avatar, ok := rawUserInfo["avatar"].(string); ok && avatar != "" {
userInfo.Avatar = avatar
} else if avatarURL, ok := rawUserInfo["avatar_url"].(string); ok && avatarURL != "" {
userInfo.AvatarURL = avatarURL
} else if picture, ok := rawUserInfo["picture"].(string); ok && picture != "" {
userInfo.Picture = picture
}
// 验证用户信息
if userInfo.Username == "" {
log.Printf("[Auth] ERR %s %s -> 500 (%s) could not extract username from user info from %s",
r.Method, r.URL.Path, iputil.GetClientIP(r), utils.GetRequestSource(r))
http.Error(w, "Invalid user information: missing username", http.StatusInternalServerError)
return
}
// 生成内部访问令牌 // 生成内部访问令牌
internalToken := h.auth.generateToken() internalToken := h.auth.generateToken()
h.auth.addToken(internalToken, userInfo.Username, tokenExpiry) h.auth.addToken(internalToken, userInfo.Username, tokenExpiry)
log.Printf("[Auth] %s %s -> 200 (%s) login success for user %s from %s", r.Method, r.URL.Path, iputil.GetClientIP(r), userInfo.Username, utils.GetRequestSource(r))
// 返回登录成功页面 // 返回登录成功页面
w.Header().Set("Content-Type", "text/html") w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, ` fmt.Fprintf(w, `

View File

@ -11,13 +11,13 @@ import (
// ConfigHandler 配置管理处理器 // ConfigHandler 配置管理处理器
type ConfigHandler struct { type ConfigHandler struct {
config *config.Config configManager *config.ConfigManager
} }
// NewConfigHandler 创建新的配置管理处理器 // NewConfigHandler 创建新的配置管理处理器
func NewConfigHandler(cfg *config.Config) *ConfigHandler { func NewConfigHandler(configManager *config.ConfigManager) *ConfigHandler {
return &ConfigHandler{ return &ConfigHandler{
config: cfg, configManager: configManager,
} }
} }
@ -67,29 +67,14 @@ func (h *ConfigHandler) handleSaveConfig(w http.ResponseWriter, r *http.Request)
return return
} }
// 将新配置格式化为JSON // 使用ConfigManager更新配置
configData, err := json.MarshalIndent(newConfig, "", " ") if err := h.configManager.UpdateConfig(&newConfig); err != nil {
if err != nil { http.Error(w, fmt.Sprintf("更新配置失败: %v", err), http.StatusInternalServerError)
http.Error(w, fmt.Sprintf("格式化配置失败: %v", err), http.StatusInternalServerError)
return return
} }
// 保存到临时文件 // 添加日志
tempFile := "data/config.json.tmp" fmt.Printf("[Config] 配置已更新: %d 个路径映射\n", len(newConfig.MAP))
if err := os.WriteFile(tempFile, configData, 0644); err != nil {
http.Error(w, fmt.Sprintf("保存配置失败: %v", err), http.StatusInternalServerError)
return
}
// 重命名临时文件为正式文件
if err := os.Rename(tempFile, "data/config.json"); err != nil {
http.Error(w, fmt.Sprintf("更新配置文件失败: %v", err), http.StatusInternalServerError)
return
}
// 更新运行时配置
*h.config = newConfig
config.TriggerCallbacks(h.config)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"message": "配置已更新并生效"}`)) w.Write([]byte(`{"message": "配置已更新并生效"}`))

View File

@ -27,6 +27,7 @@ type Metrics struct {
// 性能指标 // 性能指标
AverageResponseTime string `json:"avg_response_time"` AverageResponseTime string `json:"avg_response_time"`
RequestsPerSecond float64 `json:"requests_per_second"` RequestsPerSecond float64 `json:"requests_per_second"`
CurrentSessionRequests int64 `json:"current_session_requests"`
// 传输指标 // 传输指标
TotalBytes int64 `json:"total_bytes"` TotalBytes int64 `json:"total_bytes"`
@ -35,9 +36,6 @@ type Metrics struct {
// 状态码统计 // 状态码统计
StatusCodeStats map[string]int64 `json:"status_code_stats"` StatusCodeStats map[string]int64 `json:"status_code_stats"`
// 路径统计
TopPaths []models.PathMetricsJSON `json:"top_paths"`
// 最近请求 // 最近请求
RecentRequests []models.RequestLog `json:"recent_requests"` RecentRequests []models.RequestLog `json:"recent_requests"`
@ -51,13 +49,6 @@ type Metrics struct {
Distribution map[string]int64 `json:"distribution"` Distribution map[string]int64 `json:"distribution"`
} `json:"latency_stats"` } `json:"latency_stats"`
// 错误统计
ErrorStats struct {
ClientErrors int64 `json:"client_errors"`
ServerErrors int64 `json:"server_errors"`
Types map[string]int64 `json:"types"`
} `json:"error_stats"`
// 带宽统计 // 带宽统计
BandwidthHistory map[string]string `json:"bandwidth_history"` BandwidthHistory map[string]string `json:"bandwidth_history"`
CurrentBandwidth string `json:"current_bandwidth"` CurrentBandwidth string `json:"current_bandwidth"`
@ -82,8 +73,8 @@ func (h *ProxyHandler) MetricsHandler(w http.ResponseWriter, r *http.Request) {
"total_bytes": int64(0), "total_bytes": int64(0),
"bytes_per_second": float64(0), "bytes_per_second": float64(0),
"requests_per_second": float64(0), "requests_per_second": float64(0),
"current_session_requests": int64(0),
"status_code_stats": make(map[string]int64), "status_code_stats": make(map[string]int64),
"top_paths": make([]models.PathMetrics, 0),
"recent_requests": make([]models.RequestLog, 0), "recent_requests": make([]models.RequestLog, 0),
"top_referers": make([]models.PathMetrics, 0), "top_referers": make([]models.PathMetrics, 0),
"latency_stats": map[string]interface{}{ "latency_stats": map[string]interface{}{
@ -115,26 +106,8 @@ func (h *ProxyHandler) MetricsHandler(w http.ResponseWriter, r *http.Request) {
} }
} }
// 计算客户端错误和服务器错误数量 // 处理状态码统计数据
var clientErrors, serverErrors int64
statusCodeStats := models.SafeStatusCodeStats(stats["status_code_stats"]) statusCodeStats := models.SafeStatusCodeStats(stats["status_code_stats"])
for code, count := range statusCodeStats {
codeInt := utils.ParseInt(code, 0)
if codeInt >= 400 && codeInt < 500 {
clientErrors += count
} else if codeInt >= 500 {
serverErrors += count
}
}
// 创建错误类型统计
errorTypes := make(map[string]int64)
if clientErrors > 0 {
errorTypes["客户端错误"] = clientErrors
}
if serverErrors > 0 {
errorTypes["服务器错误"] = serverErrors
}
metrics := Metrics{ metrics := Metrics{
Uptime: metrics.FormatUptime(uptime), Uptime: metrics.FormatUptime(uptime),
@ -147,9 +120,9 @@ func (h *ProxyHandler) MetricsHandler(w http.ResponseWriter, r *http.Request) {
AverageResponseTime: utils.SafeString(stats["avg_response_time"], "0 ms"), AverageResponseTime: utils.SafeString(stats["avg_response_time"], "0 ms"),
TotalBytes: totalBytes, TotalBytes: totalBytes,
BytesPerSecond: float64(totalBytes) / utils.MaxFloat64(uptimeSeconds, 1), BytesPerSecond: float64(totalBytes) / utils.MaxFloat64(uptimeSeconds, 1),
RequestsPerSecond: float64(totalRequests) / utils.MaxFloat64(uptimeSeconds, 1), RequestsPerSecond: utils.SafeFloat64(stats["requests_per_second"]),
CurrentSessionRequests: utils.SafeInt64(stats["current_session_requests"]),
StatusCodeStats: statusCodeStats, StatusCodeStats: statusCodeStats,
TopPaths: models.SafePathMetrics(stats["top_paths"]),
RecentRequests: models.SafeRequestLogs(stats["recent_requests"]), RecentRequests: models.SafeRequestLogs(stats["recent_requests"]),
TopReferers: models.SafePathMetrics(stats["top_referers"]), TopReferers: models.SafePathMetrics(stats["top_referers"]),
BandwidthHistory: bandwidthHistory, BandwidthHistory: bandwidthHistory,
@ -197,11 +170,6 @@ func (h *ProxyHandler) MetricsHandler(w http.ResponseWriter, r *http.Request) {
metrics.LatencyStats.Distribution["gt1s"] = 0 metrics.LatencyStats.Distribution["gt1s"] = 0
} }
// 填充错误统计数据
metrics.ErrorStats.ClientErrors = clientErrors
metrics.ErrorStats.ServerErrors = serverErrors
metrics.ErrorStats.Types = errorTypes
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(metrics); err != nil { if err := json.NewEncoder(w).Encode(metrics); err != nil {
log.Printf("Error encoding metrics: %v", err) log.Printf("Error encoding metrics: %v", err)

View File

@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"io" "io"
"log" "log"
"net"
"net/http" "net/http"
"net/url" "net/url"
"proxy-go/internal/cache" "proxy-go/internal/cache"
@ -11,6 +12,17 @@ import (
"proxy-go/internal/utils" "proxy-go/internal/utils"
"strings" "strings"
"time" "time"
"github.com/woodchen-ink/go-web-utils/iputil"
"golang.org/x/net/http2"
)
// 镜像代理专用配置常量
const (
mirrorMaxIdleConns = 2000 // 镜像代理全局最大空闲连接
mirrorMaxIdleConnsPerHost = 200 // 镜像代理每个主机最大空闲连接
mirrorMaxConnsPerHost = 500 // 镜像代理每个主机最大连接数
mirrorTimeout = 60 * time.Second // 镜像代理超时时间
) )
type MirrorProxyHandler struct { type MirrorProxyHandler struct {
@ -19,10 +31,38 @@ type MirrorProxyHandler struct {
} }
func NewMirrorProxyHandler() *MirrorProxyHandler { func NewMirrorProxyHandler() *MirrorProxyHandler {
// 创建优化的拨号器
dialer := &net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
}
// 创建优化的传输层
transport := &http.Transport{ transport := &http.Transport{
MaxIdleConns: 100, DialContext: dialer.DialContext,
MaxIdleConnsPerHost: 10, MaxIdleConns: mirrorMaxIdleConns,
MaxIdleConnsPerHost: mirrorMaxIdleConnsPerHost,
MaxConnsPerHost: mirrorMaxConnsPerHost,
IdleConnTimeout: 90 * time.Second, IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 5 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
DisableKeepAlives: false,
DisableCompression: false,
ForceAttemptHTTP2: true,
WriteBufferSize: 128 * 1024,
ReadBufferSize: 128 * 1024,
ResponseHeaderTimeout: 30 * time.Second,
MaxResponseHeaderBytes: 64 * 1024,
}
// 配置 HTTP/2
http2Transport, err := http2.ConfigureTransports(transport)
if err == nil && http2Transport != nil {
http2Transport.ReadIdleTimeout = 30 * time.Second
http2Transport.PingTimeout = 10 * time.Second
http2Transport.AllowHTTP = false
http2Transport.MaxReadFrameSize = 32 * 1024
http2Transport.StrictMaxConcurrentStreams = true
} }
// 初始化缓存管理器 // 初始化缓存管理器
@ -34,7 +74,13 @@ func NewMirrorProxyHandler() *MirrorProxyHandler {
return &MirrorProxyHandler{ return &MirrorProxyHandler{
client: &http.Client{ client: &http.Client{
Transport: transport, Transport: transport,
Timeout: 30 * time.Second, Timeout: mirrorTimeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return fmt.Errorf("stopped after 10 redirects")
}
return nil
},
}, },
Cache: cacheManager, Cache: cacheManager,
} }
@ -56,7 +102,7 @@ func (h *MirrorProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
log.Printf("| %-6s | %3d | %12s | %15s | %10s | %-30s | CORS Preflight", log.Printf("| %-6s | %3d | %12s | %15s | %10s | %-30s | CORS Preflight",
r.Method, http.StatusOK, time.Since(startTime), r.Method, http.StatusOK, time.Since(startTime),
utils.GetClientIP(r), "-", r.URL.Path) iputil.GetClientIP(r), "-", r.URL.Path)
return return
} }
@ -66,7 +112,7 @@ func (h *MirrorProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Invalid URL", http.StatusBadRequest) http.Error(w, "Invalid URL", http.StatusBadRequest)
log.Printf("| %-6s | %3d | %12s | %15s | %10s | %-30s | Invalid URL", log.Printf("| %-6s | %3d | %12s | %15s | %10s | %-30s | Invalid URL",
r.Method, http.StatusBadRequest, time.Since(startTime), r.Method, http.StatusBadRequest, time.Since(startTime),
utils.GetClientIP(r), "-", r.URL.Path) iputil.GetClientIP(r), "-", r.URL.Path)
return return
} }
@ -74,13 +120,33 @@ func (h *MirrorProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
actualURL += "?" + r.URL.RawQuery actualURL += "?" + r.URL.RawQuery
} }
// 早期缓存检查 - 只对GET请求进行缓存检查
if r.Method == http.MethodGet && h.Cache != nil {
cacheKey := h.Cache.GenerateCacheKey(r)
if item, hit, notModified := h.Cache.Get(cacheKey, r); hit {
// 从缓存提供响应
w.Header().Set("Content-Type", item.ContentType)
if item.ContentEncoding != "" {
w.Header().Set("Content-Encoding", item.ContentEncoding)
}
w.Header().Set("Proxy-Go-Cache-HIT", "1")
if notModified {
w.WriteHeader(http.StatusNotModified)
return
}
http.ServeFile(w, r, item.FilePath)
collector.RecordRequest(r.URL.Path, http.StatusOK, time.Since(startTime), item.Size, iputil.GetClientIP(r), r)
return
}
}
// 解析目标 URL 以获取 host // 解析目标 URL 以获取 host
parsedURL, err := url.Parse(actualURL) parsedURL, err := url.Parse(actualURL)
if err != nil { if err != nil {
http.Error(w, "Invalid URL", http.StatusBadRequest) http.Error(w, "Invalid URL", http.StatusBadRequest)
log.Printf("| %-6s | %3d | %12s | %15s | %10s | %-30s | Parse URL error: %v", log.Printf("| %-6s | %3d | %12s | %15s | %10s | %-30s | Parse URL error: %v",
r.Method, http.StatusBadRequest, time.Since(startTime), r.Method, http.StatusBadRequest, time.Since(startTime),
utils.GetClientIP(r), "-", actualURL, err) iputil.GetClientIP(r), "-", actualURL, err)
return return
} }
@ -98,7 +164,7 @@ func (h *MirrorProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Error creating request", http.StatusInternalServerError) http.Error(w, "Error creating request", http.StatusInternalServerError)
log.Printf("| %-6s | %3d | %12s | %15s | %10s | %-30s | Error creating request: %v", log.Printf("| %-6s | %3d | %12s | %15s | %10s | %-30s | Error creating request: %v",
r.Method, http.StatusInternalServerError, time.Since(startTime), r.Method, http.StatusInternalServerError, time.Since(startTime),
utils.GetClientIP(r), "-", actualURL, err) iputil.GetClientIP(r), "-", actualURL, err)
return return
} }
@ -116,40 +182,20 @@ func (h *MirrorProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
proxyReq.Header.Set("Host", parsedURL.Host) proxyReq.Header.Set("Host", parsedURL.Host)
proxyReq.Host = parsedURL.Host proxyReq.Host = parsedURL.Host
// 检查是否可以使用缓存
if r.Method == http.MethodGet && h.Cache != nil {
cacheKey := h.Cache.GenerateCacheKey(r)
if item, hit, notModified := h.Cache.Get(cacheKey, r); hit {
// 从缓存提供响应
w.Header().Set("Content-Type", item.ContentType)
if item.ContentEncoding != "" {
w.Header().Set("Content-Encoding", item.ContentEncoding)
}
w.Header().Set("Proxy-Go-Cache", "HIT")
if notModified {
w.WriteHeader(http.StatusNotModified)
return
}
http.ServeFile(w, r, item.FilePath)
collector.RecordRequest(r.URL.Path, http.StatusOK, time.Since(startTime), item.Size, utils.GetClientIP(r), r)
return
}
}
// 发送请求 // 发送请求
resp, err := h.client.Do(proxyReq) resp, err := h.client.Do(proxyReq)
if err != nil { if err != nil {
http.Error(w, "Error forwarding request", http.StatusBadGateway) http.Error(w, "Error forwarding request", http.StatusBadGateway)
log.Printf("| %-6s | %3d | %12s | %15s | %10s | %-30s | Error forwarding request: %v", log.Printf("| %-6s | %3d | %12s | %15s | %10s | %-30s | Error forwarding request: %v",
r.Method, http.StatusBadGateway, time.Since(startTime), r.Method, http.StatusBadGateway, time.Since(startTime),
utils.GetClientIP(r), "-", actualURL, err) iputil.GetClientIP(r), "-", actualURL, err)
return return
} }
defer resp.Body.Close() defer resp.Body.Close()
// 复制响应头 // 复制响应头
copyHeader(w.Header(), resp.Header) copyHeader(w.Header(), resp.Header)
w.Header().Set("Proxy-Go-Cache", "MISS") w.Header().Set("Proxy-Go-Cache-HIT", "0")
// 设置状态码 // 设置状态码
w.WriteHeader(resp.StatusCode) w.WriteHeader(resp.StatusCode)
@ -183,9 +229,9 @@ func (h *MirrorProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// 记录访问日志 // 记录访问日志
log.Printf("| %-6s | %3d | %12s | %15s | %10s | %-30s | %s", log.Printf("| %-6s | %3d | %12s | %15s | %10s | %-30s | %s",
r.Method, resp.StatusCode, time.Since(startTime), r.Method, resp.StatusCode, time.Since(startTime),
utils.GetClientIP(r), utils.FormatBytes(written), iputil.GetClientIP(r), utils.FormatBytes(written),
utils.GetRequestSource(r), actualURL) utils.GetRequestSource(r), actualURL)
// 记录统计信息 // 记录统计信息
collector.RecordRequest(r.URL.Path, resp.StatusCode, time.Since(startTime), written, utils.GetClientIP(r), r) collector.RecordRequest(r.URL.Path, resp.StatusCode, time.Since(startTime), written, iputil.GetClientIP(r), r)
} }

View File

@ -11,10 +11,13 @@ import (
"proxy-go/internal/cache" "proxy-go/internal/cache"
"proxy-go/internal/config" "proxy-go/internal/config"
"proxy-go/internal/metrics" "proxy-go/internal/metrics"
"proxy-go/internal/service"
"proxy-go/internal/utils" "proxy-go/internal/utils"
"sort"
"strings" "strings"
"time" "time"
"github.com/woodchen-ink/go-web-utils/iputil"
"golang.org/x/net/http2" "golang.org/x/net/http2"
) )
@ -22,9 +25,9 @@ const (
// 超时时间常量 // 超时时间常量
clientConnTimeout = 10 * time.Second clientConnTimeout = 10 * time.Second
proxyRespTimeout = 60 * time.Second proxyRespTimeout = 60 * time.Second
backendServTimeout = 40 * time.Second backendServTimeout = 30 * time.Second
idleConnTimeout = 120 * time.Second idleConnTimeout = 90 * time.Second
tlsHandshakeTimeout = 10 * time.Second tlsHandshakeTimeout = 5 * time.Second
) )
// 添加 hop-by-hop 头部映射 // 添加 hop-by-hop 头部映射
@ -40,17 +43,93 @@ var hopHeadersBase = map[string]bool{
"Upgrade": true, "Upgrade": true,
} }
// 优化后的连接池配置常量
const (
// 连接池配置
maxIdleConns = 5000 // 全局最大空闲连接数(增加)
maxIdleConnsPerHost = 500 // 每个主机最大空闲连接数(增加)
maxConnsPerHost = 1000 // 每个主机最大连接数(增加)
// 缓冲区大小优化
writeBufferSize = 256 * 1024 // 写缓冲区(增加)
readBufferSize = 256 * 1024 // 读缓冲区(增加)
// HTTP/2 配置
maxReadFrameSize = 64 * 1024 // HTTP/2 最大读帧大小(增加)
)
// ErrorHandler 定义错误处理函数类型 // ErrorHandler 定义错误处理函数类型
type ErrorHandler func(w http.ResponseWriter, r *http.Request, err error) type ErrorHandler func(w http.ResponseWriter, r *http.Request, err error)
type ProxyHandler struct { type ProxyHandler struct {
pathMap map[string]config.PathConfig pathMap map[string]config.PathConfig
prefixTree *prefixMatcher // 添加前缀匹配树
client *http.Client client *http.Client
startTime time.Time startTime time.Time
config *config.Config config *config.Config
auth *authManager auth *authManager
errorHandler ErrorHandler errorHandler ErrorHandler
Cache *cache.CacheManager Cache *cache.CacheManager
redirectHandler *RedirectHandler // 添加302跳转处理器
ruleService *service.RuleService // 添加规则服务
}
// 前缀匹配器结构体
type prefixMatcher struct {
prefixes []string
configs map[string]config.PathConfig
}
// 创建新的前缀匹配器
func newPrefixMatcher(pathMap map[string]config.PathConfig) *prefixMatcher {
pm := &prefixMatcher{
prefixes: make([]string, 0, len(pathMap)),
configs: make(map[string]config.PathConfig, len(pathMap)),
}
// 按长度降序排列前缀,确保最长匹配优先
for prefix, cfg := range pathMap {
pm.prefixes = append(pm.prefixes, prefix)
pm.configs[prefix] = cfg
}
// 按长度降序排列
sort.Slice(pm.prefixes, func(i, j int) bool {
return len(pm.prefixes[i]) > len(pm.prefixes[j])
})
return pm
}
// 根据路径查找匹配的前缀和配置
func (pm *prefixMatcher) match(path string) (string, config.PathConfig, bool) {
// 按预排序的前缀列表查找最长匹配
for _, prefix := range pm.prefixes {
if strings.HasPrefix(path, prefix) {
// 确保匹配的是完整的路径段
restPath := path[len(prefix):]
if restPath == "" || restPath[0] == '/' {
return prefix, pm.configs[prefix], true
}
}
}
return "", config.PathConfig{}, false
}
// 更新前缀匹配器
func (pm *prefixMatcher) update(pathMap map[string]config.PathConfig) {
pm.prefixes = make([]string, 0, len(pathMap))
pm.configs = make(map[string]config.PathConfig, len(pathMap))
for prefix, cfg := range pathMap {
pm.prefixes = append(pm.prefixes, prefix)
pm.configs[prefix] = cfg
}
// 按长度降序排列
sort.Slice(pm.prefixes, func(i, j int) bool {
return len(pm.prefixes[i]) > len(pm.prefixes[j])
})
} }
// NewProxyHandler 创建新的代理处理器 // NewProxyHandler 创建新的代理处理器
@ -62,29 +141,30 @@ func NewProxyHandler(cfg *config.Config) *ProxyHandler {
transport := &http.Transport{ transport := &http.Transport{
DialContext: dialer.DialContext, DialContext: dialer.DialContext,
MaxIdleConns: 1000, MaxIdleConns: maxIdleConns,
MaxIdleConnsPerHost: 100, MaxIdleConnsPerHost: maxIdleConnsPerHost,
IdleConnTimeout: idleConnTimeout, IdleConnTimeout: idleConnTimeout,
TLSHandshakeTimeout: tlsHandshakeTimeout, TLSHandshakeTimeout: tlsHandshakeTimeout,
ExpectContinueTimeout: 1 * time.Second, ExpectContinueTimeout: 1 * time.Second,
MaxConnsPerHost: 200, MaxConnsPerHost: maxConnsPerHost,
DisableKeepAlives: false, DisableKeepAlives: false,
DisableCompression: false, DisableCompression: false,
ForceAttemptHTTP2: true, ForceAttemptHTTP2: true,
WriteBufferSize: 64 * 1024, WriteBufferSize: writeBufferSize,
ReadBufferSize: 64 * 1024, ReadBufferSize: readBufferSize,
ResponseHeaderTimeout: backendServTimeout, ResponseHeaderTimeout: backendServTimeout,
MaxResponseHeaderBytes: 64 * 1024, MaxResponseHeaderBytes: 128 * 1024, // 增加响应头缓冲区
} }
// 设置HTTP/2传输配置 // 设置HTTP/2传输配置
http2Transport, err := http2.ConfigureTransports(transport) http2Transport, err := http2.ConfigureTransports(transport)
if err == nil && http2Transport != nil { if err == nil && http2Transport != nil {
http2Transport.ReadIdleTimeout = 10 * time.Second http2Transport.ReadIdleTimeout = 30 * time.Second // 增加读空闲超时
http2Transport.PingTimeout = 5 * time.Second http2Transport.PingTimeout = 10 * time.Second // 增加ping超时
http2Transport.AllowHTTP = false http2Transport.AllowHTTP = false
http2Transport.MaxReadFrameSize = 32 * 1024 http2Transport.MaxReadFrameSize = maxReadFrameSize // 使用常量
http2Transport.StrictMaxConcurrentStreams = true http2Transport.StrictMaxConcurrentStreams = true
} }
// 初始化缓存管理器 // 初始化缓存管理器
@ -93,8 +173,12 @@ func NewProxyHandler(cfg *config.Config) *ProxyHandler {
log.Printf("[Cache] Failed to initialize cache manager: %v", err) log.Printf("[Cache] Failed to initialize cache manager: %v", err)
} }
// 初始化规则服务
ruleService := service.NewRuleService(cacheManager)
handler := &ProxyHandler{ handler := &ProxyHandler{
pathMap: cfg.MAP, pathMap: cfg.MAP,
prefixTree: newPrefixMatcher(cfg.MAP), // 初始化前缀匹配树
client: &http.Client{ client: &http.Client{
Transport: transport, Transport: transport,
Timeout: proxyRespTimeout, Timeout: proxyRespTimeout,
@ -109,6 +193,8 @@ func NewProxyHandler(cfg *config.Config) *ProxyHandler {
config: cfg, config: cfg,
auth: newAuthManager(), auth: newAuthManager(),
Cache: cacheManager, Cache: cacheManager,
ruleService: ruleService,
redirectHandler: NewRedirectHandler(ruleService), // 初始化302跳转处理器
errorHandler: func(w http.ResponseWriter, r *http.Request, err error) { errorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
log.Printf("[Error] %s %s -> %v from %s", r.Method, r.URL.Path, err, utils.GetRequestSource(r)) log.Printf("[Error] %s %s -> %v from %s", r.Method, r.URL.Path, err, utils.GetRequestSource(r))
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
@ -118,9 +204,22 @@ func NewProxyHandler(cfg *config.Config) *ProxyHandler {
// 注册配置更新回调 // 注册配置更新回调
config.RegisterUpdateCallback(func(newCfg *config.Config) { config.RegisterUpdateCallback(func(newCfg *config.Config) {
// 注意config包已经在回调触发前处理了所有ExtRules这里无需再次处理
handler.pathMap = newCfg.MAP handler.pathMap = newCfg.MAP
handler.prefixTree.update(newCfg.MAP) // 更新前缀匹配树
handler.config = newCfg handler.config = newCfg
log.Printf("[Config] 配置已更新并生效")
// 清理ExtensionMatcher缓存确保使用新配置
if handler.Cache != nil {
handler.Cache.InvalidateAllExtensionMatchers()
log.Printf("[Config] ExtensionMatcher缓存已清理")
}
// 清理URL可访问性缓存和文件大小缓存
utils.ClearAccessibilityCache()
utils.ClearFileSizeCache()
log.Printf("[Config] 代理处理器配置已更新: %d 个路径映射", len(newCfg.MAP))
}) })
return handler return handler
@ -150,35 +249,41 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" { if r.URL.Path == "/" {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "Welcome to CZL proxy.") fmt.Fprint(w, "Welcome to CZL proxy.")
log.Printf("[Proxy] %s %s -> %d (%s) from %s", r.Method, r.URL.Path, http.StatusOK, utils.GetClientIP(r), utils.GetRequestSource(r)) log.Printf("[Proxy] %s %s -> %d (%s) from %s", r.Method, r.URL.Path, http.StatusOK, iputil.GetClientIP(r), utils.GetRequestSource(r))
return return
} }
// 查找匹配的代理路径 // 使用前缀匹配树快速查找匹配的路径
var matchedPrefix string matchedPrefix, pathConfig, matched := h.prefixTree.match(r.URL.Path)
var pathConfig config.PathConfig
// 首先尝试完全匹配路径段 // 如果没有找到匹配返回404
for prefix, cfg := range h.pathMap { if !matched {
// 检查是否是完整的路径段匹配
if strings.HasPrefix(r.URL.Path, prefix) {
// 确保匹配的是完整的路径段
restPath := r.URL.Path[len(prefix):]
if restPath == "" || restPath[0] == '/' {
matchedPrefix = prefix
pathConfig = cfg
break
}
}
}
// 如果没有找到完全匹配返回404
if matchedPrefix == "" {
// 返回 404
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
// 早期缓存检查 - 只对GET请求进行缓存检查
if r.Method == http.MethodGet && h.Cache != nil {
cacheKey := h.Cache.GenerateCacheKey(r)
if item, hit, notModified := h.Cache.Get(cacheKey, r); hit {
// 从缓存提供响应
w.Header().Set("Content-Type", item.ContentType)
if item.ContentEncoding != "" {
w.Header().Set("Content-Encoding", item.ContentEncoding)
}
w.Header().Set("Proxy-Go-Cache-HIT", "1")
w.Header().Set("Proxy-Go-AltTarget", "0") // 缓存命中时设为0
if notModified {
w.WriteHeader(http.StatusNotModified)
return
}
http.ServeFile(w, r, item.FilePath)
collector.RecordRequest(r.URL.Path, http.StatusOK, time.Since(start), item.Size, iputil.GetClientIP(r), r)
return
}
}
// 构建目标 URL // 构建目标 URL
targetPath := strings.TrimPrefix(r.URL.Path, matchedPrefix) targetPath := strings.TrimPrefix(r.URL.Path, matchedPrefix)
@ -189,8 +294,15 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return return
} }
// 检查是否需要进行302跳转
if h.redirectHandler != nil && h.redirectHandler.HandleRedirect(w, r, pathConfig, decodedPath, h.client) {
// 如果进行了302跳转直接返回不继续处理
collector.RecordRequest(r.URL.Path, http.StatusFound, time.Since(start), 0, iputil.GetClientIP(r), r)
return
}
// 使用统一的路由选择逻辑 // 使用统一的路由选择逻辑
targetBase := utils.GetTargetURL(h.client, r, pathConfig, decodedPath) targetBase, usedAltTarget := h.ruleService.GetTargetURL(h.client, r, pathConfig, decodedPath)
// 重新编码路径,保留 '/' // 重新编码路径,保留 '/'
parts := strings.Split(decodedPath, "/") parts := strings.Split(decodedPath, "/")
@ -200,6 +312,11 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
encodedPath := strings.Join(parts, "/") encodedPath := strings.Join(parts, "/")
targetURL := targetBase + encodedPath targetURL := targetBase + encodedPath
// 添加原始请求的查询参数
if r.URL.RawQuery != "" {
targetURL = targetURL + "?" + r.URL.RawQuery
}
// 解析目标 URL 以获取 host // 解析目标 URL 以获取 host
parsedURL, err := url.Parse(targetURL) parsedURL, err := url.Parse(targetURL)
if err != nil { if err != nil {
@ -214,34 +331,61 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return return
} }
// 复制并处理请求头 // 复制并处理请求头 - 使用更高效的方式
copyHeader(proxyReq.Header, r.Header) copyHeader(proxyReq.Header, r.Header)
// 添加常见浏览器User-Agent - 避免冗余字符串操作
if r.Header.Get("User-Agent") == "" {
proxyReq.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
}
// 使用预先构建的URL字符串
hostScheme := parsedURL.Scheme + "://" + parsedURL.Host
// 添加Origin
proxyReq.Header.Set("Origin", hostScheme)
// 设置Referer为源站的完整域名带上斜杠
proxyReq.Header.Set("Referer", hostScheme+"/")
// 设置Host头和proxyReq.Host
proxyReq.Header.Set("Host", parsedURL.Host)
proxyReq.Host = parsedURL.Host
// 确保设置适当的Accept头 - 避免冗余字符串操作
if r.Header.Get("Accept") == "" {
proxyReq.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7")
}
// 确保设置Accept-Encoding - 避免冗余字符串操作
if ae := r.Header.Get("Accept-Encoding"); ae != "" {
proxyReq.Header.Set("Accept-Encoding", ae)
} else {
proxyReq.Header.Del("Accept-Encoding")
}
// 特别处理图片请求 // 特别处理图片请求
if utils.IsImageRequest(r.URL.Path) { if utils.IsImageRequest(r.URL.Path) {
// 获取 Accept 头
accept := r.Header.Get("Accept") accept := r.Header.Get("Accept")
// 根据 Accept 头设置合适的图片格式 // 使用switch语句优化条件分支
if strings.Contains(accept, "image/avif") { switch {
case strings.Contains(accept, "image/avif"):
proxyReq.Header.Set("Accept", "image/avif") proxyReq.Header.Set("Accept", "image/avif")
} else if strings.Contains(accept, "image/webp") { case strings.Contains(accept, "image/webp"):
proxyReq.Header.Set("Accept", "image/webp") proxyReq.Header.Set("Accept", "image/webp")
} }
// 设置 Cloudflare 特定的头部 // 设置 Cloudflare 特定的头部
proxyReq.Header.Set("CF-Image-Format", "auto") // 让 Cloudflare 根据 Accept 头自动选择格式 proxyReq.Header.Set("CF-Image-Format", "auto")
} }
// 设置其他必要的头部 // 设置最小必要的代理头部
proxyReq.Host = parsedURL.Host clientIP := iputil.GetClientIP(r)
proxyReq.Header.Set("Host", parsedURL.Host) proxyReq.Header.Set("X-Real-IP", clientIP)
proxyReq.Header.Set("X-Real-IP", utils.GetClientIP(r))
proxyReq.Header.Set("X-Forwarded-Host", r.Host)
proxyReq.Header.Set("X-Forwarded-Proto", r.URL.Scheme)
// 添加或更新 X-Forwarded-For // 添加或更新 X-Forwarded-For - 减少重复获取客户端IP
if clientIP := utils.GetClientIP(r); clientIP != "" { if clientIP != "" {
if prior := proxyReq.Header.Get("X-Forwarded-For"); prior != "" { if prior := proxyReq.Header.Get("X-Forwarded-For"); prior != "" {
proxyReq.Header.Set("X-Forwarded-For", prior+", "+clientIP) proxyReq.Header.Set("X-Forwarded-For", prior+", "+clientIP)
} else { } else {
@ -262,35 +406,15 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} }
} }
// 检查是否可以使用缓存
if r.Method == http.MethodGet && h.Cache != nil {
cacheKey := h.Cache.GenerateCacheKey(r)
if item, hit, notModified := h.Cache.Get(cacheKey, r); hit {
// 从缓存提供响应
w.Header().Set("Content-Type", item.ContentType)
if item.ContentEncoding != "" {
w.Header().Set("Content-Encoding", item.ContentEncoding)
}
w.Header().Set("Proxy-Go-Cache", "HIT")
if notModified {
w.WriteHeader(http.StatusNotModified)
return
}
http.ServeFile(w, r, item.FilePath)
collector.RecordRequest(r.URL.Path, http.StatusOK, time.Since(start), item.Size, utils.GetClientIP(r), r)
return
}
}
// 发送代理请求 // 发送代理请求
resp, err := h.client.Do(proxyReq) resp, err := h.client.Do(proxyReq)
if err != nil { if err != nil {
if ctx.Err() == context.DeadlineExceeded { if ctx.Err() == context.DeadlineExceeded {
h.errorHandler(w, r, fmt.Errorf("request timeout after %v", proxyRespTimeout)) h.errorHandler(w, r, fmt.Errorf("request timeout after %v", proxyRespTimeout))
log.Printf("[Proxy] ERR %s %s -> 408 (%s) timeout from %s", r.Method, r.URL.Path, utils.GetClientIP(r), utils.GetRequestSource(r)) log.Printf("[Proxy] ERR %s %s -> 408 (%s) timeout from %s", r.Method, r.URL.Path, iputil.GetClientIP(r), utils.GetRequestSource(r))
} else { } else {
h.errorHandler(w, r, fmt.Errorf("proxy error: %v", err)) h.errorHandler(w, r, fmt.Errorf("proxy error: %v", err))
log.Printf("[Proxy] ERR %s %s -> 502 (%s) proxy error from %s", r.Method, r.URL.Path, utils.GetClientIP(r), utils.GetRequestSource(r)) log.Printf("[Proxy] ERR %s %s -> 502 (%s) proxy error from %s", r.Method, r.URL.Path, iputil.GetClientIP(r), utils.GetRequestSource(r))
} }
return return
} }
@ -298,7 +422,26 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// 复制响应头 // 复制响应头
copyHeader(w.Header(), resp.Header) copyHeader(w.Header(), resp.Header)
w.Header().Set("Proxy-Go-Cache", "MISS") w.Header().Set("Proxy-Go-Cache-HIT", "0")
// 如果使用了扩展名映射的备用目标,添加标记响应头
if usedAltTarget {
w.Header().Set("Proxy-Go-AltTarget", "1")
} else {
w.Header().Set("Proxy-Go-AltTarget", "0")
}
// 对于图片请求,添加 Vary 头部以支持 CDN 基于 Accept 头部的缓存
if utils.IsImageRequest(r.URL.Path) {
// 添加 Vary: Accept 头部,让 CDN 知道响应会根据 Accept 头部变化
if existingVary := w.Header().Get("Vary"); existingVary != "" {
if !strings.Contains(existingVary, "Accept") {
w.Header().Set("Vary", existingVary+", Accept")
}
} else {
w.Header().Set("Vary", "Accept")
}
}
// 设置响应状态码 // 设置响应状态码
w.WriteHeader(resp.StatusCode) w.WriteHeader(resp.StatusCode)
@ -309,28 +452,47 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
cacheKey := h.Cache.GenerateCacheKey(r) cacheKey := h.Cache.GenerateCacheKey(r)
if cacheFile, err := h.Cache.CreateTemp(cacheKey, resp); err == nil { if cacheFile, err := h.Cache.CreateTemp(cacheKey, resp); err == nil {
defer cacheFile.Close() defer cacheFile.Close()
// 使用缓冲IO提高性能
bufSize := 32 * 1024 // 32KB 缓冲区
buf := make([]byte, bufSize)
teeReader := io.TeeReader(resp.Body, cacheFile) teeReader := io.TeeReader(resp.Body, cacheFile)
written, err = io.Copy(w, teeReader) written, err = io.CopyBuffer(w, teeReader, buf)
if err == nil { if err == nil {
h.Cache.Commit(cacheKey, cacheFile.Name(), resp, written) // 异步提交缓存,不阻塞当前请求处理
fileName := cacheFile.Name()
respClone := *resp // 创建响应的浅拷贝
go func() {
h.Cache.Commit(cacheKey, fileName, &respClone, written)
}()
} }
} else { } else {
written, err = io.Copy(w, resp.Body) // 使用缓冲的复制提高性能
bufSize := 32 * 1024 // 32KB 缓冲区
buf := make([]byte, bufSize)
written, err = io.CopyBuffer(w, resp.Body, buf)
if err != nil && !isConnectionClosed(err) { if err != nil && !isConnectionClosed(err) {
log.Printf("[Proxy] ERR %s %s -> write error (%s) from %s", r.Method, r.URL.Path, utils.GetClientIP(r), utils.GetRequestSource(r)) log.Printf("[Proxy] ERR %s %s -> write error (%s) from %s", r.Method, r.URL.Path, iputil.GetClientIP(r), utils.GetRequestSource(r))
return return
} }
} }
} else { } else {
written, err = io.Copy(w, resp.Body) // 使用缓冲的复制提高性能
bufSize := 32 * 1024 // 32KB 缓冲区
buf := make([]byte, bufSize)
written, err = io.CopyBuffer(w, resp.Body, buf)
if err != nil && !isConnectionClosed(err) { if err != nil && !isConnectionClosed(err) {
log.Printf("[Proxy] ERR %s %s -> write error (%s) from %s", r.Method, r.URL.Path, utils.GetClientIP(r), utils.GetRequestSource(r)) log.Printf("[Proxy] ERR %s %s -> write error (%s) from %s", r.Method, r.URL.Path, iputil.GetClientIP(r), utils.GetRequestSource(r))
return return
} }
} }
// 记录统计信息 // 记录统计信息
collector.RecordRequest(r.URL.Path, resp.StatusCode, time.Since(start), written, utils.GetClientIP(r), r) collector.RecordRequest(r.URL.Path, resp.StatusCode, time.Since(start), written, iputil.GetClientIP(r), r)
} }
func copyHeader(dst, src http.Header) { func copyHeader(dst, src http.Header) {
@ -347,9 +509,17 @@ func copyHeader(dst, src http.Header) {
} }
} }
// 使用局部 map 快速查找,跳过 hop-by-hop 头部 // 添加需要过滤的安全头部
securityHeaders := map[string]bool{
"Content-Security-Policy": true,
"Content-Security-Policy-Report-Only": true,
"X-Content-Security-Policy": true,
"X-WebKit-CSP": true,
}
// 使用局部 map 快速查找,跳过 hop-by-hop 头部和安全头部
for k, vv := range src { for k, vv := range src {
if !hopHeaders[k] { if !hopHeaders[k] && !securityHeaders[k] {
for _, v := range vv { for _, v := range vv {
dst.Add(k, v) dst.Add(k, v)
} }

View File

@ -0,0 +1,124 @@
package handler
import (
"log"
"net/http"
"net/url"
"proxy-go/internal/config"
"proxy-go/internal/service"
"proxy-go/internal/utils"
"strings"
"github.com/woodchen-ink/go-web-utils/iputil"
)
// RedirectHandler 处理302跳转逻辑
type RedirectHandler struct {
ruleService *service.RuleService
}
// NewRedirectHandler 创建新的跳转处理器
func NewRedirectHandler(ruleService *service.RuleService) *RedirectHandler {
return &RedirectHandler{
ruleService: ruleService,
}
}
// HandleRedirect 处理302跳转请求
func (rh *RedirectHandler) HandleRedirect(w http.ResponseWriter, r *http.Request, pathConfig config.PathConfig, targetPath string, client *http.Client) bool {
// 检查是否需要进行302跳转
shouldRedirect, targetURL := rh.shouldRedirect(r, pathConfig, targetPath, client)
if !shouldRedirect {
return false
}
// 执行302跳转
rh.performRedirect(w, r, targetURL)
return true
}
// shouldRedirect 判断是否应该进行302跳转并返回目标URL优化版本
func (rh *RedirectHandler) shouldRedirect(r *http.Request, pathConfig config.PathConfig, targetPath string, client *http.Client) (bool, string) {
// 使用service包的规则选择函数传递请求的域名
result := rh.ruleService.SelectRuleForRedirect(client, pathConfig, targetPath, r.Host)
if result.ShouldRedirect {
// 构建完整的目标URL
targetURL := rh.buildTargetURL(result.TargetURL, targetPath, r.URL.RawQuery)
if result.Rule != nil {
log.Printf("[Redirect] %s -> 使用选中规则进行302跳转 (域名: %s): %s", targetPath, r.Host, targetURL)
} else {
log.Printf("[Redirect] %s -> 使用默认目标进行302跳转 (域名: %s): %s", targetPath, r.Host, targetURL)
}
return true, targetURL
}
return false, ""
}
// buildTargetURL 构建完整的目标URL
func (rh *RedirectHandler) buildTargetURL(baseURL, targetPath, rawQuery string) string {
// URL 解码,然后重新编码,确保特殊字符被正确处理
decodedPath, err := url.QueryUnescape(targetPath)
if err != nil {
// 如果解码失败,使用原始路径
decodedPath = targetPath
}
// 重新编码路径,保留 '/'
parts := strings.Split(decodedPath, "/")
for i, part := range parts {
parts[i] = url.PathEscape(part)
}
encodedPath := strings.Join(parts, "/")
// 构建完整URL
targetURL := baseURL + encodedPath
// 添加查询参数
if rawQuery != "" {
targetURL = targetURL + "?" + rawQuery
}
return targetURL
}
// performRedirect 执行302跳转
func (rh *RedirectHandler) performRedirect(w http.ResponseWriter, r *http.Request, targetURL string) {
// 设置302跳转响应头
w.Header().Set("Location", targetURL)
w.Header().Set("Proxy-Go-Redirect", "1")
// 添加缓存控制头,避免浏览器缓存跳转响应
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
// 设置状态码为302
w.WriteHeader(http.StatusFound)
// 记录跳转日志
clientIP := iputil.GetClientIP(r)
log.Printf("[Redirect] %s %s -> 302 %s (%s) from %s",
r.Method, r.URL.Path, targetURL, clientIP, utils.GetRequestSource(r))
}
// IsRedirectEnabled 检查路径配置是否启用了任何形式的302跳转
func (rh *RedirectHandler) IsRedirectEnabled(pathConfig config.PathConfig) bool {
// 检查默认目标是否启用跳转
if pathConfig.RedirectMode {
return true
}
// 检查扩展名规则是否有启用跳转的
for _, rule := range pathConfig.ExtRules {
if rule.RedirectMode {
return true
}
}
return false
}

View File

@ -0,0 +1,130 @@
package handler
import (
"encoding/json"
"net/http"
"proxy-go/internal/security"
"time"
"github.com/woodchen-ink/go-web-utils/iputil"
)
// SecurityHandler 安全管理处理器
type SecurityHandler struct {
banManager *security.IPBanManager
}
// NewSecurityHandler 创建安全管理处理器
func NewSecurityHandler(banManager *security.IPBanManager) *SecurityHandler {
return &SecurityHandler{
banManager: banManager,
}
}
// GetBannedIPs 获取被封禁的IP列表
func (sh *SecurityHandler) GetBannedIPs(w http.ResponseWriter, r *http.Request) {
if sh.banManager == nil {
http.Error(w, "Security manager not enabled", http.StatusServiceUnavailable)
return
}
bannedIPs := sh.banManager.GetBannedIPs()
// 转换为前端友好的格式
result := make([]map[string]interface{}, 0, len(bannedIPs))
for ip, banEndTime := range bannedIPs {
result = append(result, map[string]interface{}{
"ip": ip,
"ban_end_time": banEndTime.Format("2006-01-02 15:04:05"),
"remaining_seconds": int64(time.Until(banEndTime).Seconds()),
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"banned_ips": result,
"count": len(result),
})
}
// UnbanIP 手动解封IP
func (sh *SecurityHandler) UnbanIP(w http.ResponseWriter, r *http.Request) {
if sh.banManager == nil {
http.Error(w, "Security manager not enabled", http.StatusServiceUnavailable)
return
}
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
IP string `json:"ip"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.IP == "" {
http.Error(w, "IP address is required", http.StatusBadRequest)
return
}
success := sh.banManager.UnbanIP(req.IP)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"success": success,
"message": func() string {
if success {
return "IP解封成功"
}
return "IP未在封禁列表中"
}(),
})
}
// GetSecurityStats 获取安全统计信息
func (sh *SecurityHandler) GetSecurityStats(w http.ResponseWriter, r *http.Request) {
if sh.banManager == nil {
http.Error(w, "Security manager not enabled", http.StatusServiceUnavailable)
return
}
stats := sh.banManager.GetStats()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(stats)
}
// CheckIPStatus 检查IP状态
func (sh *SecurityHandler) CheckIPStatus(w http.ResponseWriter, r *http.Request) {
if sh.banManager == nil {
http.Error(w, "Security manager not enabled", http.StatusServiceUnavailable)
return
}
ip := r.URL.Query().Get("ip")
if ip == "" {
// 如果没有指定IP使用请求的IP
ip = iputil.GetClientIP(r)
}
banned, banEndTime := sh.banManager.GetBanInfo(ip)
result := map[string]interface{}{
"ip": ip,
"banned": banned,
}
if banned {
result["ban_end_time"] = banEndTime.Format("2006-01-02 15:04:05")
result["remaining_seconds"] = int64(time.Until(banEndTime).Seconds())
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
}

14
internal/initapp/init.go Normal file
View File

@ -0,0 +1,14 @@
package initapp
import (
"log"
)
func Init(configPath string) error {
log.Printf("[Init] 开始初始化应用程序...")
// 迁移配置文件已移除,不再需要
log.Printf("[Init] 应用程序初始化完成")
return nil
}

View File

@ -10,12 +10,163 @@ import (
"proxy-go/internal/utils" "proxy-go/internal/utils"
"runtime" "runtime"
"sort" "sort"
"strings" "strconv"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "time"
) )
// 优化的状态码统计结构
type StatusCodeStats struct {
mu sync.RWMutex
stats map[int]*int64 // 预分配常见状态码
}
// 优化的延迟分布统计
type LatencyBuckets struct {
lt10ms int64
ms10_50 int64
ms50_200 int64
ms200_1000 int64
gt1s int64
}
// 优化的引用来源统计(使用分片减少锁竞争)
type RefererStats struct {
shards []*RefererShard
mask uint64
}
type RefererShard struct {
mu sync.RWMutex
data map[string]*models.PathMetrics
}
const (
refererShardCount = 32 // 分片数量必须是2的幂
)
func NewRefererStats() *RefererStats {
rs := &RefererStats{
shards: make([]*RefererShard, refererShardCount),
mask: refererShardCount - 1,
}
for i := 0; i < refererShardCount; i++ {
rs.shards[i] = &RefererShard{
data: make(map[string]*models.PathMetrics),
}
}
return rs
}
func (rs *RefererStats) hash(key string) uint64 {
// 简单的字符串哈希函数
var h uint64 = 14695981039346656037
for _, b := range []byte(key) {
h ^= uint64(b)
h *= 1099511628211
}
return h
}
func (rs *RefererStats) getShard(key string) *RefererShard {
return rs.shards[rs.hash(key)&rs.mask]
}
func (rs *RefererStats) Load(key string) (*models.PathMetrics, bool) {
shard := rs.getShard(key)
shard.mu.RLock()
defer shard.mu.RUnlock()
val, ok := shard.data[key]
return val, ok
}
func (rs *RefererStats) Store(key string, value *models.PathMetrics) {
shard := rs.getShard(key)
shard.mu.Lock()
defer shard.mu.Unlock()
shard.data[key] = value
}
func (rs *RefererStats) Delete(key string) {
shard := rs.getShard(key)
shard.mu.Lock()
defer shard.mu.Unlock()
delete(shard.data, key)
}
func (rs *RefererStats) Range(f func(key string, value *models.PathMetrics) bool) {
for _, shard := range rs.shards {
shard.mu.RLock()
for k, v := range shard.data {
if !f(k, v) {
shard.mu.RUnlock()
return
}
}
shard.mu.RUnlock()
}
}
func (rs *RefererStats) Cleanup(cutoff int64) int {
deleted := 0
for _, shard := range rs.shards {
shard.mu.Lock()
for k, v := range shard.data {
if v.LastAccessTime.Load() < cutoff {
delete(shard.data, k)
deleted++
}
}
shard.mu.Unlock()
}
return deleted
}
func NewStatusCodeStats() *StatusCodeStats {
s := &StatusCodeStats{
stats: make(map[int]*int64),
}
// 预分配常见状态码
commonCodes := []int{200, 201, 204, 301, 302, 304, 400, 401, 403, 404, 429, 500, 502, 503, 504}
for _, code := range commonCodes {
counter := new(int64)
s.stats[code] = counter
}
return s
}
func (s *StatusCodeStats) Increment(code int) {
s.mu.RLock()
if counter, exists := s.stats[code]; exists {
s.mu.RUnlock()
atomic.AddInt64(counter, 1)
return
}
s.mu.RUnlock()
// 需要创建新的计数器
s.mu.Lock()
defer s.mu.Unlock()
if counter, exists := s.stats[code]; exists {
atomic.AddInt64(counter, 1)
} else {
counter := new(int64)
*counter = 1
s.stats[code] = counter
}
}
func (s *StatusCodeStats) GetStats() map[string]int64 {
s.mu.RLock()
defer s.mu.RUnlock()
result := make(map[string]int64)
for code, counter := range s.stats {
result[fmt.Sprintf("%d", code)] = atomic.LoadInt64(counter)
}
return result
}
// Collector 指标收集器 // Collector 指标收集器
type Collector struct { type Collector struct {
startTime time.Time startTime time.Time
@ -24,10 +175,9 @@ type Collector struct {
latencySum int64 latencySum int64
maxLatency int64 // 最大响应时间 maxLatency int64 // 最大响应时间
minLatency int64 // 最小响应时间 minLatency int64 // 最小响应时间
pathStats sync.Map statusCodeStats *StatusCodeStats
statusCodeStats sync.Map latencyBuckets *LatencyBuckets // 使用结构体替代 sync.Map
latencyBuckets sync.Map // 响应时间分布 refererStats *RefererStats // 使用分片哈希表
refererStats sync.Map // 引用来源统计
bandwidthStats struct { bandwidthStats struct {
sync.RWMutex sync.RWMutex
window time.Duration window time.Duration
@ -36,9 +186,32 @@ type Collector struct {
history map[string]int64 history map[string]int64
} }
recentRequests *models.RequestQueue recentRequests *models.RequestQueue
pathStatsMutex sync.RWMutex
config *config.Config config *config.Config
// 新增:当前会话统计
sessionRequests int64 // 当前会话的请求数(不包含历史数据)
// 新增:基于时间窗口的请求统计
requestsWindow struct {
sync.RWMutex
window time.Duration // 时间窗口大小5分钟
buckets []int64 // 时间桶每个桶统计10秒内的请求数
bucketSize time.Duration // 每个桶的时间长度10秒
lastUpdate time.Time // 最后更新时间
current int64 // 当前桶的请求数
} }
}
type RequestMetric struct {
Path string
Status int
Latency time.Duration
Bytes int64
ClientIP string
Request *http.Request
}
var requestChan chan RequestMetric
var ( var (
instance *Collector instance *Collector
@ -53,6 +226,9 @@ func InitCollector(cfg *config.Config) error {
recentRequests: models.NewRequestQueue(100), recentRequests: models.NewRequestQueue(100),
config: cfg, config: cfg,
minLatency: math.MaxInt64, minLatency: math.MaxInt64,
statusCodeStats: NewStatusCodeStats(),
latencyBuckets: &LatencyBuckets{},
refererStats: NewRefererStats(),
} }
// 初始化带宽统计 // 初始化带宽统计
@ -60,13 +236,36 @@ func InitCollector(cfg *config.Config) error {
instance.bandwidthStats.lastUpdate = time.Now() instance.bandwidthStats.lastUpdate = time.Now()
instance.bandwidthStats.history = make(map[string]int64) instance.bandwidthStats.history = make(map[string]int64)
// 初始化请求窗口统计5分钟窗口10秒一个桶共30个桶
instance.requestsWindow.window = 5 * time.Minute
instance.requestsWindow.bucketSize = 10 * time.Second
bucketCount := int(instance.requestsWindow.window / instance.requestsWindow.bucketSize)
instance.requestsWindow.buckets = make([]int64, bucketCount)
instance.requestsWindow.lastUpdate = time.Now()
// 初始化延迟分布桶 // 初始化延迟分布桶
buckets := []string{"lt10ms", "10-50ms", "50-200ms", "200-1000ms", "gt1s"} buckets := []string{"lt10ms", "10-50ms", "50-200ms", "200-1000ms", "gt1s"}
for _, bucket := range buckets { for _, bucket := range buckets {
counter := new(int64) counter := new(int64)
*counter = 0 *counter = 0
instance.latencyBuckets.Store(bucket, counter) // 根据 bucket 名称设置对应的桶计数器
switch bucket {
case "lt10ms":
instance.latencyBuckets.lt10ms = atomic.LoadInt64(counter)
case "10-50ms":
instance.latencyBuckets.ms10_50 = atomic.LoadInt64(counter)
case "50-200ms":
instance.latencyBuckets.ms50_200 = atomic.LoadInt64(counter)
case "200-1000ms":
instance.latencyBuckets.ms200_1000 = atomic.LoadInt64(counter)
case "gt1s":
instance.latencyBuckets.gt1s = atomic.LoadInt64(counter)
} }
}
// 初始化异步指标收集通道
requestChan = make(chan RequestMetric, 10000)
instance.startAsyncMetricsUpdater()
// 启动数据一致性检查器 // 启动数据一致性检查器
instance.startConsistencyChecker() instance.startConsistencyChecker()
@ -92,131 +291,22 @@ func (c *Collector) EndRequest() {
atomic.AddInt64(&c.activeRequests, -1) atomic.AddInt64(&c.activeRequests, -1)
} }
// RecordRequest 记录请求 // RecordRequest 记录请求异步写入channel
func (c *Collector) RecordRequest(path string, status int, latency time.Duration, bytes int64, clientIP string, r *http.Request) { func (c *Collector) RecordRequest(path string, status int, latency time.Duration, bytes int64, clientIP string, r *http.Request) {
// 更新状态码统计 metric := RequestMetric{
statusKey := fmt.Sprintf("%d", status)
if counter, ok := c.statusCodeStats.Load(statusKey); ok {
atomic.AddInt64(counter.(*int64), 1)
} else {
counter := new(int64)
*counter = 1
c.statusCodeStats.Store(statusKey, counter)
}
// 更新总字节数和带宽统计
atomic.AddInt64(&c.totalBytes, bytes)
c.updateBandwidthStats(bytes)
// 更新延迟统计
atomic.AddInt64(&c.latencySum, int64(latency))
latencyNanos := int64(latency)
for {
oldMin := atomic.LoadInt64(&c.minLatency)
if oldMin <= latencyNanos {
break
}
if atomic.CompareAndSwapInt64(&c.minLatency, oldMin, latencyNanos) {
break
}
}
for {
oldMax := atomic.LoadInt64(&c.maxLatency)
if oldMax >= latencyNanos {
break
}
if atomic.CompareAndSwapInt64(&c.maxLatency, oldMax, latencyNanos) {
break
}
}
// 更新延迟分布
latencyMs := latency.Milliseconds()
var bucketKey string
switch {
case latencyMs < 10:
bucketKey = "lt10ms"
case latencyMs < 50:
bucketKey = "10-50ms"
case latencyMs < 200:
bucketKey = "50-200ms"
case latencyMs < 1000:
bucketKey = "200-1000ms"
default:
bucketKey = "gt1s"
}
if counter, ok := c.latencyBuckets.Load(bucketKey); ok {
atomic.AddInt64(counter.(*int64), 1)
} else {
counter := new(int64)
*counter = 1
c.latencyBuckets.Store(bucketKey, counter)
}
// 更新路径统计
c.pathStatsMutex.Lock()
if value, ok := c.pathStats.Load(path); ok {
stat := value.(*models.PathMetrics)
stat.AddRequest()
if status >= 400 {
stat.AddError()
}
stat.AddLatency(int64(latency))
stat.AddBytes(bytes)
} else {
newStat := &models.PathMetrics{
Path: path,
}
newStat.RequestCount.Store(1)
if status >= 400 {
newStat.ErrorCount.Store(1)
}
newStat.TotalLatency.Store(int64(latency))
newStat.BytesTransferred.Store(bytes)
c.pathStats.Store(path, newStat)
}
c.pathStatsMutex.Unlock()
// 更新引用来源统计
if r != nil {
referer := r.Header.Get("Referer")
if referer != "" {
// 简化引用来源,只保留域名部分
referer = simplifyReferer(referer)
if value, ok := c.refererStats.Load(referer); ok {
stat := value.(*models.PathMetrics)
stat.AddRequest()
if status >= 400 {
stat.AddError()
}
stat.AddLatency(int64(latency))
stat.AddBytes(bytes)
} else {
newStat := &models.PathMetrics{
Path: referer,
}
newStat.RequestCount.Store(1)
if status >= 400 {
newStat.ErrorCount.Store(1)
}
newStat.TotalLatency.Store(int64(latency))
newStat.BytesTransferred.Store(bytes)
c.refererStats.Store(referer, newStat)
}
}
}
// 更新最近请求记录
c.recentRequests.Push(models.RequestLog{
Time: time.Now(),
Path: path, Path: path,
Status: status, Status: status,
Latency: int64(latency), Latency: latency,
BytesSent: bytes, Bytes: bytes,
ClientIP: clientIP, ClientIP: clientIP,
}) Request: r,
}
select {
case requestChan <- metric:
// ok
default:
// channel 满了,丢弃或降级处理
}
} }
// FormatUptime 格式化运行时间 // FormatUptime 格式化运行时间
@ -249,78 +339,40 @@ func (c *Collector) GetStats() map[string]interface{} {
// 计算总请求数和平均延迟 // 计算总请求数和平均延迟
var totalRequests int64 var totalRequests int64
c.statusCodeStats.Range(func(key, value interface{}) bool { var totalErrors int64
if counter, ok := value.(*int64); ok { statusCodeStats := c.statusCodeStats.GetStats()
totalRequests += atomic.LoadInt64(counter) for statusCode, count := range statusCodeStats {
} else { totalRequests += count
totalRequests += value.(int64) // 计算错误数4xx和5xx状态码
if code, err := strconv.Atoi(statusCode); err == nil && code >= 400 {
totalErrors += count
}
} }
return true
})
avgLatency := float64(0) avgLatency := float64(0)
if totalRequests > 0 { if totalRequests > 0 {
avgLatency = float64(atomic.LoadInt64(&c.latencySum)) / float64(totalRequests) avgLatency = float64(atomic.LoadInt64(&c.latencySum)) / float64(totalRequests)
} }
// 计算总体平均每秒请求数 // 计算错误率
requestsPerSecond := float64(totalRequests) / totalRuntime.Seconds() errorRate := float64(0)
if totalRequests > 0 {
// 收集状态码统计 errorRate = float64(totalErrors) / float64(totalRequests)
statusCodeStats := make(map[string]int64)
c.statusCodeStats.Range(func(key, value interface{}) bool {
if counter, ok := value.(*int64); ok {
statusCodeStats[key.(string)] = atomic.LoadInt64(counter)
} else {
statusCodeStats[key.(string)] = value.(int64)
}
return true
})
// 收集路径统计
var pathMetrics []*models.PathMetrics
pathCount := 0
c.pathStats.Range(func(key, value interface{}) bool {
stats := value.(*models.PathMetrics)
requestCount := stats.GetRequestCount()
if requestCount > 0 {
totalLatency := stats.GetTotalLatency()
avgLatencyMs := float64(totalLatency) / float64(requestCount) / float64(time.Millisecond)
stats.AvgLatency = fmt.Sprintf("%.2fms", avgLatencyMs)
pathMetrics = append(pathMetrics, stats)
} }
// 限制遍历的数量,避免过多数据导致内存占用过高 // 计算当前会话的请求数(基于本次启动后的实际请求)
pathCount++ sessionRequests := atomic.LoadInt64(&c.sessionRequests)
return pathCount < 100 // 最多遍历100个路径
})
// 按请求数降序排序,请求数相同时按路径字典序排序 // 计算最近5分钟的平均每秒请求数
sort.Slice(pathMetrics, func(i, j int) bool { requestsPerSecond := c.getRecentRequestsPerSecond()
countI := pathMetrics[i].GetRequestCount()
countJ := pathMetrics[j].GetRequestCount()
if countI != countJ {
return countI > countJ
}
return pathMetrics[i].Path < pathMetrics[j].Path
})
// 只保留前10个 // 收集状态码统计(已经在上面获取了)
if len(pathMetrics) > 10 {
pathMetrics = pathMetrics[:10]
}
// 转换为值切片
pathMetricsValues := make([]models.PathMetricsJSON, len(pathMetrics))
for i, metric := range pathMetrics {
pathMetricsValues[i] = metric.ToJSON()
}
// 收集引用来源统计 // 收集引用来源统计
var refererMetrics []*models.PathMetrics var refererMetrics []*models.PathMetrics
refererCount := 0 refererCount := 0
c.refererStats.Range(func(key, value interface{}) bool { c.refererStats.Range(func(key string, value *models.PathMetrics) bool {
stats := value.(*models.PathMetrics) stats := value
requestCount := stats.GetRequestCount() requestCount := stats.GetRequestCount()
if requestCount > 0 { if requestCount > 0 {
totalLatency := stats.GetTotalLatency() totalLatency := stats.GetTotalLatency()
@ -344,9 +396,9 @@ func (c *Collector) GetStats() map[string]interface{} {
return refererMetrics[i].Path < refererMetrics[j].Path return refererMetrics[i].Path < refererMetrics[j].Path
}) })
// 只保留前10个 // 只保留前20个
if len(refererMetrics) > 10 { if len(refererMetrics) > 20 {
refererMetrics = refererMetrics[:10] refererMetrics = refererMetrics[:20]
} }
// 转换为值切片 // 转换为值切片
@ -357,21 +409,11 @@ func (c *Collector) GetStats() map[string]interface{} {
// 收集延迟分布 // 收集延迟分布
latencyDistribution := make(map[string]int64) latencyDistribution := make(map[string]int64)
latencyDistribution["lt10ms"] = atomic.LoadInt64(&c.latencyBuckets.lt10ms)
// 确保所有桶都存在即使计数为0 latencyDistribution["10-50ms"] = atomic.LoadInt64(&c.latencyBuckets.ms10_50)
buckets := []string{"lt10ms", "10-50ms", "50-200ms", "200-1000ms", "gt1s"} latencyDistribution["50-200ms"] = atomic.LoadInt64(&c.latencyBuckets.ms50_200)
for _, bucket := range buckets { latencyDistribution["200-1000ms"] = atomic.LoadInt64(&c.latencyBuckets.ms200_1000)
if counter, ok := c.latencyBuckets.Load(bucket); ok { latencyDistribution["gt1s"] = atomic.LoadInt64(&c.latencyBuckets.gt1s)
if counter != nil {
value := atomic.LoadInt64(counter.(*int64))
latencyDistribution[bucket] = value
} else {
latencyDistribution[bucket] = 0
}
} else {
latencyDistribution[bucket] = 0
}
}
// 获取最近请求记录(使用读锁) // 获取最近请求记录(使用读锁)
recentRequests := c.recentRequests.GetAll() recentRequests := c.recentRequests.GetAll()
@ -389,6 +431,9 @@ func (c *Collector) GetStats() map[string]interface{} {
return map[string]interface{}{ return map[string]interface{}{
"uptime": FormatUptime(totalRuntime), "uptime": FormatUptime(totalRuntime),
"active_requests": atomic.LoadInt64(&c.activeRequests), "active_requests": atomic.LoadInt64(&c.activeRequests),
"total_requests": totalRequests,
"total_errors": totalErrors,
"error_rate": errorRate,
"total_bytes": atomic.LoadInt64(&c.totalBytes), "total_bytes": atomic.LoadInt64(&c.totalBytes),
"num_goroutine": runtime.NumGoroutine(), "num_goroutine": runtime.NumGoroutine(),
"memory_usage": utils.FormatBytes(int64(mem.Alloc)), "memory_usage": utils.FormatBytes(int64(mem.Alloc)),
@ -396,7 +441,6 @@ func (c *Collector) GetStats() map[string]interface{} {
"requests_per_second": requestsPerSecond, "requests_per_second": requestsPerSecond,
"bytes_per_second": float64(atomic.LoadInt64(&c.totalBytes)) / totalRuntime.Seconds(), "bytes_per_second": float64(atomic.LoadInt64(&c.totalBytes)) / totalRuntime.Seconds(),
"status_code_stats": statusCodeStats, "status_code_stats": statusCodeStats,
"top_paths": pathMetricsValues,
"top_referers": refererMetricsValues, "top_referers": refererMetricsValues,
"recent_requests": recentRequests, "recent_requests": recentRequests,
"latency_stats": map[string]interface{}{ "latency_stats": map[string]interface{}{
@ -406,6 +450,7 @@ func (c *Collector) GetStats() map[string]interface{} {
}, },
"bandwidth_history": bandwidthHistory, "bandwidth_history": bandwidthHistory,
"current_bandwidth": utils.FormatBytes(int64(c.getCurrentBandwidth())) + "/s", "current_bandwidth": utils.FormatBytes(int64(c.getCurrentBandwidth())) + "/s",
"current_session_requests": sessionRequests,
} }
} }
@ -443,36 +488,12 @@ func (c *Collector) validateLoadedData() error {
// 验证状态码统计 // 验证状态码统计
var statusCodeTotal int64 var statusCodeTotal int64
c.statusCodeStats.Range(func(key, value interface{}) bool { statusStats := c.statusCodeStats.GetStats()
count := atomic.LoadInt64(value.(*int64)) for _, count := range statusStats {
if count < 0 { if count < 0 {
return false return fmt.Errorf("invalid negative status code count")
} }
statusCodeTotal += count statusCodeTotal += count
return true
})
// 验证路径统计
var totalPathRequests int64
c.pathStats.Range(func(_, value interface{}) bool {
stats := value.(*models.PathMetrics)
requestCount := stats.GetRequestCount()
errorCount := stats.GetErrorCount()
if requestCount < 0 || errorCount < 0 {
return false
}
if errorCount > requestCount {
return false
}
totalPathRequests += requestCount
return true
})
// 由于我们限制了路径统计的收集数量,路径统计总数可能小于状态码统计总数
// 因此,我们只需要确保路径统计总数不超过状态码统计总数即可
if float64(totalPathRequests) > float64(statusCodeTotal)*1.1 { // 允许10%的误差
return fmt.Errorf("path stats total (%d) significantly exceeds status code total (%d)",
totalPathRequests, statusCodeTotal)
} }
return nil return nil
@ -555,6 +576,76 @@ func (c *Collector) getCurrentBandwidth() float64 {
return float64(c.bandwidthStats.current) / duration return float64(c.bandwidthStats.current) / duration
} }
// updateRequestsWindow 更新请求窗口统计
func (c *Collector) updateRequestsWindow(count int64) {
c.requestsWindow.Lock()
defer c.requestsWindow.Unlock()
now := time.Now()
// 如果是第一次调用,初始化时间
if c.requestsWindow.lastUpdate.IsZero() {
c.requestsWindow.lastUpdate = now
}
// 计算当前时间桶的索引
timeSinceLastUpdate := now.Sub(c.requestsWindow.lastUpdate)
// 如果时间跨度超过桶大小,需要移动到新桶
if timeSinceLastUpdate >= c.requestsWindow.bucketSize {
bucketsToMove := int(timeSinceLastUpdate / c.requestsWindow.bucketSize)
if bucketsToMove >= len(c.requestsWindow.buckets) {
// 如果移动的桶数超过总桶数,清空所有桶
for i := range c.requestsWindow.buckets {
c.requestsWindow.buckets[i] = 0
}
} else {
// 向右移动桶数据新数据在索引0
copy(c.requestsWindow.buckets[bucketsToMove:], c.requestsWindow.buckets[:len(c.requestsWindow.buckets)-bucketsToMove])
// 清空前面的桶
for i := 0; i < bucketsToMove; i++ {
c.requestsWindow.buckets[i] = 0
}
}
// 更新时间为当前桶的开始时间
c.requestsWindow.lastUpdate = now.Truncate(c.requestsWindow.bucketSize)
}
// 将请求数加到第一个桶(当前时间桶)
if len(c.requestsWindow.buckets) > 0 {
c.requestsWindow.buckets[0] += count
}
}
// getRecentRequestsPerSecond 获取最近5分钟的平均每秒请求数
func (c *Collector) getRecentRequestsPerSecond() float64 {
c.requestsWindow.RLock()
defer c.requestsWindow.RUnlock()
// 统计所有桶的总请求数
var totalRequests int64
for _, bucket := range c.requestsWindow.buckets {
totalRequests += bucket
}
// 计算实际的时间窗口可能不满5分钟
now := time.Now()
actualWindow := c.requestsWindow.window
// 如果程序运行时间不足5分钟使用实际运行时间
if runTime := now.Sub(c.startTime); runTime < c.requestsWindow.window {
actualWindow = runTime
}
if actualWindow.Seconds() == 0 {
return 0
}
return float64(totalRequests) / actualWindow.Seconds()
}
// getBandwidthHistory 获取带宽历史记录 // getBandwidthHistory 获取带宽历史记录
func (c *Collector) getBandwidthHistory() map[string]string { func (c *Collector) getBandwidthHistory() map[string]string {
c.bandwidthStats.RLock() c.bandwidthStats.RLock()
@ -567,177 +658,137 @@ func (c *Collector) getBandwidthHistory() map[string]string {
return history return history
} }
// simplifyReferer 简化引用来源URL只保留域名部分
func simplifyReferer(referer string) string {
// 移除协议部分
if idx := strings.Index(referer, "://"); idx != -1 {
referer = referer[idx+3:]
}
// 只保留域名部分
if idx := strings.Index(referer, "/"); idx != -1 {
referer = referer[:idx]
}
return referer
}
// startCleanupTask 启动定期清理任务 // startCleanupTask 启动定期清理任务
func (c *Collector) startCleanupTask() { func (c *Collector) startCleanupTask() {
go func() { go func() {
// 先立即执行一次清理 ticker := time.NewTicker(1 * time.Hour)
c.cleanupOldData()
ticker := time.NewTicker(15 * time.Minute) // 每15分钟清理一次
defer ticker.Stop() defer ticker.Stop()
for range ticker.C { for {
c.cleanupOldData() <-ticker.C
oneDayAgo := time.Now().Add(-24 * time.Hour).Unix()
// 清理超过24小时的引用来源统计
deletedCount := c.refererStats.Cleanup(oneDayAgo)
if deletedCount > 0 {
log.Printf("[Collector] 已清理 %d 条过期的引用来源统计", deletedCount)
}
// 强制GC
runtime.GC()
} }
}() }()
} }
// cleanupOldData 清理旧数据 // 异步批量处理请求指标
func (c *Collector) cleanupOldData() { func (c *Collector) startAsyncMetricsUpdater() {
log.Printf("[Metrics] 开始清理旧数据...") go func() {
batch := make([]RequestMetric, 0, 1000)
// 清理路径统计 - 只保留有请求且请求数较多的路径 ticker := time.NewTicker(time.Second)
var pathsToRemove []string defer ticker.Stop()
var pathsCount int for {
var totalRequests int64 select {
case metric := <-requestChan:
// 先收集所有路径及其请求数 batch = append(batch, metric)
type pathInfo struct { if len(batch) >= 1000 {
path string c.updateMetricsBatch(batch)
count int64 batch = batch[:0]
} }
var paths []pathInfo case <-ticker.C:
if len(batch) > 0 {
c.pathStats.Range(func(key, value interface{}) bool { c.updateMetricsBatch(batch)
path := key.(string) batch = batch[:0]
stats := value.(*models.PathMetrics) }
count := stats.GetRequestCount() }
pathsCount++ }
totalRequests += count }()
paths = append(paths, pathInfo{path, count})
return true
})
// 按请求数排序
sort.Slice(paths, func(i, j int) bool {
return paths[i].count > paths[j].count
})
// 只保留前100个请求数最多的路径或者请求数占总请求数1%以上的路径
threshold := totalRequests / 100 // 1%的阈值
if threshold < 10 {
threshold = 10 // 至少保留请求数>=10的路径
} }
// 标记要删除的路径 // 批量更新指标
for _, pi := range paths { func (c *Collector) updateMetricsBatch(batch []RequestMetric) {
if len(paths)-len(pathsToRemove) <= 100 { for _, m := range batch {
// 已经只剩下100个路径了不再删除 // 增加当前会话请求计数
atomic.AddInt64(&c.sessionRequests, 1)
// 更新请求窗口统计
c.updateRequestsWindow(1)
// 更新状态码统计
c.statusCodeStats.Increment(m.Status)
// 更新总字节数和带宽统计
atomic.AddInt64(&c.totalBytes, m.Bytes)
c.updateBandwidthStats(m.Bytes)
// 更新延迟统计
atomic.AddInt64(&c.latencySum, int64(m.Latency))
latencyNanos := int64(m.Latency)
for {
oldMin := atomic.LoadInt64(&c.minLatency)
if oldMin <= latencyNanos {
break break
} }
if atomic.CompareAndSwapInt64(&c.minLatency, oldMin, latencyNanos) {
if pi.count < threshold {
pathsToRemove = append(pathsToRemove, pi.path)
}
}
// 删除标记的路径
for _, path := range pathsToRemove {
c.pathStats.Delete(path)
}
// 清理引用来源统计 - 类似地处理
var referersToRemove []string
var referersCount int
var totalRefererRequests int64
// 先收集所有引用来源及其请求数
type refererInfo struct {
referer string
count int64
}
var referers []refererInfo
c.refererStats.Range(func(key, value interface{}) bool {
referer := key.(string)
stats := value.(*models.PathMetrics)
count := stats.GetRequestCount()
referersCount++
totalRefererRequests += count
referers = append(referers, refererInfo{referer, count})
return true
})
// 按请求数排序
sort.Slice(referers, func(i, j int) bool {
return referers[i].count > referers[j].count
})
// 只保留前50个请求数最多的引用来源或者请求数占总请求数2%以上的引用来源
refThreshold := totalRefererRequests / 50 // 2%的阈值
if refThreshold < 5 {
refThreshold = 5 // 至少保留请求数>=5的引用来源
}
// 标记要删除的引用来源
for _, ri := range referers {
if len(referers)-len(referersToRemove) <= 50 {
// 已经只剩下50个引用来源了不再删除
break break
} }
}
if ri.count < refThreshold { for {
referersToRemove = append(referersToRemove, ri.referer) oldMax := atomic.LoadInt64(&c.maxLatency)
if oldMax >= latencyNanos {
break
}
if atomic.CompareAndSwapInt64(&c.maxLatency, oldMax, latencyNanos) {
break
} }
} }
// 删除标记的引用来源 // 更新延迟分布
for _, referer := range referersToRemove { latencyMs := m.Latency.Milliseconds()
c.refererStats.Delete(referer) switch {
case latencyMs < 10:
atomic.AddInt64(&c.latencyBuckets.lt10ms, 1)
case latencyMs < 50:
atomic.AddInt64(&c.latencyBuckets.ms10_50, 1)
case latencyMs < 200:
atomic.AddInt64(&c.latencyBuckets.ms50_200, 1)
case latencyMs < 1000:
atomic.AddInt64(&c.latencyBuckets.ms200_1000, 1)
default:
atomic.AddInt64(&c.latencyBuckets.gt1s, 1)
} }
// 清理带宽历史 - 只保留最近的记录 // 记录引用来源
c.bandwidthStats.Lock() if m.Request != nil {
if len(c.bandwidthStats.history) > 10 { referer := m.Request.Referer()
// 找出最旧的记录并删除 if referer != "" {
var oldestKeys []string var refererMetrics *models.PathMetrics
var oldestTimes []time.Time if existingMetrics, ok := c.refererStats.Load(referer); ok {
refererMetrics = existingMetrics
for k := range c.bandwidthStats.history { } else {
t, err := time.Parse("01-02 15:04", k) refererMetrics = &models.PathMetrics{Path: referer}
if err != nil { c.refererStats.Store(referer, refererMetrics)
continue
}
oldestTimes = append(oldestTimes, t)
oldestKeys = append(oldestKeys, k)
} }
// 按时间排序 refererMetrics.AddRequest()
sort.Slice(oldestKeys, func(i, j int) bool { if m.Status >= 400 {
return oldestTimes[i].Before(oldestTimes[j]) refererMetrics.AddError()
}
refererMetrics.AddBytes(m.Bytes)
refererMetrics.AddLatency(m.Latency.Nanoseconds())
// 更新最后访问时间
refererMetrics.LastAccessTime.Store(time.Now().Unix())
}
}
// 更新最近请求记录
c.recentRequests.Push(models.RequestLog{
Time: time.Now(),
Path: m.Path,
Status: m.Status,
Latency: int64(m.Latency),
BytesSent: m.Bytes,
ClientIP: m.ClientIP,
}) })
// 删除最旧的记录只保留最近10条
for i := 0; i < len(oldestKeys)-10; i++ {
delete(c.bandwidthStats.history, oldestKeys[i])
} }
} }
c.bandwidthStats.Unlock()
// 强制进行一次GC
runtime.GC()
// 打印内存使用情况
var mem runtime.MemStats
runtime.ReadMemStats(&mem)
log.Printf("[Metrics] 清理完成: 删除了 %d/%d 个路径, %d/%d 个引用来源, 当前内存使用: %s",
len(pathsToRemove), pathsCount,
len(referersToRemove), referersCount,
utils.FormatBytes(int64(mem.Alloc)))
}

View File

@ -2,47 +2,25 @@ package metrics
import ( import (
"log" "log"
"path/filepath"
"proxy-go/internal/config" "proxy-go/internal/config"
"time"
) )
var ( func Init(cfg *config.Config) error {
metricsStorage *MetricsStorage // 初始化收集器
)
// InitMetricsStorage 初始化指标存储服务
func InitMetricsStorage(cfg *config.Config) error {
// 确保收集器已初始化
if err := InitCollector(cfg); err != nil { if err := InitCollector(cfg); err != nil {
log.Printf("[Metrics] 初始化收集器失败: %v", err)
//继续运行
return err return err
} }
// 创建指标存储服务 // 初始化指标存储服务
dataDir := filepath.Join("data", "metrics") if err := InitMetricsStorage(cfg); err != nil {
saveInterval := 30 * time.Minute // 默认30分钟保存一次减少IO操作 log.Printf("[Metrics] 初始化指标存储服务失败: %v", err)
//继续运行
metricsStorage = NewMetricsStorage(GetCollector(), dataDir, saveInterval)
// 启动指标存储服务
if err := metricsStorage.Start(); err != nil {
log.Printf("[Metrics] 启动指标存储服务失败: %v", err)
return err return err
} }
log.Printf("[Metrics] 指标存储服务已初始化,保存间隔: %v", saveInterval) log.Printf("[Metrics] 初始化完成")
return nil return nil
} }
// StopMetricsStorage 停止指标存储服务
func StopMetricsStorage() {
if metricsStorage != nil {
metricsStorage.Stop()
log.Printf("[Metrics] 指标存储服务已停止")
}
}
// GetMetricsStorage 获取指标存储服务实例
func GetMetricsStorage() *MetricsStorage {
return metricsStorage
}

View File

@ -0,0 +1,44 @@
package metrics
import (
"log"
"path/filepath"
"proxy-go/internal/config"
"time"
)
var (
metricsStorage *MetricsStorage
)
// InitMetricsStorage 初始化指标存储服务
func InitMetricsStorage(cfg *config.Config) error {
// 创建指标存储服务
dataDir := filepath.Join("data", "metrics")
saveInterval := 30 * time.Minute // 默认30分钟保存一次减少IO操作
metricsStorage = NewMetricsStorage(GetCollector(), dataDir, saveInterval)
// 启动指标存储服务
if err := metricsStorage.Start(); err != nil {
log.Printf("[Metrics] 启动指标存储服务失败: %v", err)
return err
}
log.Printf("[Metrics] 指标存储服务已初始化,保存间隔: %v", saveInterval)
return nil
}
// StopMetricsStorage 停止指标存储服务
func StopMetricsStorage() {
if metricsStorage != nil {
metricsStorage.Stop()
log.Printf("[Metrics] 指标存储服务已停止")
}
}
// GetMetricsStorage 获取指标存储服务实例
func GetMetricsStorage() *MetricsStorage {
return metricsStorage
}

View File

@ -6,9 +6,9 @@ import (
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"proxy-go/internal/models"
"proxy-go/internal/utils" "proxy-go/internal/utils"
"runtime" "runtime"
"strconv"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "time"
@ -23,10 +23,7 @@ type MetricsStorage struct {
wg sync.WaitGroup wg sync.WaitGroup
lastSaveTime time.Time lastSaveTime time.Time
mutex sync.RWMutex mutex sync.RWMutex
metricsFile string
pathStatsFile string
statusCodeFile string statusCodeFile string
refererStatsFile string
} }
// NewMetricsStorage 创建新的指标存储 // NewMetricsStorage 创建新的指标存储
@ -40,10 +37,7 @@ func NewMetricsStorage(collector *Collector, dataDir string, saveInterval time.D
saveInterval: saveInterval, saveInterval: saveInterval,
dataDir: dataDir, dataDir: dataDir,
stopChan: make(chan struct{}), stopChan: make(chan struct{}),
metricsFile: filepath.Join(dataDir, "metrics.json"),
pathStatsFile: filepath.Join(dataDir, "path_stats.json"),
statusCodeFile: filepath.Join(dataDir, "status_codes.json"), statusCodeFile: filepath.Join(dataDir, "status_codes.json"),
refererStatsFile: filepath.Join(dataDir, "referer_stats.json"),
} }
} }
@ -106,40 +100,12 @@ func (ms *MetricsStorage) SaveMetrics() error {
// 获取当前指标数据 // 获取当前指标数据
stats := ms.collector.GetStats() stats := ms.collector.GetStats()
// 保存基本指标 - 只保存必要的字段
basicMetrics := map[string]interface{}{
"uptime": stats["uptime"],
"total_bytes": stats["total_bytes"],
"avg_response_time": stats["avg_response_time"],
"save_time": time.Now().Format(time.RFC3339),
}
// 单独保存延迟统计,避免嵌套结构导致的内存占用
if latencyStats, ok := stats["latency_stats"].(map[string]interface{}); ok {
basicMetrics["latency_min"] = latencyStats["min"]
basicMetrics["latency_max"] = latencyStats["max"]
}
if err := saveJSONToFile(ms.metricsFile, basicMetrics); err != nil {
return fmt.Errorf("保存基本指标失败: %v", err)
}
// 保存路径统计 - 限制数量
topPaths := stats["top_paths"]
if err := saveJSONToFile(ms.pathStatsFile, topPaths); err != nil {
return fmt.Errorf("保存路径统计失败: %v", err)
}
// 保存状态码统计 // 保存状态码统计
if err := saveJSONToFile(ms.statusCodeFile, stats["status_code_stats"]); err != nil { if err := saveJSONToFile(ms.statusCodeFile, stats["status_code_stats"]); err != nil {
return fmt.Errorf("保存状态码统计失败: %v", err) return fmt.Errorf("保存状态码统计失败: %v", err)
} }
// 保存引用来源统计 - 限制数量 // 不再保存引用来源统计,因为它现在只保存在内存中
topReferers := stats["top_referers"]
if err := saveJSONToFile(ms.refererStatsFile, topReferers); err != nil {
return fmt.Errorf("保存引用来源统计失败: %v", err)
}
// 单独保存延迟分布 // 单独保存延迟分布
if latencyStats, ok := stats["latency_stats"].(map[string]interface{}); ok { if latencyStats, ok := stats["latency_stats"].(map[string]interface{}); ok {
@ -150,10 +116,6 @@ func (ms *MetricsStorage) SaveMetrics() error {
} }
} }
ms.mutex.Lock()
ms.lastSaveTime = time.Now()
ms.mutex.Unlock()
// 强制进行一次GC // 强制进行一次GC
runtime.GC() runtime.GC()
@ -171,157 +133,79 @@ func (ms *MetricsStorage) LoadMetrics() error {
start := time.Now() start := time.Now()
log.Printf("[MetricsStorage] 开始加载指标数据...") log.Printf("[MetricsStorage] 开始加载指标数据...")
// 检查文件是否存在 // 不再加载 basicMetricsmetrics.json
if !fileExists(ms.metricsFile) {
return fmt.Errorf("指标数据文件不存在")
}
// 加载基本指标 // 1. 加载状态码统计(如果文件存在)
var basicMetrics map[string]interface{}
if err := loadJSONFromFile(ms.metricsFile, &basicMetrics); err != nil {
return fmt.Errorf("加载基本指标失败: %v", err)
}
// 将加载的数据应用到收集器
// 1. 应用总字节数
if totalBytes, ok := basicMetrics["total_bytes"].(float64); ok {
atomic.StoreInt64(&ms.collector.totalBytes, int64(totalBytes))
}
// 2. 加载路径统计(如果文件存在)
if fileExists(ms.pathStatsFile) {
var pathStats []map[string]interface{}
if err := loadJSONFromFile(ms.pathStatsFile, &pathStats); err != nil {
log.Printf("[MetricsStorage] 加载路径统计失败: %v", err)
} else {
// 只加载前10个路径统计
maxPaths := 10
if len(pathStats) > maxPaths {
pathStats = pathStats[:maxPaths]
}
for _, pathStat := range pathStats {
path, ok := pathStat["path"].(string)
if !ok {
continue
}
requestCount, _ := pathStat["request_count"].(float64)
errorCount, _ := pathStat["error_count"].(float64)
bytesTransferred, _ := pathStat["bytes_transferred"].(float64)
// 创建或更新路径统计
var pathMetrics *models.PathMetrics
if existingMetrics, ok := ms.collector.pathStats.Load(path); ok {
pathMetrics = existingMetrics.(*models.PathMetrics)
} else {
pathMetrics = &models.PathMetrics{Path: path}
ms.collector.pathStats.Store(path, pathMetrics)
}
// 设置统计值
pathMetrics.RequestCount.Store(int64(requestCount))
pathMetrics.ErrorCount.Store(int64(errorCount))
pathMetrics.BytesTransferred.Store(int64(bytesTransferred))
}
log.Printf("[MetricsStorage] 加载了 %d 条路径统计", len(pathStats))
}
}
// 3. 加载状态码统计(如果文件存在)
if fileExists(ms.statusCodeFile) { if fileExists(ms.statusCodeFile) {
var statusCodeStats map[string]interface{} var statusCodeStats map[string]interface{}
if err := loadJSONFromFile(ms.statusCodeFile, &statusCodeStats); err != nil { if err := loadJSONFromFile(ms.statusCodeFile, &statusCodeStats); err != nil {
log.Printf("[MetricsStorage] 加载状态码统计失败: %v", err) log.Printf("[MetricsStorage] 加载状态码统计失败: %v", err)
} else { } else {
for statusCode, count := range statusCodeStats { // 由于新的 StatusCodeStats 结构,我们需要手动设置值
countValue, ok := count.(float64) loadedCount := 0
if !ok { for codeStr, countValue := range statusCodeStats {
// 解析状态码
if code, err := strconv.Atoi(codeStr); err == nil {
// 解析计数值
var count int64
switch v := countValue.(type) {
case float64:
count = int64(v)
case int64:
count = v
case int:
count = int64(v)
default:
continue continue
} }
// 创建或更新状态码统计 // 手动设置到新的 StatusCodeStats 结构中
if counter, ok := ms.collector.statusCodeStats.Load(statusCode); ok { ms.collector.statusCodeStats.mu.Lock()
atomic.StoreInt64(counter.(*int64), int64(countValue)) if _, exists := ms.collector.statusCodeStats.stats[code]; !exists {
} else { ms.collector.statusCodeStats.stats[code] = new(int64)
counter := new(int64) }
*counter = int64(countValue) atomic.StoreInt64(ms.collector.statusCodeStats.stats[code], count)
ms.collector.statusCodeStats.Store(statusCode, counter) ms.collector.statusCodeStats.mu.Unlock()
loadedCount++
} }
} }
log.Printf("[MetricsStorage] 加载了 %d 条状态码统计", len(statusCodeStats)) log.Printf("[MetricsStorage] 成功加载了 %d 条状态码统计", loadedCount)
} }
} }
// 4. 加载引用来源统计(如果文件存在) // 不再加载引用来源统计,因为它现在只保存在内存中
if fileExists(ms.refererStatsFile) {
var refererStats []map[string]interface{}
if err := loadJSONFromFile(ms.refererStatsFile, &refererStats); err != nil {
log.Printf("[MetricsStorage] 加载引用来源统计失败: %v", err)
} else {
// 只加载前10个引用来源统计
maxReferers := 10
if len(refererStats) > maxReferers {
refererStats = refererStats[:maxReferers]
}
for _, refererStat := range refererStats { // 3. 加载延迟分布(如果文件存在)
referer, ok := refererStat["path"].(string)
if !ok {
continue
}
requestCount, _ := refererStat["request_count"].(float64)
errorCount, _ := refererStat["error_count"].(float64)
bytesTransferred, _ := refererStat["bytes_transferred"].(float64)
// 创建或更新引用来源统计
var refererMetrics *models.PathMetrics
if existingMetrics, ok := ms.collector.refererStats.Load(referer); ok {
refererMetrics = existingMetrics.(*models.PathMetrics)
} else {
refererMetrics = &models.PathMetrics{Path: referer}
ms.collector.refererStats.Store(referer, refererMetrics)
}
// 设置统计值
refererMetrics.RequestCount.Store(int64(requestCount))
refererMetrics.ErrorCount.Store(int64(errorCount))
refererMetrics.BytesTransferred.Store(int64(bytesTransferred))
}
log.Printf("[MetricsStorage] 加载了 %d 条引用来源统计", len(refererStats))
}
}
// 5. 加载延迟分布(如果文件存在)
latencyDistributionFile := filepath.Join(ms.dataDir, "latency_distribution.json") latencyDistributionFile := filepath.Join(ms.dataDir, "latency_distribution.json")
if fileExists(latencyDistributionFile) { if fileExists(latencyDistributionFile) {
var distribution map[string]interface{} var distribution map[string]interface{}
if err := loadJSONFromFile(latencyDistributionFile, &distribution); err != nil { if err := loadJSONFromFile(latencyDistributionFile, &distribution); err != nil {
log.Printf("[MetricsStorage] 加载延迟分布失败: %v", err) log.Printf("[MetricsStorage] 加载延迟分布失败: %v", err)
} else { } else {
// 由于新的 LatencyBuckets 结构,我们需要手动设置值
for bucket, count := range distribution { for bucket, count := range distribution {
countValue, ok := count.(float64) countValue, ok := count.(float64)
if !ok { if !ok {
continue continue
} }
if counter, ok := ms.collector.latencyBuckets.Load(bucket); ok { // 根据桶名称设置对应的值
atomic.StoreInt64(counter.(*int64), int64(countValue)) switch bucket {
case "lt10ms":
atomic.StoreInt64(&ms.collector.latencyBuckets.lt10ms, int64(countValue))
case "10-50ms":
atomic.StoreInt64(&ms.collector.latencyBuckets.ms10_50, int64(countValue))
case "50-200ms":
atomic.StoreInt64(&ms.collector.latencyBuckets.ms50_200, int64(countValue))
case "200-1000ms":
atomic.StoreInt64(&ms.collector.latencyBuckets.ms200_1000, int64(countValue))
case "gt1s":
atomic.StoreInt64(&ms.collector.latencyBuckets.gt1s, int64(countValue))
} }
} }
log.Printf("[MetricsStorage] 加载了延迟分布数据") log.Printf("[MetricsStorage] 加载了延迟分布数据")
} }
} }
ms.mutex.Lock()
if saveTime, ok := basicMetrics["save_time"].(string); ok {
if t, err := time.Parse(time.RFC3339, saveTime); err == nil {
ms.lastSaveTime = t
}
}
ms.mutex.Unlock()
// 强制进行一次GC // 强制进行一次GC
runtime.GC() runtime.GC()

View File

@ -0,0 +1,86 @@
package middleware
import (
"fmt"
"net/http"
"proxy-go/internal/security"
"time"
"github.com/woodchen-ink/go-web-utils/iputil"
)
// SecurityMiddleware 安全中间件
type SecurityMiddleware struct {
banManager *security.IPBanManager
}
// NewSecurityMiddleware 创建安全中间件
func NewSecurityMiddleware(banManager *security.IPBanManager) *SecurityMiddleware {
return &SecurityMiddleware{
banManager: banManager,
}
}
// IPBanMiddleware IP封禁中间件
func (sm *SecurityMiddleware) IPBanMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
clientIP := iputil.GetClientIP(r)
// 检查IP是否被封禁
if sm.banManager.IsIPBanned(clientIP) {
banned, banEndTime := sm.banManager.GetBanInfo(clientIP)
if banned {
// 返回429状态码和封禁信息
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Retry-After", fmt.Sprintf("%.0f", time.Until(banEndTime).Seconds()))
w.WriteHeader(http.StatusTooManyRequests)
remainingTime := time.Until(banEndTime)
response := fmt.Sprintf(`{
"error": "IP temporarily banned due to excessive 404 errors",
"message": "您的IP因频繁访问不存在的资源而被暂时封禁",
"ban_end_time": "%s",
"remaining_seconds": %.0f
}`, banEndTime.Format("2006-01-02 15:04:05"), remainingTime.Seconds())
w.Write([]byte(response))
return
}
}
// 创建响应写入器包装器来捕获状态码
wrapper := &responseWrapper{
ResponseWriter: w,
statusCode: http.StatusOK,
}
// 继续处理请求
next.ServeHTTP(wrapper, r)
// 如果响应是404记录错误
if wrapper.statusCode == http.StatusNotFound {
sm.banManager.RecordError(clientIP)
}
})
}
// responseWrapper 响应包装器,用于捕获状态码
type responseWrapper struct {
http.ResponseWriter
statusCode int
}
// WriteHeader 重写WriteHeader方法来捕获状态码
func (rw *responseWrapper) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}
// Write 重写Write方法确保状态码被正确设置
func (rw *responseWrapper) Write(b []byte) (int, error) {
// 如果还没有设置状态码默认为200
if rw.statusCode == 0 {
rw.statusCode = http.StatusOK
}
return rw.ResponseWriter.Write(b)
}

View File

@ -19,6 +19,7 @@ type PathMetrics struct {
TotalLatency atomic.Int64 `json:"-"` TotalLatency atomic.Int64 `json:"-"`
BytesTransferred atomic.Int64 `json:"bytes_transferred"` BytesTransferred atomic.Int64 `json:"bytes_transferred"`
AvgLatency string `json:"avg_latency"` AvgLatency string `json:"avg_latency"`
LastAccessTime atomic.Int64 `json:"last_access_time"` // 最后访问时间戳
} }
// PathMetricsJSON 用于 JSON 序列化的路径统计信息 // PathMetricsJSON 用于 JSON 序列化的路径统计信息
@ -28,6 +29,7 @@ type PathMetricsJSON struct {
ErrorCount int64 `json:"error_count"` ErrorCount int64 `json:"error_count"`
BytesTransferred int64 `json:"bytes_transferred"` BytesTransferred int64 `json:"bytes_transferred"`
AvgLatency string `json:"avg_latency"` AvgLatency string `json:"avg_latency"`
LastAccessTime int64 `json:"last_access_time"` // 最后访问时间戳
} }
// GetRequestCount 获取请求数 // GetRequestCount 获取请求数
@ -78,6 +80,7 @@ func (p *PathMetrics) ToJSON() PathMetricsJSON {
ErrorCount: p.ErrorCount.Load(), ErrorCount: p.ErrorCount.Load(),
BytesTransferred: p.BytesTransferred.Load(), BytesTransferred: p.BytesTransferred.Load(),
AvgLatency: p.AvgLatency, AvgLatency: p.AvgLatency,
LastAccessTime: p.LastAccessTime.Load(),
} }
} }

View File

@ -0,0 +1,278 @@
package security
import (
"log"
"sync"
"time"
)
// IPBanManager IP封禁管理器
type IPBanManager struct {
// 404错误计数器 map[ip]count
errorCounts sync.Map
// IP封禁列表 map[ip]banEndTime
bannedIPs sync.Map
// 配置参数
config *IPBanConfig
// 清理任务停止信号
stopCleanup chan struct{}
// 清理任务等待组
cleanupWG sync.WaitGroup
}
// IPBanConfig IP封禁配置
type IPBanConfig struct {
// 404错误阈值超过此数量将被封禁
ErrorThreshold int `json:"error_threshold"`
// 统计窗口时间(分钟)
WindowMinutes int `json:"window_minutes"`
// 封禁时长(分钟)
BanDurationMinutes int `json:"ban_duration_minutes"`
// 清理间隔(分钟)
CleanupIntervalMinutes int `json:"cleanup_interval_minutes"`
}
// errorRecord 错误记录
type errorRecord struct {
count int
firstTime time.Time
lastTime time.Time
}
// DefaultIPBanConfig 默认配置
func DefaultIPBanConfig() *IPBanConfig {
return &IPBanConfig{
ErrorThreshold: 10, // 10次404错误
WindowMinutes: 5, // 5分钟内
BanDurationMinutes: 5, // 封禁5分钟
CleanupIntervalMinutes: 1, // 每分钟清理一次
}
}
// NewIPBanManager 创建IP封禁管理器
func NewIPBanManager(config *IPBanConfig) *IPBanManager {
if config == nil {
config = DefaultIPBanConfig()
}
manager := &IPBanManager{
config: config,
stopCleanup: make(chan struct{}),
}
// 启动清理任务
manager.startCleanupTask()
log.Printf("[Security] IP封禁管理器已启动 - 阈值: %d次/%.0f分钟, 封禁时长: %.0f分钟",
config.ErrorThreshold,
float64(config.WindowMinutes),
float64(config.BanDurationMinutes))
return manager
}
// RecordError 记录404错误
func (m *IPBanManager) RecordError(ip string) {
now := time.Now()
windowStart := now.Add(-time.Duration(m.config.WindowMinutes) * time.Minute)
// 加载或创建错误记录
value, _ := m.errorCounts.LoadOrStore(ip, &errorRecord{
count: 0,
firstTime: now,
lastTime: now,
})
record := value.(*errorRecord)
// 如果第一次记录时间超出窗口,重置计数
if record.firstTime.Before(windowStart) {
record.count = 1
record.firstTime = now
record.lastTime = now
} else {
record.count++
record.lastTime = now
}
// 检查是否需要封禁
if record.count >= m.config.ErrorThreshold {
m.banIP(ip, now)
// 重置计数器,避免重复封禁
record.count = 0
record.firstTime = now
}
log.Printf("[Security] 记录404错误 IP: %s, 当前计数: %d/%d (窗口: %.0f分钟)",
ip, record.count, m.config.ErrorThreshold, float64(m.config.WindowMinutes))
}
// banIP 封禁IP
func (m *IPBanManager) banIP(ip string, banTime time.Time) {
banEndTime := banTime.Add(time.Duration(m.config.BanDurationMinutes) * time.Minute)
m.bannedIPs.Store(ip, banEndTime)
log.Printf("[Security] IP已被封禁: %s, 封禁至: %s (%.0f分钟)",
ip, banEndTime.Format("15:04:05"), float64(m.config.BanDurationMinutes))
}
// IsIPBanned 检查IP是否被封禁
func (m *IPBanManager) IsIPBanned(ip string) bool {
value, exists := m.bannedIPs.Load(ip)
if !exists {
return false
}
banEndTime := value.(time.Time)
now := time.Now()
// 检查封禁是否已过期
if now.After(banEndTime) {
m.bannedIPs.Delete(ip)
log.Printf("[Security] IP封禁已过期自动解封: %s", ip)
return false
}
return true
}
// GetBanInfo 获取IP封禁信息
func (m *IPBanManager) GetBanInfo(ip string) (bool, time.Time) {
value, exists := m.bannedIPs.Load(ip)
if !exists {
return false, time.Time{}
}
banEndTime := value.(time.Time)
now := time.Now()
if now.After(banEndTime) {
m.bannedIPs.Delete(ip)
return false, time.Time{}
}
return true, banEndTime
}
// UnbanIP 手动解封IP
func (m *IPBanManager) UnbanIP(ip string) bool {
_, exists := m.bannedIPs.Load(ip)
if exists {
m.bannedIPs.Delete(ip)
log.Printf("[Security] 手动解封IP: %s", ip)
return true
}
return false
}
// GetBannedIPs 获取所有被封禁的IP列表
func (m *IPBanManager) GetBannedIPs() map[string]time.Time {
result := make(map[string]time.Time)
now := time.Now()
m.bannedIPs.Range(func(key, value interface{}) bool {
ip := key.(string)
banEndTime := value.(time.Time)
// 清理过期的封禁
if now.After(banEndTime) {
m.bannedIPs.Delete(ip)
} else {
result[ip] = banEndTime
}
return true
})
return result
}
// GetStats 获取统计信息
func (m *IPBanManager) GetStats() map[string]interface{} {
bannedCount := 0
errorRecordCount := 0
m.bannedIPs.Range(func(key, value interface{}) bool {
bannedCount++
return true
})
m.errorCounts.Range(func(key, value interface{}) bool {
errorRecordCount++
return true
})
return map[string]interface{}{
"banned_ips_count": bannedCount,
"error_records_count": errorRecordCount,
"config": m.config,
}
}
// startCleanupTask 启动清理任务
func (m *IPBanManager) startCleanupTask() {
m.cleanupWG.Add(1)
go func() {
defer m.cleanupWG.Done()
ticker := time.NewTicker(time.Duration(m.config.CleanupIntervalMinutes) * time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
m.cleanup()
case <-m.stopCleanup:
return
}
}
}()
}
// cleanup 清理过期数据
func (m *IPBanManager) cleanup() {
now := time.Now()
windowStart := now.Add(-time.Duration(m.config.WindowMinutes) * time.Minute)
// 清理过期的错误记录
var expiredIPs []string
m.errorCounts.Range(func(key, value interface{}) bool {
ip := key.(string)
record := value.(*errorRecord)
// 如果最后一次错误时间超出窗口,删除记录
if record.lastTime.Before(windowStart) {
expiredIPs = append(expiredIPs, ip)
}
return true
})
for _, ip := range expiredIPs {
m.errorCounts.Delete(ip)
}
// 清理过期的封禁记录
var expiredBans []string
m.bannedIPs.Range(func(key, value interface{}) bool {
ip := key.(string)
banEndTime := value.(time.Time)
if now.After(banEndTime) {
expiredBans = append(expiredBans, ip)
}
return true
})
for _, ip := range expiredBans {
m.bannedIPs.Delete(ip)
}
if len(expiredIPs) > 0 || len(expiredBans) > 0 {
log.Printf("[Security] 清理任务完成 - 清理错误记录: %d, 清理过期封禁: %d",
len(expiredIPs), len(expiredBans))
}
}
// Stop 停止IP封禁管理器
func (m *IPBanManager) Stop() {
close(m.stopCleanup)
m.cleanupWG.Wait()
log.Printf("[Security] IP封禁管理器已停止")
}

View File

@ -0,0 +1,240 @@
package service
import (
"fmt"
"log"
"net/http"
"proxy-go/internal/config"
"proxy-go/internal/utils"
"strings"
)
// RuleService 规则选择服务
type RuleService struct {
cacheManager CacheManager
}
// CacheManager 缓存管理器接口
type CacheManager interface {
GetExtensionMatcher(pathKey string, rules []config.ExtensionRule) *utils.ExtensionMatcher
}
// NewRuleService 创建规则选择服务
func NewRuleService(cacheManager CacheManager) *RuleService {
return &RuleService{
cacheManager: cacheManager,
}
}
// SelectBestRule 选择最合适的规则
func (rs *RuleService) SelectBestRule(client *http.Client, pathConfig config.PathConfig, path string, requestHost string) (*config.ExtensionRule, bool, bool) {
// 如果没有扩展名规则返回nil
if len(pathConfig.ExtRules) == 0 {
return nil, false, false
}
// 提取扩展名
ext := extractExtension(path)
var matcher *utils.ExtensionMatcher
// 尝试使用缓存管理器
if rs.cacheManager != nil {
pathKey := fmt.Sprintf("path_%p", &pathConfig)
matcher = rs.cacheManager.GetExtensionMatcher(pathKey, pathConfig.ExtRules)
} else {
// 直接创建新的匹配器
matcher = utils.NewExtensionMatcher(pathConfig.ExtRules)
}
// 获取匹配的规则
matchingRules := matcher.GetMatchingRules(ext)
if len(matchingRules) == 0 {
return nil, false, false
}
// 过滤符合域名条件的规则
var domainMatchingRules []*config.ExtensionRule
for _, rule := range matchingRules {
if rs.isDomainMatching(rule, requestHost) {
domainMatchingRules = append(domainMatchingRules, rule)
}
}
// 如果没有域名匹配的规则返回nil
if len(domainMatchingRules) == 0 {
log.Printf("[SelectRule] %s -> 没有找到匹配域名 %s 的扩展名规则", path, requestHost)
return nil, false, false
}
// 检查是否需要获取文件大小
// 如果所有匹配的规则都没有设置大小阈值(都是默认值),则跳过文件大小检查
needSizeCheck := false
for _, rule := range domainMatchingRules {
if rule.SizeThreshold > 0 || rule.MaxSize < (1<<63-1) {
needSizeCheck = true
break
}
}
if !needSizeCheck {
// 不需要检查文件大小,直接使用第一个匹配的规则
for _, rule := range domainMatchingRules {
if utils.IsTargetAccessible(client, rule.Target+path) {
log.Printf("[SelectRule] %s -> 选中规则 (域名: %s, 跳过大小检查)", path, requestHost)
return rule, true, true
}
}
return nil, false, false
}
// 获取文件大小(使用同步检查)
contentLength, err := utils.GetFileSize(client, pathConfig.DefaultTarget+path)
if err != nil {
log.Printf("[SelectRule] %s -> 获取文件大小出错: %v使用宽松模式回退", path, err)
// 宽松模式:如果无法获取文件大小,尝试使用第一个匹配的规则
for _, rule := range domainMatchingRules {
if utils.IsTargetAccessible(client, rule.Target+path) {
log.Printf("[SelectRule] %s -> 使用宽松模式选中规则 (域名: %s, 跳过大小检查)", path, requestHost)
return rule, true, true
}
}
return nil, false, false
}
// 根据文件大小找出最匹配的规则(规则已经预排序)
for _, rule := range domainMatchingRules {
// 检查文件大小是否在阈值范围内
if contentLength >= rule.SizeThreshold && contentLength <= rule.MaxSize {
// 找到匹配的规则
log.Printf("[SelectRule] %s -> 选中规则 (域名: %s, 文件大小: %s, 在区间 %s 到 %s 之间)",
path, requestHost, utils.FormatBytes(contentLength),
utils.FormatBytes(rule.SizeThreshold), utils.FormatBytes(rule.MaxSize))
// 检查目标是否可访问
if utils.IsTargetAccessible(client, rule.Target+path) {
return rule, true, true
} else {
log.Printf("[SelectRule] %s -> 规则目标不可访问,继续查找", path)
// 继续查找下一个匹配的规则
continue
}
}
}
// 没有找到合适的规则
return nil, false, false
}
// isDomainMatching 检查规则的域名是否匹配请求的域名
func (rs *RuleService) isDomainMatching(rule *config.ExtensionRule, requestHost string) bool {
// 如果规则没有指定域名,则匹配所有域名
if len(rule.Domains) == 0 {
return true
}
// 提取请求域名(去除端口号)
host := requestHost
if colonIndex := strings.Index(host, ":"); colonIndex != -1 {
host = host[:colonIndex]
}
// 检查是否匹配任一指定的域名
for _, domain := range rule.Domains {
if strings.EqualFold(host, domain) {
return true
}
}
return false
}
// RuleSelectionResult 规则选择结果
type RuleSelectionResult struct {
Rule *config.ExtensionRule
Found bool
UsedAltTarget bool
TargetURL string
ShouldRedirect bool
}
// SelectRuleForRedirect 专门为302跳转优化的规则选择函数
func (rs *RuleService) SelectRuleForRedirect(client *http.Client, pathConfig config.PathConfig, path string, requestHost string) *RuleSelectionResult {
result := &RuleSelectionResult{}
// 快速检查如果没有任何302跳转配置直接返回
if !pathConfig.RedirectMode && len(pathConfig.ExtRules) == 0 {
return result
}
// 优先检查扩展名规则即使根级别配置了302跳转
if len(pathConfig.ExtRules) > 0 {
// 尝试选择最佳规则(包括文件大小检测)
if rule, found, usedAlt := rs.SelectBestRule(client, pathConfig, path, requestHost); found && rule != nil && rule.RedirectMode {
result.Rule = rule
result.Found = found
result.UsedAltTarget = usedAlt
result.ShouldRedirect = true
result.TargetURL = rule.Target
return result
}
// 注意:这里不再进行"忽略大小"的回退匹配
// 如果SelectBestRule没有找到合适的规则说明
// 1. 扩展名不匹配,或者
// 2. 扩展名匹配但文件大小不在配置范围内,或者
// 3. 无法获取文件大小,或者
// 4. 目标服务器不可访问,或者
// 5. 域名不匹配
// 在这些情况下,我们不应该强制使用扩展名规则
}
// 如果没有匹配的扩展名规则且默认目标配置了302跳转使用默认目标
if pathConfig.RedirectMode {
result.Found = true
result.ShouldRedirect = true
result.TargetURL = pathConfig.DefaultTarget
return result
}
return result
}
// GetTargetURL 根据路径和配置决定目标URL
func (rs *RuleService) GetTargetURL(client *http.Client, r *http.Request, pathConfig config.PathConfig, path string) (string, bool) {
// 默认使用默认目标
targetBase := pathConfig.DefaultTarget
usedAltTarget := false
// 如果没有扩展名规则,直接返回默认目标
if len(pathConfig.ExtRules) == 0 {
ext := extractExtension(path)
if ext == "" {
log.Printf("[Route] %s -> %s (无扩展名)", path, targetBase)
}
return targetBase, false
}
// 使用严格的规则选择逻辑
rule, found, usedAlt := rs.SelectBestRule(client, pathConfig, path, r.Host)
if found && rule != nil {
targetBase = rule.Target
usedAltTarget = usedAlt
log.Printf("[Route] %s -> %s (使用选中的规则)", path, targetBase)
} else {
// 如果没有找到合适的规则,使用默认目标
// 不再进行"基于扩展名直接匹配"的回退
log.Printf("[Route] %s -> %s (使用默认目标,扩展名规则不匹配)", path, targetBase)
}
return targetBase, usedAltTarget
}
// extractExtension 提取文件扩展名
func extractExtension(path string) string {
lastDotIndex := strings.LastIndex(path, ".")
if lastDotIndex > 0 && lastDotIndex < len(path)-1 {
return strings.ToLower(path[lastDotIndex+1:])
}
return ""
}

View File

@ -1,32 +0,0 @@
package utils
import (
"os"
"os/signal"
"sync"
"syscall"
)
func SetupCloseHandler(callback func()) {
c := make(chan os.Signal, 1)
done := make(chan bool, 1)
var once sync.Once
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
go func() {
<-c
once.Do(func() {
callback()
done <- true
})
}()
go func() {
select {
case <-done:
os.Exit(0)
case <-c:
os.Exit(1)
}
}()
}

View File

@ -6,81 +6,212 @@ import (
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"log" "log"
"net"
"net/http" "net/http"
neturl "net/url"
"path/filepath" "path/filepath"
"proxy-go/internal/config" "proxy-go/internal/config"
"runtime"
"sort" "sort"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
) )
// 文件大小缓存项 // Goroutine 池相关结构
type GoroutinePool struct {
maxWorkers int
taskQueue chan func()
wg sync.WaitGroup
once sync.Once
stopped int32
}
// 全局 goroutine 池
var (
globalPool *GoroutinePool
poolOnce sync.Once
defaultWorkers = runtime.NumCPU() * 4 // 默认工作协程数量
)
// GetGoroutinePool 获取全局 goroutine 池
func GetGoroutinePool() *GoroutinePool {
poolOnce.Do(func() {
globalPool = NewGoroutinePool(defaultWorkers)
})
return globalPool
}
// NewGoroutinePool 创建新的 goroutine 池
func NewGoroutinePool(maxWorkers int) *GoroutinePool {
if maxWorkers <= 0 {
maxWorkers = runtime.NumCPU() * 2
}
pool := &GoroutinePool{
maxWorkers: maxWorkers,
taskQueue: make(chan func(), maxWorkers*10), // 缓冲区为工作协程数的10倍
}
// 启动工作协程
for i := 0; i < maxWorkers; i++ {
pool.wg.Add(1)
go pool.worker()
}
return pool
}
// worker 工作协程
func (p *GoroutinePool) worker() {
defer p.wg.Done()
for {
select {
case task, ok := <-p.taskQueue:
if !ok {
return // 通道关闭,退出
}
// 执行任务,捕获 panic
func() {
defer func() {
if r := recover(); r != nil {
fmt.Printf("[GoroutinePool] Worker panic: %v\n", r)
}
}()
task()
}()
}
}
}
// Submit 提交任务到池中
func (p *GoroutinePool) Submit(task func()) error {
if atomic.LoadInt32(&p.stopped) == 1 {
return fmt.Errorf("goroutine pool is stopped")
}
select {
case p.taskQueue <- task:
return nil
case <-time.After(100 * time.Millisecond): // 100ms 超时
return fmt.Errorf("goroutine pool is busy")
}
}
// SubmitWithTimeout 提交任务到池中,带超时
func (p *GoroutinePool) SubmitWithTimeout(task func(), timeout time.Duration) error {
if atomic.LoadInt32(&p.stopped) == 1 {
return fmt.Errorf("goroutine pool is stopped")
}
select {
case p.taskQueue <- task:
return nil
case <-time.After(timeout):
return fmt.Errorf("goroutine pool submit timeout")
}
}
// Stop 停止 goroutine 池
func (p *GoroutinePool) Stop() {
p.once.Do(func() {
atomic.StoreInt32(&p.stopped, 1)
close(p.taskQueue)
p.wg.Wait()
})
}
// Size 返回池中工作协程数量
func (p *GoroutinePool) Size() int {
return p.maxWorkers
}
// QueueSize 返回当前任务队列大小
func (p *GoroutinePool) QueueSize() int {
return len(p.taskQueue)
}
// 异步执行函数的包装器
func GoSafe(fn func()) {
pool := GetGoroutinePool()
err := pool.Submit(fn)
if err != nil {
// 如果池满了,直接启动 goroutine降级处理
go func() {
defer func() {
if r := recover(); r != nil {
fmt.Printf("[GoSafe] Panic: %v\n", r)
}
}()
fn()
}()
}
}
// 带超时的异步执行
func GoSafeWithTimeout(fn func(), timeout time.Duration) error {
pool := GetGoroutinePool()
return pool.SubmitWithTimeout(fn, timeout)
}
// 文件大小缓存相关
type fileSizeCache struct { type fileSizeCache struct {
size int64 size int64
timestamp time.Time timestamp time.Time
} }
// 可访问性缓存项
type accessibilityCache struct { type accessibilityCache struct {
accessible bool accessible bool
timestamp time.Time timestamp time.Time
} }
// 全局缓存
var ( var (
// 文件大小缓存过期时间5分钟
sizeCache sync.Map sizeCache sync.Map
// 可访问性缓存过期时间30秒
accessCache sync.Map accessCache sync.Map
cacheTTL = 5 * time.Minute cacheTTL = 5 * time.Minute
accessTTL = 30 * time.Second accessTTL = 2 * time.Minute
maxCacheSize = 10000 // 最大缓存条目数
) )
// 清理过期缓存 // 初始化函数
func init() { func init() {
go func() { // 启动定期清理缓存的协程
ticker := time.NewTicker(time.Minute) GoSafe(func() {
ticker := time.NewTicker(10 * time.Minute)
defer ticker.Stop()
for range ticker.C { for range ticker.C {
now := time.Now() cleanExpiredCache()
// 清理文件大小缓存
var items []struct {
key interface{}
timestamp time.Time
} }
})
}
// 清理过期缓存
func cleanExpiredCache() {
now := time.Now()
// 清理文件大小缓存
sizeCache.Range(func(key, value interface{}) bool { sizeCache.Range(func(key, value interface{}) bool {
cache := value.(fileSizeCache) if cache, ok := value.(fileSizeCache); ok {
if now.Sub(cache.timestamp) > cacheTTL { if now.Sub(cache.timestamp) > cacheTTL {
sizeCache.Delete(key) sizeCache.Delete(key)
} else { }
items = append(items, struct {
key interface{}
timestamp time.Time
}{key, cache.timestamp})
} }
return true return true
}) })
if len(items) > maxCacheSize {
sort.Slice(items, func(i, j int) bool {
return items[i].timestamp.Before(items[j].timestamp)
})
for i := 0; i < len(items)/2; i++ {
sizeCache.Delete(items[i].key)
}
}
// 清理可访问性缓存 // 清理可访问性缓存
accessCache.Range(func(key, value interface{}) bool { accessCache.Range(func(key, value interface{}) bool {
cache := value.(accessibilityCache) if cache, ok := value.(accessibilityCache); ok {
if now.Sub(cache.timestamp) > accessTTL { if now.Sub(cache.timestamp) > accessTTL {
accessCache.Delete(key) accessCache.Delete(key)
} }
}
return true return true
}) })
} }
}()
}
// GenerateRequestID 生成唯一的请求ID // GenerateRequestID 生成唯一的请求ID
func GenerateRequestID() string { func GenerateRequestID() string {
@ -92,21 +223,11 @@ func GenerateRequestID() string {
return hex.EncodeToString(b) return hex.EncodeToString(b)
} }
func GetClientIP(r *http.Request) string {
if ip := r.Header.Get("X-Real-IP"); ip != "" {
return ip
}
if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
return strings.Split(ip, ",")[0]
}
if ip, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return ip
}
return r.RemoteAddr
}
// 获取请求来源 // 获取请求来源
func GetRequestSource(r *http.Request) string { func GetRequestSource(r *http.Request) string {
if r == nil {
return ""
}
referer := r.Header.Get("Referer") referer := r.Header.Get("Referer")
if referer != "" { if referer != "" {
return fmt.Sprintf(" (from: %s)", referer) return fmt.Sprintf(" (from: %s)", referer)
@ -144,7 +265,7 @@ func IsImageRequest(path string) bool {
return imageExts[ext] return imageExts[ext]
} }
// GetFileSize 发送HEAD请求获取文件大小 // GetFileSize 发送HEAD请求获取文件大小(保持向后兼容)
func GetFileSize(client *http.Client, url string) (int64, error) { func GetFileSize(client *http.Client, url string) (int64, error) {
// 先查缓存 // 先查缓存
if cache, ok := sizeCache.Load(url); ok { if cache, ok := sizeCache.Load(url); ok {
@ -182,127 +303,122 @@ func GetFileSize(client *http.Client, url string) (int64, error) {
return resp.ContentLength, nil return resp.ContentLength, nil
} }
// GetTargetURL 根据路径和配置决定目标URL // ExtensionMatcher 扩展名匹配器,用于优化扩展名匹配性能
func GetTargetURL(client *http.Client, r *http.Request, pathConfig config.PathConfig, path string) string { type ExtensionMatcher struct {
// 默认使用默认目标 exactMatches map[string][]*config.ExtensionRule // 精确匹配的扩展名
targetBase := pathConfig.DefaultTarget wildcardRules []*config.ExtensionRule // 通配符规则
hasRedirectRule bool // 是否有任何302跳转规则
// 如果配置了扩展名映射
if pathConfig.ExtensionMap != nil {
ext := strings.ToLower(filepath.Ext(path))
if ext != "" {
ext = ext[1:] // 移除开头的点
// 检查是否在扩展名映射中
if altTarget, exists := pathConfig.GetExtensionTarget(ext); exists {
// 检查文件大小
contentLength, err := GetFileSize(client, targetBase+path)
if err != nil {
log.Printf("[Route] %s -> %s (error getting size: %v)", path, targetBase, err)
return targetBase
} }
// 如果没有设置最小阈值,使用默认值 500KB // NewExtensionMatcher 创建扩展名匹配器
minThreshold := pathConfig.SizeThreshold func NewExtensionMatcher(rules []config.ExtensionRule) *ExtensionMatcher {
if minThreshold <= 0 { matcher := &ExtensionMatcher{
minThreshold = 500 * 1024 exactMatches: make(map[string][]*config.ExtensionRule),
wildcardRules: make([]*config.ExtensionRule, 0),
} }
// 如果没有设置最大阈值,使用默认值 10MB for i := range rules {
maxThreshold := pathConfig.MaxSize rule := &rules[i]
if maxThreshold <= 0 {
maxThreshold = 10 * 1024 * 1024 // 处理阈值默认值
if rule.SizeThreshold < 0 {
rule.SizeThreshold = 0
}
if rule.MaxSize <= 0 {
rule.MaxSize = 1<<63 - 1
} }
if contentLength > minThreshold && contentLength <= maxThreshold { // 检查是否有302跳转规则
// 创建一个带超时的 context if rule.RedirectMode {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) matcher.hasRedirectRule = true
defer cancel()
// 使用 channel 来接收备用源检查结果
altChan := make(chan struct {
accessible bool
err error
}, 1)
// 在 goroutine 中检查备用源可访问性
go func() {
accessible := isTargetAccessible(client, altTarget+path)
select {
case altChan <- struct {
accessible bool
err error
}{accessible: accessible}:
case <-ctx.Done():
// context 已取消,不需要发送结果
} }
}()
// 等待结果或超时 // 分类存储规则
select { for _, ext := range rule.Extensions {
case result := <-altChan: if ext == "*" {
if result.accessible { matcher.wildcardRules = append(matcher.wildcardRules, rule)
log.Printf("[Route] %s -> %s (size: %s > %s and <= %s)",
path, altTarget, FormatBytes(contentLength),
FormatBytes(minThreshold), FormatBytes(maxThreshold))
return altTarget
}
log.Printf("[Route] %s -> %s (fallback: alternative target not accessible)",
path, targetBase)
case <-ctx.Done():
log.Printf("[Route] %s -> %s (fallback: alternative target check timeout)",
path, targetBase)
}
} else if contentLength <= minThreshold {
log.Printf("[Route] %s -> %s (size: %s <= %s)",
path, targetBase, FormatBytes(contentLength), FormatBytes(minThreshold))
} else { } else {
log.Printf("[Route] %s -> %s (size: %s > %s)", if matcher.exactMatches[ext] == nil {
path, targetBase, FormatBytes(contentLength), FormatBytes(maxThreshold)) matcher.exactMatches[ext] = make([]*config.ExtensionRule, 0, 1)
} }
} else { matcher.exactMatches[ext] = append(matcher.exactMatches[ext], rule)
log.Printf("[Route] %s -> %s (no extension mapping)", path, targetBase)
} }
} else {
log.Printf("[Route] %s -> %s (no extension)", path, targetBase)
} }
} else {
log.Printf("[Route] %s -> %s (no extension map)", path, targetBase)
} }
return targetBase // 预排序所有规则组
for ext := range matcher.exactMatches {
sortRulesByThreshold(matcher.exactMatches[ext])
}
sortRulesByThreshold(matcher.wildcardRules)
return matcher
} }
// isTargetAccessible 检查目标URL是否可访问 // sortRulesByThreshold 按阈值排序规则
func isTargetAccessible(client *http.Client, url string) bool { func sortRulesByThreshold(rules []*config.ExtensionRule) {
sort.Slice(rules, func(i, j int) bool {
if rules[i].SizeThreshold == rules[j].SizeThreshold {
return rules[i].MaxSize > rules[j].MaxSize
}
return rules[i].SizeThreshold < rules[j].SizeThreshold
})
}
// GetMatchingRules 获取匹配的规则
func (em *ExtensionMatcher) GetMatchingRules(ext string) []*config.ExtensionRule {
// 先查找精确匹配
if rules, exists := em.exactMatches[ext]; exists {
return rules
}
// 返回通配符规则
return em.wildcardRules
}
// HasRedirectRule 检查是否有任何302跳转规则
func (em *ExtensionMatcher) HasRedirectRule() bool {
return em.hasRedirectRule
}
// IsTargetAccessible 检查目标URL是否可访问
func IsTargetAccessible(client *http.Client, targetURL string) bool {
// 先查缓存 // 先查缓存
if cache, ok := accessCache.Load(url); ok { if cache, ok := accessCache.Load(targetURL); ok {
cacheItem := cache.(accessibilityCache) cacheItem := cache.(accessibilityCache)
if time.Since(cacheItem.timestamp) < accessTTL { if time.Since(cacheItem.timestamp) < accessTTL {
return cacheItem.accessible return cacheItem.accessible
} }
accessCache.Delete(url) accessCache.Delete(targetURL)
} }
req, err := http.NewRequest("HEAD", url, nil) req, err := http.NewRequest("HEAD", targetURL, nil)
if err != nil { if err != nil {
log.Printf("[Check] Failed to create request for %s: %v", url, err) log.Printf("[Check] Failed to create request for %s: %v", targetURL, err)
return false return false
} }
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) // 添加浏览器User-Agent
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
// 设置Referer为目标域名
if parsedURL, parseErr := neturl.Parse(targetURL); parseErr == nil {
req.Header.Set("Referer", fmt.Sprintf("%s://%s", parsedURL.Scheme, parsedURL.Host))
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel() defer cancel()
req = req.WithContext(ctx) req = req.WithContext(ctx)
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
log.Printf("[Check] Failed to access %s: %v", url, err) log.Printf("[Check] Failed to access %s: %v", targetURL, err)
return false return false
} }
defer resp.Body.Close() defer resp.Body.Close()
accessible := resp.StatusCode >= 200 && resp.StatusCode < 400 accessible := resp.StatusCode >= 200 && resp.StatusCode < 400
// 缓存结果 // 缓存结果
accessCache.Store(url, accessibilityCache{ accessCache.Store(targetURL, accessibilityCache{
accessible: accessible, accessible: accessible,
timestamp: time.Now(), timestamp: time.Now(),
}) })
@ -343,6 +459,26 @@ func SafeString(v interface{}, defaultValue string) string {
return defaultValue return defaultValue
} }
func SafeFloat64(v interface{}) float64 {
if v == nil {
return 0
}
switch val := v.(type) {
case float64:
return val
case float32:
return float64(val)
case int64:
return float64(val)
case int:
return float64(val)
case int32:
return float64(val)
default:
return 0
}
}
// Max 返回两个 int64 中的较大值 // Max 返回两个 int64 中的较大值
func Max(a, b int64) int64 { func Max(a, b int64) int64 {
if a > b { if a > b {
@ -368,3 +504,29 @@ func ParseInt(s string, defaultValue int) int {
} }
return result return result
} }
// ClearAccessibilityCache 清理可访问性缓存
func ClearAccessibilityCache() {
count := 0
accessCache.Range(func(key, value interface{}) bool {
accessCache.Delete(key)
count++
return true
})
if count > 0 {
log.Printf("[AccessibilityCache] 清理了 %d 个可访问性缓存项", count)
}
}
// ClearFileSizeCache 清理文件大小缓存
func ClearFileSizeCache() {
count := 0
sizeCache.Range(func(key, value interface{}) bool {
sizeCache.Delete(key)
count++
return true
})
if count > 0 {
log.Printf("[FileSizeCache] 清理了 %d 个文件大小缓存项", count)
}
}

121
main.go
View File

@ -10,9 +10,12 @@ import (
"proxy-go/internal/config" "proxy-go/internal/config"
"proxy-go/internal/constants" "proxy-go/internal/constants"
"proxy-go/internal/handler" "proxy-go/internal/handler"
"proxy-go/internal/initapp"
"proxy-go/internal/metrics" "proxy-go/internal/metrics"
"proxy-go/internal/middleware" "proxy-go/internal/middleware"
"proxy-go/internal/security"
"strings" "strings"
"sync/atomic"
"syscall" "syscall"
) )
@ -25,36 +28,72 @@ type Route struct {
} }
func main() { func main() {
// 加载配置
cfg, err := config.Load("data/config.json") // 初始化应用程序(包括配置迁移)
configPath := "data/config.json"
initapp.Init(configPath)
// 初始化配置管理器
configManager, err := config.Init(configPath)
if err != nil { if err != nil {
log.Fatal("Error loading config:", err) log.Fatal("Error initializing config manager:", err)
} }
// 获取配置
cfg := configManager.GetConfig()
// 更新常量配置 // 更新常量配置
constants.UpdateFromConfig(cfg) constants.UpdateFromConfig(cfg)
// 初始化指标收集器 // 初始化统计服务
if err := metrics.InitCollector(cfg); err != nil { metrics.Init(cfg)
log.Fatal("Error initializing metrics collector:", err)
}
// 初始化指标存储服务 // 创建压缩管理器使用atomic.Value来支持动态更新
if err := metrics.InitMetricsStorage(cfg); err != nil { var compManagerAtomic atomic.Value
log.Printf("Warning: Failed to initialize metrics storage: %v", err)
// 不致命,继续运行
}
// 创建压缩管理器
compManager := compression.NewManager(compression.Config{ compManager := compression.NewManager(compression.Config{
Gzip: compression.CompressorConfig(cfg.Compression.Gzip), Gzip: compression.CompressorConfig(cfg.Compression.Gzip),
Brotli: compression.CompressorConfig(cfg.Compression.Brotli), Brotli: compression.CompressorConfig(cfg.Compression.Brotli),
}) })
compManagerAtomic.Store(compManager)
// 创建安全管理器
var banManager *security.IPBanManager
var securityMiddleware *middleware.SecurityMiddleware
if cfg.Security.IPBan.Enabled {
banConfig := &security.IPBanConfig{
ErrorThreshold: cfg.Security.IPBan.ErrorThreshold,
WindowMinutes: cfg.Security.IPBan.WindowMinutes,
BanDurationMinutes: cfg.Security.IPBan.BanDurationMinutes,
CleanupIntervalMinutes: cfg.Security.IPBan.CleanupIntervalMinutes,
}
banManager = security.NewIPBanManager(banConfig)
securityMiddleware = middleware.NewSecurityMiddleware(banManager)
}
// 创建代理处理器 // 创建代理处理器
mirrorHandler := handler.NewMirrorProxyHandler() mirrorHandler := handler.NewMirrorProxyHandler()
proxyHandler := handler.NewProxyHandler(cfg) proxyHandler := handler.NewProxyHandler(cfg)
// 创建配置处理器
configHandler := handler.NewConfigHandler(configManager)
// 创建安全管理处理器
var securityHandler *handler.SecurityHandler
if banManager != nil {
securityHandler = handler.NewSecurityHandler(banManager)
}
// 注册压缩配置更新回调
config.RegisterUpdateCallback(func(newCfg *config.Config) {
// 更新压缩管理器
newCompManager := compression.NewManager(compression.Config{
Gzip: compression.CompressorConfig(newCfg.Compression.Gzip),
Brotli: compression.CompressorConfig(newCfg.Compression.Brotli),
})
compManagerAtomic.Store(newCompManager)
log.Printf("[Config] 压缩管理器配置已更新")
})
// 定义API路由 // 定义API路由
apiRoutes := []Route{ apiRoutes := []Route{
{http.MethodGet, "/admin/api/auth", proxyHandler.LoginHandler, false}, {http.MethodGet, "/admin/api/auth", proxyHandler.LoginHandler, false},
@ -65,8 +104,8 @@ func main() {
}, true}, }, true},
{http.MethodPost, "/admin/api/logout", proxyHandler.LogoutHandler, false}, {http.MethodPost, "/admin/api/logout", proxyHandler.LogoutHandler, false},
{http.MethodGet, "/admin/api/metrics", proxyHandler.MetricsHandler, true}, {http.MethodGet, "/admin/api/metrics", proxyHandler.MetricsHandler, true},
{http.MethodGet, "/admin/api/config/get", handler.NewConfigHandler(cfg).ServeHTTP, true}, {http.MethodGet, "/admin/api/config/get", configHandler.ServeHTTP, true},
{http.MethodPost, "/admin/api/config/save", handler.NewConfigHandler(cfg).ServeHTTP, true}, {http.MethodPost, "/admin/api/config/save", configHandler.ServeHTTP, true},
{http.MethodGet, "/admin/api/cache/stats", handler.NewCacheAdminHandler(proxyHandler.Cache, mirrorHandler.Cache).GetCacheStats, true}, {http.MethodGet, "/admin/api/cache/stats", handler.NewCacheAdminHandler(proxyHandler.Cache, mirrorHandler.Cache).GetCacheStats, true},
{http.MethodPost, "/admin/api/cache/enable", handler.NewCacheAdminHandler(proxyHandler.Cache, mirrorHandler.Cache).SetCacheEnabled, true}, {http.MethodPost, "/admin/api/cache/enable", handler.NewCacheAdminHandler(proxyHandler.Cache, mirrorHandler.Cache).SetCacheEnabled, true},
{http.MethodPost, "/admin/api/cache/clear", handler.NewCacheAdminHandler(proxyHandler.Cache, mirrorHandler.Cache).ClearCache, true}, {http.MethodPost, "/admin/api/cache/clear", handler.NewCacheAdminHandler(proxyHandler.Cache, mirrorHandler.Cache).ClearCache, true},
@ -74,11 +113,41 @@ func main() {
{http.MethodPost, "/admin/api/cache/config", handler.NewCacheAdminHandler(proxyHandler.Cache, mirrorHandler.Cache).UpdateCacheConfig, true}, {http.MethodPost, "/admin/api/cache/config", handler.NewCacheAdminHandler(proxyHandler.Cache, mirrorHandler.Cache).UpdateCacheConfig, true},
} }
// 添加安全API路由如果启用了安全功能
if securityHandler != nil {
securityRoutes := []Route{
{http.MethodGet, "/admin/api/security/banned-ips", securityHandler.GetBannedIPs, true},
{http.MethodPost, "/admin/api/security/unban", securityHandler.UnbanIP, true},
{http.MethodGet, "/admin/api/security/stats", securityHandler.GetSecurityStats, true},
{http.MethodGet, "/admin/api/security/check-ip", securityHandler.CheckIPStatus, true},
}
apiRoutes = append(apiRoutes, securityRoutes...)
}
// 创建路由处理器 // 创建路由处理器
handlers := []struct { handlers := []struct {
matcher func(*http.Request) bool matcher func(*http.Request) bool
handler http.Handler handler http.Handler
}{ }{
// favicon.ico 处理器
{
matcher: func(r *http.Request) bool {
return r.URL.Path == "/favicon.ico"
},
handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 检查是否有自定义favicon文件
faviconPath := "favicon/favicon.ico"
if _, err := os.Stat(faviconPath); err == nil {
// 设置正确的Content-Type和缓存头
w.Header().Set("Content-Type", "image/x-icon")
w.Header().Set("Cache-Control", "public, max-age=31536000") // 1年缓存
http.ServeFile(w, r, faviconPath)
} else {
// 如果没有自定义favicon返回404
http.NotFound(w, r)
}
}),
},
// 管理路由处理器 // 管理路由处理器
{ {
matcher: func(r *http.Request) bool { matcher: func(r *http.Request) bool {
@ -147,10 +216,21 @@ func main() {
http.NotFound(w, r) http.NotFound(w, r)
}) })
// 添加压缩中间件 // 构建中间件链
var handler http.Handler = mainHandler var handler http.Handler = mainHandler
// 添加安全中间件(最外层,优先级最高)
if securityMiddleware != nil {
handler = securityMiddleware.IPBanMiddleware(handler)
}
// 添加压缩中间件
if cfg.Compression.Gzip.Enabled || cfg.Compression.Brotli.Enabled { if cfg.Compression.Gzip.Enabled || cfg.Compression.Brotli.Enabled {
handler = middleware.CompressionMiddleware(compManager)(handler) // 创建动态压缩中间件包装器
handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
currentCompManager := compManagerAtomic.Load().(compression.Manager)
middleware.CompressionMiddleware(currentCompManager)(handler).ServeHTTP(w, r)
})
} }
// 创建服务器 // 创建服务器
@ -166,6 +246,11 @@ func main() {
<-sigChan <-sigChan
log.Println("Shutting down server...") log.Println("Shutting down server...")
// 停止安全管理器
if banManager != nil {
banManager.Stop()
}
// 停止指标存储服务 // 停止指标存储服务
metrics.StopMetricsStorage() metrics.StopMetricsStorage()

141
readme.md
View File

@ -2,14 +2,14 @@
A 'simple' reverse proxy server written in Go. A 'simple' reverse proxy server written in Go.
使用方法: https://www.q58.club/t/topic/165 使用方法: https://www.sunai.net/t/topic/165
最新镜像地址: woodchen/proxy-go:latest
## 新版统计仪表盘 ## 新版统计仪表盘
![image](https://github.com/user-attachments/assets/0b87863e-5566-4ee6-a3b7-94a994cdd572) ![image](https://github.com/user-attachments/assets/0b87863e-5566-4ee6-a3b7-94a994cdd572)
## 图片 ## 图片
![image](https://github.com/user-attachments/assets/99b1767f-9470-4838-a4eb-3ce70bbe2094) ![image](https://github.com/user-attachments/assets/99b1767f-9470-4838-a4eb-3ce70bbe2094)
@ -18,19 +18,14 @@ A 'simple' reverse proxy server written in Go.
![image](https://github.com/user-attachments/assets/e09d0eb1-e1bb-435b-8f90-b04bc474477b) ![image](https://github.com/user-attachments/assets/e09d0eb1-e1bb-435b-8f90-b04bc474477b)
### 配置页 ### 配置页
![image](https://github.com/user-attachments/assets/5acddc06-57f5-417c-9fec-87e906dc22af) ![image](https://github.com/user-attachments/assets/5acddc06-57f5-417c-9fec-87e906dc22af)
### 缓存页 ### 缓存页
![image](https://github.com/user-attachments/assets/6225b909-c5ff-4374-bb07-c472fbec791d) ![image](https://github.com/user-attachments/assets/6225b909-c5ff-4374-bb07-c472fbec791d)
## 说明 ## 说明
1. 支持gzip和brotli压缩 1. 支持gzip和brotli压缩
@ -41,5 +36,135 @@ A 'simple' reverse proxy server written in Go.
6. 适配Cloudflare Images的图片自适应功能, 透传`Accept`头, 支持`format=auto` 6. 适配Cloudflare Images的图片自适应功能, 透传`Accept`头, 支持`format=auto`
7. 支持网页端监控和管理 7. 支持网页端监控和管理
## 功能特性
- 🚀 **多路径代理**: 根据不同路径代理到不同的目标服务器
- 🔄 **扩展名规则**: 根据文件扩展名和大小智能选择目标服务器
- 🌐 **域名过滤**: 支持根据请求域名应用不同的扩展规则
- 📦 **压缩支持**: 支持Gzip和Brotli压缩
- 🎯 **302跳转**: 支持302跳转模式
- 📊 **缓存管理**: 智能缓存机制提升性能
- 📈 **监控指标**: 内置监控和指标收集
## 域名过滤功能
### 功能介绍
新增的域名过滤功能允许你为不同的请求域名配置不同的扩展规则。这在以下场景中非常有用:
1. **多域名服务**: 一个代理服务绑定多个域名(如 a.com 和 b.com
2. **差异化配置**: 不同域名使用不同的CDN或存储服务
3. **精细化控制**: 根据域名和文件类型组合进行精确路由
### 配置示例
```json
{
"MAP": {
"/images": {
"DefaultTarget": "https://default-cdn.com",
"ExtensionMap": [
{
"Extensions": "jpg,png,webp",
"Target": "https://a-domain-cdn.com",
"SizeThreshold": 1024,
"MaxSize": 2097152,
"Domains": "a.com",
"RedirectMode": false
},
{
"Extensions": "jpg,png,webp",
"Target": "https://b-domain-cdn.com",
"SizeThreshold": 1024,
"MaxSize": 2097152,
"Domains": "b.com",
"RedirectMode": true
},
{
"Extensions": "mp4,avi",
"Target": "https://video-cdn.com",
"SizeThreshold": 1048576,
"MaxSize": 52428800
// 不指定Domains对所有域名生效
}
]
}
}
}
```
### 使用场景
#### 场景1: 多域名图片CDN
```
请求: https://a.com/images/photo.jpg (1MB)
结果: 代理到 https://a-domain-cdn.com/photo.jpg
请求: https://b.com/images/photo.jpg (1MB)
结果: 302跳转到 https://b-domain-cdn.com/photo.jpg
请求: https://c.com/images/photo.jpg (1MB)
结果: 代理到 https://default-cdn.com/photo.jpg (使用默认目标)
```
#### 场景2: 域名+扩展名组合规则
```
请求: https://a.com/files/video.mp4 (10MB)
结果: 代理到 https://video-cdn.com/video.mp4 (匹配通用视频规则)
请求: https://b.com/files/video.mp4 (10MB)
结果: 代理到 https://video-cdn.com/video.mp4 (匹配通用视频规则)
```
### 配置字段说明
- **Domains**: 逗号分隔的域名列表,指定该规则适用的域名
- 为空或不设置:匹配所有域名
- 单个域名:`"a.com"`
- 多个域名:`"a.com,b.com,c.com"`
- **Extensions**: 文件扩展名(与之前相同)
- **Target**: 目标服务器(与之前相同)
- **SizeThreshold/MaxSize**: 文件大小范围(与之前相同)
- **RedirectMode**: 是否使用302跳转与之前相同
### 匹配优先级
1. **域名匹配**: 首先筛选出匹配请求域名的规则
2. **扩展名匹配**: 在域名匹配的规则中筛选扩展名匹配的规则
3. **文件大小匹配**: 根据文件大小选择最合适的规则
4. **目标可用性**: 检查目标服务器是否可访问
5. **默认回退**: 如果没有匹配的规则,使用默认目标
### 日志输出
启用域名过滤后,日志会包含域名信息:
```
[SelectRule] /image.jpg -> 选中规则 (域名: a.com, 文件大小: 1.2MB, 在区间 1KB 到 2MB 之间)
[Redirect] /image.jpg -> 使用选中规则进行302跳转 (域名: b.com): https://b-domain-cdn.com/image.jpg
```
## 原有功能
### 功能作用
主要是最好有一台国外服务器, 回国又不慢的, 可以反代国外资源, 然后在proxy-go外面套个cloudfront或者Edgeone, 方便国内访问.
config里MAP的功能
目前我的主要使用是反代B2, R2, Oracle存储桶之类的. 也可以反代网站静态资源, 可以一并在CDN环节做缓存.
根据config示例作示范
访问https://proxy-go/path1/123.jpg, 实际是访问 https://path1.com/path/path/path/123.jpg
访问https://proxy-go/path2/749.movie, 实际是访问https://path2.com/749.movie
### mirror 固定路由
比较适合接口类的CORS问题
访问https://proxy-go/mirror/https://example.com/path/to/resource
会实际访问https://example.com/path/to/resource

View File

@ -7,7 +7,24 @@ import { useToast } from "@/components/ui/use-toast"
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import {
HardDrive,
Database,
TrendingUp,
TrendingDown,
Activity,
Image as ImageIcon,
FileText,
RefreshCw,
Trash2,
Settings,
Info,
Zap,
Target,
RotateCcw
} from "lucide-react"
interface CacheStats { interface CacheStats {
total_items: number total_items: number
@ -17,6 +34,9 @@ interface CacheStats {
hit_rate: number hit_rate: number
bytes_saved: number bytes_saved: number
enabled: boolean enabled: boolean
format_fallback_hit: number
image_cache_hit: number
regular_cache_hit: number
} }
interface CacheConfig { interface CacheConfig {
@ -256,15 +276,19 @@ export default function CachePage() {
const config = configs[type] const config = configs[type]
return ( return (
<div className="space-y-4 mt-4"> <div className="space-y-4 mt-4 p-4 bg-gray-50 rounded-lg border">
<h3 className="text-sm font-medium"></h3> <div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-gray-600" />
<h3 className="text-sm font-medium text-gray-800"></h3>
</div>
<div className="grid gap-4"> <div className="grid gap-4">
<div className="grid grid-cols-2 items-center gap-4"> <div className="grid grid-cols-2 items-center gap-4">
<Label htmlFor={`${type}-max-age`}></Label> <Label htmlFor={`${type}-max-age`} className="text-sm"></Label>
<Input <Input
id={`${type}-max-age`} id={`${type}-max-age`}
type="number" type="number"
value={config.max_age} value={config.max_age}
className="h-8"
onChange={(e) => { onChange={(e) => {
const newConfigs = { ...configs } const newConfigs = { ...configs }
newConfigs[type].max_age = parseInt(e.target.value) newConfigs[type].max_age = parseInt(e.target.value)
@ -274,11 +298,12 @@ export default function CachePage() {
/> />
</div> </div>
<div className="grid grid-cols-2 items-center gap-4"> <div className="grid grid-cols-2 items-center gap-4">
<Label htmlFor={`${type}-cleanup-tick`}></Label> <Label htmlFor={`${type}-cleanup-tick`} className="text-sm"></Label>
<Input <Input
id={`${type}-cleanup-tick`} id={`${type}-cleanup-tick`}
type="number" type="number"
value={config.cleanup_tick} value={config.cleanup_tick}
className="h-8"
onChange={(e) => { onChange={(e) => {
const newConfigs = { ...configs } const newConfigs = { ...configs }
newConfigs[type].cleanup_tick = parseInt(e.target.value) newConfigs[type].cleanup_tick = parseInt(e.target.value)
@ -288,11 +313,12 @@ export default function CachePage() {
/> />
</div> </div>
<div className="grid grid-cols-2 items-center gap-4"> <div className="grid grid-cols-2 items-center gap-4">
<Label htmlFor={`${type}-max-cache-size`}>GB</Label> <Label htmlFor={`${type}-max-cache-size`} className="text-sm">GB</Label>
<Input <Input
id={`${type}-max-cache-size`} id={`${type}-max-cache-size`}
type="number" type="number"
value={config.max_cache_size} value={config.max_cache_size}
className="h-8"
onChange={(e) => { onChange={(e) => {
const newConfigs = { ...configs } const newConfigs = { ...configs }
newConfigs[type].max_cache_size = parseInt(e.target.value) newConfigs[type].max_cache_size = parseInt(e.target.value)
@ -310,6 +336,7 @@ export default function CachePage() {
return ( return (
<div className="flex h-[calc(100vh-4rem)] items-center justify-center"> <div className="flex h-[calc(100vh-4rem)] items-center justify-center">
<div className="text-center"> <div className="text-center">
<RefreshCw className="h-8 w-8 animate-spin mx-auto mb-4 text-blue-500" />
<div className="text-lg font-medium">...</div> <div className="text-lg font-medium">...</div>
<div className="text-sm text-gray-500 mt-1"></div> <div className="text-sm text-gray-500 mt-1"></div>
</div> </div>
@ -318,18 +345,120 @@ export default function CachePage() {
} }
return ( return (
<TooltipProvider>
<div className="space-y-6"> <div className="space-y-6">
<div className="flex justify-end space-x-2"> <div className="flex justify-between items-center">
<Button variant="outline" onClick={() => handleClearCache("all")}> <div className="flex items-center gap-2">
<Database className="h-6 w-6 text-blue-600" />
<h1 className="text-2xl font-bold"></h1>
</div>
<Button
variant="outline"
onClick={() => handleClearCache("all")}
className="flex items-center gap-2"
>
<Trash2 className="h-4 w-4" />
</Button> </Button>
</div> </div>
{/* 智能缓存汇总 */}
<Card className="border-2 border-blue-100 bg-gradient-to-r from-blue-50 to-purple-50">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-blue-800">
<Zap className="h-5 w-5" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Tooltip>
<TooltipTrigger asChild>
<div className="text-center p-4 bg-white rounded-lg shadow-sm border cursor-help hover:shadow-md transition-shadow">
<div className="flex items-center justify-center gap-2 mb-2">
<FileText className="h-5 w-5 text-blue-600" />
<Info className="h-3 w-3 text-gray-400" />
</div>
<div className="text-2xl font-bold text-blue-600">
{(stats?.proxy.regular_cache_hit ?? 0) + (stats?.mirror.regular_cache_hit ?? 0)}
</div>
<div className="text-sm text-gray-600 font-medium"></div>
</div>
</TooltipTrigger>
<TooltipContent>
<p></p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<div className="text-center p-4 bg-white rounded-lg shadow-sm border cursor-help hover:shadow-md transition-shadow">
<div className="flex items-center justify-center gap-2 mb-2">
<ImageIcon className="h-5 w-5 text-green-600" aria-hidden="true" />
<Info className="h-3 w-3 text-gray-400" />
</div>
<div className="text-2xl font-bold text-green-600">
{(stats?.proxy.image_cache_hit ?? 0) + (stats?.mirror.image_cache_hit ?? 0)}
</div>
<div className="text-sm text-gray-600 font-medium"></div>
</div>
</TooltipTrigger>
<TooltipContent>
<p></p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<div className="text-center p-4 bg-white rounded-lg shadow-sm border cursor-help hover:shadow-md transition-shadow">
<div className="flex items-center justify-center gap-2 mb-2">
<RotateCcw className="h-5 w-5 text-orange-600" />
<Info className="h-3 w-3 text-gray-400" />
</div>
<div className="text-2xl font-bold text-orange-600">
{(stats?.proxy.format_fallback_hit ?? 0) + (stats?.mirror.format_fallback_hit ?? 0)}
</div>
<div className="text-sm text-gray-600 font-medium">退</div>
</div>
</TooltipTrigger>
<TooltipContent>
<p>退</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<div className="text-center p-4 bg-white rounded-lg shadow-sm border cursor-help hover:shadow-md transition-shadow">
<div className="flex items-center justify-center gap-2 mb-2">
<Target className="h-5 w-5 text-purple-600" />
<Info className="h-3 w-3 text-gray-400" />
</div>
<div className="text-2xl font-bold text-purple-600">
{(() => {
const totalImageRequests = (stats?.proxy.image_cache_hit ?? 0) + (stats?.mirror.image_cache_hit ?? 0) + (stats?.proxy.format_fallback_hit ?? 0) + (stats?.mirror.format_fallback_hit ?? 0)
const fallbackHits = (stats?.proxy.format_fallback_hit ?? 0) + (stats?.mirror.format_fallback_hit ?? 0)
return totalImageRequests > 0 ? ((fallbackHits / totalImageRequests) * 100).toFixed(1) : '0.0'
})()}%
</div>
<div className="text-sm text-gray-600 font-medium">退</div>
</div>
</TooltipTrigger>
<TooltipContent>
<p>退</p>
</TooltipContent>
</Tooltip>
</div>
</CardContent>
</Card>
<div className="grid gap-6 md:grid-cols-2"> <div className="grid gap-6 md:grid-cols-2">
{/* 代理缓存 */} {/* 代理缓存 */}
<Card> <Card className="border-l-4 border-l-blue-500">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle></CardTitle> <CardTitle className="flex items-center gap-2">
<HardDrive className="h-5 w-5 text-blue-600" />
</CardTitle>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Switch <Switch
checked={stats?.proxy.enabled ?? false} checked={stats?.proxy.enabled ?? false}
@ -339,46 +468,116 @@ export default function CachePage() {
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => handleClearCache("proxy")} onClick={() => handleClearCache("proxy")}
className="flex items-center gap-1"
> >
<Trash2 className="h-3 w-3" />
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<dl className="space-y-2"> <dl className="space-y-3">
<div className="flex justify-between"> <div className="flex justify-between items-center p-2 bg-gray-50 rounded">
<dt className="text-sm font-medium text-gray-500"></dt> <dt className="text-sm font-medium text-gray-600 flex items-center gap-2">
<dd className="text-sm text-gray-900">{stats?.proxy.total_items ?? 0}</dd> <Database className="h-4 w-4" />
</dt>
<dd className="text-sm font-semibold text-gray-900">{stats?.proxy.total_items ?? 0}</dd>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between items-center p-2 bg-gray-50 rounded">
<dt className="text-sm font-medium text-gray-500"></dt> <dt className="text-sm font-medium text-gray-600 flex items-center gap-2">
<dd className="text-sm text-gray-900">{formatBytes(stats?.proxy.total_size ?? 0)}</dd> <HardDrive className="h-4 w-4" />
</dt>
<dd className="text-sm font-semibold text-gray-900">{formatBytes(stats?.proxy.total_size ?? 0)}</dd>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between items-center p-2 bg-green-50 rounded">
<dt className="text-sm font-medium text-gray-500"></dt> <dt className="text-sm font-medium text-green-700 flex items-center gap-2">
<dd className="text-sm text-gray-900">{stats?.proxy.hit_count ?? 0}</dd> <TrendingUp className="h-4 w-4" />
</dt>
<dd className="text-sm font-semibold text-green-800">{stats?.proxy.hit_count ?? 0}</dd>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between items-center p-2 bg-red-50 rounded">
<dt className="text-sm font-medium text-gray-500"></dt> <dt className="text-sm font-medium text-red-700 flex items-center gap-2">
<dd className="text-sm text-gray-900">{stats?.proxy.miss_count ?? 0}</dd> <TrendingDown className="h-4 w-4" />
</dt>
<dd className="text-sm font-semibold text-red-800">{stats?.proxy.miss_count ?? 0}</dd>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between items-center p-2 bg-blue-50 rounded">
<dt className="text-sm font-medium text-gray-500"></dt> <dt className="text-sm font-medium text-blue-700 flex items-center gap-2">
<dd className="text-sm text-gray-900">{(stats?.proxy.hit_rate ?? 0).toFixed(2)}%</dd> <Activity className="h-4 w-4" />
</dt>
<dd className="text-sm font-semibold text-blue-800">{(stats?.proxy.hit_rate ?? 0).toFixed(2)}%</dd>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between items-center p-2 bg-purple-50 rounded">
<dt className="text-sm font-medium text-gray-500"></dt> <dt className="text-sm font-medium text-purple-700 flex items-center gap-2">
<dd className="text-sm text-gray-900">{formatBytes(stats?.proxy.bytes_saved ?? 0)}</dd> <Zap className="h-4 w-4" />
</dt>
<dd className="text-sm font-semibold text-purple-800">{formatBytes(stats?.proxy.bytes_saved ?? 0)}</dd>
</div> </div>
</dl> </dl>
<div className="border-t pt-4 mt-4">
<div className="flex items-center gap-2 mb-3">
<Zap className="h-4 w-4 text-gray-600" />
<div className="text-sm font-medium text-gray-800"></div>
</div>
<div className="grid grid-cols-3 gap-3">
<Tooltip>
<TooltipTrigger asChild>
<div className="text-center p-3 bg-blue-50 rounded-lg border cursor-help hover:bg-blue-100 transition-colors">
<FileText className="h-4 w-4 mx-auto mb-1 text-blue-600" />
<div className="text-lg font-bold text-blue-600">{stats?.proxy.regular_cache_hit ?? 0}</div>
<div className="text-xs text-blue-700"></div>
</div>
</TooltipTrigger>
<TooltipContent>
<p></p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<div className="text-center p-3 bg-green-50 rounded-lg border cursor-help hover:bg-green-100 transition-colors">
<ImageIcon className="h-4 w-4 mx-auto mb-1 text-green-600" aria-hidden="true" />
<div className="text-lg font-bold text-green-600">{stats?.proxy.image_cache_hit ?? 0}</div>
<div className="text-xs text-green-700"></div>
</div>
</TooltipTrigger>
<TooltipContent>
<p></p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<div className="text-center p-3 bg-orange-50 rounded-lg border cursor-help hover:bg-orange-100 transition-colors">
<RotateCcw className="h-4 w-4 mx-auto mb-1 text-orange-600" />
<div className="text-lg font-bold text-orange-600">{stats?.proxy.format_fallback_hit ?? 0}</div>
<div className="text-xs text-orange-700">退</div>
</div>
</TooltipTrigger>
<TooltipContent>
<p>退WebP但提供JPEG</p>
</TooltipContent>
</Tooltip>
</div>
</div>
{renderCacheConfig("proxy")} {renderCacheConfig("proxy")}
</CardContent> </CardContent>
</Card> </Card>
{/* 镜像缓存 */} {/* 镜像缓存 */}
<Card> <Card className="border-l-4 border-l-green-500">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle></CardTitle> <CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5 text-green-600" />
</CardTitle>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Switch <Switch
checked={stats?.mirror.enabled ?? false} checked={stats?.mirror.enabled ?? false}
@ -388,42 +587,110 @@ export default function CachePage() {
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => handleClearCache("mirror")} onClick={() => handleClearCache("mirror")}
className="flex items-center gap-1"
> >
<Trash2 className="h-3 w-3" />
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<dl className="space-y-2"> <dl className="space-y-3">
<div className="flex justify-between"> <div className="flex justify-between items-center p-2 bg-gray-50 rounded">
<dt className="text-sm font-medium text-gray-500"></dt> <dt className="text-sm font-medium text-gray-600 flex items-center gap-2">
<dd className="text-sm text-gray-900">{stats?.mirror.total_items ?? 0}</dd> <Database className="h-4 w-4" />
</dt>
<dd className="text-sm font-semibold text-gray-900">{stats?.mirror.total_items ?? 0}</dd>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between items-center p-2 bg-gray-50 rounded">
<dt className="text-sm font-medium text-gray-500"></dt> <dt className="text-sm font-medium text-gray-600 flex items-center gap-2">
<dd className="text-sm text-gray-900">{formatBytes(stats?.mirror.total_size ?? 0)}</dd> <HardDrive className="h-4 w-4" />
</dt>
<dd className="text-sm font-semibold text-gray-900">{formatBytes(stats?.mirror.total_size ?? 0)}</dd>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between items-center p-2 bg-green-50 rounded">
<dt className="text-sm font-medium text-gray-500"></dt> <dt className="text-sm font-medium text-green-700 flex items-center gap-2">
<dd className="text-sm text-gray-900">{stats?.mirror.hit_count ?? 0}</dd> <TrendingUp className="h-4 w-4" />
</dt>
<dd className="text-sm font-semibold text-green-800">{stats?.mirror.hit_count ?? 0}</dd>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between items-center p-2 bg-red-50 rounded">
<dt className="text-sm font-medium text-gray-500"></dt> <dt className="text-sm font-medium text-red-700 flex items-center gap-2">
<dd className="text-sm text-gray-900">{stats?.mirror.miss_count ?? 0}</dd> <TrendingDown className="h-4 w-4" />
</dt>
<dd className="text-sm font-semibold text-red-800">{stats?.mirror.miss_count ?? 0}</dd>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between items-center p-2 bg-blue-50 rounded">
<dt className="text-sm font-medium text-gray-500"></dt> <dt className="text-sm font-medium text-blue-700 flex items-center gap-2">
<dd className="text-sm text-gray-900">{(stats?.mirror.hit_rate ?? 0).toFixed(2)}%</dd> <Activity className="h-4 w-4" />
</dt>
<dd className="text-sm font-semibold text-blue-800">{(stats?.mirror.hit_rate ?? 0).toFixed(2)}%</dd>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between items-center p-2 bg-purple-50 rounded">
<dt className="text-sm font-medium text-gray-500"></dt> <dt className="text-sm font-medium text-purple-700 flex items-center gap-2">
<dd className="text-sm text-gray-900">{formatBytes(stats?.mirror.bytes_saved ?? 0)}</dd> <Zap className="h-4 w-4" />
</dt>
<dd className="text-sm font-semibold text-purple-800">{formatBytes(stats?.mirror.bytes_saved ?? 0)}</dd>
</div> </div>
</dl> </dl>
<div className="border-t pt-4 mt-4">
<div className="flex items-center gap-2 mb-3">
<Zap className="h-4 w-4 text-gray-600" />
<div className="text-sm font-medium text-gray-800"></div>
</div>
<div className="grid grid-cols-3 gap-3">
<Tooltip>
<TooltipTrigger asChild>
<div className="text-center p-3 bg-blue-50 rounded-lg border cursor-help hover:bg-blue-100 transition-colors">
<FileText className="h-4 w-4 mx-auto mb-1 text-blue-600" />
<div className="text-lg font-bold text-blue-600">{stats?.mirror.regular_cache_hit ?? 0}</div>
<div className="text-xs text-blue-700"></div>
</div>
</TooltipTrigger>
<TooltipContent>
<p></p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<div className="text-center p-3 bg-green-50 rounded-lg border cursor-help hover:bg-green-100 transition-colors">
<ImageIcon className="h-4 w-4 mx-auto mb-1 text-green-600" aria-hidden="true" />
<div className="text-lg font-bold text-green-600">{stats?.mirror.image_cache_hit ?? 0}</div>
<div className="text-xs text-green-700"></div>
</div>
</TooltipTrigger>
<TooltipContent>
<p></p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<div className="text-center p-3 bg-orange-50 rounded-lg border cursor-help hover:bg-orange-100 transition-colors">
<RotateCcw className="h-4 w-4 mx-auto mb-1 text-orange-600" />
<div className="text-lg font-bold text-orange-600">{stats?.mirror.format_fallback_hit ?? 0}</div>
<div className="text-xs text-orange-700">退</div>
</div>
</TooltipTrigger>
<TooltipContent>
<p>退WebP但提供JPEG</p>
</TooltipContent>
</Tooltip>
</div>
</div>
{renderCacheConfig("mirror")} {renderCacheConfig("mirror")}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</div> </div>
</TooltipProvider>
) )
} }

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,7 @@ import { useEffect, useState, useCallback } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { useToast } from "@/components/ui/use-toast" import { useToast } from "@/components/ui/use-toast"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import Link from "next/link"
interface Metrics { interface Metrics {
uptime: string uptime: string
@ -17,13 +18,6 @@ interface Metrics {
bytes_per_second: number bytes_per_second: number
error_rate: number error_rate: number
status_code_stats: Record<string, number> status_code_stats: Record<string, number>
top_paths: Array<{
path: string
request_count: number
error_count: number
avg_latency: string
bytes_transferred: number
}>
recent_requests: Array<{ recent_requests: Array<{
Time: string Time: string
Path: string Path: string
@ -37,20 +31,17 @@ interface Metrics {
max: string max: string
distribution: Record<string, number> distribution: Record<string, number>
} }
error_stats: {
client_errors: number
server_errors: number
types: Record<string, number>
}
bandwidth_history: Record<string, string> bandwidth_history: Record<string, string>
current_bandwidth: string current_bandwidth: string
total_bytes: number total_bytes: number
current_session_requests: number
top_referers: Array<{ top_referers: Array<{
path: string path: string
request_count: number request_count: number
error_count: number error_count: number
avg_latency: string avg_latency: string
bytes_transferred: number bytes_transferred: number
last_access_time: number
}> }>
} }
@ -156,6 +147,18 @@ export default function DashboardPage() {
<div className="text-sm font-medium text-gray-500"></div> <div className="text-sm font-medium text-gray-500"></div>
<div className="text-lg font-semibold">{metrics.active_requests}</div> <div className="text-lg font-semibold">{metrics.active_requests}</div>
</div> </div>
<div>
<div className="text-sm font-medium text-gray-500"></div>
<div className="text-lg font-semibold">{metrics.total_requests || Object.values(metrics.status_code_stats || {}).reduce((a, b) => a + (b as number), 0)}</div>
</div>
<div>
<div className="text-sm font-medium text-gray-500"></div>
<div className="text-lg font-semibold text-red-600">{metrics.total_errors || 0}</div>
</div>
<div>
<div className="text-sm font-medium text-gray-500"></div>
<div className="text-lg font-semibold text-red-600">{((metrics.error_rate || 0) * 100).toFixed(2)}%</div>
</div>
<div> <div>
<div className="text-sm font-medium text-gray-500"></div> <div className="text-sm font-medium text-gray-500"></div>
<div className="text-lg font-semibold">{formatBytes(metrics.total_bytes)}</div> <div className="text-lg font-semibold">{formatBytes(metrics.total_bytes)}</div>
@ -164,6 +167,14 @@ export default function DashboardPage() {
<div className="text-sm font-medium text-gray-500"></div> <div className="text-sm font-medium text-gray-500"></div>
<div className="text-lg font-semibold">{formatBytes(metrics.bytes_per_second)}/s</div> <div className="text-lg font-semibold">{formatBytes(metrics.bytes_per_second)}/s</div>
</div> </div>
<div>
<div className="text-sm font-medium text-gray-500"></div>
<div className="text-lg font-semibold text-blue-600">{metrics.current_session_requests || 0}</div>
</div>
<div>
<div className="text-sm font-medium text-gray-500">5</div>
<div className="text-lg font-semibold">{(metrics.requests_per_second || 0).toFixed(2)}</div>
</div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -187,10 +198,8 @@ export default function DashboardPage() {
<div className="text-lg font-semibold">{metrics.avg_response_time}</div> <div className="text-lg font-semibold">{metrics.avg_response_time}</div>
</div> </div>
<div> <div>
<div className="text-sm font-medium text-gray-500"></div> <div className="text-sm font-medium text-gray-500"></div>
<div className="text-lg font-semibold"> <div className="text-lg font-semibold">{metrics.current_bandwidth}</div>
{metrics.requests_per_second.toFixed(2)}
</div>
</div> </div>
</div> </div>
</CardContent> </CardContent>
@ -199,7 +208,10 @@ export default function DashboardPage() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle></CardTitle> <CardTitle>
<span className="ml-2 text-sm font-normal text-gray-500 align-middle">(: {Object.values(metrics.status_code_stats || {}).reduce((a, b) => a + (b as number), 0)})</span>
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4"> <div className="grid grid-cols-2 md:grid-cols-5 gap-4">
@ -325,70 +337,16 @@ export default function DashboardPage() {
</Card> </Card>
</div> </div>
{/* 错误统计卡片 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="space-y-2">
<div className="text-sm font-medium text-gray-500"> (4xx)</div>
<div className="text-2xl font-semibold text-yellow-600">
{metrics.error_stats?.client_errors || 0}
</div>
<div className="text-sm text-gray-500">
{metrics.total_requests ?
((metrics.error_stats?.client_errors || 0) / metrics.total_requests * 100).toFixed(2) : 0}%
</div>
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-gray-500"> (5xx)</div>
<div className="text-2xl font-semibold text-red-600">
{metrics.error_stats?.server_errors || 0}
</div>
<div className="text-sm text-gray-500">
{metrics.total_requests ?
((metrics.error_stats?.server_errors || 0) / metrics.total_requests * 100).toFixed(2) : 0}%
</div>
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-gray-500"></div>
<div className="text-2xl font-semibold">
{(metrics.error_rate * 100).toFixed(2)}%
</div>
<div className="text-sm text-gray-500">
: {metrics.total_errors || 0}
</div>
</div>
</div>
{metrics.error_stats?.types && Object.keys(metrics.error_stats.types).length > 0 && (
<div className="mt-6">
<div className="text-sm font-medium text-gray-500 mb-2"></div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{Object.entries(metrics.error_stats.types).map(([type, count]) => (
<div key={type} className="p-3 rounded-lg border bg-card text-card-foreground shadow-sm">
<div className="text-sm font-medium text-gray-500">{type}</div>
<div className="text-lg font-semibold">{count}</div>
<div className="text-xs text-gray-500 mt-1">
{metrics.total_errors ? ((count / metrics.total_errors) * 100).toFixed(1) : 0}%
</div>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* 引用来源统计卡片 */} {/* 引用来源统计卡片 */}
{metrics.top_referers && metrics.top_referers.length > 0 && ( {metrics.top_referers && metrics.top_referers.length > 0 && (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle> (Top {metrics.top_referers.length})</CardTitle> <CardTitle>
<span className="ml-2 text-sm font-normal text-gray-500 align-middle">
(24, {metrics.top_referers.length} )
</span>
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
@ -398,24 +356,49 @@ export default function DashboardPage() {
<th className="text-left p-2"></th> <th className="text-left p-2"></th>
<th className="text-left p-2"></th> <th className="text-left p-2"></th>
<th className="text-left p-2"></th> <th className="text-left p-2"></th>
<th className="text-left p-2"></th>
<th className="text-left p-2"></th> <th className="text-left p-2"></th>
<th className="text-left p-2"></th> <th className="text-left p-2"></th>
<th className="text-left p-2">访</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{metrics.top_referers.map((referer, index) => ( {metrics.top_referers
<tr key={index} className="border-b"> .sort((a, b) => b.request_count - a.request_count)
.map((referer, index) => {
const errorRate = ((referer.error_count / referer.request_count) * 100).toFixed(1);
const lastAccessTime = new Date(referer.last_access_time * 1000);
const timeAgo = getTimeAgo(lastAccessTime);
return (
<tr key={index} className="border-b hover:bg-gray-50">
<td className="p-2 max-w-xs truncate"> <td className="p-2 max-w-xs truncate">
<span className="text-blue-600"> <a
href={referer.path}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 hover:underline"
>
{referer.path} {referer.path}
</span> </a>
</td> </td>
<td className="p-2">{referer.request_count}</td> <td className="p-2">{referer.request_count}</td>
<td className="p-2">{referer.error_count}</td> <td className="p-2">{referer.error_count}</td>
<td className="p-2">
<span className={errorRate === "0.0" ? "text-green-600" : "text-red-600"}>
{errorRate}%
</span>
</td>
<td className="p-2">{referer.avg_latency}</td> <td className="p-2">{referer.avg_latency}</td>
<td className="p-2">{formatBytes(referer.bytes_transferred)}</td> <td className="p-2">{formatBytes(referer.bytes_transferred)}</td>
<td className="p-2">
<span title={lastAccessTime.toLocaleString()}>
{timeAgo}
</span>
</td>
</tr> </tr>
))} );
})}
</tbody> </tbody>
</table> </table>
</div> </div>
@ -423,47 +406,6 @@ export default function DashboardPage() {
</Card> </Card>
)} )}
<Card>
<CardHeader>
<CardTitle> (Top 10)</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left p-2"></th>
<th className="text-left p-2"></th>
<th className="text-left p-2"></th>
<th className="text-left p-2"></th>
<th className="text-left p-2"></th>
</tr>
</thead>
<tbody>
{(metrics.top_paths || []).map((path, index) => (
<tr key={index} className="border-b">
<td className="p-2 max-w-xs truncate">
<a
href={path.path}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 hover:underline"
>
{path.path}
</a>
</td>
<td className="p-2">{path.request_count}</td>
<td className="p-2">{path.error_count}</td>
<td className="p-2">{path.avg_latency}</td>
<td className="p-2">{formatBytes(path.bytes_transferred)}</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle></CardTitle> <CardTitle></CardTitle>
@ -508,7 +450,11 @@ export default function DashboardPage() {
</td> </td>
<td className="p-2">{formatLatency(req.Latency)}</td> <td className="p-2">{formatLatency(req.Latency)}</td>
<td className="p-2">{formatBytes(req.BytesSent)}</td> <td className="p-2">{formatBytes(req.BytesSent)}</td>
<td className="p-2">{req.ClientIP}</td> <td className="p-2">
<Link href={`https://ipinfo.io/${req.ClientIP}`} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:text-blue-800 hover:underline">
{req.ClientIP}
</Link>
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@ -548,6 +494,27 @@ function formatLatency(nanoseconds: number) {
} }
} }
function getTimeAgo(date: Date) {
const now = new Date();
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
if (diffInSeconds < 60) {
return `${diffInSeconds}秒前`;
}
const diffInMinutes = Math.floor(diffInSeconds / 60);
if (diffInMinutes < 60) {
return `${diffInMinutes}分钟前`;
}
const diffInHours = Math.floor(diffInMinutes / 60);
if (diffInHours < 24) {
return `${diffInHours}小时前`;
}
return date.toLocaleString();
}
function getStatusColor(status: number) { function getStatusColor(status: number) {
if (status >= 500) return "bg-red-100 text-red-800" if (status >= 500) return "bg-red-100 text-red-800"
if (status >= 400) return "bg-yellow-100 text-yellow-800" if (status >= 400) return "bg-yellow-100 text-yellow-800"

View File

@ -0,0 +1,386 @@
"use client"
import React, { useEffect, useState, useCallback } from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { useToast } from "@/components/ui/use-toast"
import { useRouter } from "next/navigation"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Shield, Ban, Clock, Trash2, RefreshCw } from "lucide-react"
interface BannedIP {
ip: string
ban_end_time: string
remaining_seconds: number
}
interface SecurityStats {
banned_ips_count: number
error_records_count: number
config: {
ErrorThreshold: number
WindowMinutes: number
BanDurationMinutes: number
CleanupIntervalMinutes: number
}
}
interface IPStatus {
ip: string
banned: boolean
ban_end_time?: string
remaining_seconds?: number
}
export default function SecurityPage() {
const [bannedIPs, setBannedIPs] = useState<BannedIP[]>([])
const [stats, setStats] = useState<SecurityStats | null>(null)
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [checkingIP, setCheckingIP] = useState("")
const [ipStatus, setIPStatus] = useState<IPStatus | null>(null)
const [unbanning, setUnbanning] = useState<string | null>(null)
const { toast } = useToast()
const router = useRouter()
const fetchData = useCallback(async () => {
try {
const token = localStorage.getItem("token")
if (!token) {
router.push("/login")
return
}
const [bannedResponse, statsResponse] = await Promise.all([
fetch("/admin/api/security/banned-ips", {
headers: { 'Authorization': `Bearer ${token}` }
}),
fetch("/admin/api/security/stats", {
headers: { 'Authorization': `Bearer ${token}` }
})
])
if (bannedResponse.status === 401 || statsResponse.status === 401) {
localStorage.removeItem("token")
router.push("/login")
return
}
if (bannedResponse.ok) {
const bannedData = await bannedResponse.json()
setBannedIPs(bannedData.banned_ips || [])
}
if (statsResponse.ok) {
const statsData = await statsResponse.json()
setStats(statsData)
}
} catch (error) {
console.error("获取安全数据失败:", error)
toast({
title: "错误",
description: "获取安全数据失败",
variant: "destructive",
})
} finally {
setLoading(false)
setRefreshing(false)
}
}, [router, toast])
useEffect(() => {
fetchData()
// 每30秒自动刷新一次数据
const interval = setInterval(fetchData, 30000)
return () => clearInterval(interval)
}, [fetchData])
const handleRefresh = () => {
setRefreshing(true)
fetchData()
}
const checkIPStatus = async () => {
if (!checkingIP.trim()) return
try {
const token = localStorage.getItem("token")
if (!token) {
router.push("/login")
return
}
const response = await fetch(`/admin/api/security/check-ip?ip=${encodeURIComponent(checkingIP)}`, {
headers: { 'Authorization': `Bearer ${token}` }
})
if (response.status === 401) {
localStorage.removeItem("token")
router.push("/login")
return
}
if (response.ok) {
const data = await response.json()
setIPStatus(data)
} else {
throw new Error("检查IP状态失败")
}
} catch {
toast({
title: "错误",
description: "检查IP状态失败",
variant: "destructive",
})
}
}
const unbanIP = async (ip: string) => {
try {
const token = localStorage.getItem("token")
if (!token) {
router.push("/login")
return
}
const response = await fetch("/admin/api/security/unban", {
method: "POST",
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ ip })
})
if (response.status === 401) {
localStorage.removeItem("token")
router.push("/login")
return
}
if (response.ok) {
const data = await response.json()
if (data.success) {
toast({
title: "成功",
description: `IP ${ip} 已解封`,
})
fetchData() // 刷新数据
} else {
toast({
title: "提示",
description: data.message,
})
}
} else {
throw new Error("解封IP失败")
}
} catch {
toast({
title: "错误",
description: "解封IP失败",
variant: "destructive",
})
} finally {
setUnbanning(null)
}
}
const formatTime = (seconds: number) => {
if (seconds <= 0) return "已过期"
const minutes = Math.floor(seconds / 60)
const remainingSeconds = seconds % 60
if (minutes > 0) {
return `${minutes}${remainingSeconds}`
}
return `${remainingSeconds}`
}
if (loading) {
return (
<div className="flex h-[calc(100vh-4rem)] items-center justify-center">
<div className="text-center">
<div className="text-lg font-medium">...</div>
<div className="text-sm text-gray-500 mt-1"></div>
</div>
</div>
)
}
return (
<div className="space-y-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Shield className="w-5 h-5" />
</CardTitle>
<Button onClick={handleRefresh} disabled={refreshing} variant="outline">
<RefreshCw className={`w-4 h-4 mr-2 ${refreshing ? 'animate-spin' : ''}`} />
</Button>
</CardHeader>
<CardContent>
{stats && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-red-50 p-4 rounded-lg">
<div className="flex items-center gap-2">
<Ban className="w-5 h-5 text-red-600" />
<div>
<div className="text-2xl font-bold text-red-600">{stats.banned_ips_count}</div>
<div className="text-sm text-red-600">IP</div>
</div>
</div>
</div>
<div className="bg-yellow-50 p-4 rounded-lg">
<div className="flex items-center gap-2">
<Clock className="w-5 h-5 text-yellow-600" />
<div>
<div className="text-2xl font-bold text-yellow-600">{stats.error_records_count}</div>
<div className="text-sm text-yellow-600"></div>
</div>
</div>
</div>
<div className="bg-blue-50 p-4 rounded-lg">
<div className="text-sm text-blue-600 mb-1"></div>
<div className="text-lg font-bold text-blue-600">
{stats.config.ErrorThreshold}/{stats.config.WindowMinutes}
</div>
</div>
<div className="bg-green-50 p-4 rounded-lg">
<div className="text-sm text-green-600 mb-1"></div>
<div className="text-lg font-bold text-green-600">
{stats.config.BanDurationMinutes}
</div>
</div>
</div>
)}
<div className="space-y-4">
<div className="flex gap-4">
<div className="flex-1">
<Label>IP状态</Label>
<div className="flex gap-2 mt-1">
<Input
placeholder="输入IP地址"
value={checkingIP}
onChange={(e) => setCheckingIP(e.target.value)}
/>
<Button onClick={checkIPStatus}></Button>
</div>
</div>
</div>
{ipStatus && (
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-4">
<div>
<strong>IP: {ipStatus.ip}</strong>
</div>
<div className={`px-2 py-1 rounded text-sm ${
ipStatus.banned
? 'bg-red-100 text-red-800'
: 'bg-green-100 text-green-800'
}`}>
{ipStatus.banned ? '已封禁' : '正常'}
</div>
{ipStatus.banned && ipStatus.remaining_seconds && ipStatus.remaining_seconds > 0 && (
<div className="text-sm text-muted-foreground">
: {formatTime(ipStatus.remaining_seconds)}
</div>
)}
</div>
</CardContent>
</Card>
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>IP列表</CardTitle>
</CardHeader>
<CardContent>
{bannedIPs.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
IP
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>IP地址</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{bannedIPs.map((bannedIP) => (
<TableRow key={bannedIP.ip}>
<TableCell className="font-mono">{bannedIP.ip}</TableCell>
<TableCell>{bannedIP.ban_end_time}</TableCell>
<TableCell>
<span className={bannedIP.remaining_seconds <= 0 ? 'text-muted-foreground' : 'text-orange-600'}>
{formatTime(bannedIP.remaining_seconds)}
</span>
</TableCell>
<TableCell>
<Button
variant="outline"
size="sm"
onClick={() => setUnbanning(bannedIP.ip)}
disabled={bannedIP.remaining_seconds <= 0}
>
<Trash2 className="w-4 h-4 mr-1" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<AlertDialog open={!!unbanning} onOpenChange={(open) => !open && setUnbanning(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
IP地址 &ldquo;{unbanning}&rdquo;
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={() => unbanning && unbanIP(unbanning)}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@ -8,57 +8,102 @@ body {
@layer base { @layer base {
:root { :root {
--background: 0 0% 100%; --background: 30 12.5000% 96.8627%;
--foreground: 0 0% 3.9%; --foreground: 0 0% 0%;
--card: 0 0% 100%; --card: 0 0% 100%;
--card-foreground: 0 0% 3.9%; --card-foreground: 0 0% 0%;
--popover: 0 0% 100%; --popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%; --popover-foreground: 0 0% 0%;
--primary: 0 0% 9%; --primary: 23.8835 44.9782% 55.0980%;
--primary-foreground: 0 0% 98%; --primary-foreground: 0 0% 100%;
--secondary: 0 0% 96.1%; --secondary: 0 0% 96.0784%;
--secondary-foreground: 0 0% 9%; --secondary-foreground: 0 0% 0%;
--muted: 0 0% 96.1%; --muted: 0 0% 89.8039%;
--muted-foreground: 0 0% 45.1%; --muted-foreground: 0 0% 45.0980%;
--accent: 0 0% 96.1%; --accent: 23.8835 44.9782% 55.0980%;
--accent-foreground: 0 0% 9%; --accent-foreground: 0 0% 0%;
--destructive: 0 84.2% 60.2%; --destructive: 11.7857 44.0945% 50.1961%;
--destructive-foreground: 0 0% 98%; --destructive-foreground: 0 0% 100%;
--border: 0 0% 89.8%; --border: 0 0% 89.8039%;
--input: 0 0% 89.8%; --input: 0 0% 89.8039%;
--ring: 0 0% 3.9%; --ring: 23.8835 44.9782% 55.0980%;
--chart-1: 12 76% 61%; --chart-1: 23.8835 44.9782% 55.0980%;
--chart-2: 173 58% 39%; --chart-2: 11.7857 44.0945% 50.1961%;
--chart-3: 197 37% 24%; --chart-3: 120 25.0000% 42.3529%;
--chart-4: 43 74% 66%; --chart-4: 346.0563 93.4211% 70.1961%;
--chart-5: 27 87% 67%; --chart-5: 60 76.5432% 68.2353%;
--sidebar: 0 0% 96.0784%;
--sidebar-foreground: 0 0% 0%;
--sidebar-primary: 23.8835 44.9782% 55.0980%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 23.8835 44.9782% 55.0980%;
--sidebar-accent-foreground: 0 0% 0%;
--sidebar-border: 0 0% 89.8039%;
--sidebar-ring: 23.8835 44.9782% 55.0980%;
--font-sans: #000000;
--font-serif: #000000;
--font-mono: #000000;
--radius: 0.5rem; --radius: 0.5rem;
--shadow-2xs: 0 0 0 0 hsl(0 0% 0% / 0.00);
--shadow-xs: 0 0 0 0 hsl(0 0% 0% / 0.00);
--shadow-sm: 0 0 0 0 hsl(0 0% 0% / 0.00), 0 1px 2px -1px hsl(0 0% 0% / 0.00);
--shadow: 0 0 0 0 hsl(0 0% 0% / 0.00), 0 1px 2px -1px hsl(0 0% 0% / 0.00);
--shadow-md: 0 0 0 0 hsl(0 0% 0% / 0.00), 0 2px 4px -1px hsl(0 0% 0% / 0.00);
--shadow-lg: 0 0 0 0 hsl(0 0% 0% / 0.00), 0 4px 6px -1px hsl(0 0% 0% / 0.00);
--shadow-xl: 0 0 0 0 hsl(0 0% 0% / 0.00), 0 8px 10px -1px hsl(0 0% 0% / 0.00);
--shadow-2xl: 0 0 0 0 hsl(0 0% 0% / 0.00);
--tracking-normal: -0.025em;
} }
.dark { .dark {
--background: 0 0% 3.9%; --background: 0 0% 10.1961%;
--foreground: 0 0% 98%; --foreground: 30 12.5000% 96.8627%;
--card: 0 0% 3.9%; --card: 0 0% 14.9020%;
--card-foreground: 0 0% 98%; --card-foreground: 30 12.5000% 96.8627%;
--popover: 0 0% 3.9%; --popover: 0 0% 14.9020%;
--popover-foreground: 0 0% 98%; --popover-foreground: 30 12.5000% 96.8627%;
--primary: 0 0% 98%; --primary: 23.8835 44.9782% 55.0980%;
--primary-foreground: 0 0% 9%; --primary-foreground: 0 0% 0%;
--secondary: 0 0% 14.9%; --secondary: 0 0% 25.0980%;
--secondary-foreground: 0 0% 98%; --secondary-foreground: 30 12.5000% 96.8627%;
--muted: 0 0% 14.9%; --muted: 0 0% 25.0980%;
--muted-foreground: 0 0% 63.9%; --muted-foreground: 0 0% 63.9216%;
--accent: 0 0% 14.9%; --accent: 23.8835 44.9782% 55.0980%;
--accent-foreground: 0 0% 98%; --accent-foreground: 0 0% 0%;
--destructive: 0 62.8% 30.6%; --destructive: 11.7857 44.0945% 50.1961%;
--destructive-foreground: 0 0% 98%; --destructive-foreground: 0 0% 0%;
--border: 0 0% 14.9%; --border: 0 0% 25.0980%;
--input: 0 0% 14.9%; --input: 0 0% 14.9020%;
--ring: 0 0% 83.1%; --ring: 23.8835 44.9782% 55.0980%;
--chart-1: 220 70% 50%; --chart-1: 23.8835 44.9782% 55.0980%;
--chart-2: 160 60% 45%; --chart-2: 11.7857 44.0945% 50.1961%;
--chart-3: 30 80% 55%; --chart-3: 120 25.0000% 42.3529%;
--chart-4: 280 65% 60%; --chart-4: 346.0563 93.4211% 70.1961%;
--chart-5: 340 75% 55%; --chart-5: 60 76.5432% 68.2353%;
--sidebar: 0 0% 12.1569%;
--sidebar-foreground: 30 12.5000% 96.8627%;
--sidebar-primary: 23.8835 44.9782% 55.0980%;
--sidebar-primary-foreground: 0 0% 0%;
--sidebar-accent: 23.8835 44.9782% 55.0980%;
--sidebar-accent-foreground: 0 0% 0%;
--sidebar-border: 0 0% 20%;
--sidebar-ring: 23.8835 44.9782% 55.0980%;
--font-sans: #F8F7F6;
--font-serif: #F8F7F6;
--font-mono: #F8F7F6;
--radius: 0.5rem;
--shadow-2xs: 0 0 0 0 hsl(0 0% 0% / 0.00);
--shadow-xs: 0 0 0 0 hsl(0 0% 0% / 0.00);
--shadow-sm: 0 0 0 0 hsl(0 0% 0% / 0.00), 0 1px 2px -1px hsl(0 0% 0% / 0.00);
--shadow: 0 0 0 0 hsl(0 0% 0% / 0.00), 0 1px 2px -1px hsl(0 0% 0% / 0.00);
--shadow-md: 0 0 0 0 hsl(0 0% 0% / 0.00), 0 2px 4px -1px hsl(0 0% 0% / 0.00);
--shadow-lg: 0 0 0 0 hsl(0 0% 0% / 0.00), 0 4px 6px -1px hsl(0 0% 0% / 0.00);
--shadow-xl: 0 0 0 0 hsl(0 0% 0% / 0.00), 0 8px 10px -1px hsl(0 0% 0% / 0.00);
--shadow-2xl: 0 0 0 0 hsl(0 0% 0% / 0.00);
}
body {
letter-spacing: var(--tracking-normal);
} }
} }

View File

@ -55,6 +55,12 @@ export function Nav() {
> >
</Link> </Link>
<Link
href="/dashboard/security"
className={pathname === "/dashboard/security" ? "text-primary" : "text-muted-foreground"}
>
</Link>
</div> </div>
<Button variant="ghost" onClick={handleLogout}> <Button variant="ghost" onClick={handleLogout}>
退 退

View File

@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

425
web/package-lock.json generated
View File

@ -17,6 +17,7 @@
"@radix-ui/react-switch": "^1.1.3", "@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.2.6", "@radix-ui/react-toast": "^1.2.6",
"@radix-ui/react-tooltip": "^1.2.7",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.475.0", "lucide-react": "^0.475.0",
@ -1542,6 +1543,397 @@
} }
} }
}, },
"node_modules/@radix-ui/react-tooltip": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.7.tgz",
"integrity": "sha512-Ap+fNYwKTYJ9pzqW+Xe2HtMRbQ/EeWkj2qykZ6SuEV4iS/o1bZI5ssJbk4D2r8XuDuOBVz/tIx2JObtuqU+5Zw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.2",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.10",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.7",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.4",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-visually-hidden": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/primitive": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz",
"integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==",
"license": "MIT"
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-arrow": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
"integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-context": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz",
"integrity": "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.2",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-escape-keydown": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-id": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-popper": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.7.tgz",
"integrity": "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.0.0",
"@radix-ui/react-arrow": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-rect": "1.1.1",
"@radix-ui/react-use-size": "1.1.1",
"@radix-ui/rect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-portal": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
"integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-presence": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.4.tgz",
"integrity": "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-effect-event": "0.0.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-use-escape-keydown": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
"integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-callback-ref": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-use-rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
"integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==",
"license": "MIT",
"dependencies": {
"@radix-ui/rect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-use-size": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
"integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-visually-hidden": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
"integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT"
},
"node_modules/@radix-ui/react-use-callback-ref": { "node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz",
@ -1575,6 +1967,39 @@
} }
} }
}, },
"node_modules/@radix-ui/react-use-effect-event": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-effect-event/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-escape-keydown": { "node_modules/@radix-ui/react-use-escape-keydown": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz",

View File

@ -18,6 +18,7 @@
"@radix-ui/react-switch": "^1.1.3", "@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.2.6", "@radix-ui/react-toast": "^1.2.6",
"@radix-ui/react-tooltip": "^1.2.7",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.475.0", "lucide-react": "^0.475.0",