107 Commits

Author SHA1 Message Date
apple
6907ceab61 Merge branch 'main'
* main:
  补齐访问控制
  优化离线下载镜像的实现
  版本注入
  更新文档
  支持禁用前端静态文件路由
  优化代码质量
  shell OOM
  update
  v1.2.1
  修复GitHub用户名通配符
  兼容Containerd的ns参数
  v1.2.0
  判断是否已经添加加速域名,避免重复添加。
  Update README with documentation links
  更新文档
  Update README.md

# Conflicts:
#	src/main.go
2026-03-02 16:05:55 +08:00
user123
f5bc86ef79 补齐访问控制 2026-02-02 09:53:45 +08:00
user123
23dd077f5d 优化离线下载镜像的实现 2026-02-02 06:12:31 +08:00
user123
3917b2503a 版本注入 2026-01-26 23:49:53 +08:00
user123
bb61eb5025 更新文档 2026-01-26 23:27:58 +08:00
user123
11c34459ca 支持禁用前端静态文件路由 2026-01-26 23:06:05 +08:00
user123
6659e977ae 优化代码质量 2026-01-25 14:03:21 +08:00
starry
f77d951500 Merge pull request #93 from sky22333/registry-alpha
shell OOM
2026-01-10 23:11:02 +08:00
user123
685388fff9 shell OOM 2026-01-10 23:04:16 +08:00
user123
c6d95e683f update 2026-01-10 21:23:38 +08:00
user123
f8828ccb74 v1.2.1 2026-01-10 21:06:02 +08:00
user123
fdc156adad 修复GitHub用户名通配符 2026-01-10 20:54:45 +08:00
user123
80b0173d7c 兼容Containerd的ns参数 2026-01-10 20:29:42 +08:00
starry
31f62fde35 v1.2.0 2025-11-28 22:16:57 +08:00
starry
8d7619c7e4 判断是否已经添加加速域名,避免重复添加。 2025-11-28 13:37:23 +00:00
root
4eb55f958d 设置caddy邮箱 2025-11-22 14:24:10 +08:00
root
04a88a5b5a add caddy 2025-11-22 14:09:20 +08:00
starry
a09db34787 Update README with documentation links
Added links to Chinese and English documentation in README.
2025-11-16 08:58:51 +08:00
starry
31a3b67ab0 更新文档 2025-11-16 08:49:12 +08:00
starry
3590c7c073 Update README.md 2025-11-16 08:46:24 +08:00
apple
e026aaabbf 删除链接 2025-11-05 14:22:56 +08:00
apple
bea69c5b8b 整理文件 2025-11-05 14:10:46 +08:00
starry
3f614e8011 Merge pull request #74 from eryajf/main
feat: 针对action流水线做了一些优化
2025-09-29 14:20:49 +08:00
eryajf
198a18508b refactor: 重构 Docker 构建流程,使用多阶段构建 2025-09-29 14:18:40 +08:00
eryajf
780ac14a8f feat: 优化构建流程,使用预编译二进制文件 2025-09-29 10:11:02 +08:00
eryajf
62b3cb6b70 feat: 添加 UPX 压缩二进制文件 2025-09-29 09:51:23 +08:00
starry
714224bd29 Update README.md 2025-09-17 02:05:46 +08:00
starry
7f6c46f0c8 add截图 2025-09-17 01:58:46 +08:00
starry
fd9b0cf829 add截图 2025-09-17 01:51:41 +08:00
starry
42ddfaab9d Update docker-compose.yml 2025-09-13 03:45:28 +08:00
starry
6144883a6e Update docker-compose.yml 2025-09-13 03:44:25 +08:00
starry
c704923b64 禁用CGO 2025-09-09 12:25:21 +08:00
starry
dcb502d3c8 v1.1.9 2025-09-08 00:02:51 +08:00
starry
a011d560c6 shell转换中确保host有协议头 2025-09-04 04:13:21 +08:00
starry
53060d50db update 2025-09-02 12:34:42 +08:00
starry
68868388d3 更新为v1.1.8 2025-09-02 10:33:41 +08:00
starry
75833b937b 放宽gist匹配限制 2025-09-02 10:06:32 +08:00
starry
45b4acc31f 调整一些默认配置 2025-09-02 01:03:50 +08:00
starry
0cd5a7334d 增加.ps1脚本的处理 2025-09-01 12:16:42 +08:00
starry
40f5b597ab 增加检查是否为网页类型 2025-09-01 12:05:16 +08:00
starry
30bc88ed93 去掉greenteagc 2025-09-01 02:22:07 +08:00
starry
737a522afc Update README.md 2025-09-01 01:50:10 +08:00
starry
eee0a3220c Update README.md 2025-08-29 22:27:50 +08:00
user123456
9d5d3012a5 更新依赖,开启Green Tea GC新特性 2025-08-29 22:12:00 +08:00
starry
e2413fc30d 写响应的最大允许时间改为30分钟
h2写响应的最大允许时间从5分钟增加至30分钟,兼容大文件下载
2025-08-15 21:52:00 +08:00
starry
6193a07837 Update .gitattributes 2025-08-01 14:43:04 +08:00
starry
bb2f7bcda6 启动显示版本号 2025-08-01 13:23:52 +08:00
starry
4ec36da9b5 优化github上游链接404的处理 2025-08-01 13:19:47 +08:00
starry
83a1211067 Merge pull request #51 from RedwindA/fix/ratelimit-when-0
fix: 仅白名单
2025-08-01 10:47:49 +08:00
RedwindA
367038a4b5 移除InitGlobalLimiter中burstSize的最小值设置以正确实现仅白名单功能 2025-08-01 04:58:15 +08:00
user123456
a0df3b1a54 修复gist正则匹配 2025-07-28 04:46:08 +08:00
starry
70bf552daf Update release.yml 2025-07-27 12:16:18 +08:00
starry
d5e2abdcff Merge pull request #39 from sky22333/dev
优化代码结构,支持h2
2025-07-27 12:11:39 +08:00
user123456
07a926902a 优化代码格式 2025-07-27 10:58:20 +08:00
user123456
1881b5b1ba 增加HTTP2多路复用的支持 2025-07-27 10:25:52 +08:00
user123456
75e37158ef update 2025-07-27 08:05:36 +08:00
user123456
506de49586 IP白名单优化 2025-07-27 08:01:34 +08:00
user123456
dd704dc499 update 2025-07-27 07:37:35 +08:00
starry
9a8b850bce Delete src/test.exe 2025-07-27 06:15:42 +08:00
user123456
187e842445 拆分包结构 2025-07-27 05:50:34 +08:00
starry
badafd2899 Update README.md 2025-07-20 19:34:02 +08:00
starry
4bf075fcaf Update README.md 2025-07-18 21:12:47 +08:00
starry
208a239af3 修复cf导致的协议头问题,简化健康检查 2025-07-18 21:10:03 +08:00
starry
1fb97b5347 Merge pull request #34 from Thinker-Joe/main
Add registry mirror usage
2025-07-16 20:17:23 +08:00
Thinker-Joe
95c2e4fd68 Merge pull request #4 from Thinker-Joe/codex/readmeregistry-mirrors
Add registry mirror usage
2025-07-16 19:35:37 +08:00
Thinker-Joe
79fa21321f docs: add registry mirror usage 2025-07-16 19:35:10 +08:00
starry
c4c5993bd1 Update README.md 2025-06-30 18:19:14 +08:00
starry
d46fd3fec4 Update README.md 2025-06-28 08:46:24 +08:00
starry
279b48d432 Update README.md 2025-06-28 08:29:34 +08:00
starry
61f09192bb Update README.md 2025-06-27 09:06:44 +08:00
starry
d876809086 完善一些小细节 2025-06-27 08:50:04 +08:00
user123456
fe9156f878 Merge commit 'refs/pull/origin/28' 2025-06-21 00:30:51 +08:00
starry
35651e214f proxy字段修复 2025-06-21 00:15:27 +08:00
user123456
d373e0104d 获取更多镜像tag 2025-06-20 23:44:13 +08:00
starry
207a03a511 Merge pull request #25 from beck-8/me/op_proxy
优化代理配置
2025-06-19 23:00:44 +08:00
beck-8
5bd32cd6c1 go fmt . 2025-06-19 22:53:20 +08:00
beck-8
8c127a795b op http client proxy 2025-06-19 22:52:51 +08:00
user123456
2567652a7d 更新配置说明 2025-06-18 22:26:19 +08:00
user123456
c023e6a9c4 清理冗余written字段 2025-06-18 22:05:28 +08:00
user123456
44c6e4cd7b 修复双重写入 2025-06-18 21:29:56 +08:00
user123456
c22bd0637a 更新默认配置 2025-06-18 20:49:45 +08:00
user123456
a94b476726 移除冗余的限流智能判断逻辑 2025-06-18 20:44:26 +08:00
user123456
4c6751b862 限流改为全局应用 2025-06-18 19:44:32 +08:00
user123456
acc63d7b68 删除热重载 2025-06-18 19:14:13 +08:00
starry
d0b1ea8582 LF 2025-06-18 17:08:14 +08:00
starry
c607061dae LF 2025-06-18 17:07:43 +08:00
starry
143de7b254 Normalize all line endings to LF 2025-06-18 17:03:29 +08:00
user123456
51ace73b78 优化离线镜像的防抖以及日志 2025-06-18 16:04:53 +08:00
user123456
fa9e9210ab 默认为原始压缩层 2025-06-18 15:14:33 +08:00
user123456
f308410920 修复函数调用点传递 2025-06-18 15:00:41 +08:00
user123456
252dc319c6 优化离线包体积 2025-06-18 14:55:35 +08:00
user123456
29ceeef45b IPv6日志适配 2025-06-17 18:49:34 +08:00
user123456
182dced403 修复ipv6标准化的潜在BUG 2025-06-17 18:38:48 +08:00
user123456
aea36939a3 增加支持走代理 2025-06-17 18:18:17 +08:00
starry
4240c1452a Update README.md 2025-06-16 00:51:06 +08:00
starry
212c8e529d Update README.md 2025-06-15 16:18:54 +08:00
starry
3fd630159b Update config.toml 2025-06-14 14:11:14 +08:00
starry
17d827f50b Update README.md 2025-06-14 14:10:31 +08:00
starry
7dcbc839c6 Update README.md 2025-06-14 14:10:07 +08:00
starry
45ffebc820 Update README.md 2025-06-14 14:08:08 +08:00
starry
3027b1f218 Update README.md 2025-06-13 18:31:13 +08:00
starry
3d2c419ebe Update README.md 2025-06-13 18:30:14 +08:00
starry
b529fbfdd2 Update README.md 2025-06-13 18:29:38 +08:00
user123456
737c1dbf46 io.Copy 2025-06-13 17:58:13 +08:00
user123456
a67ef6c52c 离线镜像下载去掉缓存,避免缓存不完整导致空指针 2025-06-13 17:00:47 +08:00
starry
0adf11099e add 2025-06-13 16:25:27 +08:00
starry
dbb9432eb0 Create LICENSE 2025-06-13 14:11:56 +08:00
32 changed files with 3941 additions and 3400 deletions

2
.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
* text=auto eol=lf
*.html linguist-vendored

BIN
.github/demo/demo1.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

View File

@@ -3,9 +3,9 @@ on:
workflow_dispatch:
inputs:
version:
description: 'Version number'
description: '版本号 (例如: v1.0.0)'
required: true
default: 'latest'
default: 'v1.0.0'
jobs:
build:
@@ -15,13 +15,13 @@ jobs:
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v3
- name: Cache Docker layers
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
@@ -29,14 +29,19 @@ jobs:
${{ runner.os }}-buildx-
- name: Log in to GitHub Docker Registry
uses: docker/login-action@v2
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set version from input
run: echo "VERSION=${{ github.event.inputs.version }}" >> $GITHUB_ENV
run: |
VERSION=${{ github.event.inputs.version }}
if [[ $VERSION == v* ]]; then
VERSION=${VERSION:1}
fi
echo "VERSION=$VERSION" >> $GITHUB_ENV
- name: Convert repository name to lowercase
run: |
@@ -47,10 +52,10 @@ jobs:
- name: Build and push Docker image
run: |
docker buildx build --push \
--platform linux/amd64,linux/arm64/v8 \
--platform linux/amd64,linux/arm64 \
--tag ghcr.io/${{ env.REPO_LOWER }}:${{ env.VERSION }} \
--tag ghcr.io/${{ env.REPO_LOWER }}:latest \
--build-arg VERSION=${{ env.VERSION }} \
-f Dockerfile .
env:
GHCR_PUBLIC: true # 将镜像设置为公开
GHCR_PUBLIC: true

View File

@@ -1,7 +1,7 @@
name: 发布二进制文件
on:
workflow_dispatch: # 手动触发
workflow_dispatch:
inputs:
version:
description: '版本号 (例如: v1.0.0)'
@@ -18,12 +18,13 @@ jobs:
- name: 检出代码
uses: actions/checkout@v4
with:
fetch-depth: 0 # 获取完整历史,用于生成变更日志
fetch-depth: 0
- name: 设置Go环境
uses: actions/setup-go@v4
uses: actions/setup-go@v5
with:
go-version: '1.24'
go-version-file: "src/go.mod"
cache-dependency-path: "src/go.sum"
- name: 获取版本号
id: version
@@ -53,15 +54,25 @@ jobs:
run: |
mkdir -p build/hubproxy
- name: 安装 UPX
uses: crazy-max/ghaction-upx@v3
with:
install-only: true
- name: 编译二进制文件
run: |
cd src
VERSION=${{ steps.version.outputs.version }}
# Linux AMD64
GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o ../build/hubproxy/hubproxy-linux-amd64 .
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.Version=${VERSION}" -o ../build/hubproxy/hubproxy-linux-amd64 .
# Linux ARM64
GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o ../build/hubproxy/hubproxy-linux-arm64 .
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w -X main.Version=${VERSION}" -o ../build/hubproxy/hubproxy-linux-arm64 .
# 压缩二进制文件
upx -9 ../build/hubproxy/hubproxy-linux-amd64
upx -9 ../build/hubproxy/hubproxy-linux-arm64
- name: 复制配置文件
run: |
@@ -72,7 +83,7 @@ jobs:
cp hubproxy.service build/hubproxy/
# 复制安装脚本
cp install-service.sh build/hubproxy/
cp install.sh build/hubproxy/
# 创建README文件
cat > build/hubproxy/README.md << 'EOF'
@@ -88,13 +99,13 @@ jobs:
# Linux AMD64 包
mkdir -p linux-amd64/hubproxy
cp hubproxy/hubproxy-linux-amd64 linux-amd64/hubproxy/hubproxy
cp hubproxy/config.toml hubproxy/hubproxy.service hubproxy/install-service.sh hubproxy/README.md linux-amd64/hubproxy/
cp hubproxy/config.toml hubproxy/hubproxy.service hubproxy/install.sh hubproxy/README.md linux-amd64/hubproxy/
tar -czf hubproxy-${{ steps.version.outputs.version }}-linux-amd64.tar.gz -C linux-amd64 hubproxy
# Linux ARM64 包
mkdir -p linux-arm64/hubproxy
cp hubproxy/hubproxy-linux-arm64 linux-arm64/hubproxy/hubproxy
cp hubproxy/config.toml hubproxy/hubproxy.service hubproxy/install-service.sh hubproxy/README.md linux-arm64/hubproxy/
cp hubproxy/config.toml hubproxy/hubproxy.service hubproxy/install.sh hubproxy/README.md linux-arm64/hubproxy/
tar -czf hubproxy-${{ steps.version.outputs.version }}-linux-arm64.tar.gz -C linux-arm64 hubproxy
# 列出生成的文件
@@ -107,7 +118,7 @@ jobs:
cat checksums.txt
- name: 创建或更新Release
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.version.outputs.version }}
name: "HubProxy ${{ steps.version.outputs.version }}"
@@ -125,4 +136,4 @@ jobs:
build/checksums.txt
draft: false
prerelease: false
token: ${{ secrets.GITHUB_TOKEN }}
token: ${{ secrets.GITHUB_TOKEN }}

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.idea
.vscode
.DS_Store
hubproxy*
!hubproxy.service

View File

@@ -1,11 +1,15 @@
FROM golang:1.24-alpine AS builder
FROM golang:1.25-alpine AS builder
ARG TARGETARCH
ARG VERSION=dev
WORKDIR /app
COPY src/go.mod src/go.sum ./
RUN go mod download
RUN go mod download && apk add upx
COPY src/ .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -trimpath -o hubproxy .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -ldflags="-s -w -X main.Version=${VERSION}" -trimpath -o hubproxy . && upx -9 hubproxy
FROM alpine

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 sky22333
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

211
README.md
View File

@@ -1,22 +1,35 @@
# HubProxy
🚀 **Docker 和 GitHub 加速代理服务器**
**Docker 和 GitHub 加速代理服务器**
一个轻量级、高性能的多功能代理服务,提供 Docker 镜像加速、GitHub 文件加速等功能。
一个轻量级、高性能的多功能代理服务,提供 Docker 镜像加速、GitHub 文件加速、下载离线镜像、在线搜索 Docker 镜像等功能。
## ✨ 特性
- 🐳 **Docker 镜像加速** - 单域名实现 Docker Hub、GHCR、Quay 等多个镜像仓库加速,流式传输优化拉取速度。
<p align="center">
<img src="https://count.getloli.com/get/@sky22333.hubproxy?theme=rule34" alt="Visitors">
</p>
## 特性
- 🐳 **Docker 镜像加速** - 支持 Docker Hub、GHCR、Quay 等多个镜像仓库加速,流式传输优化拉取速度。
- 🐳 **离线镜像包** - 支持下载离线镜像包,流式传输加防抖设计。
- 📁 **GitHub 文件加速** - 加速 GitHub Release、Raw 文件下载,支持`api.github.com`,脚本嵌套加速等等
- 🤖 **AI 模型库支持** - 支持 Hugging Face 模型下载加速
- 🛡️ **智能限流** - IP 限流保护,防止滥用
- 🚫 **仓库审计** - 强大的自定义黑名单白名单同时审计镜像仓库和GitHub仓库
- 🔍 **镜像搜索** - 在线搜索 Docker 镜像
-**轻量高效** - 基于 Go 语言,单二进制文件运行,资源占用低,优雅的内存清理机制
- 🔧 **配置热重载** - 统一配置管理,部分配置项支持热重载,无需重启服务
-**轻量高效** - 基于 Go 语言,单二进制文件运行,资源占用低。
- 🔧 **统一配置** - 统一配置管理,便于维护。
- 🛡️ **完全自托管** - 避免依赖免费第三方服务的不稳定性,例如`cloudflare`等等。
- 🚀 **多服务统一加速** - 单个程序即可统一加速 Docker、GitHub、Hugging Face 等多种服务,简化部署与管理。
## 🚀 快速开始
## 详细文档
[中文文档](https://zread.ai/sky22333/hubproxy)
[English](https://deepwiki.com/sky22333/hubproxy)
## 快速开始
### Docker部署推荐
```
@@ -27,23 +40,21 @@ docker run -d \
ghcr.io/sky22333/hubproxy
```
### 一键安装
### 一键脚本安装
```bash
curl -fsSL https://raw.githubusercontent.com/sky22333/hubproxy/main/install-service.sh | sudo bash
curl -fsSL https://raw.githubusercontent.com/sky22333/hubproxy/main/install.sh | sudo bash
```
这个命令会:
- 🔍 自动检测系统架构AMD64/ARM64
- 📥 从 GitHub Releases 下载最新版本
- ⚙️ 自动配置系统服务
- 🔄 保留现有配置(升级时)
支持单个二进制文件直接启动,无需其他配置,内置默认配置,支持所有功能。
这个脚本会:
- 自动检测系统架构AMD64/ARM64
- 从 GitHub Releases 下载最新版本
- 自动配置系统服务
- 保留现有配置(升级时)
## 📖 使用方法
## 使用方法
### Docker 镜像加速
@@ -55,9 +66,25 @@ docker pull nginx
docker pull yourdomain.com/nginx
# ghcr加速
docker pull yourdomain.com/ghcr.io/user/images
docker pull yourdomain.com/ghcr.io/sky22333/hubproxy
# 符合Docker Registry API v2标准的仓库都支持
```
当然也支持配置为全局镜像加速,在主机上新建(或编辑)`/etc/docker/daemon.json`
`"registry-mirrors"` 中加入域名:
```json
{
"registry-mirrors": [
"https://yourdomain.com"
]
}
```
若已设置其他加速地址,直接并列添加后保存,再执行 `sudo systemctl restart docker` 重启docker服务让配置生效。
### GitHub 文件加速
```bash
@@ -66,16 +93,149 @@ https://github.com/user/repo/releases/download/v1.0.0/file.tar.gz
# 加速链接
https://yourdomain.com/https://github.com/user/repo/releases/download/v1.0.0/file.tar.gz
# 加速下载仓库
git clone https://yourdomain.com/https://github.com/sky22333/hubproxy.git
```
## 配置
<details>
<summary>config.toml 配置说明</summary>
## ⚙️ 提示
*此配置是默认配置,已经内置在程序中了*
主配置文件位于 `/opt/hubproxy/config.toml`
```
[server]
host = "0.0.0.0"
# 监听端口
port = 5000
# Github文件大小限制字节默认2GB
fileSize = 2147483648
# HTTP/2 多路复用,提升下载速度
enableH2C = false
# 是否启用前端静态页面
enableFrontend = true
[rateLimit]
# 每个IP每周期允许的请求数(注意Docker镜像会有多个层会消耗多个次数)
requestLimit = 500
# 限流周期(小时)
periodHours = 3.0
[security]
# IP白名单支持单个IP或IP段
# 白名单中的IP不受限流限制
whiteList = [
"127.0.0.1",
"172.17.0.0/16",
"192.168.1.0/24"
]
# IP黑名单支持单个IP或IP段
# 黑名单中的IP将被直接拒绝访问
blackList = [
"192.168.100.1",
"192.168.100.0/24"
]
[access]
# 代理服务白名单支持GitHub仓库和Docker镜像支持通配符
# 只允许访问白名单中的仓库/镜像,为空时不限制
whiteList = []
# 代理服务黑名单支持GitHub仓库和Docker镜像支持通配符
# 禁止访问黑名单中的仓库/镜像
blackList = [
"baduser/malicious-repo",
"*/malicious-repo",
"baduser/*"
]
# 代理配置,支持有用户名/密码认证和无认证模式
# 无认证: socks5://127.0.0.1:1080
# 有认证: socks5://username:password@127.0.0.1:1080
# 留空不使用代理
proxy = ""
[download]
# 批量下载离线镜像数量限制
maxImages = 10
# Registry映射配置支持多种镜像仓库上游
[registries]
# GitHub Container Registry
[registries."ghcr.io"]
upstream = "ghcr.io"
authHost = "ghcr.io/token"
authType = "github"
enabled = true
# Google Container Registry
[registries."gcr.io"]
upstream = "gcr.io"
authHost = "gcr.io/v2/token"
authType = "google"
enabled = true
# Quay.io Container Registry
[registries."quay.io"]
upstream = "quay.io"
authHost = "quay.io/v2/auth"
authType = "quay"
enabled = true
# Kubernetes Container Registry
[registries."registry.k8s.io"]
upstream = "registry.k8s.io"
authHost = "registry.k8s.io"
authType = "anonymous"
enabled = true
[tokenCache]
# 是否启用缓存(同时控制Token和Manifest缓存)显著提升性能
enabled = true
# 默认缓存时间(分钟)
defaultTTL = "20m"
```
</details>
容器内的配置文件位于 `/root/config.toml`
脚本部署配置文件位于 `/opt/hubproxy/config.toml`
### 环境变量(可选)
支持通过环境变量覆盖部分配置,优先级高于`config.toml`,以下是默认值:
```
SERVER_HOST=0.0.0.0 # 监听地址
SERVER_PORT=5000 # 监听端口
ENABLE_H2C=false # 是否启用 H2C
ENABLE_FRONTEND=true # 是否启用前端静态页面
MAX_FILE_SIZE=2147483648 # GitHub 文件大小限制(字节)
RATE_LIMIT=500 # 每周期请求数
RATE_PERIOD_HOURS=3 # 限流周期(小时)
IP_WHITELIST=127.0.0.1,192.168.1.0/24 # IP 白名单(逗号分隔)
IP_BLACKLIST=192.168.100.1,192.168.100.0/24 # IP 黑名单(逗号分隔)
MAX_IMAGES=10 # 批量下载镜像数量限制
```
为了IP限流能够正常运行反向代理需要传递IP头用来获取访客真实IP以caddy为例
```
example.com {
reverse_proxy {
to 127.0.0.1:5000
header_up X-Real-IP {remote}
header_up X-Forwarded-For {remote}
header_up X-Forwarded-Proto {scheme}
}
}
```
cloudflare CDN
```
example.com {
reverse_proxy 127.0.0.1:5000 {
header_up X-Forwarded-For {http.request.header.CF-Connecting-IP}
@@ -86,6 +246,8 @@ example.com {
}
```
> 对于使用nginx反代的用户Github加速提示`无效输入`的问题可以参见[issues/62](https://github.com/sky22333/hubproxy/issues/62#issuecomment-3219572440)
## ⚠️ 免责声明
@@ -100,3 +262,10 @@ example.com {
**⭐ 如果这个项目对你有帮助,请给个 Star⭐**
</div>
## 界面预览
![1](./.github/demo/demo1.jpg)
## Star 趋势
[![Star 趋势](https://starchart.cc/sky22333/hubproxy.svg?variant=adaptive)](https://starchart.cc/sky22333/hubproxy)

10
conf/Caddyfile Normal file
View File

@@ -0,0 +1,10 @@
# 代理到 hubproxy 的 5000 端i口
#
{
# 可选:关闭自动 HTTPS用于测试
# auto_https off
email ssl@diyla.com
}
hub.diyla.com {
reverse_proxy hubproxy:5000
}

View File

@@ -1,8 +1,37 @@
services:
ghproxy:
build: .
restart: always
ports:
- '5000:5000'
volumes:
- ./src/config.toml:/root/config.toml
hubproxy:
build:
context: .
container_name: hubproxy
restart: unless-stopped
ports:
- "5000:5000"
volumes:
- ./src/config.toml:/root/config.toml
logging:
driver: json-file
options:
max-size: "1g"
max-file: "2"
caddy:
image: caddy:latest
restart: unless-stopped
cap_add:
- NET_ADMIN
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- $PWD/conf:/etc/caddy
- $PWD/site:/srv
- caddy_data:/data
- caddy_config:/config
depends_on:
- hubproxy
volumes:
caddy_data:
caddy_config:

View File

@@ -1,89 +1,91 @@
[server]
# 监听地址,默认监听所有接口
host = "0.0.0.0"
# 监听端口
port = 5000
# 文件大小限制字节默认2GB
fileSize = 2147483648
[rateLimit]
# 每个IP每小时允许的请求数(Docker镜像每个层为一个请求)
requestLimit = 200
# 限流周期(小时)
periodHours = 1.0
[security]
# IP白名单支持单个IP或CIDR格式
# 白名单中的IP不受限流限制
whiteList = [
"127.0.0.1",
"192.168.1.0/24"
]
# IP黑名单支持单个IP或CIDR格式
# 黑名单中的IP将被直接拒绝访问
blackList = [
"192.168.100.1"
]
[proxy]
# 代理服务白名单支持GitHub仓库和Docker镜像支持通配符
# 只允许访问白名单中的仓库/镜像,为空时不限制
whiteList = []
# 代理服务名单支持GitHub仓库和Docker镜像支持通配符
# 禁止访问名单中的仓库/镜像
blackList = [
"baduser/malicious-repo",
"*/malicious-repo",
"baduser/*"
]
[download]
# 单次并发下载离线镜像数量限制
maxImages = 10
# Registry映射配置支持多种Container Registry
[registries]
# GitHub Container Registry
[registries."ghcr.io"]
upstream = "ghcr.io"
authHost = "ghcr.io/token"
authType = "github"
enabled = true
# Google Container Registry
[registries."gcr.io"]
upstream = "gcr.io"
authHost = "gcr.io/v2/token"
authType = "google"
enabled = true
# Quay.io Container Registry
[registries."quay.io"]
upstream = "quay.io"
authHost = "quay.io/v2/auth"
authType = "quay"
enabled = true
# Kubernetes Container Registry
[registries."registry.k8s.io"]
upstream = "registry.k8s.io"
authHost = "registry.k8s.io"
authType = "anonymous"
enabled = true
# 私有Registry示例默认禁用
# [registries."harbor.company.com"]
# upstream = "harbor.company.com"
# authHost = "harbor.company.com/service/token"
# authType = "basic"
# enabled = false
# 缓存配置Docker临时Token和Manifest统一管理显著提升性能
[tokenCache]
# 是否启用缓存(同时控制Token和Manifest缓存)
enabled = true
# 默认缓存时间
defaultTTL = "20m"
[server]
host = "0.0.0.0"
# 监听端口
port = 5000
# Github文件大小限制字节默认2GB
fileSize = 2147483648
# HTTP/2 多路复用
enableH2C = false
enableFrontend = true
[rateLimit]
# 每个IP每周期允许的请求数
requestLimit = 500
# 限流周期(小时)
periodHours = 3.0
[security]
# IP白名单支持单个IP或IP段
# 白名单中的IP不受限流限制
whiteList = [
"127.0.0.1",
"172.17.0.0/16",
"192.168.1.0/24"
]
# IP黑名单支持单个IP或IP段
# 黑名单中的IP将被直接拒绝访问
blackList = [
"192.168.100.1",
"192.168.100.0/24"
]
[access]
# 代理服务名单支持GitHub仓库和Docker镜像支持通配符
# 只允许访问名单中的仓库/镜像,为空时不限制
whiteList = []
# 代理服务黑名单支持GitHub仓库和Docker镜像支持通配符
# 禁止访问黑名单中的仓库/镜像
blackList = [
"baduser/malicious-repo",
"*/malicious-repo",
"baduser/*"
]
# 代理配置,支持有用户名/密码认证和无认证模式
# 无认证: socks5://127.0.0.1:1080
# 有认证: socks5://username:password@127.0.0.1:1080
# 留空不使用代理
proxy = ""
[download]
# 批量下载离线镜像数量限制
maxImages = 10
# Registry映射配置支持多种镜像仓库上游
[registries]
# GitHub Container Registry
[registries."ghcr.io"]
upstream = "ghcr.io"
authHost = "ghcr.io/token"
authType = "github"
enabled = true
# Google Container Registry
[registries."gcr.io"]
upstream = "gcr.io"
authHost = "gcr.io/v2/token"
authType = "google"
enabled = true
# Quay.io Container Registry
[registries."quay.io"]
upstream = "quay.io"
authHost = "quay.io/v2/auth"
authType = "quay"
enabled = true
# Kubernetes Container Registry
[registries."registry.k8s.io"]
upstream = "registry.k8s.io"
authHost = "registry.k8s.io"
authType = "anonymous"
enabled = true
[tokenCache]
# 是否启用缓存(同时控制Token和Manifest缓存)显著提升性能
enabled = true
# 默认缓存时间(分钟)
defaultTTL = "20m"

View File

@@ -1,367 +1,279 @@
package main
import (
"fmt"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/pelletier/go-toml/v2"
"github.com/spf13/viper"
"github.com/fsnotify/fsnotify"
)
// RegistryMapping Registry映射配置
type RegistryMapping struct {
Upstream string `toml:"upstream"` // 上游Registry地址
AuthHost string `toml:"authHost"` // 认证服务器地址
AuthType string `toml:"authType"` // 认证类型: docker/github/google/basic
Enabled bool `toml:"enabled"` // 是否启用
}
// AppConfig 应用配置结构体
type AppConfig struct {
Server struct {
Host string `toml:"host"` // 监听地址
Port int `toml:"port"` // 监听端口
FileSize int64 `toml:"fileSize"` // 文件大小限制(字节)
} `toml:"server"`
RateLimit struct {
RequestLimit int `toml:"requestLimit"` // 每小时请求限制
PeriodHours float64 `toml:"periodHours"` // 限制周期(小时)
} `toml:"rateLimit"`
Security struct {
WhiteList []string `toml:"whiteList"` // 白名单IP/CIDR列表
BlackList []string `toml:"blackList"` // 黑名单IP/CIDR列表
} `toml:"security"`
Proxy struct {
WhiteList []string `toml:"whiteList"` // 代理白名单(仓库级别)
BlackList []string `toml:"blackList"` // 代理黑名单(仓库级别)
} `toml:"proxy"`
Download struct {
MaxImages int `toml:"maxImages"` // 单次下载最大镜像数量限制
} `toml:"download"`
Registries map[string]RegistryMapping `toml:"registries"`
TokenCache struct {
Enabled bool `toml:"enabled"` // 是否启用token缓存
DefaultTTL string `toml:"defaultTTL"` // 默认缓存时间
} `toml:"tokenCache"`
}
var (
appConfig *AppConfig
appConfigLock sync.RWMutex
isViperEnabled bool
viperInstance *viper.Viper
cachedConfig *AppConfig
configCacheTime time.Time
configCacheTTL = 5 * time.Second
configCacheMutex sync.RWMutex
)
// DefaultConfig 返回默认配置
func DefaultConfig() *AppConfig {
return &AppConfig{
Server: struct {
Host string `toml:"host"`
Port int `toml:"port"`
FileSize int64 `toml:"fileSize"`
}{
Host: "0.0.0.0",
Port: 5000,
FileSize: 2 * 1024 * 1024 * 1024, // 2GB
},
RateLimit: struct {
RequestLimit int `toml:"requestLimit"`
PeriodHours float64 `toml:"periodHours"`
}{
RequestLimit: 20,
PeriodHours: 1.0,
},
Security: struct {
WhiteList []string `toml:"whiteList"`
BlackList []string `toml:"blackList"`
}{
WhiteList: []string{},
BlackList: []string{},
},
Proxy: struct {
WhiteList []string `toml:"whiteList"`
BlackList []string `toml:"blackList"`
}{
WhiteList: []string{},
BlackList: []string{},
},
Download: struct {
MaxImages int `toml:"maxImages"`
}{
MaxImages: 10, // 默认值最多同时下载10个镜像
},
Registries: map[string]RegistryMapping{
"ghcr.io": {
Upstream: "ghcr.io",
AuthHost: "ghcr.io/token",
AuthType: "github",
Enabled: true,
},
"gcr.io": {
Upstream: "gcr.io",
AuthHost: "gcr.io/v2/token",
AuthType: "google",
Enabled: true,
},
"quay.io": {
Upstream: "quay.io",
AuthHost: "quay.io/v2/auth",
AuthType: "quay",
Enabled: true,
},
"registry.k8s.io": {
Upstream: "registry.k8s.io",
AuthHost: "registry.k8s.io",
AuthType: "anonymous",
Enabled: true,
},
},
TokenCache: struct {
Enabled bool `toml:"enabled"`
DefaultTTL string `toml:"defaultTTL"`
}{
Enabled: true, // docker认证的匿名Token缓存配置用于提升性能
DefaultTTL: "20m",
},
}
}
// GetConfig 安全地获取配置副本
func GetConfig() *AppConfig {
configCacheMutex.RLock()
if cachedConfig != nil && time.Since(configCacheTime) < configCacheTTL {
config := cachedConfig
configCacheMutex.RUnlock()
return config
}
configCacheMutex.RUnlock()
// 缓存过期,重新生成配置
configCacheMutex.Lock()
defer configCacheMutex.Unlock()
// 双重检查,防止重复生成
if cachedConfig != nil && time.Since(configCacheTime) < configCacheTTL {
return cachedConfig
}
appConfigLock.RLock()
if appConfig == nil {
appConfigLock.RUnlock()
defaultCfg := DefaultConfig()
cachedConfig = defaultCfg
configCacheTime = time.Now()
return defaultCfg
}
// 生成新的配置深拷贝
configCopy := *appConfig
configCopy.Security.WhiteList = append([]string(nil), appConfig.Security.WhiteList...)
configCopy.Security.BlackList = append([]string(nil), appConfig.Security.BlackList...)
configCopy.Proxy.WhiteList = append([]string(nil), appConfig.Proxy.WhiteList...)
configCopy.Proxy.BlackList = append([]string(nil), appConfig.Proxy.BlackList...)
appConfigLock.RUnlock()
cachedConfig = &configCopy
configCacheTime = time.Now()
return cachedConfig
}
// setConfig 安全地设置配置
func setConfig(cfg *AppConfig) {
appConfigLock.Lock()
defer appConfigLock.Unlock()
appConfig = cfg
configCacheMutex.Lock()
cachedConfig = nil
configCacheMutex.Unlock()
}
// LoadConfig 加载配置文件
func LoadConfig() error {
// 首先使用默认配置
cfg := DefaultConfig()
// 尝试加载TOML配置文件
if data, err := os.ReadFile("config.toml"); err == nil {
if err := toml.Unmarshal(data, cfg); err != nil {
return fmt.Errorf("解析配置文件失败: %v", err)
}
} else {
fmt.Println("未找到config.toml使用默认配置")
}
// 从环境变量覆盖配置
overrideFromEnv(cfg)
// 设置配置
setConfig(cfg)
if !isViperEnabled {
go enableViperHotReload()
}
return nil
}
func enableViperHotReload() {
if isViperEnabled {
return
}
// 创建Viper实例
viperInstance = viper.New()
// 配置Viper
viperInstance.SetConfigName("config")
viperInstance.SetConfigType("toml")
viperInstance.AddConfigPath(".")
// 读取配置文件
if err := viperInstance.ReadInConfig(); err != nil {
fmt.Printf("读取配置失败,继续使用当前配置: %v\n", err)
return
}
isViperEnabled = true
viperInstance.WatchConfig()
viperInstance.OnConfigChange(func(e fsnotify.Event) {
fmt.Printf("检测到配置文件变化: %s\n", e.Name)
hotReloadWithViper()
})
}
func hotReloadWithViper() {
start := time.Now()
fmt.Println("🔄 自动热重载...")
// 创建新配置
cfg := DefaultConfig()
// 使用Viper解析配置到结构体
if err := viperInstance.Unmarshal(cfg); err != nil {
fmt.Printf("❌ 配置解析失败: %v\n", err)
return
}
overrideFromEnv(cfg)
setConfig(cfg)
// 异步更新受影响的组件
go func() {
updateAffectedComponents()
fmt.Printf("✅ Viper配置热重载完成耗时: %v\n", time.Since(start))
}()
}
func updateAffectedComponents() {
// 重新初始化限流器
if globalLimiter != nil {
fmt.Println("📡 重新初始化限流器...")
initLimiter()
}
// 重新加载访问控制
fmt.Println("🔒 重新加载访问控制规则...")
if GlobalAccessController != nil {
GlobalAccessController.Reload()
}
fmt.Println("🌐 更新Registry配置映射...")
reloadRegistryConfig()
// 其他需要重新初始化的组件可以在这里添加
fmt.Println("🔧 组件更新完成")
}
func reloadRegistryConfig() {
cfg := GetConfig()
enabledCount := 0
// 统计启用的Registry数量
for _, mapping := range cfg.Registries {
if mapping.Enabled {
enabledCount++
}
}
fmt.Printf("🌐 Registry配置已更新: %d个启用\n", enabledCount)
}
// overrideFromEnv 从环境变量覆盖配置
func overrideFromEnv(cfg *AppConfig) {
// 服务器配置
if val := os.Getenv("SERVER_HOST"); val != "" {
cfg.Server.Host = val
}
if val := os.Getenv("SERVER_PORT"); val != "" {
if port, err := strconv.Atoi(val); err == nil && port > 0 {
cfg.Server.Port = port
}
}
if val := os.Getenv("MAX_FILE_SIZE"); val != "" {
if size, err := strconv.ParseInt(val, 10, 64); err == nil && size > 0 {
cfg.Server.FileSize = size
}
}
// 限流配置
if val := os.Getenv("RATE_LIMIT"); val != "" {
if limit, err := strconv.Atoi(val); err == nil && limit > 0 {
cfg.RateLimit.RequestLimit = limit
}
}
if val := os.Getenv("RATE_PERIOD_HOURS"); val != "" {
if period, err := strconv.ParseFloat(val, 64); err == nil && period > 0 {
cfg.RateLimit.PeriodHours = period
}
}
// IP限制配置
if val := os.Getenv("IP_WHITELIST"); val != "" {
cfg.Security.WhiteList = append(cfg.Security.WhiteList, strings.Split(val, ",")...)
}
if val := os.Getenv("IP_BLACKLIST"); val != "" {
cfg.Security.BlackList = append(cfg.Security.BlackList, strings.Split(val, ",")...)
}
// 下载限制配置
if val := os.Getenv("MAX_IMAGES"); val != "" {
if maxImages, err := strconv.Atoi(val); err == nil && maxImages > 0 {
cfg.Download.MaxImages = maxImages
}
}
}
// CreateDefaultConfigFile 创建默认配置文件
func CreateDefaultConfigFile() error {
cfg := DefaultConfig()
data, err := toml.Marshal(cfg)
if err != nil {
return fmt.Errorf("序列化默认配置失败: %v", err)
}
return os.WriteFile("config.toml", data, 0644)
}
package config
import (
"fmt"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/pelletier/go-toml/v2"
)
// RegistryMapping Registry映射配置
type RegistryMapping struct {
Upstream string `toml:"upstream"`
AuthHost string `toml:"authHost"`
AuthType string `toml:"authType"`
Enabled bool `toml:"enabled"`
}
// AppConfig 应用配置结构体
type AppConfig struct {
Server struct {
Host string `toml:"host"`
Port int `toml:"port"`
FileSize int64 `toml:"fileSize"`
EnableH2C bool `toml:"enableH2C"`
EnableFrontend bool `toml:"enableFrontend"`
} `toml:"server"`
RateLimit struct {
RequestLimit int `toml:"requestLimit"`
PeriodHours float64 `toml:"periodHours"`
} `toml:"rateLimit"`
Security struct {
WhiteList []string `toml:"whiteList"`
BlackList []string `toml:"blackList"`
} `toml:"security"`
Access struct {
WhiteList []string `toml:"whiteList"`
BlackList []string `toml:"blackList"`
Proxy string `toml:"proxy"`
} `toml:"access"`
Download struct {
MaxImages int `toml:"maxImages"`
} `toml:"download"`
Registries map[string]RegistryMapping `toml:"registries"`
TokenCache struct {
Enabled bool `toml:"enabled"`
DefaultTTL string `toml:"defaultTTL"`
} `toml:"tokenCache"`
}
var (
appConfig *AppConfig
appConfigLock sync.RWMutex
cachedConfig *AppConfig
configCacheTime time.Time
configCacheTTL = 5 * time.Second
configCacheMutex sync.RWMutex
)
// DefaultConfig 返回默认配置
func DefaultConfig() *AppConfig {
return &AppConfig{
Server: struct {
Host string `toml:"host"`
Port int `toml:"port"`
FileSize int64 `toml:"fileSize"`
EnableH2C bool `toml:"enableH2C"`
EnableFrontend bool `toml:"enableFrontend"`
}{
Host: "0.0.0.0",
Port: 5000,
FileSize: 2 * 1024 * 1024 * 1024,
EnableH2C: false,
EnableFrontend: true,
},
RateLimit: struct {
RequestLimit int `toml:"requestLimit"`
PeriodHours float64 `toml:"periodHours"`
}{
RequestLimit: 500,
PeriodHours: 3.0,
},
Security: struct {
WhiteList []string `toml:"whiteList"`
BlackList []string `toml:"blackList"`
}{
WhiteList: []string{},
BlackList: []string{},
},
Access: struct {
WhiteList []string `toml:"whiteList"`
BlackList []string `toml:"blackList"`
Proxy string `toml:"proxy"`
}{
WhiteList: []string{},
BlackList: []string{},
Proxy: "",
},
Download: struct {
MaxImages int `toml:"maxImages"`
}{
MaxImages: 10,
},
Registries: map[string]RegistryMapping{
"ghcr.io": {
Upstream: "ghcr.io",
AuthHost: "ghcr.io/token",
AuthType: "github",
Enabled: true,
},
"gcr.io": {
Upstream: "gcr.io",
AuthHost: "gcr.io/v2/token",
AuthType: "google",
Enabled: true,
},
"quay.io": {
Upstream: "quay.io",
AuthHost: "quay.io/v2/auth",
AuthType: "quay",
Enabled: true,
},
"registry.k8s.io": {
Upstream: "registry.k8s.io",
AuthHost: "registry.k8s.io",
AuthType: "anonymous",
Enabled: true,
},
},
TokenCache: struct {
Enabled bool `toml:"enabled"`
DefaultTTL string `toml:"defaultTTL"`
}{
Enabled: true,
DefaultTTL: "20m",
},
}
}
// GetConfig 安全地获取配置副本
func GetConfig() *AppConfig {
configCacheMutex.RLock()
if cachedConfig != nil && time.Since(configCacheTime) < configCacheTTL {
config := cachedConfig
configCacheMutex.RUnlock()
return config
}
configCacheMutex.RUnlock()
configCacheMutex.Lock()
defer configCacheMutex.Unlock()
if cachedConfig != nil && time.Since(configCacheTime) < configCacheTTL {
return cachedConfig
}
appConfigLock.RLock()
if appConfig == nil {
appConfigLock.RUnlock()
defaultCfg := DefaultConfig()
cachedConfig = defaultCfg
configCacheTime = time.Now()
return defaultCfg
}
configCopy := *appConfig
configCopy.Security.WhiteList = append([]string(nil), appConfig.Security.WhiteList...)
configCopy.Security.BlackList = append([]string(nil), appConfig.Security.BlackList...)
configCopy.Access.WhiteList = append([]string(nil), appConfig.Access.WhiteList...)
configCopy.Access.BlackList = append([]string(nil), appConfig.Access.BlackList...)
appConfigLock.RUnlock()
cachedConfig = &configCopy
configCacheTime = time.Now()
return cachedConfig
}
// setConfig 安全地设置配置
func setConfig(cfg *AppConfig) {
appConfigLock.Lock()
defer appConfigLock.Unlock()
appConfig = cfg
configCacheMutex.Lock()
cachedConfig = nil
configCacheMutex.Unlock()
}
// LoadConfig 加载配置文件
func LoadConfig() error {
cfg := DefaultConfig()
if data, err := os.ReadFile("config.toml"); err == nil {
if err := toml.Unmarshal(data, cfg); err != nil {
return fmt.Errorf("解析配置文件失败: %v", err)
}
} else {
fmt.Println("未找到config.toml使用默认配置")
}
overrideFromEnv(cfg)
setConfig(cfg)
return nil
}
// overrideFromEnv 从环境变量覆盖配置
func overrideFromEnv(cfg *AppConfig) {
if val := os.Getenv("SERVER_HOST"); val != "" {
cfg.Server.Host = val
}
if val := os.Getenv("SERVER_PORT"); val != "" {
if port, err := strconv.Atoi(val); err == nil && port > 0 {
cfg.Server.Port = port
}
}
if val := os.Getenv("ENABLE_H2C"); val != "" {
if enable, err := strconv.ParseBool(val); err == nil {
cfg.Server.EnableH2C = enable
}
}
if val := os.Getenv("ENABLE_FRONTEND"); val != "" {
if enable, err := strconv.ParseBool(val); err == nil {
cfg.Server.EnableFrontend = enable
}
}
if val := os.Getenv("MAX_FILE_SIZE"); val != "" {
if size, err := strconv.ParseInt(val, 10, 64); err == nil && size > 0 {
cfg.Server.FileSize = size
}
}
if val := os.Getenv("RATE_LIMIT"); val != "" {
if limit, err := strconv.Atoi(val); err == nil && limit > 0 {
cfg.RateLimit.RequestLimit = limit
}
}
if val := os.Getenv("RATE_PERIOD_HOURS"); val != "" {
if period, err := strconv.ParseFloat(val, 64); err == nil && period > 0 {
cfg.RateLimit.PeriodHours = period
}
}
if val := os.Getenv("IP_WHITELIST"); val != "" {
cfg.Security.WhiteList = append(cfg.Security.WhiteList, strings.Split(val, ",")...)
}
if val := os.Getenv("IP_BLACKLIST"); val != "" {
cfg.Security.BlackList = append(cfg.Security.BlackList, strings.Split(val, ",")...)
}
if val := os.Getenv("MAX_IMAGES"); val != "" {
if maxImages, err := strconv.Atoi(val); err == nil && maxImages > 0 {
cfg.Download.MaxImages = maxImages
}
}
}
// CreateDefaultConfigFile 创建默认配置文件
func CreateDefaultConfigFile() error {
cfg := DefaultConfig()
data, err := toml.Marshal(cfg)
if err != nil {
return fmt.Errorf("序列化默认配置失败: %v", err)
}
return os.WriteFile("config.toml", data, 0644)
}

View File

@@ -1,35 +1,34 @@
module hubproxy
go 1.24.0
go 1.25
require (
github.com/fsnotify/fsnotify v1.8.0
github.com/gin-gonic/gin v1.10.0
github.com/google/go-containerregistry v0.20.5
github.com/pelletier/go-toml/v2 v2.2.3
github.com/spf13/viper v1.20.1
golang.org/x/time v0.11.0
github.com/gin-gonic/gin v1.11.0
github.com/google/go-containerregistry v0.20.6
github.com/pelletier/go-toml/v2 v2.2.4
golang.org/x/net v0.46.0
golang.org/x/time v0.14.0
)
require (
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.14.2 // indirect
github.com/bytedance/sonic/loader v0.4.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect
github.com/docker/cli v28.1.1+incompatible // indirect
github.com/docker/cli v28.2.2+incompatible // indirect
github.com/docker/distribution v2.8.3+incompatible // indirect
github.com/docker/docker-credential-helpers v0.9.3 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/go-playground/validator/v10 v10.28.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
@@ -38,24 +37,19 @@ require (
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.55.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.12.0 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/vbatts/tar-split v0.12.1 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.32.0 // indirect
golang.org/x/net v0.33.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.21.0 // indirect
google.golang.org/protobuf v1.36.3 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
go.uber.org/mock v0.6.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/crypto v0.43.0 // indirect
golang.org/x/mod v0.29.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/text v0.30.0 // indirect
golang.org/x/tools v0.38.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
)

View File

@@ -1,61 +1,51 @@
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980=
github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/containerd/stargz-snapshotter/estargz v0.16.3 h1:7evrXtoh1mSbGj/pfRccTampEyKpjpOnS3CyiV1Ebr8=
github.com/containerd/stargz-snapshotter/estargz v0.16.3/go.mod h1:uyr4BfYfOj3G9WBVE8cOlQmXAbPN9VEQpBBeJIuOipU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/docker/cli v28.1.1+incompatible h1:eyUemzeI45DY7eDPuwUcmDyDj1pM98oD5MdSpiItp8k=
github.com/docker/cli v28.1.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/cli v28.2.2+incompatible h1:qzx5BNUDFqlvyq4AHzdNB7gSyVTmU4cgsyN9SdInc1A=
github.com/docker/cli v28.2.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk=
github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8=
github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-containerregistry v0.20.5 h1:4RnlYcDs5hoA++CeFjlbZ/U9Yp1EuWr+UhhTyYQjOP0=
github.com/google/go-containerregistry v0.20.5/go.mod h1:Q14vdOOzug02bwnhMkZKD4e30pDaD9W65qzXpyzF49E=
github.com/google/go-containerregistry v0.20.6 h1:cvWX87UxxLgaH76b4hIvya6Dzz9qHB31qAwjAohdSTU=
github.com/google/go-containerregistry v0.20.6/go.mod h1:T0x8MuoAoKX/873bkeSfLD2FAkwCDf9/HZgsFJ02E2Y=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -71,77 +61,63 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk=
github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/vbatts/tar-split v0.12.1 h1:CqKoORW7BUWBe7UL/iqTVvkTBOF8UvOMKOIZykxnnbo=
github.com/vbatts/tar-split v0.12.1/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU=
google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0=
gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

File diff suppressed because it is too large Load Diff

235
src/handlers/github.go Normal file
View File

@@ -0,0 +1,235 @@
package handlers
import (
"fmt"
"io"
"net/http"
"regexp"
"strconv"
"strings"
"hubproxy/config"
"hubproxy/utils"
"github.com/gin-gonic/gin"
)
var (
// GitHub URL匹配正则表达式
githubExps = []*regexp.Regexp{
regexp.MustCompile(`^(?:https?://)?github\.com/([^/]+)/([^/]+)/(?:releases|archive)/.*`),
regexp.MustCompile(`^(?:https?://)?github\.com/([^/]+)/([^/]+)/(?:blob|raw)/.*`),
regexp.MustCompile(`^(?:https?://)?github\.com/([^/]+)/([^/]+)/(?:info|git-).*`),
regexp.MustCompile(`^(?:https?://)?raw\.github(?:usercontent|)\.com/([^/]+)/([^/]+)/.+?/.+`),
regexp.MustCompile(`^(?:https?://)?gist\.(?:githubusercontent|github)\.com/([^/]+)/([^/]+).*`),
regexp.MustCompile(`^(?:https?://)?api\.github\.com/repos/([^/]+)/([^/]+)/.*`),
regexp.MustCompile(`^(?:https?://)?huggingface\.co(?:/spaces)?/([^/]+)/(.+)`),
regexp.MustCompile(`^(?:https?://)?cdn-lfs\.hf\.co(?:/spaces)?/([^/]+)/([^/]+)(?:/(.*))?`),
regexp.MustCompile(`^(?:https?://)?download\.docker\.com/([^/]+)/.*\.(tgz|zip)`),
regexp.MustCompile(`^(?:https?://)?(github|opengraph)\.githubassets\.com/([^/]+)/.+?`),
}
)
// 全局变量:被阻止的内容类型
var blockedContentTypes = map[string]bool{
"text/html": true,
"application/xhtml+xml": true,
"text/xml": true,
"application/xml": true,
}
// GitHubProxyHandler GitHub代理处理器
func GitHubProxyHandler(c *gin.Context) {
rawPath := strings.TrimPrefix(c.Request.URL.RequestURI(), "/")
for strings.HasPrefix(rawPath, "/") {
rawPath = strings.TrimPrefix(rawPath, "/")
}
// 自动补全协议头
if !strings.HasPrefix(rawPath, "https://") {
if strings.HasPrefix(rawPath, "http:/") || strings.HasPrefix(rawPath, "https:/") {
rawPath = strings.Replace(rawPath, "http:/", "", 1)
rawPath = strings.Replace(rawPath, "https:/", "", 1)
} else if strings.HasPrefix(rawPath, "http://") {
rawPath = strings.TrimPrefix(rawPath, "http://")
}
rawPath = "https://" + rawPath
}
matches := CheckGitHubURL(rawPath)
if matches != nil {
if allowed, reason := utils.GlobalAccessController.CheckGitHubAccess(matches); !allowed {
var repoPath string
if len(matches) >= 2 {
username := matches[0]
repoName := strings.TrimSuffix(matches[1], ".git")
repoPath = username + "/" + repoName
}
fmt.Printf("GitHub仓库 %s 访问被拒绝: %s\n", repoPath, reason)
c.String(http.StatusForbidden, reason)
return
}
} else {
c.String(http.StatusForbidden, "无效输入")
return
}
// 将blob链接转换为raw链接
if githubExps[1].MatchString(rawPath) {
rawPath = strings.Replace(rawPath, "/blob/", "/raw/", 1)
}
ProxyGitHubRequest(c, rawPath)
}
// CheckGitHubURL 检查URL是否匹配GitHub模式
func CheckGitHubURL(u string) []string {
for _, exp := range githubExps {
if matches := exp.FindStringSubmatch(u); matches != nil {
return matches[1:]
}
}
return nil
}
// ProxyGitHubRequest 代理GitHub请求
func ProxyGitHubRequest(c *gin.Context, u string) {
proxyGitHubWithRedirect(c, u, 0)
}
// proxyGitHubWithRedirect 带重定向的GitHub代理请求
func proxyGitHubWithRedirect(c *gin.Context, u string, redirectCount int) {
const maxRedirects = 20
if redirectCount > maxRedirects {
c.String(http.StatusLoopDetected, "重定向次数过多,可能存在循环重定向")
return
}
req, err := http.NewRequest(c.Request.Method, u, c.Request.Body)
if err != nil {
c.String(http.StatusInternalServerError, fmt.Sprintf("server error %v", err))
return
}
// 复制请求头
for key, values := range c.Request.Header {
for _, value := range values {
req.Header.Add(key, value)
}
}
req.Header.Del("Host")
resp, err := utils.GetGlobalHTTPClient().Do(req)
if err != nil {
c.String(http.StatusInternalServerError, fmt.Sprintf("server error %v", err))
return
}
defer func() {
if err := resp.Body.Close(); err != nil {
fmt.Printf("关闭响应体失败: %v\n", err)
}
}()
// 检查并处理被阻止的内容类型
if c.Request.Method == "GET" {
if contentType := resp.Header.Get("Content-Type"); blockedContentTypes[strings.ToLower(strings.Split(contentType, ";")[0])] {
c.JSON(http.StatusForbidden, map[string]string{
"error": "Content type not allowed",
"message": "检测到网页类型,本服务不支持加速网页,请检查您的链接是否正确。",
})
return
}
}
// 检查文件大小限制
cfg := config.GetConfig()
if contentLength := resp.Header.Get("Content-Length"); contentLength != "" {
if size, err := strconv.ParseInt(contentLength, 10, 64); err == nil && size > cfg.Server.FileSize {
c.String(http.StatusRequestEntityTooLarge,
fmt.Sprintf("文件过大,限制大小: %d MB", cfg.Server.FileSize/(1024*1024)))
return
}
}
// 清理安全相关的头
resp.Header.Del("Content-Security-Policy")
resp.Header.Del("Referrer-Policy")
resp.Header.Del("Strict-Transport-Security")
// 获取真实域名
realHost := c.Request.Header.Get("X-Forwarded-Host")
if realHost == "" {
realHost = c.Request.Host
}
if !strings.HasPrefix(realHost, "http://") && !strings.HasPrefix(realHost, "https://") {
realHost = "https://" + realHost
}
// 处理.sh和.ps1文件的智能处理
if strings.HasSuffix(strings.ToLower(u), ".sh") || strings.HasSuffix(strings.ToLower(u), ".ps1") {
isGzipCompressed := resp.Header.Get("Content-Encoding") == "gzip"
processedBody, processedSize, err := utils.ProcessSmart(resp.Body, isGzipCompressed, realHost)
if err != nil {
fmt.Printf("脚本处理失败: %v\n", err)
c.String(http.StatusBadGateway, "Script processing failed: %v", err)
return
}
// 智能设置响应头
if processedSize > 0 {
resp.Header.Del("Content-Length")
resp.Header.Del("Content-Encoding")
resp.Header.Set("Transfer-Encoding", "chunked")
}
// 复制其他响应头
for key, values := range resp.Header {
for _, value := range values {
c.Header(key, value)
}
}
// 处理重定向
if location := resp.Header.Get("Location"); location != "" {
if CheckGitHubURL(location) != nil {
c.Header("Location", "/"+location)
} else {
proxyGitHubWithRedirect(c, location, redirectCount+1)
return
}
}
c.Status(resp.StatusCode)
// 输出处理后的内容
if _, err := io.Copy(c.Writer, processedBody); err != nil {
return
}
} else {
// 复制响应头
for key, values := range resp.Header {
for _, value := range values {
c.Header(key, value)
}
}
// 处理重定向
if location := resp.Header.Get("Location"); location != "" {
if CheckGitHubURL(location) != nil {
c.Header("Location", "/"+location)
} else {
proxyGitHubWithRedirect(c, location, redirectCount+1)
return
}
}
c.Status(resp.StatusCode)
// 直接流式转发
if _, err := io.Copy(c.Writer, resp.Body); err != nil {
fmt.Printf("转发响应体失败: %v\n", err)
}
}
}

File diff suppressed because it is too large Load Diff

518
src/handlers/search.go Normal file
View File

@@ -0,0 +1,518 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
"hubproxy/utils"
"github.com/gin-gonic/gin"
)
// SearchResult Docker Hub搜索结果
type SearchResult struct {
Count int `json:"count"`
Next string `json:"next"`
Previous string `json:"previous"`
Results []Repository `json:"results"`
}
// Repository 仓库信息
type Repository struct {
Name string `json:"repo_name"`
Description string `json:"short_description"`
IsOfficial bool `json:"is_official"`
IsAutomated bool `json:"is_automated"`
StarCount int `json:"star_count"`
PullCount int `json:"pull_count"`
RepoOwner string `json:"repo_owner"`
LastUpdated string `json:"last_updated"`
Status int `json:"status"`
Organization string `json:"affiliation"`
PullsLastWeek int `json:"pulls_last_week"`
Namespace string `json:"namespace"`
}
// TagInfo 标签信息
type TagInfo struct {
Name string `json:"name"`
FullSize int64 `json:"full_size"`
LastUpdated time.Time `json:"last_updated"`
LastPusher string `json:"last_pusher"`
Images []Image `json:"images"`
Vulnerabilities struct {
Critical int `json:"critical"`
High int `json:"high"`
Medium int `json:"medium"`
Low int `json:"low"`
Unknown int `json:"unknown"`
} `json:"vulnerabilities"`
}
// Image 镜像信息
type Image struct {
Architecture string `json:"architecture"`
Features string `json:"features"`
Variant string `json:"variant,omitempty"`
Digest string `json:"digest"`
OS string `json:"os"`
OSFeatures string `json:"os_features"`
Size int64 `json:"size"`
}
// TagPageResult 分页标签结果
type TagPageResult struct {
Tags []TagInfo `json:"tags"`
HasMore bool `json:"has_more"`
}
type cacheEntry struct {
data interface{}
expiresAt time.Time
}
const (
maxCacheSize = 1000
maxPaginationCache = 200
cacheTTL = 30 * time.Minute
)
type Cache struct {
data map[string]cacheEntry
mu sync.RWMutex
maxSize int
}
var (
searchCache = &Cache{
data: make(map[string]cacheEntry),
maxSize: maxCacheSize,
}
)
func (c *Cache) Get(key string) (interface{}, bool) {
c.mu.RLock()
entry, exists := c.data[key]
c.mu.RUnlock()
if !exists {
return nil, false
}
if time.Now().After(entry.expiresAt) {
c.mu.Lock()
delete(c.data, key)
c.mu.Unlock()
return nil, false
}
return entry.data, true
}
func (c *Cache) Set(key string, data interface{}) {
c.SetWithTTL(key, data, cacheTTL)
}
func (c *Cache) SetWithTTL(key string, data interface{}, ttl time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
if len(c.data) >= c.maxSize {
c.cleanupExpiredLocked()
}
c.data[key] = cacheEntry{
data: data,
expiresAt: time.Now().Add(ttl),
}
}
func (c *Cache) Cleanup() {
c.mu.Lock()
defer c.mu.Unlock()
c.cleanupExpiredLocked()
}
func (c *Cache) cleanupExpiredLocked() {
now := time.Now()
for key, entry := range c.data {
if now.After(entry.expiresAt) {
delete(c.data, key)
}
}
}
func init() {
go func() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
searchCache.Cleanup()
}
}()
}
// normalizeRepository 统一规范化仓库信息
func normalizeRepository(repo *Repository) {
if repo.IsOfficial {
repo.Namespace = "library"
if !strings.Contains(repo.Name, "/") {
repo.Name = "library/" + repo.Name
}
} else {
if repo.Namespace == "" && repo.RepoOwner != "" {
repo.Namespace = repo.RepoOwner
}
if strings.Contains(repo.Name, "/") {
parts := strings.Split(repo.Name, "/")
if len(parts) > 1 {
if repo.Namespace == "" {
repo.Namespace = parts[0]
}
repo.Name = parts[len(parts)-1]
}
}
}
}
// searchDockerHub 搜索镜像
func searchDockerHub(ctx context.Context, query string, page, pageSize int) (*SearchResult, error) {
return searchDockerHubWithDepth(ctx, query, page, pageSize, 0)
}
func searchDockerHubWithDepth(ctx context.Context, query string, page, pageSize int, depth int) (*SearchResult, error) {
if depth > 1 {
return nil, fmt.Errorf("搜索请求过于复杂,请尝试更具体的关键词")
}
cacheKey := fmt.Sprintf("search:%s:%d:%d", query, page, pageSize)
if cached, ok := searchCache.Get(cacheKey); ok {
return cached.(*SearchResult), nil
}
isUserRepo := strings.Contains(query, "/")
var namespace, repoName string
if isUserRepo {
parts := strings.Split(query, "/")
if len(parts) == 2 {
namespace = parts[0]
repoName = parts[1]
}
}
baseURL := "https://registry.hub.docker.com/v2"
var fullURL string
var params url.Values
if isUserRepo && namespace != "" {
fullURL = fmt.Sprintf("%s/repositories/%s/", baseURL, namespace)
params = url.Values{
"page": {fmt.Sprintf("%d", page)},
"page_size": {fmt.Sprintf("%d", pageSize)},
}
} else {
fullURL = baseURL + "/search/repositories/"
params = url.Values{
"query": {query},
"page": {fmt.Sprintf("%d", page)},
"page_size": {fmt.Sprintf("%d", pageSize)},
}
}
fullURL = fullURL + "?" + params.Encode()
resp, err := utils.GetSearchHTTPClient().Get(fullURL)
if err != nil {
return nil, fmt.Errorf("请求Docker Hub API失败: %v", err)
}
defer safeCloseResponseBody(resp.Body, "搜索响应体")
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取响应失败: %v", err)
}
if resp.StatusCode != http.StatusOK {
switch resp.StatusCode {
case http.StatusTooManyRequests:
return nil, fmt.Errorf("请求过于频繁,请稍后重试")
case http.StatusNotFound:
if isUserRepo && namespace != "" {
return searchDockerHubWithDepth(ctx, repoName, page, pageSize, depth+1)
}
return nil, fmt.Errorf("未找到相关镜像")
case http.StatusBadGateway, http.StatusServiceUnavailable:
return nil, fmt.Errorf("docker hub 服务暂时不可用,请稍后重试")
default:
return nil, fmt.Errorf("请求失败: 状态码=%d, 响应=%s", resp.StatusCode, string(body))
}
}
var result *SearchResult
if isUserRepo && namespace != "" {
var userRepos struct {
Count int `json:"count"`
Next string `json:"next"`
Previous string `json:"previous"`
Results []Repository `json:"results"`
}
if err := json.Unmarshal(body, &userRepos); err != nil {
return nil, fmt.Errorf("解析响应失败: %v", err)
}
result = &SearchResult{
Count: userRepos.Count,
Next: userRepos.Next,
Previous: userRepos.Previous,
Results: make([]Repository, 0),
}
for _, repo := range userRepos.Results {
if repoName == "" || strings.Contains(strings.ToLower(repo.Name), strings.ToLower(repoName)) {
repo.Namespace = namespace
normalizeRepository(&repo)
result.Results = append(result.Results, repo)
}
}
if len(result.Results) == 0 {
return searchDockerHubWithDepth(ctx, repoName, page, pageSize, depth+1)
}
result.Count = len(result.Results)
} else {
result = &SearchResult{}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("解析响应失败: %v", err)
}
for i := range result.Results {
normalizeRepository(&result.Results[i])
}
if isUserRepo && namespace != "" {
filteredResults := make([]Repository, 0)
for _, repo := range result.Results {
if strings.EqualFold(repo.Namespace, namespace) {
filteredResults = append(filteredResults, repo)
}
}
result.Results = filteredResults
result.Count = len(filteredResults)
}
}
searchCache.Set(cacheKey, result)
return result, nil
}
func isRetryableError(err error) bool {
if err == nil {
return false
}
if strings.Contains(err.Error(), "timeout") ||
strings.Contains(err.Error(), "connection refused") ||
strings.Contains(err.Error(), "no such host") ||
strings.Contains(err.Error(), "too many requests") {
return true
}
return false
}
// getRepositoryTags 获取仓库标签信息
func getRepositoryTags(ctx context.Context, namespace, name string, page, pageSize int) ([]TagInfo, bool, error) {
if namespace == "" || name == "" {
return nil, false, fmt.Errorf("无效输入:命名空间和名称不能为空")
}
if page <= 0 {
page = 1
}
if pageSize <= 0 || pageSize > 100 {
pageSize = 100
}
cacheKey := fmt.Sprintf("tags:%s:%s:page_%d", namespace, name, page)
if cached, ok := searchCache.Get(cacheKey); ok {
result := cached.(TagPageResult)
return result.Tags, result.HasMore, nil
}
baseURL := fmt.Sprintf("https://registry.hub.docker.com/v2/repositories/%s/%s/tags", namespace, name)
params := url.Values{}
params.Set("page", fmt.Sprintf("%d", page))
params.Set("page_size", fmt.Sprintf("%d", pageSize))
params.Set("ordering", "last_updated")
fullURL := baseURL + "?" + params.Encode()
pageResult, err := fetchTagPage(ctx, fullURL, 3)
if err != nil {
return nil, false, fmt.Errorf("获取标签失败: %v", err)
}
hasMore := pageResult.Next != ""
result := TagPageResult{Tags: pageResult.Results, HasMore: hasMore}
searchCache.SetWithTTL(cacheKey, result, 30*time.Minute)
return pageResult.Results, hasMore, nil
}
func fetchTagPage(ctx context.Context, url string, maxRetries int) (*struct {
Count int `json:"count"`
Next string `json:"next"`
Previous string `json:"previous"`
Results []TagInfo `json:"results"`
}, error) {
var lastErr error
for retry := 0; retry < maxRetries; retry++ {
if retry > 0 {
time.Sleep(time.Duration(retry) * 500 * time.Millisecond)
}
resp, err := utils.GetSearchHTTPClient().Get(url)
if err != nil {
lastErr = err
if isRetryableError(err) && retry < maxRetries-1 {
continue
}
return nil, fmt.Errorf("发送请求失败: %v", err)
}
body, err := func() ([]byte, error) {
defer safeCloseResponseBody(resp.Body, "标签响应体")
return io.ReadAll(resp.Body)
}()
if err != nil {
lastErr = err
if retry < maxRetries-1 {
continue
}
return nil, fmt.Errorf("读取响应失败: %v", err)
}
if resp.StatusCode != http.StatusOK {
lastErr = fmt.Errorf("状态码=%d, 响应=%s", resp.StatusCode, string(body))
if resp.StatusCode >= 400 && resp.StatusCode < 500 && resp.StatusCode != 429 {
return nil, fmt.Errorf("请求失败: %v", lastErr)
}
if retry < maxRetries-1 {
continue
}
return nil, fmt.Errorf("请求失败: %v", lastErr)
}
var result struct {
Count int `json:"count"`
Next string `json:"next"`
Previous string `json:"previous"`
Results []TagInfo `json:"results"`
}
if err := json.Unmarshal(body, &result); err != nil {
lastErr = err
if retry < maxRetries-1 {
continue
}
return nil, fmt.Errorf("解析响应失败: %v", err)
}
return &result, nil
}
return nil, lastErr
}
func parsePaginationParams(c *gin.Context, defaultPageSize int) (page, pageSize int) {
page = 1
pageSize = defaultPageSize
if p := c.Query("page"); p != "" {
if _, err := fmt.Sscanf(p, "%d", &page); err != nil {
fmt.Printf("解析page参数失败: %v\n", err)
}
}
if ps := c.Query("page_size"); ps != "" {
if _, err := fmt.Sscanf(ps, "%d", &pageSize); err != nil {
fmt.Printf("解析page_size参数失败: %v\n", err)
}
}
return page, pageSize
}
func safeCloseResponseBody(body io.ReadCloser, context string) {
if body != nil {
if err := body.Close(); err != nil {
fmt.Printf("关闭%s失败: %v\n", context, err)
}
}
}
func sendErrorResponse(c *gin.Context, message string) {
c.JSON(http.StatusBadRequest, gin.H{"error": message})
}
// RegisterSearchRoute 注册搜索相关路由
func RegisterSearchRoute(r *gin.Engine) {
r.GET("/search", func(c *gin.Context) {
query := c.Query("q")
if query == "" {
sendErrorResponse(c, "搜索关键词不能为空")
return
}
page, pageSize := parsePaginationParams(c, 25)
result, err := searchDockerHub(c.Request.Context(), query, page, pageSize)
if err != nil {
sendErrorResponse(c, err.Error())
return
}
c.JSON(http.StatusOK, result)
})
r.GET("/tags/:namespace/:name", func(c *gin.Context) {
namespace := c.Param("namespace")
name := c.Param("name")
if namespace == "" || name == "" {
sendErrorResponse(c, "命名空间和名称不能为空")
return
}
page, pageSize := parsePaginationParams(c, 100)
tags, hasMore, err := getRepositoryTags(c.Request.Context(), namespace, name, page, pageSize)
if err != nil {
sendErrorResponse(c, err.Error())
return
}
if c.Query("page") != "" || c.Query("page_size") != "" {
c.JSON(http.StatusOK, gin.H{
"tags": tags,
"has_more": hasMore,
"page": page,
"page_size": pageSize,
})
} else {
c.JSON(http.StatusOK, tags)
}
})
}

View File

@@ -1,381 +1,216 @@
package main
import (
"embed"
"fmt"
"io"
"log"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
)
//go:embed public/*
var staticFiles embed.FS
// 服务嵌入的静态文件
func serveEmbedFile(c *gin.Context, filename string) {
data, err := staticFiles.ReadFile(filename)
if err != nil {
c.Status(404)
return
}
contentType := "text/html; charset=utf-8"
if strings.HasSuffix(filename, ".ico") {
contentType = "image/x-icon"
}
c.Data(200, contentType, data)
}
var (
exps = []*regexp.Regexp{
regexp.MustCompile(`^(?:https?://)?github\.com/([^/]+)/([^/]+)/(?:releases|archive)/.*$`),
regexp.MustCompile(`^(?:https?://)?github\.com/([^/]+)/([^/]+)/(?:blob|raw)/.*$`),
regexp.MustCompile(`^(?:https?://)?github\.com/([^/]+)/([^/]+)/(?:info|git-).*$`),
regexp.MustCompile(`^(?:https?://)?raw\.github(?:usercontent|)\.com/([^/]+)/([^/]+)/.+?/.+$`),
regexp.MustCompile(`^(?:https?://)?gist\.github(?:usercontent|)\.com/([^/]+)/.+?/.+`),
regexp.MustCompile(`^(?:https?://)?api\.github\.com/repos/([^/]+)/([^/]+)/.*`),
regexp.MustCompile(`^(?:https?://)?huggingface\.co(?:/spaces)?/([^/]+)/(.+)$`),
regexp.MustCompile(`^(?:https?://)?cdn-lfs\.hf\.co(?:/spaces)?/([^/]+)/([^/]+)(?:/(.*))?$`),
regexp.MustCompile(`^(?:https?://)?download\.docker\.com/([^/]+)/.*\.(tgz|zip)$`),
regexp.MustCompile(`^(?:https?://)?(github|opengraph)\.githubassets\.com/([^/]+)/.+?$`),
}
globalLimiter *IPRateLimiter
// 服务启动时间
serviceStartTime = time.Now()
)
func main() {
// 加载配置
if err := LoadConfig(); err != nil {
fmt.Printf("配置加载失败: %v\n", err)
return
}
// 初始化HTTP客户端
initHTTPClients()
// 初始化限流器
initLimiter()
// 初始化Docker流式代理
initDockerProxy()
// 初始化镜像流式下载器
initImageStreamer()
// 初始化防抖器
initDebouncer()
gin.SetMode(gin.ReleaseMode)
router := gin.Default()
// 全局Panic恢复保护
router.Use(gin.CustomRecovery(func(c *gin.Context, recovered interface{}) {
log.Printf("🚨 Panic recovered: %v", recovered)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Internal server error",
"code": "INTERNAL_ERROR",
})
}))
// 初始化监控端点
initHealthRoutes(router)
// 初始化镜像tar下载路由
initImageTarRoutes(router)
// 静态文件路由
router.GET("/", func(c *gin.Context) {
serveEmbedFile(c, "public/index.html")
})
router.GET("/public/*filepath", func(c *gin.Context) {
filepath := strings.TrimPrefix(c.Param("filepath"), "/")
serveEmbedFile(c, "public/"+filepath)
})
router.GET("/images.html", func(c *gin.Context) {
serveEmbedFile(c, "public/images.html")
})
router.GET("/search.html", func(c *gin.Context) {
serveEmbedFile(c, "public/search.html")
})
router.GET("/favicon.ico", func(c *gin.Context) {
serveEmbedFile(c, "public/favicon.ico")
})
// 注册dockerhub搜索路由
RegisterSearchRoute(router)
// 注册Docker认证路由/token*
router.Any("/token", RateLimitMiddleware(globalLimiter), ProxyDockerAuthGin)
router.Any("/token/*path", RateLimitMiddleware(globalLimiter), ProxyDockerAuthGin)
// 注册Docker Registry代理路由
router.Any("/v2/*path", RateLimitMiddleware(globalLimiter), ProxyDockerRegistryGin)
// 注册NoRoute处理器
router.NoRoute(RateLimitMiddleware(globalLimiter), handler)
cfg := GetConfig()
fmt.Printf("🚀 HubProxy 启动成功\n")
fmt.Printf("📡 监听地址: %s:%d\n", cfg.Server.Host, cfg.Server.Port)
fmt.Printf("⚡ 限流配置: %d请求/%g小时\n", cfg.RateLimit.RequestLimit, cfg.RateLimit.PeriodHours)
fmt.Printf("🔗 项目地址: https://github.com/sky22333/hubproxy\n")
err := router.Run(fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port))
if err != nil {
fmt.Printf("启动服务失败: %v\n", err)
}
}
func handler(c *gin.Context) {
rawPath := strings.TrimPrefix(c.Request.URL.RequestURI(), "/")
for strings.HasPrefix(rawPath, "/") {
rawPath = strings.TrimPrefix(rawPath, "/")
}
if !strings.HasPrefix(rawPath, "http") {
c.String(http.StatusForbidden, "无效输入")
return
}
matches := checkURL(rawPath)
if matches != nil {
// GitHub仓库访问控制检查
if allowed, reason := GlobalAccessController.CheckGitHubAccess(matches); !allowed {
// 构建仓库名用于日志
var repoPath string
if len(matches) >= 2 {
username := matches[0]
repoName := strings.TrimSuffix(matches[1], ".git")
repoPath = username + "/" + repoName
}
fmt.Printf("GitHub仓库 %s 访问被拒绝: %s\n", repoPath, reason)
c.String(http.StatusForbidden, reason)
return
}
} else {
c.String(http.StatusForbidden, "无效输入")
return
}
if exps[1].MatchString(rawPath) {
rawPath = strings.Replace(rawPath, "/blob/", "/raw/", 1)
}
proxy(c, rawPath)
}
func proxy(c *gin.Context, u string) {
proxyWithRedirect(c, u, 0)
}
func proxyWithRedirect(c *gin.Context, u string, redirectCount int) {
// 限制最大重定向次数,防止无限递归
const maxRedirects = 20
if redirectCount > maxRedirects {
c.String(http.StatusLoopDetected, "重定向次数过多,可能存在循环重定向")
return
}
req, err := http.NewRequest(c.Request.Method, u, c.Request.Body)
if err != nil {
c.String(http.StatusInternalServerError, fmt.Sprintf("server error %v", err))
return
}
for key, values := range c.Request.Header {
for _, value := range values {
req.Header.Add(key, value)
}
}
req.Header.Del("Host")
resp, err := GetGlobalHTTPClient().Do(req)
if err != nil {
c.String(http.StatusInternalServerError, fmt.Sprintf("server error %v", err))
return
}
defer func() {
if err := resp.Body.Close(); err != nil {
fmt.Printf("关闭响应体失败: %v\n", err)
}
}()
// 检查文件大小限制
cfg := GetConfig()
if contentLength := resp.Header.Get("Content-Length"); contentLength != "" {
if size, err := strconv.ParseInt(contentLength, 10, 64); err == nil && size > cfg.Server.FileSize {
c.String(http.StatusRequestEntityTooLarge,
fmt.Sprintf("文件过大,限制大小: %d MB", cfg.Server.FileSize/(1024*1024)))
return
}
}
// 清理安全相关的头
resp.Header.Del("Content-Security-Policy")
resp.Header.Del("Referrer-Policy")
resp.Header.Del("Strict-Transport-Security")
// 获取真实域名
realHost := c.Request.Header.Get("X-Forwarded-Host")
if realHost == "" {
realHost = c.Request.Host
}
// 如果域名中没有协议前缀添加https://
if !strings.HasPrefix(realHost, "http://") && !strings.HasPrefix(realHost, "https://") {
realHost = "https://" + realHost
}
if strings.HasSuffix(strings.ToLower(u), ".sh") {
isGzipCompressed := resp.Header.Get("Content-Encoding") == "gzip"
processedBody, processedSize, err := ProcessSmart(resp.Body, isGzipCompressed, realHost)
if err != nil {
fmt.Printf("智能处理失败,回退到直接代理: %v\n", err)
processedBody = resp.Body
processedSize = 0
}
// 智能设置响应头
if processedSize > 0 {
resp.Header.Del("Content-Length")
resp.Header.Del("Content-Encoding")
resp.Header.Set("Transfer-Encoding", "chunked")
}
// 复制其他响应头
for key, values := range resp.Header {
for _, value := range values {
c.Header(key, value)
}
}
if location := resp.Header.Get("Location"); location != "" {
if checkURL(location) != nil {
c.Header("Location", "/"+location)
} else {
proxyWithRedirect(c, location, redirectCount+1)
return
}
}
c.Status(resp.StatusCode)
// 输出处理后的内容
if _, err := io.Copy(c.Writer, processedBody); err != nil {
return
}
} else {
for key, values := range resp.Header {
for _, value := range values {
c.Header(key, value)
}
}
// 处理重定向
if location := resp.Header.Get("Location"); location != "" {
if checkURL(location) != nil {
c.Header("Location", "/"+location)
} else {
proxyWithRedirect(c, location, redirectCount+1)
return
}
}
c.Status(resp.StatusCode)
// 直接流式转发
if _, err := io.Copy(c.Writer, resp.Body); err != nil {
fmt.Printf("直接代理失败: %v\n", err)
}
}
}
func checkURL(u string) []string {
for _, exp := range exps {
if matches := exp.FindStringSubmatch(u); matches != nil {
return matches[1:]
}
}
return nil
}
// 初始化健康监控路由
func initHealthRoutes(router *gin.Engine) {
// 健康检查端点
router.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "healthy",
"timestamp": time.Now().Unix(),
"uptime": time.Since(serviceStartTime).Seconds(),
"service": "hubproxy",
})
})
// 就绪检查端点
router.GET("/ready", func(c *gin.Context) {
checks := make(map[string]string)
allReady := true
if GetConfig() != nil {
checks["config"] = "ok"
} else {
checks["config"] = "failed"
allReady = false
}
// 检查全局缓存状态
if globalCache != nil {
checks["cache"] = "ok"
} else {
checks["cache"] = "failed"
allReady = false
}
// 检查限流器状态
if globalLimiter != nil {
checks["ratelimiter"] = "ok"
} else {
checks["ratelimiter"] = "failed"
allReady = false
}
// 检查镜像下载器状态
if globalImageStreamer != nil {
checks["imagestreamer"] = "ok"
} else {
checks["imagestreamer"] = "failed"
allReady = false
}
// 检查HTTP客户端状态
if GetGlobalHTTPClient() != nil {
checks["httpclient"] = "ok"
} else {
checks["httpclient"] = "failed"
allReady = false
}
status := http.StatusOK
if !allReady {
status = http.StatusServiceUnavailable
}
c.JSON(status, gin.H{
"ready": allReady,
"checks": checks,
"timestamp": time.Now().Unix(),
"uptime": time.Since(serviceStartTime).Seconds(),
})
})
}
package main
import (
"embed"
"fmt"
"log"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
"hubproxy/config"
"hubproxy/handlers"
"hubproxy/utils"
)
//go:embed public/*
var staticFiles embed.FS
// 服务嵌入的静态文件
func serveEmbedFile(c *gin.Context, filename string) {
data, err := staticFiles.ReadFile(filename)
if err != nil {
c.Status(404)
return
}
contentType := "text/html; charset=utf-8"
if strings.HasSuffix(filename, ".ico") {
contentType = "image/x-icon"
}
c.Data(200, contentType, data)
}
var (
globalLimiter *utils.IPRateLimiter
// 服务启动时间
serviceStartTime = time.Now()
)
var Version = "dev"
func buildRouter(cfg *config.AppConfig) *gin.Engine {
gin.SetMode(gin.ReleaseMode)
router := gin.Default()
// 全局Panic恢复保护
router.Use(gin.CustomRecovery(func(c *gin.Context, recovered interface{}) {
log.Printf("🚨 Panic recovered: %v", recovered)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Internal server error",
"code": "INTERNAL_ERROR",
})
}))
// 全局限流中间件
router.Use(utils.RateLimitMiddleware(globalLimiter))
// 初始化监控端点
initHealthRoutes(router)
// 初始化镜像tar下载路由
handlers.InitImageTarRoutes(router)
if cfg.Server.EnableFrontend {
router.GET("/", func(c *gin.Context) {
serveEmbedFile(c, "public/index.html")
})
router.GET("/public/*filepath", func(c *gin.Context) {
filepath := strings.TrimPrefix(c.Param("filepath"), "/")
serveEmbedFile(c, "public/"+filepath)
})
router.GET("/images.html", func(c *gin.Context) {
serveEmbedFile(c, "public/images.html")
})
router.GET("/search.html", func(c *gin.Context) {
serveEmbedFile(c, "public/search.html")
})
router.GET("/favicon.ico", func(c *gin.Context) {
serveEmbedFile(c, "public/favicon.ico")
})
} else {
router.GET("/", func(c *gin.Context) {
c.Status(http.StatusNotFound)
})
router.GET("/public/*filepath", func(c *gin.Context) {
c.Status(http.StatusNotFound)
})
router.GET("/images.html", func(c *gin.Context) {
c.Status(http.StatusNotFound)
})
router.GET("/search.html", func(c *gin.Context) {
c.Status(http.StatusNotFound)
})
router.GET("/favicon.ico", func(c *gin.Context) {
c.Status(http.StatusNotFound)
})
}
// 注册dockerhub搜索路由
handlers.RegisterSearchRoute(router)
// 注册Docker认证路由
router.Any("/token", handlers.ProxyDockerAuthGin)
router.Any("/token/*path", handlers.ProxyDockerAuthGin)
// 注册Docker Registry代理路由
router.Any("/v2/*path", handlers.ProxyDockerRegistryGin)
// 注册GitHub代理路由NoRoute处理器
router.NoRoute(handlers.GitHubProxyHandler)
return router
}
func main() {
// 加载配置
if err := config.LoadConfig(); err != nil {
fmt.Printf("配置加载失败: %v\n", err)
return
}
// 初始化HTTP客户端
utils.InitHTTPClients()
// 初始化限流器
globalLimiter = utils.InitGlobalLimiter()
// 初始化Docker流式代理
handlers.InitDockerProxy()
// 初始化镜像流式下载器
handlers.InitImageStreamer()
// 初始化防抖器
handlers.InitDebouncer()
cfg := config.GetConfig()
router := buildRouter(cfg)
fmt.Printf("HubProxy 启动成功\n")
fmt.Printf("监听地址: %s:%d\n", cfg.Server.Host, cfg.Server.Port)
fmt.Printf("限流配置: %d请求/%g小时\n", cfg.RateLimit.RequestLimit, cfg.RateLimit.PeriodHours)
// 显示HTTP/2支持状态
if cfg.Server.EnableH2C {
fmt.Printf("H2c: 已启用\n")
}
fmt.Printf("版本号: %s\n", Version)
fmt.Printf("项目地址: https://github.com/sky22333/hubproxy\n")
// 创建HTTP2服务器
server := &http.Server{
Addr: fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port),
ReadTimeout: 60 * time.Second,
WriteTimeout: 30 * time.Minute,
IdleTimeout: 120 * time.Second,
}
// 根据配置决定是否启用H2C
if cfg.Server.EnableH2C {
h2cHandler := h2c.NewHandler(router, &http2.Server{
MaxConcurrentStreams: 250,
IdleTimeout: 300 * time.Second,
MaxReadFrameSize: 4 << 20,
MaxUploadBufferPerConnection: 8 << 20,
MaxUploadBufferPerStream: 2 << 20,
})
server.Handler = h2cHandler
} else {
server.Handler = router
}
err := server.ListenAndServe()
if err != nil {
fmt.Printf("启动服务失败: %v\n", err)
}
}
// 简单的健康检查
func formatDuration(d time.Duration) string {
if d < time.Minute {
return fmt.Sprintf("%d秒", int(d.Seconds()))
} else if d < time.Hour {
return fmt.Sprintf("%d分钟%d秒", int(d.Minutes()), int(d.Seconds())%60)
} else if d < 24*time.Hour {
return fmt.Sprintf("%d小时%d分钟", int(d.Hours()), int(d.Minutes())%60)
} else {
days := int(d.Hours()) / 24
hours := int(d.Hours()) % 24
return fmt.Sprintf("%d天%d小时", days, hours)
}
}
func getUptimeInfo() (time.Duration, float64, string) {
uptime := time.Since(serviceStartTime)
return uptime, uptime.Seconds(), formatDuration(uptime)
}
func initHealthRoutes(router *gin.Engine) {
router.GET("/ready", func(c *gin.Context) {
_, uptimeSec, uptimeHuman := getUptimeInfo()
c.JSON(http.StatusOK, gin.H{
"ready": true,
"service": "hubproxy",
"version": Version,
"start_time_unix": serviceStartTime.Unix(),
"uptime_sec": uptimeSec,
"uptime_human": uptimeHuman,
})
})
}

View File

@@ -1,95 +0,0 @@
package main
import (
"bytes"
"compress/gzip"
"fmt"
"io"
"regexp"
"strings"
)
// GitHub URL正则表达式
var githubRegex = regexp.MustCompile(`https?://(?:github\.com|raw\.githubusercontent\.com|raw\.github\.com|gist\.githubusercontent\.com|gist\.github\.com|api\.github\.com)[^\s'"]+`)
// ProcessSmart Shell脚本智能处理函数
func ProcessSmart(input io.ReadCloser, isCompressed bool, host string) (io.Reader, int64, error) {
defer input.Close()
content, err := readShellContent(input, isCompressed)
if err != nil {
return nil, 0, fmt.Errorf("内容读取失败: %v", err)
}
if len(content) == 0 {
return strings.NewReader(""), 0, nil
}
if len(content) > 10*1024*1024 {
return strings.NewReader(content), int64(len(content)), nil
}
if !strings.Contains(content, "github.com") && !strings.Contains(content, "githubusercontent.com") {
return strings.NewReader(content), int64(len(content)), nil
}
processed := processGitHubURLs(content, host)
return strings.NewReader(processed), int64(len(processed)), nil
}
func readShellContent(input io.ReadCloser, isCompressed bool) (string, error) {
var reader io.Reader = input
// 处理gzip压缩
if isCompressed {
peek := make([]byte, 2)
n, err := input.Read(peek)
if err != nil && err != io.EOF {
return "", fmt.Errorf("读取数据失败: %v", err)
}
if n >= 2 && peek[0] == 0x1f && peek[1] == 0x8b {
combinedReader := io.MultiReader(bytes.NewReader(peek[:n]), input)
gzReader, err := gzip.NewReader(combinedReader)
if err != nil {
return "", fmt.Errorf("gzip解压失败: %v", err)
}
defer gzReader.Close()
reader = gzReader
} else {
reader = io.MultiReader(bytes.NewReader(peek[:n]), input)
}
}
data, err := io.ReadAll(reader)
if err != nil {
return "", fmt.Errorf("读取内容失败: %v", err)
}
return string(data), nil
}
func processGitHubURLs(content, host string) string {
return githubRegex.ReplaceAllStringFunc(content, func(url string) string {
return transformURL(url, host)
})
}
// transformURL URL转换函数
func transformURL(url, host string) string {
if strings.Contains(url, host) {
return url
}
if strings.HasPrefix(url, "http://") {
url = "https" + url[4:]
} else if !strings.HasPrefix(url, "https://") && !strings.HasPrefix(url, "//") {
url = "https://" + url
}
cleanHost := strings.TrimPrefix(host, "https://")
cleanHost = strings.TrimPrefix(cleanHost, "http://")
cleanHost = strings.TrimSuffix(cleanHost, "/")
return cleanHost + "/" + url
}

253
src/public/images.html vendored
View File

@@ -1,13 +1,13 @@
<!DOCTYPE html>
<html lang="zh">
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Docker镜像流式下载工具,即点即下,无需等待">
<meta name="keywords" content="Docker,镜像下载,流式下载,即时下载">
<meta name="description" content="Docker镜像流式下载工具即点即下无需等待">
<meta name="keywords" content="Docker镜像下载流式下载即时下载">
<meta name="color-scheme" content="dark light">
<title>Docker离线镜像下载</title>
<link rel="icon" href="./favicon.ico">
<link rel="icon" href="/favicon.ico">
<style>
:root {
--background: #ffffff;
@@ -399,6 +399,67 @@
100% { transform: rotate(360deg); }
}
/* 切换开关样式 */
.switch-container {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.switch {
position: relative;
display: inline-block;
width: 50px;
height: 24px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--muted);
transition: 0.2s;
border-radius: 24px;
border: 1px solid var(--border);
}
.slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 2px;
bottom: 2px;
background-color: white;
transition: 0.2s;
border-radius: 50%;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
input:checked + .slider {
background-color: var(--primary);
}
input:checked + .slider:before {
transform: translateX(26px);
}
.switch-label {
font-weight: 500;
color: var(--foreground);
cursor: pointer;
}
.hidden {
display: none;
}
@@ -520,7 +581,7 @@
</div>
<div class="feature">
<span class="feature-icon">💾</span>
<span>无需打包</span>
<span>无需等待</span>
</div>
<div class="feature">
<span class="feature-icon">🏗️</span>
@@ -559,6 +620,14 @@
</div>
</div>
<div class="switch-container">
<label class="switch">
<input type="checkbox" id="compressedToggle" checked>
<span class="slider"></span>
</label>
<label for="compressedToggle" class="switch-label">使用压缩层(减小包体积)</label>
</div>
<button type="submit" class="btn btn-primary btn-full" id="downloadBtn">
<span id="downloadText">立即下载</span>
<span id="downloadLoading" class="loading hidden"></span>
@@ -573,7 +642,7 @@
<form id="batchForm">
<div class="form-group">
<label class="form-label" for="imagesTextarea">镜像列表,每行一个,会将多个镜像自动合并,符合官方标准,完全兼容docker load</label>
<label class="form-label" for="imagesTextarea">镜像列表每行一个会将多个镜像自动合并符合官方标准兼容docker load</label>
<textarea
id="imagesTextarea"
class="textarea"
@@ -595,6 +664,14 @@
</div>
</div>
<div class="switch-container">
<label class="switch">
<input type="checkbox" id="batchCompressedToggle" checked>
<span class="slider"></span>
</label>
<label for="batchCompressedToggle" class="switch-label">使用压缩层(减小包体积)</label>
</div>
<button type="submit" class="btn btn-primary btn-full" id="batchDownloadBtn">
<span id="batchDownloadText">开始下载</span>
<span id="batchDownloadLoading" class="loading hidden"></span>
@@ -651,18 +728,85 @@
}
}
function buildDownloadUrl(imageName, platform = '') {
function buildDownloadUrl(imageName, platform = '', useCompressed = true, mode = '') {
const encodedImage = imageName.replace(/\//g, '_');
let url = `/api/image/download/${encodedImage}`;
const params = new URLSearchParams();
if (platform && platform.trim()) {
url += `?platform=${encodeURIComponent(platform.trim())}`;
params.append('platform', platform.trim());
}
params.append('compressed', useCompressed.toString());
if (mode) {
params.append('mode', mode);
}
if (params.toString()) {
url += '?' + params.toString();
}
return url;
}
document.getElementById('singleForm').addEventListener('submit', function(e) {
function buildInfoUrl(imageName) {
const encodedImage = imageName.replace(/\//g, '_');
return `/api/image/info/${encodedImage}`;
}
async function preflightImageDownload(imageName) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 8000);
try {
const response = await fetch(buildInfoUrl(imageName), {
method: 'GET',
headers: {
'Accept': 'application/json'
},
cache: 'no-store',
signal: controller.signal
});
const contentType = response.headers.get('Content-Type') || '';
let payload = null;
if (contentType.includes('application/json')) {
payload = await response.json();
}
if (!response.ok) {
return { ok: false, error: (payload && payload.error) ? payload.error : '镜像预检失败' };
}
if (payload && payload.success === false) {
return { ok: false, error: payload.error || '镜像预检失败' };
}
return { ok: true };
} catch (error) {
if (error.name === 'AbortError') {
return { ok: false, error: '预检超时,请稍后重试' };
}
return { ok: false, error: '网络错误: ' + error.message };
} finally {
clearTimeout(timeoutId);
}
}
async function preflightImages(images) {
const uniqueImages = Array.from(new Set(images));
const results = await Promise.allSettled(uniqueImages.map((imageName) => preflightImageDownload(imageName)));
for (let i = 0; i < results.length; i++) {
const result = results[i];
if (result.status === 'rejected') {
return { ok: false, error: '预检失败,请稍后重试' };
}
if (!result.value.ok) {
return { ok: false, error: result.value.error || '预检失败' };
}
}
return { ok: true };
}
document.getElementById('singleForm').addEventListener('submit', async function(e) {
e.preventDefault();
const imageName = document.getElementById('imageInput').value.trim();
@@ -672,24 +816,55 @@
}
const platform = document.getElementById('platformInput').value.trim();
const useCompressed = document.getElementById('compressedToggle').checked;
hideStatus('singleStatus');
setButtonLoading('downloadBtn', 'downloadText', 'downloadLoading', true);
const downloadUrl = buildDownloadUrl(imageName, platform);
const link = document.createElement('a');
link.href = downloadUrl;
link.download = '';
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
const platformText = platform ? ` (${platform})` : '';
showStatus('singleStatus', `开始下载 ${imageName}${platformText}`, 'success');
setButtonLoading('downloadBtn', 'downloadText', 'downloadLoading', false);
showStatus('singleStatus', '正在准备下载...', 'success');
const preflightResult = await preflightImages([imageName]);
if (!preflightResult.ok) {
showStatus('singleStatus', preflightResult.error, 'error');
setButtonLoading('downloadBtn', 'downloadText', 'downloadLoading', false);
return;
}
const prepareUrl = buildDownloadUrl(imageName, platform, useCompressed, 'prepare');
try {
const response = await fetch(prepareUrl, {
method: 'GET',
headers: {
'Accept': 'application/json'
},
cache: 'no-store'
});
if (response.ok) {
const data = await response.json();
if (!data || !data.download_url) {
showStatus('singleStatus', '下载地址生成失败', 'error');
return;
}
const link = document.createElement('a');
link.href = data.download_url;
link.download = '';
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
const platformText = platform ? ` (${platform})` : '';
showStatus('singleStatus', `开始下载 ${imageName}${platformText}`, 'success');
} else {
const error = await response.json();
showStatus('singleStatus', error.error || '下载失败', 'error');
}
} catch (error) {
showStatus('singleStatus', '网络错误: ' + error.message, 'error');
} finally {
setButtonLoading('downloadBtn', 'downloadText', 'downloadLoading', false);
}
});
document.getElementById('batchForm').addEventListener('submit', async function(e) {
@@ -711,9 +886,11 @@
}
const platform = document.getElementById('batchPlatformInput').value.trim();
const useCompressed = document.getElementById('batchCompressedToggle').checked;
const options = {
images: images
images: images,
useCompressedLayers: useCompressed
};
if (platform) {
@@ -722,9 +899,16 @@
hideStatus('batchStatus');
setButtonLoading('batchDownloadBtn', 'batchDownloadText', 'batchDownloadLoading', true);
showStatus('batchStatus', '正在准备下载...', 'success');
const preflightResult = await preflightImages(images);
if (!preflightResult.ok) {
showStatus('batchStatus', preflightResult.error, 'error');
setButtonLoading('batchDownloadBtn', 'batchDownloadText', 'batchDownloadLoading', false);
return;
}
try {
const response = await fetch('/api/image/batch', {
const response = await fetch('/api/image/batch?mode=prepare', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -733,24 +917,19 @@
});
if (response.ok) {
const contentDisposition = response.headers.get('Content-Disposition');
let filename = `batch_${images.length}_images.tar`;
if (contentDisposition) {
const matches = contentDisposition.match(/filename="(.+)"/);
if (matches) filename = matches[1];
const data = await response.json();
if (!data || !data.download_url) {
showStatus('batchStatus', '下载地址生成失败', 'error');
return;
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const url = data.download_url;
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
const platformText = platform ? ` (${platform})` : '';
showStatus('batchStatus', `开始下载 ${images.length} 个镜像${platformText}`, 'success');
@@ -786,4 +965,4 @@
initMobileMenu();
</script>
</body>
</html>
</html>

37
src/public/index.html vendored
View File

@@ -1,14 +1,13 @@
<!DOCTYPE html>
<html lang="zh">
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Github文件加速,docker镜像加速">
<meta name="keywords" content="Github,文件加速,ghproxy,docker镜像加速">
<meta name="description" content="Github文件加速docker镜像加速">
<meta name="keywords" content="Github文件加速ghproxydocker镜像加速">
<meta name="color-scheme" content="dark light">
<title>Github文件加速</title>
<link rel="icon" href="./favicon.ico">
<title>Github、Docker加速</title>
<link rel="icon" href="/favicon.ico">
<style>
:root {
--background: #ffffff;
@@ -602,17 +601,17 @@
<div class="hero">
<h1 class="hero-title">GitHub 文件加速</h1>
<p class="hero-subtitle">
快速下载GitHub上的文件和仓库解决国内访问GitHub速度慢的问题支持AI模型库Hugging Face
快速下载GitHub上的文件和仓库解决国内访问GitHub速度慢的问题支持Docker镜像加速和Hugging Face仓库。
</p>
</div>
<div class="card">
<div class="card-header">
<h2 class="card-title">
⚡ 快速生成加速链接
⚡ 快速转换加速链接
</h2>
<p class="card-description">
输入GitHub文件或仓库链接自动转换加速链接可以直接在Github域名前面加上本站域名使用。
输入GitHub文件链接自动转换加速链接可以直接在Github文件链接前加上本站域名使用。
</p>
</div>
@@ -622,7 +621,7 @@
type="text"
class="input"
id="githubLinkInput"
placeholder="请输入GitHub链接例如https://github.com/user/repo/releases/download/..."
placeholder="请输入GitHub文件链接例如https://github.com/user/repo/releases/download/..."
>
<button class="button button-primary" id="formatButton">
获取加速链接
@@ -653,12 +652,12 @@
🐳 Docker 镜像加速
</h3>
<p class="card-description">
支持多种Registry,在镜像名前添加本站域名即可加速下载。
支持多种镜像仓库,在镜像名前添加本站域名即可加速下载。
</p>
</div>
<button class="docker-button" id="dockerButton">
查看 Docker 镜像加速配置
查看 Docker 镜像加速使用说明
</button>
</div>
</div>
@@ -669,23 +668,23 @@
<button class="close-button" id="closeModal">&times;</button>
<div class="modal-header">
<h2 class="modal-title">Docker 镜像加速</h2>
<p>支持多种Registry,在镜像名前添加本站域名即可加速下载。</p>
<p>支持多种镜像仓库,在镜像名前添加本站域名即可加速下载。</p>
</div>
<div class="domain-examples">
<strong>Docker Hub 官方镜像:</strong>
<strong>Docker 官方镜像:</strong>
docker pull <span class="domain-base"></span>/nginx
<strong>Docker Hub 第三方镜像:</strong>
<strong>Docker 镜像:</strong>
docker pull <span class="domain-base"></span>/user/image
<strong>GitHub Container Registry</strong>
<strong>ghcr.io 镜像</strong>
docker pull <span class="domain-base"></span>/ghcr.io/user/image
<strong>Quay.io Registry</strong>
<strong>Quay.io 镜像</strong>
docker pull <span class="domain-base"></span>/quay.io/org/image
<strong>Kubernetes Registry</strong>
<strong>Kubernetes 镜像</strong>
docker pull <span class="domain-base"></span>/registry.k8s.io/pause:3.8
</div>
</div>
@@ -696,7 +695,7 @@
</div>
<footer class="footer">
<a href="https://github.com/sky22333/hubproxy" target="_blank" class="github-link">
<a href="//" target="_blank" class="github-link">
<svg width="20" height="20" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/>
</svg>

337
src/public/search.html vendored
View File

@@ -1,13 +1,13 @@
<!DOCTYPE html>
<html lang="zh">
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Docker镜像搜索">
<meta name="keywords" content="Docker,镜像搜索,docker search">
<meta name="keywords" content="Docker镜像搜索docker search">
<meta name="color-scheme" content="dark light">
<title>Docker镜像搜索</title>
<link rel="icon" href="./favicon.ico">
<link rel="icon" href="/favicon.ico">
<style>
:root {
--background: #ffffff;
@@ -782,7 +782,6 @@
</div>
<div id="toast"></div>
<script>
const formatUtils = {
formatNumber(num) {
@@ -853,6 +852,10 @@
let totalPages = 1;
let currentQuery = '';
let currentRepo = null;
// 标签分页相关变量
let currentTagPage = 1;
let totalTagPages = 1;
document.getElementById('searchButton').addEventListener('click', () => {
currentPage = 1;
@@ -884,6 +887,21 @@
showSearchResults();
});
// 使用事件委托处理分页按钮点击避免DOM重建导致事件丢失
document.addEventListener('click', (e) => {
if (e.target.id === 'tagPrevPage') {
if (currentTagPage > 1) {
currentTagPage--;
loadTagPage();
}
} else if (e.target.id === 'tagNextPage') {
if (currentTagPage < totalTagPages) {
currentTagPage++;
loadTagPage();
}
}
});
function showLoading() {
document.querySelector('.loading').style.display = 'block';
}
@@ -901,71 +919,134 @@
}, 3000);
}
function updatePagination() {
const prevButton = document.getElementById('prevPage');
const nextButton = document.getElementById('nextPage');
// 统一分页更新函数(支持搜索和标签分页)
function updatePagination(config = {}) {
const {
currentPage: page = currentPage,
totalPages: total = totalPages,
prefix = ''
} = config;
const prevButtonId = prefix ? `${prefix}PrevPage` : 'prevPage';
const nextButtonId = prefix ? `${prefix}NextPage` : 'nextPage';
const paginationId = prefix ? `${prefix}Pagination` : '.pagination';
const prevButton = document.getElementById(prevButtonId);
const nextButton = document.getElementById(nextButtonId);
const paginationDiv = prefix ? document.getElementById(paginationId) : document.querySelector(paginationId);
prevButton.disabled = currentPage <= 1;
nextButton.disabled = currentPage >= totalPages;
if (!prevButton || !nextButton || !paginationDiv) {
return; // 静默处理,避免控制台警告
}
// 更新按钮状态
prevButton.disabled = page <= 1;
nextButton.disabled = page >= total;
// 更新或创建页面信息
const pageInfoId = prefix ? `${prefix}PageInfo` : 'pageInfo';
let pageInfo = document.getElementById(pageInfoId);
const paginationDiv = document.querySelector('.pagination');
let pageInfo = document.getElementById('pageInfo');
if (!pageInfo) {
const container = document.createElement('div');
container.id = 'pageInfo';
container.style.margin = '0 10px';
container.style.display = 'flex';
container.style.alignItems = 'center';
container.style.gap = '10px';
const pageText = document.createElement('span');
pageText.id = 'pageText';
const jumpInput = document.createElement('input');
jumpInput.type = 'number';
jumpInput.min = '1';
jumpInput.id = 'jumpPage';
jumpInput.style.width = '60px';
jumpInput.style.padding = '4px';
jumpInput.style.borderRadius = '4px';
jumpInput.style.border = '1px solid var(--border)';
jumpInput.style.backgroundColor = 'var(--input)';
jumpInput.style.color = 'var(--foreground)';
const jumpButton = document.createElement('button');
jumpButton.textContent = '跳转';
jumpButton.className = 'btn search-button';
jumpButton.style.padding = '4px 8px';
jumpButton.onclick = () => {
const page = parseInt(jumpInput.value);
if (page && page >= 1 && page <= totalPages) {
currentPage = page;
performSearch();
} else {
showToast('请输入有效的页码');
}
};
container.appendChild(pageText);
container.appendChild(jumpInput);
container.appendChild(jumpButton);
paginationDiv.insertBefore(container, nextButton);
pageInfo = container;
pageInfo = createPageInfo(pageInfoId, prefix, total);
paginationDiv.insertBefore(pageInfo, nextButton);
}
const pageText = document.getElementById('pageText');
pageText.textContent = `${currentPage} / ${totalPages || 1} 页 共 ${totalPages || 1}`;
const jumpInput = document.getElementById('jumpPage');
if (jumpInput) {
jumpInput.max = totalPages;
jumpInput.value = currentPage;
}
paginationDiv.style.display = totalPages > 1 ? 'flex' : 'none';
updatePageInfo(pageInfo, page, total, prefix);
paginationDiv.style.display = total > 1 ? 'flex' : 'none';
}
// 创建页面信息元素
function createPageInfo(pageInfoId, prefix, total) {
const container = document.createElement('div');
container.id = pageInfoId;
container.style.cssText = 'margin: 0 10px; display: flex; align-items: center; gap: 10px;';
const pageText = document.createElement('span');
pageText.id = prefix ? `${prefix}PageText` : 'pageText';
const jumpInput = document.createElement('input');
jumpInput.type = 'number';
jumpInput.min = '1';
jumpInput.max = prefix === 'tag' ? total : Math.min(total, 100); // 搜索页面限制100页
jumpInput.id = prefix ? `${prefix}JumpPage` : 'jumpPage';
jumpInput.style.cssText = 'width: 60px; padding: 4px; border-radius: 4px; border: 1px solid var(--border); background-color: var(--input); color: var(--foreground);';
const jumpButton = document.createElement('button');
jumpButton.textContent = '跳转';
jumpButton.className = 'btn search-button';
jumpButton.style.padding = '4px 8px';
jumpButton.onclick = () => handlePageJump(jumpInput, prefix, total);
container.append(pageText, jumpInput, jumpButton);
return container;
}
// 更新页面信息显示
function updatePageInfo(pageInfo, page, total, prefix) {
const pageText = pageInfo.querySelector('span');
const jumpInput = pageInfo.querySelector('input');
// 标签分页显示策略:根据是否确定总页数显示不同格式
const isTagPagination = prefix === 'tag';
const maxDisplayPages = isTagPagination ? total : Math.min(total, 100);
const pageTextContent = isTagPagination
? `${page}` + (total > page ? ` (至少 ${total} 页)` : ` (共 ${total} 页)`)
: `${page} / ${maxDisplayPages} 页 共 ${maxDisplayPages}` + (total > 100 ? ' (最多100页)' : '');
pageText.textContent = pageTextContent;
jumpInput.max = maxDisplayPages;
jumpInput.value = page;
}
// 处理页面跳转
function handlePageJump(jumpInput, prefix, total) {
const inputPage = parseInt(jumpInput.value);
const maxPage = prefix === 'tag' ? total : Math.min(total, 100);
if (!inputPage || inputPage < 1 || inputPage > maxPage) {
const limitText = prefix === 'tag' ? '页码' : '页码 (最多100页)';
showToast(`请输入有效的${limitText}`);
return;
}
if (prefix === 'tag') {
currentTagPage = inputPage;
loadTagPage();
} else {
currentPage = inputPage;
performSearch();
}
}
// 统一仓库信息处理
function parseRepositoryInfo(repo) {
const namespace = repo.namespace || (repo.is_official ? 'library' : '');
let name = repo.name || repo.repo_name || '';
// 清理名称,确保不包含命名空间前缀
if (name.includes('/')) {
const parts = name.split('/');
name = parts[parts.length - 1];
}
const cleanName = name.replace(/^library\//, '');
const fullRepoName = repo.is_official ? cleanName : `${namespace}/${cleanName}`;
return {
namespace,
name,
cleanName,
fullRepoName
};
}
// 分页更新函数
const updateTagPagination = () => updatePagination({
currentPage: currentTagPage,
totalPages: totalTagPages,
prefix: 'tag'
});
function showSearchResults() {
document.querySelector('.search-results').style.display = 'block';
document.querySelector('.tag-list').style.display = 'none';
@@ -1006,7 +1087,7 @@
throw new Error(data.error || '搜索请求失败');
}
totalPages = Math.ceil(data.count / 25);
totalPages = Math.min(Math.ceil(data.count / 25), 100);
updatePagination();
displayResults(data.results, targetRepo);
@@ -1108,23 +1189,55 @@
});
}
// 内存管理
async function loadTags(namespace, name) {
currentTagPage = 1;
await loadTagPage(namespace, name);
}
async function loadTagPage(namespace = null, name = null) {
showLoading();
try {
if (!namespace || !name) {
// 如果传入了新的namespace和name更新currentRepo
if (namespace && name) {
// 清理旧数据,防止内存泄露
cleanupOldTagData();
}
// 获取当前仓库信息
const repoInfo = parseRepositoryInfo(currentRepo);
const currentNamespace = namespace || repoInfo.namespace;
const currentName = name || repoInfo.name;
if (!currentNamespace || !currentName) {
showToast('命名空间和镜像名称不能为空');
return;
}
const response = await fetch(`/tags/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}`);
const response = await fetch(`/tags/${encodeURIComponent(currentNamespace)}/${encodeURIComponent(currentName)}?page=${currentTagPage}&page_size=100`);
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || '获取标签信息失败');
}
const data = await response.json();
displayTags(data);
showTagList();
// 改进的总页数计算:使用更准确的分页策略
if (data.has_more) {
// 如果还有更多页面,至少有当前页+1页但可能更多
totalTagPages = Math.max(currentTagPage + 1, totalTagPages);
} else {
// 如果没有更多页面,当前页就是最后一页
totalTagPages = currentTagPage;
}
displayTags(data.tags, data.has_more);
updateTagPagination();
if (namespace && name) {
showTagList();
}
} catch (error) {
console.error('加载标签错误:', error);
showToast(error.message || '获取标签信息失败,请稍后重试');
@@ -1133,12 +1246,24 @@
}
}
function displayTags(tags) {
function cleanupOldTagData() {
// 清理全局变量,释放内存
if (window.currentPageTags) {
window.currentPageTags.length = 0;
window.currentPageTags = null;
}
// 清理DOM缓存
const tagsContainer = document.getElementById('tagsContainer');
if (tagsContainer) {
tagsContainer.innerHTML = '';
}
}
function displayTags(tags, hasMore = false) {
const tagList = document.getElementById('tagList');
const namespace = currentRepo.namespace || (currentRepo.is_official ? 'library' : '');
const name = currentRepo.name || currentRepo.repo_name || '';
const cleanName = name.replace(/^library\//, '');
const fullRepoName = currentRepo.is_official ? cleanName : `${namespace}/${cleanName}`;
const repoInfo = parseRepositoryInfo(currentRepo);
const { fullRepoName } = repoInfo;
let header = `
<div class="tag-header">
@@ -1165,22 +1290,60 @@
<button class="tag-search-clear" onclick="clearTagSearch()">×</button>
</div>
<div id="tagsContainer"></div>
<div class="pagination" id="tagPagination" style="display: none;">
<button id="tagPrevPage" disabled>上一页</button>
<button id="tagNextPage" disabled>下一页</button>
</div>
`;
tagList.innerHTML = header;
window.allTags = tags;
// 存储当前页标签数据
window.currentPageTags = tags;
renderFilteredTags(tags);
}
function renderFilteredTags(filteredTags) {
const tagsContainer = document.getElementById('tagsContainer');
const namespace = currentRepo.namespace || (currentRepo.is_official ? 'library' : '');
const name = currentRepo.name || currentRepo.repo_name || '';
const cleanName = name.replace(/^library\//, '');
const fullRepoName = currentRepo.is_official ? cleanName : `${namespace}/${cleanName}`;
const repoInfo = parseRepositoryInfo(currentRepo);
const { fullRepoName } = repoInfo;
let tagsHtml = filteredTags.map(tag => {
if (filteredTags.length === 0) {
tagsContainer.innerHTML = '<div class="text-center" style="padding: 20px;">未找到匹配的标签</div>';
return;
}
// 渐进式渲染:分批处理大数据集
const BATCH_SIZE = 50;
if (filteredTags.length <= BATCH_SIZE) {
// 小数据集:直接渲染
renderTagsBatch(filteredTags, fullRepoName, tagsContainer, true);
} else {
// 大数据集:分批渲染
tagsContainer.innerHTML = ''; // 清空容器
let currentBatch = 0;
function renderNextBatch() {
const start = currentBatch * BATCH_SIZE;
const end = Math.min(start + BATCH_SIZE, filteredTags.length);
const batch = filteredTags.slice(start, end);
renderTagsBatch(batch, fullRepoName, tagsContainer, false);
currentBatch++;
if (end < filteredTags.length) {
// 使用requestAnimationFrame确保UI响应性
requestAnimationFrame(renderNextBatch);
}
}
renderNextBatch();
}
}
function renderTagsBatch(tags, fullRepoName, container, replaceContent = false) {
const tagsHtml = tags.map(tag => {
const vulnIndicators = Object.entries(tag.vulnerabilities || {})
.map(([level, count]) => count > 0 ? `<span class="vulnerability-dot vulnerability-${level.toLowerCase()}" title="${level}: ${count}"></span>` : '')
.join('');
@@ -1212,23 +1375,23 @@
`;
}).join('');
if (filteredTags.length === 0) {
tagsHtml = '<div class="text-center" style="padding: 20px;">未找到匹配的标签</div>';
if (replaceContent) {
container.innerHTML = tagsHtml;
} else {
container.insertAdjacentHTML('beforeend', tagsHtml);
}
tagsContainer.innerHTML = tagsHtml;
}
function filterTags(searchText) {
if (!window.allTags) return;
if (!window.currentPageTags) return;
const searchLower = searchText.toLowerCase();
let filteredTags;
if (!searchText) {
filteredTags = window.allTags;
filteredTags = window.currentPageTags;
} else {
const scoredTags = window.allTags.map(tag => {
const scoredTags = window.currentPageTags.map(tag => {
const name = tag.name.toLowerCase();
let score = 0;
@@ -1263,6 +1426,8 @@
}
}
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(() => {
showToast('已复制到剪贴板');
@@ -1313,4 +1478,4 @@
</script>
</main>
</body>
</html>
</html>

View File

@@ -1,299 +0,0 @@
package main
import (
"fmt"
"net"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/time/rate"
)
const (
// 清理间隔
CleanupInterval = 10 * time.Minute
MaxIPCacheSize = 10000
)
// IPRateLimiter IP限流器结构体
type IPRateLimiter struct {
ips map[string]*rateLimiterEntry // IP到限流器的映射
mu *sync.RWMutex // 读写锁,保证并发安全
r rate.Limit // 速率限制(每秒允许的请求数)
b int // 令牌桶容量(突发请求数)
whitelist []*net.IPNet // 白名单IP段
blacklist []*net.IPNet // 黑名单IP段
}
// rateLimiterEntry 限流器条目
type rateLimiterEntry struct {
limiter *rate.Limiter
lastAccess time.Time
}
// initGlobalLimiter 初始化全局限流器
func initGlobalLimiter() *IPRateLimiter {
cfg := GetConfig()
whitelist := make([]*net.IPNet, 0, len(cfg.Security.WhiteList))
for _, item := range cfg.Security.WhiteList {
if item = strings.TrimSpace(item); item != "" {
if !strings.Contains(item, "/") {
item = item + "/32" // 单个IP转为CIDR格式
}
_, ipnet, err := net.ParseCIDR(item)
if err == nil {
whitelist = append(whitelist, ipnet)
} else {
fmt.Printf("警告: 无效的白名单IP格式: %s\n", item)
}
}
}
// 解析黑名单IP段
blacklist := make([]*net.IPNet, 0, len(cfg.Security.BlackList))
for _, item := range cfg.Security.BlackList {
if item = strings.TrimSpace(item); item != "" {
if !strings.Contains(item, "/") {
item = item + "/32" // 单个IP转为CIDR格式
}
_, ipnet, err := net.ParseCIDR(item)
if err == nil {
blacklist = append(blacklist, ipnet)
} else {
fmt.Printf("警告: 无效的黑名单IP格式: %s\n", item)
}
}
}
// 计算速率:将 "每N小时X个请求" 转换为 "每秒Y个请求"
ratePerSecond := rate.Limit(float64(cfg.RateLimit.RequestLimit) / (cfg.RateLimit.PeriodHours * 3600))
burstSize := cfg.RateLimit.RequestLimit
if burstSize < 1 {
burstSize = 1
}
limiter := &IPRateLimiter{
ips: make(map[string]*rateLimiterEntry),
mu: &sync.RWMutex{},
r: ratePerSecond,
b: burstSize,
whitelist: whitelist,
blacklist: blacklist,
}
// 启动定期清理goroutine
go limiter.cleanupRoutine()
return limiter
}
// initLimiter 初始化限流器
func initLimiter() {
globalLimiter = initGlobalLimiter()
}
// cleanupRoutine 定期清理过期的限流器
func (i *IPRateLimiter) cleanupRoutine() {
ticker := time.NewTicker(CleanupInterval)
defer ticker.Stop()
for range ticker.C {
now := time.Now()
expired := make([]string, 0)
// 查找过期的条目
i.mu.RLock()
for ip, entry := range i.ips {
// 如果最后访问时间超过1小时认为过期
if now.Sub(entry.lastAccess) > 1*time.Hour {
expired = append(expired, ip)
}
}
i.mu.RUnlock()
// 如果有过期条目或者缓存过大,进行清理
if len(expired) > 0 || len(i.ips) > MaxIPCacheSize {
i.mu.Lock()
// 删除过期条目
for _, ip := range expired {
delete(i.ips, ip)
}
// 如果缓存仍然过大,全部清理
if len(i.ips) > MaxIPCacheSize {
i.ips = make(map[string]*rateLimiterEntry)
}
i.mu.Unlock()
}
}
}
// extractIPFromAddress 从地址中提取纯IP去除端口号
func extractIPFromAddress(address string) string {
// 处理IPv6地址 [::1]:8080 格式
if strings.HasPrefix(address, "[") {
if endIndex := strings.Index(address, "]"); endIndex != -1 {
return address[1:endIndex]
}
}
// 处理IPv4地址 192.168.1.1:8080 格式
if lastColon := strings.LastIndex(address, ":"); lastColon != -1 {
return address[:lastColon]
}
return address
}
// isIPInCIDRList 检查IP是否在CIDR列表中
func isIPInCIDRList(ip string, cidrList []*net.IPNet) bool {
// 先提取纯IP地址
cleanIP := extractIPFromAddress(ip)
parsedIP := net.ParseIP(cleanIP)
if parsedIP == nil {
return false
}
for _, cidr := range cidrList {
if cidr.Contains(parsedIP) {
return true
}
}
return false
}
// GetLimiter 获取指定IP的限流器同时返回是否允许访问
func (i *IPRateLimiter) GetLimiter(ip string) (*rate.Limiter, bool) {
// 提取纯IP地址
cleanIP := extractIPFromAddress(ip)
// 检查是否在黑名单中
if isIPInCIDRList(cleanIP, i.blacklist) {
return nil, false
}
// 检查是否在白名单中
if isIPInCIDRList(cleanIP, i.whitelist) {
return rate.NewLimiter(rate.Inf, i.b), true
}
now := time.Now()
i.mu.RLock()
entry, exists := i.ips[cleanIP]
i.mu.RUnlock()
if exists {
i.mu.Lock()
if entry, stillExists := i.ips[cleanIP]; stillExists {
entry.lastAccess = now
i.mu.Unlock()
return entry.limiter, true
}
i.mu.Unlock()
}
i.mu.Lock()
if entry, exists := i.ips[cleanIP]; exists {
entry.lastAccess = now
i.mu.Unlock()
return entry.limiter, true
}
entry = &rateLimiterEntry{
limiter: rate.NewLimiter(i.r, i.b),
lastAccess: now,
}
i.ips[cleanIP] = entry
i.mu.Unlock()
return entry.limiter, true
}
// RateLimitMiddleware 速率限制中间件
func RateLimitMiddleware(limiter *IPRateLimiter) gin.HandlerFunc {
return func(c *gin.Context) {
// 获取客户端真实IP
var ip string
// 优先尝试从请求头获取真实IP
if forwarded := c.GetHeader("X-Forwarded-For"); forwarded != "" {
// X-Forwarded-For可能包含多个IP取第一个
ips := strings.Split(forwarded, ",")
ip = strings.TrimSpace(ips[0])
} else if realIP := c.GetHeader("X-Real-IP"); realIP != "" {
// 如果有X-Real-IP头
ip = realIP
} else if remoteIP := c.GetHeader("X-Original-Forwarded-For"); remoteIP != "" {
// 某些代理可能使用此头
ips := strings.Split(remoteIP, ",")
ip = strings.TrimSpace(ips[0])
} else {
// 回退到ClientIP方法
ip = c.ClientIP()
}
// 提取纯IP地址去除端口号
cleanIP := extractIPFromAddress(ip)
// 日志记录请求IP和头信息
fmt.Printf("请求IP: %s (去除端口后: %s), X-Forwarded-For: %s, X-Real-IP: %s\n",
ip,
cleanIP,
c.GetHeader("X-Forwarded-For"),
c.GetHeader("X-Real-IP"))
// 获取限流器并检查是否允许访问
ipLimiter, allowed := limiter.GetLimiter(cleanIP)
// 如果IP在黑名单中
if !allowed {
c.JSON(403, gin.H{
"error": "您已被限制访问",
})
c.Abort()
return
}
// 智能限流判断:检查是否应该跳过限流计数
shouldSkip := smartLimiter.ShouldSkipRateLimit(cleanIP, c.Request.URL.Path)
// 只有在不跳过的情况下才检查限流
if !shouldSkip && !ipLimiter.Allow() {
c.JSON(429, gin.H{
"error": "请求频率过快,暂时限制访问",
})
c.Abort()
return
}
c.Next()
}
}
// ApplyRateLimit 应用限流到特定路由
func ApplyRateLimit(router *gin.Engine, path string, method string, handler gin.HandlerFunc) {
// 使用全局限流器
limiter := globalLimiter
if limiter == nil {
limiter = initGlobalLimiter()
}
// 根据HTTP方法应用限流
switch method {
case "GET":
router.GET(path, RateLimitMiddleware(limiter), handler)
case "POST":
router.POST(path, RateLimitMiddleware(limiter), handler)
case "PUT":
router.PUT(path, RateLimitMiddleware(limiter), handler)
case "DELETE":
router.DELETE(path, RateLimitMiddleware(limiter), handler)
default:
router.Any(path, RateLimitMiddleware(limiter), handler)
}
}

View File

@@ -1,500 +0,0 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
)
// SearchResult Docker Hub搜索结果
type SearchResult struct {
Count int `json:"count"`
Next string `json:"next"`
Previous string `json:"previous"`
Results []Repository `json:"results"`
}
// Repository 仓库信息
type Repository struct {
Name string `json:"repo_name"`
Description string `json:"short_description"`
IsOfficial bool `json:"is_official"`
IsAutomated bool `json:"is_automated"`
StarCount int `json:"star_count"`
PullCount int `json:"pull_count"`
RepoOwner string `json:"repo_owner"`
LastUpdated string `json:"last_updated"`
Status int `json:"status"`
Organization string `json:"affiliation"`
PullsLastWeek int `json:"pulls_last_week"`
Namespace string `json:"namespace"`
}
// TagInfo 标签信息
type TagInfo struct {
Name string `json:"name"`
FullSize int64 `json:"full_size"`
LastUpdated time.Time `json:"last_updated"`
LastPusher string `json:"last_pusher"`
Images []Image `json:"images"`
Vulnerabilities struct {
Critical int `json:"critical"`
High int `json:"high"`
Medium int `json:"medium"`
Low int `json:"low"`
Unknown int `json:"unknown"`
} `json:"vulnerabilities"`
}
// Image 镜像信息
type Image struct {
Architecture string `json:"architecture"`
Features string `json:"features"`
Variant string `json:"variant,omitempty"`
Digest string `json:"digest"`
OS string `json:"os"`
OSFeatures string `json:"os_features"`
Size int64 `json:"size"`
}
type cacheEntry struct {
data interface{}
timestamp time.Time
}
const (
maxCacheSize = 1000 // 最大缓存条目数
cacheTTL = 30 * time.Minute
)
type Cache struct {
data map[string]cacheEntry
mu sync.RWMutex
maxSize int
}
var (
searchCache = &Cache{
data: make(map[string]cacheEntry),
maxSize: maxCacheSize,
}
)
func (c *Cache) Get(key string) (interface{}, bool) {
c.mu.RLock()
entry, exists := c.data[key]
c.mu.RUnlock()
if !exists {
return nil, false
}
if time.Since(entry.timestamp) > cacheTTL {
c.mu.Lock()
delete(c.data, key)
c.mu.Unlock()
return nil, false
}
return entry.data, true
}
func (c *Cache) Set(key string, data interface{}) {
c.mu.Lock()
defer c.mu.Unlock()
now := time.Now()
for k, v := range c.data {
if now.Sub(v.timestamp) > cacheTTL {
delete(c.data, k)
}
}
if len(c.data) >= c.maxSize {
toDelete := len(c.data) / 4
for k := range c.data {
if toDelete <= 0 {
break
}
delete(c.data, k)
toDelete--
}
}
c.data[key] = cacheEntry{
data: data,
timestamp: now,
}
}
func (c *Cache) Cleanup() {
c.mu.Lock()
defer c.mu.Unlock()
now := time.Now()
for key, entry := range c.data {
if now.Sub(entry.timestamp) > cacheTTL {
delete(c.data, key)
}
}
}
// 定期清理过期缓存
func init() {
go func() {
ticker := time.NewTicker(5 * time.Minute)
for range ticker.C {
searchCache.Cleanup()
}
}()
}
func filterSearchResults(results []Repository, query string) []Repository {
searchTerm := strings.ToLower(strings.TrimPrefix(query, "library/"))
filtered := make([]Repository, 0)
for _, repo := range results {
// 标准化仓库名称
repoName := strings.ToLower(repo.Name)
repoDesc := strings.ToLower(repo.Description)
// 计算相关性得分
score := 0
// 完全匹配
if repoName == searchTerm {
score += 100
}
// 前缀匹配
if strings.HasPrefix(repoName, searchTerm) {
score += 50
}
// 包含匹配
if strings.Contains(repoName, searchTerm) {
score += 30
}
// 描述匹配
if strings.Contains(repoDesc, searchTerm) {
score += 10
}
// 官方镜像加分
if repo.IsOfficial {
score += 20
}
// 分数达到阈值的结果才保留
if score > 0 {
filtered = append(filtered, repo)
}
}
// 按相关性排序
sort.Slice(filtered, func(i, j int) bool {
// 优先考虑官方镜像
if filtered[i].IsOfficial != filtered[j].IsOfficial {
return filtered[i].IsOfficial
}
// 其次考虑拉取次数
return filtered[i].PullCount > filtered[j].PullCount
})
return filtered
}
// searchDockerHub 搜索镜像
func searchDockerHub(ctx context.Context, query string, page, pageSize int) (*SearchResult, error) {
cacheKey := fmt.Sprintf("search:%s:%d:%d", query, page, pageSize)
// 尝试从缓存获取
if cached, ok := searchCache.Get(cacheKey); ok {
return cached.(*SearchResult), nil
}
// 判断是否是用户/仓库格式的搜索
isUserRepo := strings.Contains(query, "/")
var namespace, repoName string
if isUserRepo {
parts := strings.Split(query, "/")
if len(parts) == 2 {
namespace = parts[0]
repoName = parts[1]
}
}
// 构建搜索URL
baseURL := "https://registry.hub.docker.com/v2"
var fullURL string
var params url.Values
if isUserRepo && namespace != "" {
// 如果是用户/仓库格式使用repositories接口
fullURL = fmt.Sprintf("%s/repositories/%s/", baseURL, namespace)
params = url.Values{
"page": {fmt.Sprintf("%d", page)},
"page_size": {fmt.Sprintf("%d", pageSize)},
}
} else {
// 普通搜索
fullURL = baseURL + "/search/repositories/"
params = url.Values{
"query": {query},
"page": {fmt.Sprintf("%d", page)},
"page_size": {fmt.Sprintf("%d", pageSize)},
}
}
fullURL = fullURL + "?" + params.Encode()
// 使用统一的搜索HTTP客户端
resp, err := GetSearchHTTPClient().Get(fullURL)
if err != nil {
return nil, fmt.Errorf("请求Docker Hub API失败: %v", err)
}
defer func() {
if err := resp.Body.Close(); err != nil {
fmt.Printf("关闭搜索响应体失败: %v\n", err)
}
}()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取响应失败: %v", err)
}
if resp.StatusCode != http.StatusOK {
switch resp.StatusCode {
case http.StatusTooManyRequests:
return nil, fmt.Errorf("请求过于频繁,请稍后重试")
case http.StatusNotFound:
if isUserRepo && namespace != "" {
// 如果用户仓库搜索失败,尝试普通搜索
return searchDockerHub(ctx, repoName, page, pageSize)
}
return nil, fmt.Errorf("未找到相关镜像")
case http.StatusBadGateway, http.StatusServiceUnavailable:
return nil, fmt.Errorf("Docker Hub服务暂时不可用请稍后重试")
default:
return nil, fmt.Errorf("请求失败: 状态码=%d, 响应=%s", resp.StatusCode, string(body))
}
}
// 解析响应
var result *SearchResult
if isUserRepo && namespace != "" {
// 解析用户仓库列表响应
var userRepos struct {
Count int `json:"count"`
Next string `json:"next"`
Previous string `json:"previous"`
Results []Repository `json:"results"`
}
if err := json.Unmarshal(body, &userRepos); err != nil {
return nil, fmt.Errorf("解析响应失败: %v", err)
}
// 转换为SearchResult格式
result = &SearchResult{
Count: userRepos.Count,
Next: userRepos.Next,
Previous: userRepos.Previous,
Results: make([]Repository, 0),
}
// 处理结果
for _, repo := range userRepos.Results {
// 如果指定了仓库名,只保留匹配的结果
if repoName == "" || strings.Contains(strings.ToLower(repo.Name), strings.ToLower(repoName)) {
// 确保设置正确的命名空间和名称
repo.Namespace = namespace
if !strings.Contains(repo.Name, "/") {
repo.Name = fmt.Sprintf("%s/%s", namespace, repo.Name)
}
result.Results = append(result.Results, repo)
}
}
// 如果没有找到结果,尝试普通搜索
if len(result.Results) == 0 {
return searchDockerHub(ctx, repoName, page, pageSize)
}
result.Count = len(result.Results)
} else {
// 解析普通搜索响应
result = &SearchResult{}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("解析响应失败: %v", err)
}
// 处理搜索结果
for i := range result.Results {
if result.Results[i].IsOfficial {
if !strings.Contains(result.Results[i].Name, "/") {
result.Results[i].Name = "library/" + result.Results[i].Name
}
result.Results[i].Namespace = "library"
} else {
parts := strings.Split(result.Results[i].Name, "/")
if len(parts) > 1 {
result.Results[i].Namespace = parts[0]
result.Results[i].Name = parts[1]
} else if result.Results[i].RepoOwner != "" {
result.Results[i].Namespace = result.Results[i].RepoOwner
result.Results[i].Name = fmt.Sprintf("%s/%s", result.Results[i].RepoOwner, result.Results[i].Name)
}
}
}
// 如果是用户/仓库搜索,过滤结果
if isUserRepo && namespace != "" {
filteredResults := make([]Repository, 0)
for _, repo := range result.Results {
if strings.EqualFold(repo.Namespace, namespace) {
filteredResults = append(filteredResults, repo)
}
}
result.Results = filteredResults
result.Count = len(filteredResults)
}
}
// 缓存结果
searchCache.Set(cacheKey, result)
return result, nil
}
// 判断错误是否可重试
func isRetryableError(err error) bool {
if err == nil {
return false
}
// 网络错误、超时等可以重试
if strings.Contains(err.Error(), "timeout") ||
strings.Contains(err.Error(), "connection refused") ||
strings.Contains(err.Error(), "no such host") ||
strings.Contains(err.Error(), "too many requests") {
return true
}
return false
}
// getRepositoryTags 获取仓库标签信息
func getRepositoryTags(ctx context.Context, namespace, name string) ([]TagInfo, error) {
if namespace == "" || name == "" {
return nil, fmt.Errorf("无效输入:命名空间和名称不能为空")
}
cacheKey := fmt.Sprintf("tags:%s:%s", namespace, name)
if cached, ok := searchCache.Get(cacheKey); ok {
return cached.([]TagInfo), nil
}
// 构建API URL
baseURL := fmt.Sprintf("https://registry.hub.docker.com/v2/repositories/%s/%s/tags", namespace, name)
params := url.Values{}
params.Set("page_size", "100")
params.Set("ordering", "last_updated")
fullURL := baseURL + "?" + params.Encode()
// 使用统一的搜索HTTP客户端
resp, err := GetSearchHTTPClient().Get(fullURL)
if err != nil {
return nil, fmt.Errorf("发送请求失败: %v", err)
}
defer func() {
if err := resp.Body.Close(); err != nil {
fmt.Printf("关闭搜索响应体失败: %v\n", err)
}
}()
// 读取响应体
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取响应失败: %v", err)
}
// 检查响应状态码
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("请求失败: 状态码=%d, 响应=%s", resp.StatusCode, string(body))
}
// 解析响应
var result struct {
Count int `json:"count"`
Next string `json:"next"`
Previous string `json:"previous"`
Results []TagInfo `json:"results"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("解析响应失败: %v", err)
}
// 缓存结果
searchCache.Set(cacheKey, result.Results)
return result.Results, nil
}
// RegisterSearchRoute 注册搜索相关路由
func RegisterSearchRoute(r *gin.Engine) {
// 搜索镜像
r.GET("/search", func(c *gin.Context) {
query := c.Query("q")
if query == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "搜索关键词不能为空"})
return
}
page := 1
pageSize := 25
if p := c.Query("page"); p != "" {
fmt.Sscanf(p, "%d", &page)
}
if ps := c.Query("page_size"); ps != "" {
fmt.Sscanf(ps, "%d", &pageSize)
}
result, err := searchDockerHub(c.Request.Context(), query, page, pageSize)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, result)
})
// 获取标签信息
r.GET("/tags/:namespace/:name", func(c *gin.Context) {
namespace := c.Param("namespace")
name := c.Param("name")
if namespace == "" || name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "命名空间和名称不能为空"})
return
}
tags, err := getRepositoryTags(c.Request.Context(), namespace, name)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, tags)
})
}

View File

@@ -1,108 +0,0 @@
package main
import (
"strings"
"sync"
"time"
)
// SmartRateLimit 智能限流会话管理
type SmartRateLimit struct {
sessions sync.Map
}
// PullSession Docker拉取会话
type PullSession struct {
LastManifestTime time.Time
RequestCount int
}
// 全局智能限流实例
var smartLimiter = &SmartRateLimit{}
const (
// manifest请求后的活跃窗口时间
activeWindowDuration = 3 * time.Minute
// 活跃窗口内最大免费blob请求数(防止滥用)
maxFreeBlobRequests = 100
sessionCleanupInterval = 10 * time.Minute
sessionExpireTime = 30 * time.Minute
)
func init() {
go smartLimiter.cleanupSessions()
}
// ShouldSkipRateLimit 判断是否应该跳过限流计数
func (s *SmartRateLimit) ShouldSkipRateLimit(ip, path string) bool {
requestType, _ := parseRequestInfo(path)
if requestType != "manifests" && requestType != "blobs" {
return false
}
sessionKey := ip
sessionInterface, _ := s.sessions.LoadOrStore(sessionKey, &PullSession{})
session := sessionInterface.(*PullSession)
now := time.Now()
if requestType == "manifests" {
session.LastManifestTime = now
session.RequestCount = 0
return false
}
if requestType == "blobs" {
if !session.LastManifestTime.IsZero() &&
now.Sub(session.LastManifestTime) <= activeWindowDuration {
session.RequestCount++
if session.RequestCount <= maxFreeBlobRequests {
return true
}
}
}
return false
}
func parseRequestInfo(path string) (requestType, imageRef string) {
path = strings.TrimPrefix(path, "/v2/")
if idx := strings.Index(path, "/manifests/"); idx != -1 {
return "manifests", path[:idx]
}
if idx := strings.Index(path, "/blobs/"); idx != -1 {
return "blobs", path[:idx]
}
if idx := strings.Index(path, "/tags/"); idx != -1 {
return "tags", path[:idx]
}
return "unknown", ""
}
// cleanupSessions 定期清理过期会话,防止内存泄露
func (s *SmartRateLimit) cleanupSessions() {
ticker := time.NewTicker(sessionCleanupInterval)
defer ticker.Stop()
for range ticker.C {
now := time.Now()
expiredKeys := make([]string, 0)
s.sessions.Range(func(key, value interface{}) bool {
session := value.(*PullSession)
if !session.LastManifestTime.IsZero() &&
now.Sub(session.LastManifestTime) > sessionExpireTime {
expiredKeys = append(expiredKeys, key.(string))
}
return true
})
for _, key := range expiredKeys {
s.sessions.Delete(key)
}
}
}

View File

@@ -1,220 +1,210 @@
package main
import (
"strings"
"sync"
)
// ResourceType 资源类型
type ResourceType string
const (
ResourceTypeGitHub ResourceType = "github"
ResourceTypeDocker ResourceType = "docker"
)
// AccessController 统一访问控制器
type AccessController struct {
mu sync.RWMutex
}
// DockerImageInfo Docker镜像信息
type DockerImageInfo struct {
Namespace string
Repository string
Tag string
FullName string
}
// 全局访问控制器实例
var GlobalAccessController = &AccessController{}
// ParseDockerImage 解析Docker镜像名称
func (ac *AccessController) ParseDockerImage(image string) DockerImageInfo {
image = strings.TrimPrefix(image, "docker://")
var tag string
if idx := strings.LastIndex(image, ":"); idx != -1 {
part := image[idx+1:]
if !strings.Contains(part, "/") {
tag = part
image = image[:idx]
}
}
if tag == "" {
tag = "latest"
}
var namespace, repository string
if strings.Contains(image, "/") {
parts := strings.Split(image, "/")
if len(parts) >= 2 {
if strings.Contains(parts[0], ".") {
if len(parts) >= 3 {
namespace = parts[1]
repository = parts[2]
} else {
namespace = "library"
repository = parts[1]
}
} else {
namespace = parts[0]
repository = parts[1]
}
}
} else {
namespace = "library"
repository = image
}
fullName := namespace + "/" + repository
return DockerImageInfo{
Namespace: namespace,
Repository: repository,
Tag: tag,
FullName: fullName,
}
}
// CheckDockerAccess 检查Docker镜像访问权限
func (ac *AccessController) CheckDockerAccess(image string) (allowed bool, reason string) {
cfg := GetConfig()
// 解析镜像名称
imageInfo := ac.ParseDockerImage(image)
// 检查白名单(如果配置了白名单,则只允许白名单中的镜像)
if len(cfg.Proxy.WhiteList) > 0 {
if !ac.matchImageInList(imageInfo, cfg.Proxy.WhiteList) {
return false, "不在Docker镜像白名单内"
}
}
// 检查黑名单
if len(cfg.Proxy.BlackList) > 0 {
if ac.matchImageInList(imageInfo, cfg.Proxy.BlackList) {
return false, "Docker镜像在黑名单内"
}
}
return true, ""
}
// CheckGitHubAccess 检查GitHub仓库访问权限
func (ac *AccessController) CheckGitHubAccess(matches []string) (allowed bool, reason string) {
if len(matches) < 2 {
return false, "无效的GitHub仓库格式"
}
cfg := GetConfig()
// 检查白名单
if len(cfg.Proxy.WhiteList) > 0 && !ac.checkList(matches, cfg.Proxy.WhiteList) {
return false, "不在GitHub仓库名单内"
}
// 检查黑名单
if len(cfg.Proxy.BlackList) > 0 && ac.checkList(matches, cfg.Proxy.BlackList) {
return false, "GitHub仓库在黑名单内"
}
return true, ""
}
// matchImageInList 检查Docker镜像是否在指定列表中
func (ac *AccessController) matchImageInList(imageInfo DockerImageInfo, list []string) bool {
fullName := strings.ToLower(imageInfo.FullName)
namespace := strings.ToLower(imageInfo.Namespace)
for _, item := range list {
item = strings.ToLower(strings.TrimSpace(item))
if item == "" {
continue
}
if fullName == item {
return true
}
if item == namespace || item == namespace+"/*" {
return true
}
if strings.HasSuffix(item, "*") {
prefix := strings.TrimSuffix(item, "*")
if strings.HasPrefix(fullName, prefix) {
return true
}
}
if strings.HasPrefix(item, "*/") {
repoPattern := strings.TrimPrefix(item, "*/")
if strings.HasSuffix(repoPattern, "*") {
repoPrefix := strings.TrimSuffix(repoPattern, "*")
if strings.HasPrefix(imageInfo.Repository, repoPrefix) {
return true
}
} else {
if strings.ToLower(imageInfo.Repository) == repoPattern {
return true
}
}
}
if strings.HasPrefix(fullName, item+"/") {
return true
}
}
return false
}
// checkList GitHub仓库检查逻辑
func (ac *AccessController) checkList(matches, list []string) bool {
if len(matches) < 2 {
return false
}
username := strings.ToLower(strings.TrimSpace(matches[0]))
repoName := strings.ToLower(strings.TrimSpace(strings.TrimSuffix(matches[1], ".git")))
fullRepo := username + "/" + repoName
for _, item := range list {
item = strings.ToLower(strings.TrimSpace(item))
if item == "" {
continue
}
// 支持多种匹配模式
if fullRepo == item {
return true
}
// 用户级匹配
if item == username || item == username+"/*" {
return true
}
// 前缀匹配(支持通配符)
if strings.HasSuffix(item, "*") {
prefix := strings.TrimSuffix(item, "*")
if strings.HasPrefix(fullRepo, prefix) {
return true
}
}
// 子仓库匹配(防止 user/repo 匹配到 user/repo-fork
if strings.HasPrefix(fullRepo, item+"/") {
return true
}
}
return false
}
// Reload 热重载访问控制规则
func (ac *AccessController) Reload() {
ac.mu.Lock()
defer ac.mu.Unlock()
// 访问控制器本身不缓存配置
}
package utils
import (
"strings"
"hubproxy/config"
)
// ResourceType 资源类型
type ResourceType string
const (
ResourceTypeGitHub ResourceType = "github"
ResourceTypeDocker ResourceType = "docker"
)
// AccessController 统一访问控制器
type AccessController struct {
}
// DockerImageInfo Docker镜像信息
type DockerImageInfo struct {
Namespace string
Repository string
Tag string
FullName string
}
// GlobalAccessController 全局访问控制器实例
var GlobalAccessController = &AccessController{}
// ParseDockerImage 解析Docker镜像名称
func (ac *AccessController) ParseDockerImage(image string) DockerImageInfo {
image = strings.TrimPrefix(image, "docker://")
var tag string
if idx := strings.LastIndex(image, ":"); idx != -1 {
part := image[idx+1:]
if !strings.Contains(part, "/") {
tag = part
image = image[:idx]
}
}
if tag == "" {
tag = "latest"
}
var namespace, repository string
if strings.Contains(image, "/") {
parts := strings.Split(image, "/")
if len(parts) >= 2 {
if strings.Contains(parts[0], ".") {
if len(parts) >= 3 {
namespace = parts[1]
repository = parts[2]
} else {
namespace = "library"
repository = parts[1]
}
} else {
namespace = parts[0]
repository = parts[1]
}
}
} else {
namespace = "library"
repository = image
}
fullName := namespace + "/" + repository
return DockerImageInfo{
Namespace: namespace,
Repository: repository,
Tag: tag,
FullName: fullName,
}
}
// CheckDockerAccess 检查Docker镜像访问权限
func (ac *AccessController) CheckDockerAccess(image string) (allowed bool, reason string) {
cfg := config.GetConfig()
imageInfo := ac.ParseDockerImage(image)
if len(cfg.Access.WhiteList) > 0 {
if !ac.matchImageInList(imageInfo, cfg.Access.WhiteList) {
return false, "不在Docker镜像白名单内"
}
}
if len(cfg.Access.BlackList) > 0 {
if ac.matchImageInList(imageInfo, cfg.Access.BlackList) {
return false, "Docker镜像在黑名单内"
}
}
return true, ""
}
// CheckGitHubAccess 检查GitHub仓库访问权限
func (ac *AccessController) CheckGitHubAccess(matches []string) (allowed bool, reason string) {
if len(matches) < 2 {
return false, "无效的GitHub仓库格式"
}
cfg := config.GetConfig()
if len(cfg.Access.WhiteList) > 0 && !ac.checkList(matches, cfg.Access.WhiteList) {
return false, "不在GitHub仓库白名单内"
}
if len(cfg.Access.BlackList) > 0 && ac.checkList(matches, cfg.Access.BlackList) {
return false, "GitHub仓库在黑名单内"
}
return true, ""
}
// matchImageInList 检查Docker镜像是否在指定列表中
func (ac *AccessController) matchImageInList(imageInfo DockerImageInfo, list []string) bool {
fullName := strings.ToLower(imageInfo.FullName)
namespace := strings.ToLower(imageInfo.Namespace)
for _, item := range list {
item = strings.ToLower(strings.TrimSpace(item))
if item == "" {
continue
}
if fullName == item {
return true
}
if item == namespace || item == namespace+"/*" {
return true
}
if strings.HasSuffix(item, "*") {
prefix := strings.TrimSuffix(item, "*")
if strings.HasPrefix(fullName, prefix) {
return true
}
}
if strings.HasPrefix(item, "*/") {
repoPattern := strings.TrimPrefix(item, "*/")
if strings.HasSuffix(repoPattern, "*") {
repoPrefix := strings.TrimSuffix(repoPattern, "*")
if strings.HasPrefix(imageInfo.Repository, repoPrefix) {
return true
}
} else {
if strings.ToLower(imageInfo.Repository) == repoPattern {
return true
}
}
}
if strings.HasPrefix(fullName, item+"/") {
return true
}
}
return false
}
// checkList GitHub仓库检查逻辑
func (ac *AccessController) checkList(matches, list []string) bool {
if len(matches) < 2 {
return false
}
username := strings.ToLower(strings.TrimSpace(matches[0]))
repoName := strings.ToLower(strings.TrimSpace(strings.TrimSuffix(matches[1], ".git")))
fullRepo := username + "/" + repoName
for _, item := range list {
item = strings.ToLower(strings.TrimSpace(item))
if item == "" {
continue
}
if fullRepo == item {
return true
}
if item == username || item == username+"/*" {
return true
}
if strings.HasSuffix(item, "*") {
prefix := strings.TrimSuffix(item, "*")
if strings.HasPrefix(fullRepo, prefix) {
return true
}
}
if strings.HasPrefix(fullRepo, item+"/") {
return true
}
if strings.HasPrefix(item, "*/") {
p := item[2:]
if p == repoName || (strings.HasSuffix(p, "*") && strings.HasPrefix(repoName, p[:len(p)-1])) {
return true
}
}
}
return false
}

View File

@@ -1,4 +1,4 @@
package main
package utils
import (
"crypto/md5"
@@ -8,23 +8,25 @@ import (
"sync"
"time"
"hubproxy/config"
"github.com/gin-gonic/gin"
)
// CachedItem 通用缓存项支持Token和Manifest
// CachedItem 通用缓存项
type CachedItem struct {
Data []byte // 缓存数据(token字符串或manifest字节)
ContentType string // 内容类型
Headers map[string]string // 额外的响应头
ExpiresAt time.Time // 过期时间
Data []byte
ContentType string
Headers map[string]string
ExpiresAt time.Time
}
// UniversalCache 通用缓存支持Token和Manifest
// UniversalCache 通用缓存
type UniversalCache struct {
cache sync.Map
}
var globalCache = &UniversalCache{}
var GlobalCache = &UniversalCache{}
// Get 获取缓存项
func (c *UniversalCache) Get(key string) *CachedItem {
@@ -57,97 +59,107 @@ func (c *UniversalCache) SetToken(key, token string, ttl time.Duration) {
c.Set(key, []byte(token), "application/json", nil, ttl)
}
// buildCacheKey 构建稳定的缓存key
func buildCacheKey(prefix, query string) string {
// BuildCacheKey 构建稳定的缓存key
func BuildCacheKey(prefix, query string) string {
return fmt.Sprintf("%s:%x", prefix, md5.Sum([]byte(query)))
}
func buildTokenCacheKey(query string) string {
return buildCacheKey("token", query)
func BuildTokenCacheKey(query string) string {
return BuildCacheKey("token", query)
}
func buildManifestCacheKey(imageRef, reference string) string {
func BuildManifestCacheKey(imageRef, reference string) string {
key := fmt.Sprintf("%s:%s", imageRef, reference)
return buildCacheKey("manifest", key)
return BuildCacheKey("manifest", key)
}
func buildManifestCacheKeyWithPlatform(imageRef, reference, platform string) string {
if platform == "" {
platform = "default"
}
key := fmt.Sprintf("%s:%s@%s", imageRef, reference, platform)
return buildCacheKey("manifest", key)
}
func getManifestTTL(reference string) time.Duration {
cfg := GetConfig()
func GetManifestTTL(reference string) time.Duration {
cfg := config.GetConfig()
defaultTTL := 30 * time.Minute
if cfg.TokenCache.DefaultTTL != "" {
if parsed, err := time.ParseDuration(cfg.TokenCache.DefaultTTL); err == nil {
defaultTTL = parsed
}
}
if strings.HasPrefix(reference, "sha256:") {
return 24 * time.Hour
}
// mutable tag的智能判断
if reference == "latest" || reference == "main" || reference == "master" ||
reference == "dev" || reference == "develop" {
// 热门可变标签: 短期缓存
if reference == "latest" || reference == "main" || reference == "master" ||
reference == "dev" || reference == "develop" {
return 10 * time.Minute
}
// 普通tag: 中等缓存时间
return defaultTTL
}
// extractTTLFromResponse 从响应中智能提取TTL
func extractTTLFromResponse(responseBody []byte) time.Duration {
// ExtractTTLFromResponse 从响应中智能提取TTL
func ExtractTTLFromResponse(responseBody []byte) time.Duration {
var tokenResp struct {
ExpiresIn int `json:"expires_in"`
}
// 默认30分钟TTL确保稳定性
defaultTTL := 30 * time.Minute
if json.Unmarshal(responseBody, &tokenResp) == nil && tokenResp.ExpiresIn > 0 {
safeTTL := time.Duration(tokenResp.ExpiresIn-300) * time.Second
if safeTTL > 5*time.Minute {
return safeTTL
}
}
return defaultTTL
}
func writeTokenResponse(c *gin.Context, cachedBody string) {
func WriteTokenResponse(c *gin.Context, cachedBody string) {
c.Header("Content-Type", "application/json")
c.String(200, cachedBody)
}
func writeCachedResponse(c *gin.Context, item *CachedItem) {
func WriteCachedResponse(c *gin.Context, item *CachedItem) {
if item.ContentType != "" {
c.Header("Content-Type", item.ContentType)
}
// 设置额外的响应头
for key, value := range item.Headers {
c.Header(key, value)
}
// 返回数据
c.Data(200, item.ContentType, item.Data)
}
// isCacheEnabled 检查缓存是否启用
func isCacheEnabled() bool {
cfg := GetConfig()
// IsCacheEnabled 检查缓存是否启用
func IsCacheEnabled() bool {
cfg := config.GetConfig()
return cfg.TokenCache.Enabled
}
// isTokenCacheEnabled 检查token缓存是否启用(向后兼容)
func isTokenCacheEnabled() bool {
return isCacheEnabled()
}
// IsTokenCacheEnabled 检查token缓存是否启用
func IsTokenCacheEnabled() bool {
return IsCacheEnabled()
}
// 定期清理过期缓存
func init() {
go func() {
ticker := time.NewTicker(20 * time.Minute)
defer ticker.Stop()
for range ticker.C {
now := time.Now()
expiredKeys := make([]string, 0)
GlobalCache.cache.Range(func(key, value interface{}) bool {
if cached := value.(*CachedItem); now.After(cached.ExpiresAt) {
expiredKeys = append(expiredKeys, key.(string))
}
return true
})
for _, key := range expiredKeys {
GlobalCache.cache.Delete(key)
}
}
}()
}

View File

@@ -1,59 +1,67 @@
package main
import (
"net"
"net/http"
"time"
)
var (
// 全局HTTP客户端 - 用于代理请求(长超时)
globalHTTPClient *http.Client
// 搜索HTTP客户端 - 用于API请求短超时
searchHTTPClient *http.Client
)
// initHTTPClients 初始化HTTP客户端
func initHTTPClients() {
// 代理客户端配置 - 适用于大文件传输
globalHTTPClient = &http.Client{
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 1000,
MaxIdleConnsPerHost: 1000,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
ResponseHeaderTimeout: 300 * time.Second,
},
}
// 搜索客户端配置 - 适用于API调用
searchHTTPClient = &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 5 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 5 * time.Second,
DisableCompression: false,
},
}
}
// GetGlobalHTTPClient 获取全局HTTP客户端用于代理
func GetGlobalHTTPClient() *http.Client {
return globalHTTPClient
}
// GetSearchHTTPClient 获取搜索HTTP客户端用于API调用
func GetSearchHTTPClient() *http.Client {
return searchHTTPClient
}
package utils
import (
"net"
"net/http"
"os"
"time"
"hubproxy/config"
)
var (
globalHTTPClient *http.Client
searchHTTPClient *http.Client
)
// InitHTTPClients 初始化HTTP客户端
func InitHTTPClients() {
cfg := config.GetConfig()
if p := cfg.Access.Proxy; p != "" {
os.Setenv("HTTP_PROXY", p)
os.Setenv("HTTPS_PROXY", p)
}
globalHTTPClient = &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 1000,
MaxIdleConnsPerHost: 1000,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
ResponseHeaderTimeout: 300 * time.Second,
},
}
searchHTTPClient = &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 5 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 5 * time.Second,
DisableCompression: false,
},
}
}
// GetGlobalHTTPClient 获取全局HTTP客户端
func GetGlobalHTTPClient() *http.Client {
return globalHTTPClient
}
// GetSearchHTTPClient 获取搜索HTTP客户端
func GetSearchHTTPClient() *http.Client {
return searchHTTPClient
}

107
src/utils/proxy_shell.go Normal file
View File

@@ -0,0 +1,107 @@
package utils
import (
"bytes"
"compress/gzip"
"fmt"
"io"
"regexp"
"strings"
)
// GitHub URL正则表达式
var githubRegex = regexp.MustCompile(`(?:^|[\s'"(=,\[{;|&<>])https?://(?:github\.com|raw\.githubusercontent\.com|raw\.github\.com|gist\.githubusercontent\.com|gist\.github\.com|api\.github\.com)[^\s'")]*`)
// MaxShellSize 限制最大处理大小为 10MB
const MaxShellSize = 10 * 1024 * 1024
// ProcessSmart Shell脚本智能处理函数
func ProcessSmart(input io.Reader, isCompressed bool, host string) (io.Reader, int64, error) {
content, err := readShellContent(input, isCompressed)
if err != nil {
return nil, 0, err
}
if len(content) == 0 {
return strings.NewReader(""), 0, nil
}
if !bytes.Contains(content, []byte("github.com")) && !bytes.Contains(content, []byte("githubusercontent.com")) {
return bytes.NewReader(content), int64(len(content)), nil
}
processed := processGitHubURLs(string(content), host)
return strings.NewReader(processed), int64(len(processed)), nil
}
func readShellContent(input io.Reader, isCompressed bool) ([]byte, error) {
var reader io.Reader = input
if isCompressed {
peek := make([]byte, 2)
n, err := input.Read(peek)
if err != nil && err != io.EOF {
return nil, fmt.Errorf("读取数据失败: %v", err)
}
if n >= 2 && peek[0] == 0x1f && peek[1] == 0x8b {
combinedReader := io.MultiReader(bytes.NewReader(peek[:n]), input)
gzReader, err := gzip.NewReader(combinedReader)
if err != nil {
return nil, fmt.Errorf("gzip解压失败: %v", err)
}
defer gzReader.Close()
reader = gzReader
} else {
reader = io.MultiReader(bytes.NewReader(peek[:n]), input)
}
}
limit := int64(MaxShellSize + 1)
limitedReader := io.LimitReader(reader, limit)
data, err := io.ReadAll(limitedReader)
if err != nil {
return nil, fmt.Errorf("读取内容失败: %v", err)
}
if int64(len(data)) > MaxShellSize {
return nil, fmt.Errorf("脚本文件过大,超过 %d MB 限制", MaxShellSize/1024/1024)
}
return data, nil
}
func processGitHubURLs(content, host string) string {
return githubRegex.ReplaceAllStringFunc(content, func(match string) string {
// 如果匹配包含前缀分隔符,保留它,防止出现重复转换
if len(match) > 0 && match[0] != 'h' {
prefix := match[0:1]
url := match[1:]
return prefix + transformURL(url, host)
}
return transformURL(match, host)
})
}
// transformURL URL转换函数
func transformURL(url, host string) string {
if strings.Contains(url, host) {
return url
}
if strings.HasPrefix(url, "http://") {
url = "https" + url[4:]
} else if !strings.HasPrefix(url, "https://") && !strings.HasPrefix(url, "//") {
url = "https://" + url
}
// 确保 host 有协议头
if !strings.HasPrefix(host, "http://") && !strings.HasPrefix(host, "https://") {
host = "https://" + host
}
host = strings.TrimSuffix(host, "/")
return host + "/" + url
}

271
src/utils/ratelimiter.go Normal file
View File

@@ -0,0 +1,271 @@
package utils
import (
"fmt"
"net"
"strings"
"sync"
"time"
"hubproxy/config"
"github.com/gin-gonic/gin"
"golang.org/x/time/rate"
)
const (
CleanupInterval = 20 * time.Minute
MaxIPCacheSize = 10000
)
// IPRateLimiter IP限流器结构体
type IPRateLimiter struct {
ips map[string]*rateLimiterEntry
mu *sync.RWMutex
r rate.Limit
b int
whitelist []*net.IPNet
blacklist []*net.IPNet
whitelistLimiter *rate.Limiter // 全局共享的白名单限流器
}
// rateLimiterEntry 限流器条目
type rateLimiterEntry struct {
limiter *rate.Limiter
lastAccess time.Time
}
// InitGlobalLimiter 初始化全局限流器
func InitGlobalLimiter() *IPRateLimiter {
cfg := config.GetConfig()
whitelist := make([]*net.IPNet, 0, len(cfg.Security.WhiteList))
for _, item := range cfg.Security.WhiteList {
if item = strings.TrimSpace(item); item != "" {
if !strings.Contains(item, "/") {
item = item + "/32"
}
_, ipnet, err := net.ParseCIDR(item)
if err == nil {
whitelist = append(whitelist, ipnet)
} else {
fmt.Printf("警告: 无效的白名单IP格式: %s\n", item)
}
}
}
blacklist := make([]*net.IPNet, 0, len(cfg.Security.BlackList))
for _, item := range cfg.Security.BlackList {
if item = strings.TrimSpace(item); item != "" {
if !strings.Contains(item, "/") {
item = item + "/32"
}
_, ipnet, err := net.ParseCIDR(item)
if err == nil {
blacklist = append(blacklist, ipnet)
} else {
fmt.Printf("警告: 无效的黑名单IP格式: %s\n", item)
}
}
}
ratePerSecond := rate.Limit(float64(cfg.RateLimit.RequestLimit) / (cfg.RateLimit.PeriodHours * 3600))
burstSize := cfg.RateLimit.RequestLimit
limiter := &IPRateLimiter{
ips: make(map[string]*rateLimiterEntry),
mu: &sync.RWMutex{},
r: ratePerSecond,
b: burstSize,
whitelist: whitelist,
blacklist: blacklist,
whitelistLimiter: rate.NewLimiter(rate.Inf, burstSize),
}
go limiter.cleanupRoutine()
return limiter
}
// cleanupRoutine 定期清理过期的限流器
func (i *IPRateLimiter) cleanupRoutine() {
ticker := time.NewTicker(CleanupInterval)
defer ticker.Stop()
for range ticker.C {
now := time.Now()
expired := make([]string, 0)
i.mu.RLock()
for ip, entry := range i.ips {
if now.Sub(entry.lastAccess) > 2*time.Hour {
expired = append(expired, ip)
}
}
i.mu.RUnlock()
if len(expired) > 0 || len(i.ips) > MaxIPCacheSize {
i.mu.Lock()
for _, ip := range expired {
delete(i.ips, ip)
}
if len(i.ips) > MaxIPCacheSize {
i.ips = make(map[string]*rateLimiterEntry)
}
i.mu.Unlock()
}
}
}
// extractIPFromAddress 从地址中提取纯IP
func extractIPFromAddress(address string) string {
if host, _, err := net.SplitHostPort(address); err == nil {
return host
}
return address
}
// normalizeIPForRateLimit 标准化IP地址用于限流
func normalizeIPForRateLimit(ipStr string) string {
ip := net.ParseIP(ipStr)
if ip == nil {
return ipStr
}
if ip.To4() != nil {
return ipStr
}
ipv6 := ip.To16()
for i := 8; i < 16; i++ {
ipv6[i] = 0
}
return ipv6.String() + "/64"
}
// isIPInCIDRList 检查IP是否在CIDR列表中
func isIPInCIDRList(ip string, cidrList []*net.IPNet) bool {
cleanIP := extractIPFromAddress(ip)
parsedIP := net.ParseIP(cleanIP)
if parsedIP == nil {
return false
}
for _, cidr := range cidrList {
if cidr.Contains(parsedIP) {
return true
}
}
return false
}
// GetLimiter 获取指定IP的限流器
func (i *IPRateLimiter) GetLimiter(ip string) (*rate.Limiter, bool) {
cleanIP := extractIPFromAddress(ip)
if isIPInCIDRList(cleanIP, i.blacklist) {
return nil, false
}
if isIPInCIDRList(cleanIP, i.whitelist) {
return i.whitelistLimiter, true
}
normalizedIP := normalizeIPForRateLimit(cleanIP)
now := time.Now()
var entry *rateLimiterEntry
i.mu.RLock()
_, exists := i.ips[normalizedIP]
i.mu.RUnlock()
if exists {
i.mu.Lock()
if entry, stillExists := i.ips[normalizedIP]; stillExists {
entry.lastAccess = now
i.mu.Unlock()
return entry.limiter, true
}
i.mu.Unlock()
}
i.mu.Lock()
if entry, exists := i.ips[normalizedIP]; exists {
entry.lastAccess = now
i.mu.Unlock()
return entry.limiter, true
}
entry = &rateLimiterEntry{
limiter: rate.NewLimiter(i.r, i.b),
lastAccess: now,
}
i.ips[normalizedIP] = entry
i.mu.Unlock()
return entry.limiter, true
}
// RateLimitMiddleware 速率限制中间件
func RateLimitMiddleware(limiter *IPRateLimiter) gin.HandlerFunc {
return func(c *gin.Context) {
path := c.Request.URL.Path
if path == "/" || path == "/favicon.ico" || path == "/images.html" || path == "/search.html" ||
strings.HasPrefix(path, "/public/") {
c.Next()
return
}
var ip string
if forwarded := c.GetHeader("X-Forwarded-For"); forwarded != "" {
ips := strings.Split(forwarded, ",")
ip = strings.TrimSpace(ips[0])
} else if realIP := c.GetHeader("X-Real-IP"); realIP != "" {
ip = realIP
} else if remoteIP := c.GetHeader("X-Original-Forwarded-For"); remoteIP != "" {
ips := strings.Split(remoteIP, ",")
ip = strings.TrimSpace(ips[0])
} else {
ip = c.ClientIP()
}
cleanIP := extractIPFromAddress(ip)
normalizedIP := normalizeIPForRateLimit(cleanIP)
if cleanIP != normalizedIP {
fmt.Printf("请求IP: %s (提纯后: %s, 限流段: %s), X-Forwarded-For: %s, X-Real-IP: %s\n",
ip, cleanIP, normalizedIP,
c.GetHeader("X-Forwarded-For"),
c.GetHeader("X-Real-IP"))
} else {
fmt.Printf("请求IP: %s (提纯后: %s), X-Forwarded-For: %s, X-Real-IP: %s\n",
ip, cleanIP,
c.GetHeader("X-Forwarded-For"),
c.GetHeader("X-Real-IP"))
}
ipLimiter, allowed := limiter.GetLimiter(cleanIP)
if !allowed {
c.JSON(403, gin.H{
"error": "您已被限制访问",
})
c.Abort()
return
}
if !ipLimiter.Allow() {
c.JSON(429, gin.H{
"error": "请求频率过快,暂时限制访问",
})
c.Abort()
return
}
c.Next()
}
}