129 Commits
Author SHA1 Message Date
starryandGitHub 77ece220ee fix 2026-08-02 21:47:26 +08:00
github-actions[bot] 644a225733 docs: update star history chart 2026-07-19 01:48:02 +00:00
starryandGitHub 28b55fb231 Delete stars.svg 2026-07-14 03:47:58 +08:00
github-actions[bot] d9eaae4a07 docs: update star history chart 2026-07-13 19:47:05 +00:00
github-actions[bot] ffea3da482 docs: update star history chart 2026-07-13 19:37:25 +00:00
starryandGitHub f101c7f29b Create stars.yml 2026-07-14 03:26:50 +08:00
starryandGitHub 4da7f90adc Delete .github/workflows/stars.yml 2026-07-14 02:17:00 +08:00
starryandGitHub 5b1c2f549b Create stars.yml 2026-07-14 02:15:35 +08:00
sky22333 5b86de8860 update 2026-07-13 08:12:07 +08:00
sky22333 5edfcc5a59 update 2026-07-13 08:09:05 +08:00
sky22333 c559f6cbb3 update docs 2026-07-13 07:59:04 +08:00
sky22333 dcc496bca3 update docs 2026-07-13 07:39:07 +08:00
sky22333 aeb045a9e3 update docs 2026-07-13 07:16:46 +08:00
sky22333 293c352518 update docs 2026-07-13 07:04:02 +08:00
sky22333 e6381d52d2 update docs 2026-07-13 06:59:07 +08:00
sky22333 8382d9c553 新增文档站 2026-07-13 06:42:37 +08:00
sky22333 320df5def5 限流优化 2026-07-13 03:10:42 +08:00
sky22333 7e0b82f8f0 fix favicon 2026-07-12 01:30:17 +08:00
sky22333 79f23d13ad 重构前端 2026-07-12 01:19:44 +08:00
sky22333 26b45c98bf 修复前端的某些小bug 2026-07-11 20:56:50 +08:00
sky22333 587c1f2144 修复 Registry token 路由与离线下载错误响应;去掉冗余正则 2026-07-11 20:42:23 +08:00
starryandGitHub 8100bcea0b Update README.md 2026-06-27 00:05:15 +08:00
sky22333 ba83a44492 fix 2026-05-11 23:14:21 +08:00
sky22333 c7a7f3d146 fix 2026-05-11 21:52:02 +08:00
sky22333 e4d4f33ea1 fix 2026-05-11 21:28:44 +08:00
sky22333 6e91fe9925 优化构建和打包 2026-05-11 21:16:24 +08:00
sky22333 d0b3c657cc 更新构建配置并补充测试 2026-05-06 19:16:27 +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
starryandGitHub 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
starryandGitHub 31f62fde35 v1.2.0 2025-11-28 22:16:57 +08:00
starry 8d7619c7e4 判断是否已经添加加速域名,避免重复添加。 2025-11-28 13:37:23 +00:00
starryandGitHub a09db34787 Update README with documentation links
Added links to Chinese and English documentation in README.
2025-11-16 08:58:51 +08:00
starryandGitHub 31a3b67ab0 更新文档 2025-11-16 08:49:12 +08:00
starryandGitHub 3590c7c073 Update README.md 2025-11-16 08:46:24 +08:00
starryandGitHub 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
starryandGitHub 714224bd29 Update README.md 2025-09-17 02:05:46 +08:00
starryandGitHub 7f6c46f0c8 add截图 2025-09-17 01:58:46 +08:00
starryandGitHub fd9b0cf829 add截图 2025-09-17 01:51:41 +08:00
starryandGitHub 42ddfaab9d Update docker-compose.yml 2025-09-13 03:45:28 +08:00
starryandGitHub 6144883a6e Update docker-compose.yml 2025-09-13 03:44:25 +08:00
starryandGitHub c704923b64 禁用CGO 2025-09-09 12:25:21 +08:00
starryandGitHub dcb502d3c8 v1.1.9 2025-09-08 00:02:51 +08:00
starryandGitHub a011d560c6 shell转换中确保host有协议头 2025-09-04 04:13:21 +08:00
starryandGitHub 53060d50db update 2025-09-02 12:34:42 +08:00
starryandGitHub 68868388d3 更新为v1.1.8 2025-09-02 10:33:41 +08:00
starryandGitHub 75833b937b 放宽gist匹配限制 2025-09-02 10:06:32 +08:00
starryandGitHub 45b4acc31f 调整一些默认配置 2025-09-02 01:03:50 +08:00
starryandGitHub 0cd5a7334d 增加.ps1脚本的处理 2025-09-01 12:16:42 +08:00
starryandGitHub 40f5b597ab 增加检查是否为网页类型 2025-09-01 12:05:16 +08:00
starryandGitHub 30bc88ed93 去掉greenteagc 2025-09-01 02:22:07 +08:00
starryandGitHub 737a522afc Update README.md 2025-09-01 01:50:10 +08:00
starryandGitHub 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
starryandGitHub e2413fc30d 写响应的最大允许时间改为30分钟
h2写响应的最大允许时间从5分钟增加至30分钟,兼容大文件下载
2025-08-15 21:52:00 +08:00
starryandGitHub 6193a07837 Update .gitattributes 2025-08-01 14:43:04 +08:00
starryandGitHub bb2f7bcda6 启动显示版本号 2025-08-01 13:23:52 +08:00
starryandGitHub 4ec36da9b5 优化github上游链接404的处理 2025-08-01 13:19:47 +08:00
starryandGitHub 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
starryandGitHub 70bf552daf Update release.yml 2025-07-27 12:16:18 +08:00
starryandGitHub 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
starryandGitHub 9a8b850bce Delete src/test.exe 2025-07-27 06:15:42 +08:00
user123456 187e842445 拆分包结构 2025-07-27 05:50:34 +08:00
starryandGitHub badafd2899 Update README.md 2025-07-20 19:34:02 +08:00
starryandGitHub 4bf075fcaf Update README.md 2025-07-18 21:12:47 +08:00
starryandGitHub 208a239af3 修复cf导致的协议头问题,简化健康检查 2025-07-18 21:10:03 +08:00
starryandGitHub 1fb97b5347 Merge pull request #34 from Thinker-Joe/main
Add registry mirror usage
2025-07-16 20:17:23 +08:00
Thinker-JoeandGitHub 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
starryandGitHub c4c5993bd1 Update README.md 2025-06-30 18:19:14 +08:00
starryandGitHub d46fd3fec4 Update README.md 2025-06-28 08:46:24 +08:00
starryandGitHub 279b48d432 Update README.md 2025-06-28 08:29:34 +08:00
starryandGitHub 61f09192bb Update README.md 2025-06-27 09:06:44 +08:00
starryandGitHub 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
starryandGitHub 35651e214f proxy字段修复 2025-06-21 00:15:27 +08:00
user123456 d373e0104d 获取更多镜像tag 2025-06-20 23:44:13 +08:00
starryandGitHub 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
starryandGitHub d0b1ea8582 LF 2025-06-18 17:08:14 +08:00
starryandGitHub c607061dae LF 2025-06-18 17:07:43 +08:00
starryandGitHub 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
starryandGitHub 4240c1452a Update README.md 2025-06-16 00:51:06 +08:00
starryandGitHub 212c8e529d Update README.md 2025-06-15 16:18:54 +08:00
starryandGitHub 3fd630159b Update config.toml 2025-06-14 14:11:14 +08:00
starryandGitHub 17d827f50b Update README.md 2025-06-14 14:10:31 +08:00
starryandGitHub 7dcbc839c6 Update README.md 2025-06-14 14:10:07 +08:00
starryandGitHub 45ffebc820 Update README.md 2025-06-14 14:08:08 +08:00
starryandGitHub 3027b1f218 Update README.md 2025-06-13 18:31:13 +08:00
starryandGitHub 3d2c419ebe Update README.md 2025-06-13 18:30:14 +08:00
starryandGitHub 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
starryandGitHub 0adf11099e add 2025-06-13 16:25:27 +08:00
starryandGitHub dbb9432eb0 Create LICENSE 2025-06-13 14:11:56 +08:00
130 changed files with 18302 additions and 6518 deletions
+2
View File
@@ -0,0 +1,2 @@
* text=auto eol=lf
*.html linguist-vendored
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

+14 -9
View File
@@ -3,9 +3,9 @@ on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
version: version:
description: 'Version number' description: '版本号 (例如: v1.0.0)'
required: true required: true
default: 'latest' default: 'v1.0.0'
jobs: jobs:
build: build:
@@ -15,13 +15,13 @@ jobs:
packages: write packages: write
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v2 uses: actions/checkout@v6
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v4
- name: Cache Docker layers - name: Cache Docker layers
uses: actions/cache@v3 uses: actions/cache@v5
with: with:
path: /tmp/.buildx-cache path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }} key: ${{ runner.os }}-buildx-${{ github.sha }}
@@ -29,14 +29,19 @@ jobs:
${{ runner.os }}-buildx- ${{ runner.os }}-buildx-
- name: Log in to GitHub Docker Registry - name: Log in to GitHub Docker Registry
uses: docker/login-action@v2 uses: docker/login-action@v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Set version from input - 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 - name: Convert repository name to lowercase
run: | run: |
@@ -47,10 +52,10 @@ jobs:
- name: Build and push Docker image - name: Build and push Docker image
run: | run: |
docker buildx build --push \ 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 }}:${{ env.VERSION }} \
--tag ghcr.io/${{ env.REPO_LOWER }}:latest \ --tag ghcr.io/${{ env.REPO_LOWER }}:latest \
--build-arg VERSION=${{ env.VERSION }} \ --build-arg VERSION=${{ env.VERSION }} \
-f Dockerfile . -f Dockerfile .
env: env:
GHCR_PUBLIC: true # 将镜像设置为公开 GHCR_PUBLIC: true
+51
View File
@@ -0,0 +1,51 @@
name: Docs 文档发布
on:
push:
branches: [main]
paths:
- 'docs/src/**'
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
cache-dependency-path: docs/package-lock.json
- name: Install dependencies
working-directory: docs
run: npm ci
- name: Build docs
working-directory: docs
run: npm run build
- uses: actions/upload-pages-artifact@v5
with:
path: docs/dist
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v5
+88 -33
View File
@@ -1,7 +1,7 @@
name: 发布二进制文件 name: 发布二进制文件
on: on:
workflow_dispatch: # 手动触发 workflow_dispatch:
inputs: inputs:
version: version:
description: '版本号 (例如: v1.0.0)' description: '版本号 (例如: v1.0.0)'
@@ -16,14 +16,27 @@ jobs:
steps: steps:
- name: 检出代码 - name: 检出代码
uses: actions/checkout@v4 uses: actions/checkout@v6
with: with:
fetch-depth: 0 # 获取完整历史,用于生成变更日志 fetch-depth: 0
- name: 设置Go环境 - name: 设置Go环境
uses: actions/setup-go@v4 uses: actions/setup-go@v6
with: with:
go-version: '1.24' go-version-file: "src/go.mod"
cache-dependency-path: "src/go.sum"
- name: 设置 Node.js
uses: actions/setup-node@v6
with:
node-version-file: web/package.json
cache-dependency-path: web/package-lock.json
- name: 构建前端
run: |
cd web
npm ci
npm run build
- name: 获取版本号 - name: 获取版本号
id: version id: version
@@ -53,34 +66,34 @@ jobs:
run: | run: |
mkdir -p build/hubproxy mkdir -p build/hubproxy
- name: 安装 UPX
uses: crazy-max/ghaction-upx@v4
with:
install-only: true
- name: 安装 nFPM
run: go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.46.3
- name: 编译二进制文件 - name: 编译二进制文件
run: | run: |
cd src cd src
VERSION=${{ steps.version.outputs.version }}
# Linux AMD64 # 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 # 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 .
- name: 复制配置文件 # 压缩二进制文件
upx -9 ../build/hubproxy/hubproxy-linux-amd64
upx -9 ../build/hubproxy/hubproxy-linux-arm64
- name: 准备压缩包文件
run: | run: |
# 复制配置文件 # 复制配置文件
cp src/config.toml build/hubproxy/ cp src/config.toml build/hubproxy/
# 复制systemd服务文件
cp hubproxy.service build/hubproxy/
# 复制安装脚本
cp install-service.sh build/hubproxy/
# 创建README文件
cat > build/hubproxy/README.md << 'EOF'
# HubProxy
项目地址:https://github.com/sky22333/hubproxy
EOF
- name: 创建压缩包 - name: 创建压缩包
run: | run: |
cd build cd build
@@ -88,26 +101,64 @@ jobs:
# Linux AMD64 包 # Linux AMD64 包
mkdir -p linux-amd64/hubproxy mkdir -p linux-amd64/hubproxy
cp hubproxy/hubproxy-linux-amd64 linux-amd64/hubproxy/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 linux-amd64/hubproxy/
tar -czf hubproxy-${{ steps.version.outputs.version }}-linux-amd64.tar.gz -C linux-amd64 hubproxy tar -czf hubproxy-linux-amd64.tar.gz -C linux-amd64 hubproxy
# Linux ARM64 包 # Linux ARM64 包
mkdir -p linux-arm64/hubproxy mkdir -p linux-arm64/hubproxy
cp hubproxy/hubproxy-linux-arm64 linux-arm64/hubproxy/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 linux-arm64/hubproxy/
tar -czf hubproxy-${{ steps.version.outputs.version }}-linux-arm64.tar.gz -C linux-arm64 hubproxy tar -czf hubproxy-linux-arm64.tar.gz -C linux-arm64 hubproxy
# 列出生成的文件 # 列出生成的文件
ls -la *.tar.gz ls -la *.tar.gz
- name: 计算文件校验和 - name: 创建Linux发行版安装包
run: | run: |
cd build mkdir -p build/packages
sha256sum *.tar.gz > checksums.txt VERSION="${{ steps.version.outputs.version }}"
cat checksums.txt NFPM_VERSION="${VERSION#v}"
package() {
hubproxy_arch="$1"
nfpm_arch="$2"
packager="$3"
config="$4"
target="build/packages/hubproxy-linux-${hubproxy_arch}.${packager}"
temp_dir="build/packages/${hubproxy_arch}-${packager}"
binary="./build/hubproxy/hubproxy-linux-${hubproxy_arch}"
rm -rf "${temp_dir}"
mkdir -p "${temp_dir}"
rm -rf build/package-root
mkdir -p build/package-root
cp "${binary}" build/package-root/hubproxy
NFPM_ARCH="${nfpm_arch}" NFPM_VERSION="${NFPM_VERSION}" nfpm package --config "${config}" --packager "${packager}" --target "${temp_dir}/"
mv "${temp_dir}"/*.${packager} "${target}"
rm -rf "${temp_dir}"
rm -rf build/package-root
}
# AMD64 包
package amd64 amd64 deb packaging/nfpm.deb-rpm.yaml
package amd64 amd64 rpm packaging/nfpm.deb-rpm.yaml
package amd64 amd64 apk packaging/nfpm.apk.yaml
# ARM64 包
package arm64 arm64 deb packaging/nfpm.deb-rpm.yaml
package arm64 arm64 rpm packaging/nfpm.deb-rpm.yaml
package arm64 arm64 apk packaging/nfpm.apk.yaml
ls -la build/packages
- name: 检查安装包内容
run: |
dpkg-deb -c build/packages/hubproxy-linux-amd64.deb
rpm -qpl build/packages/hubproxy-linux-amd64.rpm
tar -tf build/packages/hubproxy-linux-amd64.apk
- name: 创建或更新Release - name: 创建或更新Release
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@v3
with: with:
tag_name: ${{ steps.version.outputs.version }} tag_name: ${{ steps.version.outputs.version }}
name: "HubProxy ${{ steps.version.outputs.version }}" name: "HubProxy ${{ steps.version.outputs.version }}"
@@ -117,12 +168,16 @@ jobs:
## 下载文件 ## 下载文件
- **Linux AMD64**: `hubproxy-${{ steps.version.outputs.version }}-linux-amd64.tar.gz` - **Linux AMD64**: `hubproxy-linux-amd64.tar.gz`
- **Linux ARM64**: `hubproxy-${{ steps.version.outputs.version }}-linux-arm64.tar.gz` - **Linux ARM64**: `hubproxy-linux-arm64.tar.gz`
- **Debian/Ubuntu**: `.deb`
- **RHEL/CentOS/Fedora**: `.rpm`
- **Alpine Linux**: `.apk`
files: | files: |
build/*.tar.gz build/*.tar.gz
build/checksums.txt build/packages/*
overwrite_files: true
draft: false draft: false
prerelease: false prerelease: false
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
+31
View File
@@ -0,0 +1,31 @@
# IDE / OS
.idea/
.vscode/
.DS_Store
# Go build artifacts
/hubproxy*
*.exe
build/
# Frontend (web/)
web/node_modules/
web/dist/
web/dist-ssr/
web/.tmp/
web/*.local
web/npm-debug.log*
web/yarn-debug.log*
web/yarn-error.log*
web/pnpm-debug.log*
# Frontend build output embedded by Go
src/dist/
# Docs site (docs/)
docs/node_modules/
docs/dist/
docs/.astro/
# Logs
*.log
+17 -4
View File
@@ -1,15 +1,28 @@
FROM golang:1.24-alpine AS builder FROM node:24-alpine AS frontend
WORKDIR /web
COPY web/package.json web/package-lock.json ./
RUN npm ci
COPY web/ .
RUN npm run build
FROM golang:1.26-alpine AS builder
ARG TARGETARCH
ARG VERSION=dev
WORKDIR /app WORKDIR /app
COPY src/go.mod src/go.sum ./ COPY src/go.mod src/go.sum ./
RUN go mod download RUN apk add --no-cache upx && go mod download
COPY src/ . COPY src/ .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -trimpath -o hubproxy . COPY --from=frontend /src/dist ./dist
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 FROM alpine
WORKDIR /root/ WORKDIR /app
COPY --from=builder /app/hubproxy . COPY --from=builder /app/hubproxy .
COPY --from=builder /app/config.toml . COPY --from=builder /app/config.toml .
+21
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.
+55 -62
View File
@@ -1,25 +1,36 @@
# HubProxy # 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">
- 📁 **GitHub 文件加速** - 加速 GitHub Release、Raw 文件下载,支持`api.github.com`,脚本嵌套加速等等 </p>
- 🤖 **AI 模型库支持** - 支持 Hugging Face 模型下载加速
- 🛡️ **智能限流** - IP 限流保护,防止滥用
- 🚫 **仓库审计** - 强大的自定义黑名单,白名单,同时审计镜像仓库,和GitHub仓库
- 🔍 **镜像搜索** - 在线搜索 Docker 镜像
-**轻量高效** - 基于 Go 语言,单二进制文件运行,资源占用低,优雅的内存清理机制。
- 🔧 **配置热重载** - 统一配置管理,部分配置项支持热重载,无需重启服务
## 🚀 快速开始
### Docker部署(推荐) ## 特性
```
- 🐳 **Docker 镜像加速** — 兼容 Registry API v2,支持 Docker Hub、GHCR、Quay、GCR、registry.k8s.io;流式传输,Manifest / Token 缓存
- 📦 **离线镜像包** — 无需本地 Docker,在线打包单镜像或批量 tar;流式下载 + 防抖设计
- 📁 **GitHub 文件加速** — Release、Raw、Git Clone、`api.github.com``.sh` / `.ps1` 脚本内 URL 自动改写
- 🤖 **Hugging Face 加速** — 模型文件与 LFS 大文件下载
- 🔍 **镜像搜索** — Web 界面与 API 搜索 Docker Hub 镜像、浏览标签
- 🛡️ **智能限流** — 按真实客户端 IP 令牌桶限流(IPv6 按 `/64`);可配置周期与配额
- 🚫 **仓库访问控制** — IP 黑白名单(限流豁免 / 封禁)+ 镜像 / GitHub 仓库黑白名单,支持通配符
- 🌐 **上游 SOCKS5 代理** — 可选配置出站代理,适配特殊网络环境
- 🖥️ **Web 界面** — 内置 Vue SPA,镜像搜索、离线包下载、标签浏览
-**轻量高效** — Go 单二进制,支持 `deb` / `rpm` / `apk` 与 Docker 多架构镜像
- 🔧 **统一配置**`config.toml` + 环境变量覆盖,开箱即用
- 🚀 **多服务统一加速** — 单个程序覆盖 Docker、GitHub、Hugging Face,简化部署
- ☁️ **完全自托管** — 不依赖第三方免费 CDN 代理,数据与带宽自主可控
## 快速开始
### Docker 部署(推荐)
```bash
docker run -d \ docker run -d \
--name hubproxy \ --name hubproxy \
-p 5000:5000 \ -p 5000:5000 \
@@ -27,67 +38,53 @@ docker run -d \
ghcr.io/sky22333/hubproxy ghcr.io/sky22333/hubproxy
``` ```
验证服务:
### 一键安装
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/sky22333/hubproxy/main/install-service.sh | sudo bash curl http://127.0.0.1:5000/ready
``` ```
这个命令会: 或者网页访问
- 🔍 自动检测系统架构(AMD64/ARM64
- 📥 从 GitHub Releases 下载最新版本
- ⚙️ 自动配置系统服务
- 🔄 保留现有配置(升级时)
### 脚本安装
自动识别 `amd64` / `arm64``apt``dnf``apk` 等包管理器:
## 📖 使用方法
### Docker 镜像加速
```bash ```bash
# 原命令 curl -fsSL https://raw.githubusercontent.com/sky22333/hubproxy/main/install.sh | sh
docker pull nginx ```
# 使用加速 安装后配置文件位于 `/etc/hubproxy/config.toml`,服务自动启动。
### 快速上手
`yourdomain.com` 换成你的 `HubProxy` 地址
```bash
# Docker 镜像加速
docker pull yourdomain.com/nginx docker pull yourdomain.com/nginx
# ghcr加速 # GitHub Release 加速
docker pull yourdomain.com/ghcr.io/user/images wget "https://yourdomain.com/https://github.com/owner/repo/releases/download/v1.0.0/app.tar.gz"
# Git clone 加速
git clone https://yourdomain.com/https://github.com/sky22333/hubproxy.git
``` ```
### GitHub 文件加速 > **生产环境建议**:绑定自有域名,通过 Caddy / Nginx 反代并开启 HTTPS,不要长期暴露裸 `http://IP:5000`。详见 [文档](https://docs.52013120.xyz/getting-started/quick-start/)。
```bash ## 详细文档
# 原链接
https://github.com/user/repo/releases/download/v1.0.0/file.tar.gz
# 加速链接 部署架构、完整配置、K8s / NAS、传输特性与 FAQ 见官方文档站:
https://yourdomain.com/https://github.com/user/repo/releases/download/v1.0.0/file.tar.gz
``` - [**中文文档**](https://docs.52013120.xyz/)
- [**English**](https://docs.52013120.xyz/en/)
## 界面预览
## ⚙️ 提示 ![demo](.github/demo/demo.png)
主配置文件位于 `/opt/hubproxy/config.toml` ## 免责声明
为了IP限流能够正常运行,反向代理需要传递IP头用来获取访客真实IP,以caddy为例:
```
example.com {
reverse_proxy 127.0.0.1:5000 {
header_up X-Forwarded-For {http.request.header.CF-Connecting-IP}
header_up X-Real-IP {http.request.header.CF-Connecting-IP}
header_up X-Forwarded-Proto https
header_up X-Forwarded-Host {host}
}
}
```
## ⚠️ 免责声明
- 本程序仅供学习交流使用,请勿用于非法用途 - 本程序仅供学习交流使用,请勿用于非法用途
- 使用本程序需遵守当地法律法规 - 使用本程序需遵守当地法律法规
@@ -95,8 +92,4 @@ example.com {
--- ---
<div align="center"> **如果这个项目对你有帮助,请给个 Star ⭐**
**⭐ 如果这个项目对你有帮助,请给个 Star!⭐**
</div>
+10 -4
View File
@@ -1,8 +1,14 @@
services: services:
ghproxy: hubproxy:
build: . image: ghcr.io/sky22333/hubproxy
container_name: hubproxy
restart: always restart: always
ports: ports:
- '5000:5000' - "5000:5000"
volumes: volumes:
- ./src/config.toml:/root/config.toml - ./src/config.toml:/app/config.toml:ro
logging:
driver: json-file
options:
max-size: "200m"
max-file: "3"
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
.astro/
+36
View File
@@ -0,0 +1,36 @@
# HubProxy 文档站
基于 [Astro Starlight](https://starlight.astro.build/),默认中文,英文位于 `/en/`
## 本地开发
```bash
cd docs
npm install
npm run dev
```
## 构建
```bash
npm run build # 输出到 dist/
npm run preview # 本地预览构建结果
```
## 目录
```
src/content/docs/ # 中文文档
src/content/docs/en/ # 英文文档
src/assets/ # logo、hero 等资源
public/favicon.svg # 浏览器标签页图标
src/styles/custom.css # 自定义样式
astro.config.mjs # Starlight 配置(site: docs.52013120.xyz
```
## 贡献
1. 修改 `src/content/docs/` 中文页面,并同步 `en/` 镜像
2. 侧边栏结构见 `astro.config.mjs`
3. 向主仓库提交 PR
+81
View File
@@ -0,0 +1,81 @@
import { defineConfig } from 'astro/config'
import starlight from '@astrojs/starlight'
export default defineConfig({
site: 'https://docs.52013120.xyz',
base: '/',
integrations: [
starlight({
title: 'HubProxy',
description: 'Docker 与 GitHub 加速代理服务文档',
defaultLocale: 'root',
locales: {
root: {
label: '简体中文',
lang: 'zh-CN',
},
en: {
label: 'English',
lang: 'en',
},
},
logo: {
alt: 'HubProxy',
src: './src/assets/logo.svg',
},
social: [
{
icon: 'github',
label: 'GitHub',
href: 'https://github.com/sky22333/hubproxy',
},
],
editLink: {
baseUrl: 'https://github.com/sky22333/hubproxy/edit/main/docs/',
},
sidebar: [
{
label: '简介',
translations: { en: 'Introduction' },
link: '/',
},
{
label: '快速开始',
translations: { en: 'Getting Started' },
collapsed: true,
items: [{ autogenerate: { directory: 'getting-started' } }],
},
{
label: '部署',
translations: { en: 'Deployment' },
collapsed: true,
items: [{ autogenerate: { directory: 'deployment' } }],
},
{
label: '使用指南',
translations: { en: 'Guides' },
collapsed: true,
items: [{ autogenerate: { directory: 'guides' } }],
},
{
label: '配置',
translations: { en: 'Configuration' },
collapsed: true,
items: [{ autogenerate: { directory: 'configuration' } }],
},
{
label: '安全',
translations: { en: 'Security' },
collapsed: true,
items: [{ autogenerate: { directory: 'security' } }],
},
{
label: '常见问题',
translations: { en: 'FAQ' },
link: '/faq/',
},
],
customCss: ['./src/styles/custom.css'],
}),
],
})
+6883
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
{
"name": "hubproxy-docs",
"private": true,
"type": "module",
"packageManager": "npm@11.12.1",
"engines": {
"node": ">=24"
},
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview"
},
"dependencies": {
"@astrojs/starlight": "^0.41.3",
"astro": "^7.0.7",
"sharp": "^0.34.5"
}
}
+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<defs>
<linearGradient id="f-bg" x1="10" y1="6" x2="54" y2="58" gradientUnits="userSpaceOnUse">
<stop stop-color="#3b82f6"/>
<stop stop-color="#1d4ed8"/>
</linearGradient>
</defs>
<rect x="4" y="4" width="56" height="56" rx="16" fill="url(#f-bg)"/>
<rect x="28" y="18" width="8" height="28" rx="4" fill="#fff"/>
<path d="M16 32h8M48 32H40" stroke="#fff" stroke-width="3.5" stroke-linecap="round"/>
<path d="M20 27.5 24 32l-4 4.5M44 27.5 40 32l4 4.5" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 642 B

+57
View File
@@ -0,0 +1,57 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 320" fill="none" role="img" aria-label="HubProxy">
<defs>
<linearGradient id="hero-bg" x1="0" y1="0" x2="480" y2="320" gradientUnits="userSpaceOnUse">
<stop stop-color="#eff6ff"/>
<stop stop-color="#f8fafc"/>
</linearGradient>
<linearGradient id="hero-icon" x1="180" y1="72" x2="300" y2="192" gradientUnits="userSpaceOnUse">
<stop stop-color="#3b82f6"/>
<stop stop-color="#1d4ed8"/>
</linearGradient>
<linearGradient id="hero-shine" x1="200" y1="88" x2="280" y2="168" gradientUnits="userSpaceOnUse">
<stop stop-color="#fff" stop-opacity="0.35"/>
<stop stop-color="#fff" stop-opacity="0"/>
</linearGradient>
<radialGradient id="hero-glow" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(240 130) rotate(90) scale(120 160)">
<stop stop-color="#3b82f6" stop-opacity="0.18"/>
<stop stop-color="#3b82f6" stop-opacity="0"/>
</radialGradient>
<filter id="hero-shadow" x="160" y="70" width="160" height="160" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feDropShadow dx="0" dy="10" stdDeviation="14" flood-color="#1d4ed8" flood-opacity="0.18"/>
</filter>
</defs>
<rect width="480" height="320" rx="28" fill="url(#hero-bg)"/>
<rect width="480" height="320" rx="28" fill="url(#hero-glow)"/>
<g opacity="0.35" stroke="#93c5fd" stroke-width="1.5">
<path d="M72 96h64M72 224h64M344 96h64M344 224h64"/>
<circle cx="72" cy="96" r="4" fill="#bfdbfe" stroke="none"/>
<circle cx="136" cy="96" r="4" fill="#bfdbfe" stroke="none"/>
<circle cx="344" cy="96" r="4" fill="#bfdbfe" stroke="none"/>
<circle cx="408" cy="96" r="4" fill="#bfdbfe" stroke="none"/>
<circle cx="72" cy="224" r="4" fill="#bfdbfe" stroke="none"/>
<circle cx="136" cy="224" r="4" fill="#bfdbfe" stroke="none"/>
<circle cx="344" cy="224" r="4" fill="#bfdbfe" stroke="none"/>
<circle cx="408" cy="224" r="4" fill="#bfdbfe" stroke="none"/>
</g>
<path d="M136 96C168 96 196 110 214 130M344 96C312 96 284 110 266 130" stroke="#93c5fd" stroke-width="2" stroke-linecap="round" stroke-dasharray="6 8" opacity="0.7"/>
<path d="M136 224C168 224 196 210 214 190M344 224C312 224 284 210 266 190" stroke="#93c5fd" stroke-width="2" stroke-linecap="round" stroke-dasharray="6 8" opacity="0.7"/>
<g filter="url(#hero-shadow)">
<rect x="192" y="82" width="96" height="96" rx="28" fill="url(#hero-icon)"/>
<rect x="192" y="82" width="96" height="96" rx="28" fill="url(#hero-shine)"/>
<rect x="234" y="104" width="12" height="52" rx="6" fill="#fff" fill-opacity="0.96"/>
<path d="M208 130h16" stroke="#fff" stroke-width="5" stroke-linecap="round"/>
<path d="M216 121l8 9-8 9" stroke="#fff" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M272 130h-16" stroke="#fff" stroke-width="5" stroke-linecap="round"/>
<path d="M264 121l-8 9 8 9" stroke="#fff" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="240" cy="92" r="4" fill="#fff" fill-opacity="0.55"/>
<circle cx="240" cy="168" r="4" fill="#fff" fill-opacity="0.55"/>
<path d="M240 96v6M240 162v6" stroke="#fff" stroke-width="2.5" stroke-linecap="round" stroke-opacity="0.45"/>
</g>
<text x="240" y="228" text-anchor="middle" fill="#0f172a" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif" font-size="34" font-weight="700" letter-spacing="-0.02em">HubProxy</text>
<text x="240" y="258" text-anchor="middle" fill="#64748b" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif" font-size="15" font-weight="500">Docker · GitHub · Registry Proxy</text>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

+22
View File
@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" role="img" aria-label="HubProxy">
<defs>
<linearGradient id="hp-bg" x1="10" y1="6" x2="54" y2="58" gradientUnits="userSpaceOnUse">
<stop stop-color="#3b82f6"/>
<stop stop-color="#1d4ed8"/>
</linearGradient>
<linearGradient id="hp-shine" x1="20" y1="12" x2="44" y2="40" gradientUnits="userSpaceOnUse">
<stop stop-color="#fff" stop-opacity="0.28"/>
<stop stop-color="#fff" stop-opacity="0"/>
</linearGradient>
</defs>
<rect x="4" y="4" width="56" height="56" rx="16" fill="url(#hp-bg)"/>
<rect x="4" y="4" width="56" height="56" rx="16" fill="url(#hp-shine)"/>
<rect x="28" y="18" width="8" height="28" rx="4" fill="#fff" fill-opacity="0.95"/>
<path d="M16 32h8" stroke="#fff" stroke-width="3.5" stroke-linecap="round"/>
<path d="M20 27.5 24 32l-4 4.5" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M48 32H40" stroke="#fff" stroke-width="3.5" stroke-linecap="round"/>
<path d="M44 27.5 40 32l4 4.5" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="32" cy="14" r="2.5" fill="#fff" fill-opacity="0.55"/>
<circle cx="32" cy="50" r="2.5" fill="#fff" fill-opacity="0.55"/>
<path d="M32 16.5v4M32 43.5v4" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-opacity="0.45"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+7
View File
@@ -0,0 +1,7 @@
import { defineCollection } from 'astro:content'
import { docsLoader } from '@astrojs/starlight/loaders'
import { docsSchema } from '@astrojs/starlight/schema'
export const collections = {
docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }),
}
@@ -0,0 +1,46 @@
---
title: 环境变量
description: HubProxy 支持的环境变量说明。
---
环境变量在加载 `config.toml` 之后覆盖对应配置项。未列出的配置段(如 `[registries]`、`[tokenCache]`、`[access].whiteList`)只能通过配置文件修改。
## 全部变量
| 环境变量 | 对应配置 | 说明 |
|---------|---------|------|
| `CONFIG_PATH` | — | 配置文件路径,默认 `./config.toml` |
| `SERVER_HOST` | `[server].host` | 监听地址 |
| `SERVER_PORT` | `[server].port` | 监听端口 |
| `ENABLE_H2C` | `[server].enableH2C` | 启用 HTTP/2 Cleartext`true`/`false` |
| `ENABLE_FRONTEND` | `[server].enableFrontend` | 启用 Web 界面(`true`/`false` |
| `MAX_FILE_SIZE` | `[server].fileSize` | 单文件大小上限(字节) |
| `RATE_LIMIT` | `[rateLimit].requestLimit` | 每 IP 每周期请求数 |
| `RATE_PERIOD_HOURS` | `[rateLimit].periodHours` | 限流周期(小时) |
| `IP_WHITELIST` | `[security].whiteList` | 追加限流豁免 IP,逗号分隔 |
| `IP_BLACKLIST` | `[security].blackList` | 追加封禁 IP,逗号分隔 |
| `ACCESS_PROXY` | `[access].proxy` | 上游 SOCKS5 代理地址 |
| `MAX_IMAGES` | `[download].maxImages` | 批量离线镜像数量上限 |
## 示例
```bash
# systemd / 本地运行
CONFIG_PATH=/etc/hubproxy/config.toml ./hubproxy
# Docker 快速调参
docker run -d \
-e SERVER_PORT=5000 \
-e RATE_LIMIT=1000 \
-e IP_WHITELIST=10.0.0.0/8,192.168.1.100 \
-v ./config.toml:/app/config.toml:ro \
ghcr.io/sky22333/hubproxy
```
## 使用场景
| 场景 | 建议 |
|------|------|
| systemd 服务 | `CONFIG_PATH=/etc/hubproxy/config.toml`(服务文件已内置) |
| Docker | 挂载 `/app/config.toml`,临时参数用 `-e` |
| 开发调试 | `CONFIG_PATH=./src/config.toml go run .` |
@@ -0,0 +1,106 @@
---
title: config.toml 参考
description: HubProxy 配置文件完整参考。
---
配置文件默认路径为工作目录下的 `config.toml`,可通过 `CONFIG_PATH` 环境变量覆盖。完整环境变量列表见 [环境变量](/configuration/environment/)。
## [server]
| 键 | 类型 | 默认值 | 说明 |
|----|------|--------|------|
| `host` | string | `0.0.0.0` | 监听地址 |
| `port` | int | `5000` | 监听端口 |
| `fileSize` | int | `2147483648` | 单文件大小上限(字节),仅影响 GitHub / Hugging Face URL 代理 |
| `enableH2C` | bool | `false` | 启用 HTTP/2 Cleartext |
| `enableFrontend` | bool | `true` | 启用 Web 界面(Vue SPA |
## [rateLimit]
| 键 | 类型 | 默认值 | 说明 |
|----|------|--------|------|
| `requestLimit` | int | `500` | 每 IP 每周期允许请求数 |
| `periodHours` | float | `3.0` | 限流周期(小时) |
IPv4 按完整 IP 计数,IPv6 按 `/64` 网段计数。仅前端静态路由(`/`、`/images`、`/search`、`/favicon.ico`、`/assets/*`)不计入限流;`/ready`、API 与代理请求均会计入。
:::note
Docker 拉取一个镜像会请求多个 layer,每个 HTTP 请求均消耗限流配额。
:::
## [security]
| 键 | 说明 |
|----|------|
| `whiteList` | 限流豁免 IP 列表(支持 CIDR,单 IP 自动补 `/32` |
| `blackList` | 直接拒绝访问的 IP 列表(403) |
:::note
`[security]` 黑白名单仅影响限流与 IP 封禁,**不**控制可代理的仓库。
:::
## [access]
| 键 | 说明 |
|----|------|
| `whiteList` | 允许代理的仓库/镜像(空=不限制) |
| `blackList` | 禁止代理的仓库/镜像 |
| `proxy` | 上游 SOCKS5 代理,如 `socks5://127.0.0.1:1080` 或 `socks5://user:pass@host:1080` |
仓库规则支持通配符,如 `owner/*`、`*/repo-name`。
## [download]
| 键 | 类型 | 默认值 | 说明 |
|----|------|--------|------|
| `maxImages` | int | `10` | 批量离线镜像数量上限 |
## [registries]
每个 Registry 条目:
| 键 | 说明 |
|----|------|
| `upstream` | 上游 Registry 地址 |
| `authHost` | 认证端点(用于匹配 token 请求) |
| `authType` | 认证类型标识(`anonymous`/`github`/`google`/`quay` |
| `enabled` | 是否启用 |
默认预置 `ghcr.io`、`gcr.io`、`quay.io`、`registry.k8s.io`。Docker Hub 固定走 `registry-1.docker.io`,不在此段配置。
:::note
当前版本对所有 Registry 均使用匿名拉取(`authn.Anonymous`),`authType` 仅用于标识认证端点类型,**尚未**实现 GitHub Token 或 Google 服务账号等私有仓库凭据,且不转发客户端 `Authorization` 头。**无法**通过 HubProxy 拉取需要认证的私有镜像。
:::
## [tokenCache]
| 键 | 类型 | 默认值 | 说明 |
|----|------|--------|------|
| `enabled` | bool | `true` | 启用 Token/Manifest 缓存 |
| `defaultTTL` | string | `"20m"` | 普通 tag 的 Manifest 默认缓存时间 |
Manifest 缓存 TTL 规则:
| 条件 | TTL |
|------|-----|
| digest`sha256:...` | 24 小时 |
| `latest` / `main` / `master` / `dev` / `develop` | 10 分钟 |
| 其他 tag | `[tokenCache].defaultTTL` |
上游 token 响应中的 `expires_in` 会用于 token 缓存(预留 5 分钟安全余量,最短 5 分钟)。
## HTTP 端点
| 路径 | 说明 |
|------|------|
| `GET /ready` | 健康检查,返回 `ready`、`version`、`uptime_sec` 等(**计入** IP 限流) |
| `GET /api/search?q=...` | Docker Hub 镜像搜索 |
| `GET /api/tags/:namespace/:name` | 镜像标签列表 |
| `GET /api/image/info?image=...` | 镜像元信息 |
| `GET /api/image/download?mode=prepare` | 申请单镜像离线包 token |
| `GET /api/image/download?token=...` | 下载单镜像 tar |
| `POST /api/image/batch?mode=prepare` | 申请批量离线包 token |
| `GET /api/image/batch?token=...` | 下载批量 tar |
| `ANY /v2/*` | Docker Registry API v2 代理 |
| `ANY /token*` | Docker 认证代理 |
| 其他路径 | GitHub / Hugging Face 等 URL 代理 |
@@ -0,0 +1,42 @@
---
title: 推荐部署架构
description: 公网部署 HubProxy 的推荐拓扑与安全要求。
---
公网服务建议通过反向代理暴露 HubProxy,保障服务器安全。
## 推荐拓扑
```
用户 → CDN(可选)→ Caddy/Nginx(私网/本机)→ HubProxy:5000
```
| 组件 | 作用 |
|------|------|
| CDN | 可选,提供 TLS 与边缘加速 |
| 反向代理 | 终止 TLS、写入真实客户端 IP、隐藏后端端口 |
| HubProxy | 处理 Docker/GitHub 代理逻辑 |
## 反代部署时的要求
若采用上文推荐拓扑(用户 → 反代 → HubProxy),需满足:
1. **反代覆盖写** `X-Forwarded-For` / `X-Real-IP`(防止用户伪造 IP,勿 append 客户端自带头)
2. **建议 5000 仅对反代可达**(绑定 `127.0.0.1` 或防火墙限制)
3. 反代覆盖写 `X-Forwarded-Host`(防止 `.sh` 脚本 Host 注入)
## 各场景对照
| 部署方式 | 每用户独立限流 | IP 防伪造 | 推荐度 |
|---------|---------------|----------|--------|
| CDN → 私网反代 → HubProxy | ✅ | ✅ | ⭐⭐⭐ |
| Docker 内 Caddy + HubProxy | ✅ | ✅ | ⭐⭐⭐ |
| 本机 Nginx → HubProxy | ✅ | ✅ | ⭐⭐⭐ |
| Cloudflare 直连 HubProxy | ❌ 共用 CDN IP | ✅ | ⚠️ |
| 公网直连 `0.0.0.0:5000` | ✅ | ✅ | ⚠️ 容易被滥用 |
## 下一步
- [反向代理配置](/deployment/reverse-proxy/)
- [Docker 部署](/deployment/docker/)
- [安全概述](/security/overview/)
@@ -0,0 +1,57 @@
---
title: Docker 部署
description: 使用 Docker 与 Docker Compose 部署 HubProxy。
---
## docker run
```bash
docker run -d \
--name hubproxy \
-p 5000:5000 \
--restart always \
-v ./src/config.toml:/app/config.toml:ro \
ghcr.io/sky22333/hubproxy
```
## Docker Compose
项目根目录提供 `docker-compose.yml`
```bash
docker compose up -d
```
默认挂载 `./src/config.toml` 到容器内 `/app/config.toml`。Compose 文件内已配置日志轮转(`200m × 3`),无需在 `docker run` 中重复设置。
:::caution
生产环境建议不要将 `5000` 直接映射到公网。更安全的做法是让 Caddy/Nginx 容器访问 HubProxy 内网地址。
:::
## 与反代配合
典型 Compose 结构:
```
caddy: 对外 443,反代 hubproxy:5000
hubproxy: 仅内网暴露 5000,不映射到宿主机公网
```
## 健康检查
容器镜像未内置 `HEALTHCHECK`,可手动验证:
```bash
curl http://127.0.0.1:5000/ready
```
## Compose 日志(可选)
若使用 Compose,可在 `docker-compose.yml` 中配置:
| 参数 | 含义 |
|------|------|
| `max-size=200m` | 单个日志文件上限 |
| `max-file=3` | 保留 3 个文件,总量约 600MB |
`docker run` 未指定时 Docker 默认约 20MB 日志上限。
@@ -0,0 +1,65 @@
---
title: 反向代理
description: 配置 Caddy 与 Nginx 反向代理,正确传递客户端真实 IP。
---
反向代理必须**覆盖写入**真实客户端 IP,HubProxy 才能按用户独立限流并防止 IP 伪造。
## Caddy
### 基础反代
```txt
example.com {
reverse_proxy 127.0.0.1:5000 {
header_up X-Real-IP {remote}
header_up X-Forwarded-For {remote}
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-Host {host}
}
}
```
### Cloudflare CDN
```txt
example.com {
reverse_proxy 127.0.0.1:5000 {
header_up X-Forwarded-For {http.request.header.CF-Connecting-IP}
header_up X-Real-IP {http.request.header.CF-Connecting-IP}
header_up X-Forwarded-Proto https
header_up X-Forwarded-Host {host}
}
}
```
## Nginx
:::caution
避免使用 `proxy_add_x_forwarded_for`,它会 append 客户端自带的 `X-Forwarded-For`,可能导致 IP 解析错误。
:::
```nginx
location / {
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
### GitHub 加速提示「无效输入」
若使用 Nginx 反代后 GitHub 加速异常,请参考 [issue #62](https://github.com/sky22333/hubproxy/issues/62#issuecomment-3219572440) 检查 `proxy_set_header Host` 等配置。
## 工作原理
HubProxy 仅当 TCP 连接来自可信私网/本机网段时,才信任转发头:
- `127.0.0.0/8`
- `10.0.0.0/8`
- `172.16.0.0/12`
- `192.168.0.0/16`
详见 [IP 信任机制](/security/ip-trust/)。
@@ -0,0 +1,46 @@
---
title: Environment Variables
description: Environment variables supported by HubProxy.
---
Environment variables override config after `config.toml` is loaded. Sections not listed here (e.g. `[registries]`, `[tokenCache]`, `[access].whiteList`) can only be changed in the config file.
## All Variables
| Variable | Config key | Description |
|----------|-----------|-------------|
| `CONFIG_PATH` | — | Config file path, default `./config.toml` |
| `SERVER_HOST` | `[server].host` | Listen address |
| `SERVER_PORT` | `[server].port` | Listen port |
| `ENABLE_H2C` | `[server].enableH2C` | Enable HTTP/2 Cleartext (`true`/`false`) |
| `ENABLE_FRONTEND` | `[server].enableFrontend` | Enable web UI (`true`/`false`) |
| `MAX_FILE_SIZE` | `[server].fileSize` | Max single-file size (bytes) |
| `RATE_LIMIT` | `[rateLimit].requestLimit` | Requests per IP per period |
| `RATE_PERIOD_HOURS` | `[rateLimit].periodHours` | Rate limit period (hours) |
| `IP_WHITELIST` | `[security].whiteList` | Append rate-limit exempt IPs, comma-separated |
| `IP_BLACKLIST` | `[security].blackList` | Append blocked IPs, comma-separated |
| `ACCESS_PROXY` | `[access].proxy` | Upstream SOCKS5 proxy URL |
| `MAX_IMAGES` | `[download].maxImages` | Max images per batch offline download |
## Examples
```bash
# systemd / local
CONFIG_PATH=/etc/hubproxy/config.toml ./hubproxy
# Docker quick overrides
docker run -d \
-e SERVER_PORT=5000 \
-e RATE_LIMIT=1000 \
-e IP_WHITELIST=10.0.0.0/8,192.168.1.100 \
-v ./config.toml:/app/config.toml:ro \
ghcr.io/sky22333/hubproxy
```
## Use Cases
| Scenario | Recommendation |
|----------|----------------|
| systemd service | `CONFIG_PATH=/etc/hubproxy/config.toml` (built into service file) |
| Docker | Mount `/app/config.toml`, use `-e` for quick overrides |
| Local development | `CONFIG_PATH=./src/config.toml go run .` |
@@ -0,0 +1,106 @@
---
title: config.toml Reference
description: Complete reference for the HubProxy configuration file.
---
Default config path is `./config.toml` in the working directory. Override with `CONFIG_PATH`. See [Environment Variables](/en/configuration/environment/) for the full env var list.
## [server]
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `host` | string | `0.0.0.0` | Listen address |
| `port` | int | `5000` | Listen port |
| `fileSize` | int | `2147483648` | Max single-file size (bytes), GitHub / Hugging Face URL proxy only |
| `enableH2C` | bool | `false` | Enable HTTP/2 Cleartext |
| `enableFrontend` | bool | `true` | Enable web UI (Vue SPA) |
## [rateLimit]
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `requestLimit` | int | `500` | Requests per IP per period |
| `periodHours` | float | `3.0` | Rate limit period (hours) |
IPv4 uses full addresses; IPv6 uses `/64` prefixes. Only frontend static routes (`/`, `/images`, `/search`, `/favicon.ico`, `/assets/*`) are exempt; `/ready`, API, and proxy requests all count.
:::note
Pulling one Docker image triggers multiple layer requests — each HTTP request counts against the limit.
:::
## [security]
| Key | Description |
|-----|-------------|
| `whiteList` | Rate-limit exempt IPs (CIDR supported, single IPs auto-get `/32`) |
| `blackList` | Blocked IPs (403) |
:::note
`[security]` lists affect rate limiting only — they do **not** control which registries can be proxied.
:::
## [access]
| Key | Description |
|-----|-------------|
| `whiteList` | Allowed repos/images (empty = no restriction) |
| `blackList` | Blocked repos/images |
| `proxy` | Upstream SOCKS5 proxy, e.g. `socks5://127.0.0.1:1080` or `socks5://user:pass@host:1080` |
Wildcards supported: `owner/*`, `*/repo-name`.
## [download]
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `maxImages` | int | `10` | Max images per batch offline download |
## [registries]
Per-registry keys:
| Key | Description |
|-----|-------------|
| `upstream` | Upstream registry host |
| `authHost` | Auth endpoint (for token request matching) |
| `authType` | Auth type label (`anonymous` / `github` / `google` / `quay`) |
| `enabled` | Enable or disable |
Defaults include `ghcr.io`, `gcr.io`, `quay.io`, `registry.k8s.io`. Docker Hub always proxies to `registry-1.docker.io` and is not configured here.
:::note
All registries currently use anonymous pulls (`authn.Anonymous`). `authType` labels the auth endpoint only — **GitHub tokens and Google service accounts are not yet implemented**, and client `Authorization` headers are not forwarded. **Private authenticated images cannot be pulled through HubProxy.**
:::
## [tokenCache]
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `enabled` | bool | `true` | Enable token/manifest cache |
| `defaultTTL` | string | `"20m"` | Default manifest cache TTL for ordinary tags |
Manifest cache TTL rules:
| Condition | TTL |
|-----------|-----|
| Digest (`sha256:...`) | 24 hours |
| `latest` / `main` / `master` / `dev` / `develop` | 10 minutes |
| Other tags | `[tokenCache].defaultTTL` |
Upstream token `expires_in` is used for token cache (5-minute safety margin, minimum 5 minutes).
## HTTP Endpoints
| Path | Description |
|------|-------------|
| `GET /ready` | Health check — returns `ready`, `version`, `uptime_sec`, etc. (**counts toward** rate limit) |
| `GET /api/search?q=...` | Docker Hub image search |
| `GET /api/tags/:namespace/:name` | Image tag list |
| `GET /api/image/info?image=...` | Image metadata |
| `GET /api/image/download?mode=prepare` | Request single-image offline token |
| `GET /api/image/download?token=...` | Download single-image tar |
| `POST /api/image/batch?mode=prepare` | Request batch offline token |
| `GET /api/image/batch?token=...` | Download batch tar |
| `ANY /v2/*` | Docker Registry API v2 proxy |
| `ANY /token*` | Docker auth proxy |
| Other paths | GitHub / Hugging Face URL proxy |
@@ -0,0 +1,42 @@
---
title: Recommended Architecture
description: Recommended topology and security requirements for public HubProxy deployments.
---
For public deployments, a reverse proxy in front of HubProxy is recommended to keep the server secure.
## Recommended Topology
```
Users → CDN (optional) → Caddy/Nginx (private/local) → HubProxy:5000
```
| Component | Role |
|-----------|------|
| CDN | Optional TLS termination and edge caching |
| Reverse proxy | TLS, real client IP injection, hide backend port |
| HubProxy | Docker/GitHub proxy logic |
## Requirements When Using a Reverse Proxy
If you follow the recommended topology above (users → proxy → HubProxy):
1. Reverse proxy must **overwrite** `X-Forwarded-For` / `X-Real-IP` (prevent client IP spoofing — never append client-supplied headers)
2. **Preferably make port 5000 reachable only by the proxy** (bind `127.0.0.1` or firewall)
3. Overwrite `X-Forwarded-Host` to prevent Host injection in `.sh` scripts
## Scenario Comparison
| Deployment | Per-user rate limit | IP spoof protection | Rating |
|------------|--------------------|--------------------|--------|
| CDN → private proxy → HubProxy | ✅ | ✅ | ⭐⭐⭐ |
| Docker Caddy + HubProxy | ✅ | ✅ | ⭐⭐⭐ |
| Local Nginx → HubProxy | ✅ | ✅ | ⭐⭐⭐ |
| Cloudflare direct to HubProxy | ❌ shared CDN IP | ✅ | ⚠️ |
| Public `0.0.0.0:5000` | ✅ | ✅ | ⚠️ easily abused |
## Next Steps
- [Reverse Proxy](/en/deployment/reverse-proxy/)
- [Docker Deployment](/en/deployment/docker/)
- [Security Overview](/en/security/overview/)
@@ -0,0 +1,57 @@
---
title: Docker Deployment
description: Deploy HubProxy with Docker and Docker Compose.
---
## docker run
```bash
docker run -d \
--name hubproxy \
-p 5000:5000 \
--restart always \
-v ./src/config.toml:/app/config.toml:ro \
ghcr.io/sky22333/hubproxy
```
## Docker Compose
The repository includes `docker-compose.yml`:
```bash
docker compose up -d
```
Mounts `./src/config.toml` to `/app/config.toml`. Log rotation (`200m × 3`) is preconfigured in Compose — no need to duplicate in `docker run`.
:::caution
In production, avoid mapping port 5000 directly to the public internet. Let a Caddy/Nginx container reach HubProxy on the internal network instead.
:::
## With Reverse Proxy
Typical Compose layout:
```
caddy: exposes 443, proxies to hubproxy:5000
hubproxy: internal 5000 only, not mapped to host public interface
```
## Health Check
The image has no built-in `HEALTHCHECK`. Verify manually:
```bash
curl http://127.0.0.1:5000/ready
```
## Compose Logging (Optional)
When using Compose, `docker-compose.yml` configures:
| Option | Meaning |
|--------|---------|
| `max-size=200m` | Max size per log file |
| `max-file=3` | Keep 3 files (~600MB total) |
Without options, `docker run` defaults to ~20MB per log file.
@@ -0,0 +1,65 @@
---
title: Reverse Proxy
description: Configure Caddy and Nginx to pass the real client IP correctly.
---
The reverse proxy must **overwrite** the real client IP so HubProxy can rate-limit per user and resist IP spoofing.
## Caddy
### Basic
```txt
example.com {
reverse_proxy 127.0.0.1:5000 {
header_up X-Real-IP {remote}
header_up X-Forwarded-For {remote}
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-Host {host}
}
}
```
### Behind Cloudflare
```txt
example.com {
reverse_proxy 127.0.0.1:5000 {
header_up X-Forwarded-For {http.request.header.CF-Connecting-IP}
header_up X-Real-IP {http.request.header.CF-Connecting-IP}
header_up X-Forwarded-Proto https
header_up X-Forwarded-Host {host}
}
}
```
## Nginx
:::caution
Avoid `proxy_add_x_forwarded_for` — it appends client-supplied `X-Forwarded-For` and can break IP resolution.
:::
```nginx
location / {
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
### GitHub acceleration shows "invalid input"
If GitHub acceleration fails behind Nginx, check `proxy_set_header Host` per [issue #62](https://github.com/sky22333/hubproxy/issues/62#issuecomment-3219572440).
## How It Works
HubProxy only trusts forwarding headers when the TCP connection comes from trusted private/local ranges:
- `127.0.0.0/8`
- `10.0.0.0/8`
- `172.16.0.0/12`
- `192.168.0.0/16`
See [IP Trust](/en/security/ip-trust/) for details.
+92
View File
@@ -0,0 +1,92 @@
---
title: FAQ
description: Frequently asked questions about deploying and using HubProxy.
---
<details>
<summary>How is HubProxy different from free CDN proxies?</summary>
HubProxy is fully self-hosted — you control data and bandwidth without relying on unstable third-party CDNs. Customize rate limits, registry allowlists, and upstream SOCKS5 proxies.
</details>
<details>
<summary>Is a reverse proxy required for public deployment?</summary>
Strongly recommended. It provides TLS, correct client IP passing, and avoids exposing port 5000. See [Recommended Architecture](/en/deployment/architecture/).
</details>
<details>
<summary>What's the difference between rate-limit and registry whitelists?</summary>
`[security].whiteList` exempts IPs from rate limiting. `[access].whiteList` controls which Docker images, GitHub repos, and Hugging Face resources can be proxied. They are independent.
</details>
<details>
<summary>How do I format GitHub acceleration URLs?</summary>
Prefix the **full original URL** with your HubProxy host, e.g. `https://example.com/https://github.com/owner/repo/releases/download/...`. Path-prefix rewrites like `/github/owner/...` are not supported. See [GitHub Acceleration](/en/guides/github-proxy/).
</details>
<details>
<summary>GitHub acceleration shows 'invalid input'?</summary>
Common causes: wrong URL format (using `/github/` prefix instead of full URL), or incorrect Nginx `Host` header. See [GitHub Acceleration](/en/guides/github-proxy/) and [issue #62](https://github.com/sky22333/hubproxy/issues/62#issuecomment-3219572440).
</details>
<details>
<summary>How does the offline image API work?</summary>
Two steps: `GET /api/image/download?image=...&mode=prepare` for a token, then download with that token. There is no `/v2/offline/...` path. See [Offline Images](/en/guides/offline-images/).
</details>
<details>
<summary>All users share one IP behind Cloudflare?</summary>
If HubProxy connects directly to Cloudflare edges, rate limits apply per Cloudflare IP, not per user. Add a private reverse proxy that writes `CF-Connecting-IP` to forwarding headers.
</details>
<details>
<summary>How do I verify the service is running?</summary>
Visit `https://example.com/ready` — a healthy response includes `ready`, `service`, `version`, and `uptime_sec`. This path **counts toward** the IP rate limit; avoid high-frequency polling.
</details>
<details>
<summary>Docker logs consuming too much disk?</summary>
`docker run` defaults to ~20MB logs. [Docker Compose](/en/deployment/docker/) preconfigures `200m × 3` rotation; adjust `logging.options` in compose as needed.
</details>
<details>
<summary>Does HubProxy support resume or multi-part downloads?</summary>
**GitHub / Hugging Face file downloads: yes.** The proxy forwards `Range` headers and passes through upstream 206 responses — `wget -c` and multi-threaded Range downloads work (except `.sh` / `.ps1` script rewriting).
**Docker pulls: no in-layer Range.** Blobs are streamed whole; clients retry failed layers or pull layers in parallel.
**Offline tar API: no** resume. See [Transfer Capabilities](/en/guides/capabilities/).
</details>
<details>
<summary>How do I add a new acceleration domain?</summary>
Add a regex to `githubExps` in `src/handlers/github.go`. See [Development & Build](/en/guides/development/).
</details>
<details>
<summary>Why can't I pull private images with authType github?</summary>
`authType` labels auth endpoints only — credential injection is not yet implemented. All registry pulls use anonymous auth and client `Authorization` headers are not forwarded. **Private authenticated images cannot be pulled through HubProxy.**
</details>
@@ -0,0 +1,56 @@
---
title: System Installation
description: Deploy HubProxy with deb, rpm, or apk packages and manage the service.
---
## Install Script
```bash
curl -fsSL https://raw.githubusercontent.com/sky22333/hubproxy/main/install.sh | sh
```
Supports `amd64` / `arm64` with `apt`, `dnf`, `apk`, and other package managers.
## File Paths
| Path | Description |
|------|-------------|
| `/etc/hubproxy/config.toml` | Configuration file |
| `/usr/bin/hubproxy` | Binary |
| `/lib/systemd/system/hubproxy.service` | systemd unit (Debian/Ubuntu/RHEL) |
| `/etc/init.d/hubproxy` | OpenRC service (Alpine) |
| `/var/log/hubproxy.log` | Alpine log file |
## systemd
```bash
sudo systemctl status hubproxy # Check service status
sudo systemctl restart hubproxy # Restart after config changes
sudo journalctl -u hubproxy -f # Follow logs
sudo nano /etc/hubproxy/config.toml # Edit config
```
Uninstall:
```bash
sudo apt purge hubproxy # Debian/Ubuntu — remove package and config
```
## OpenRC (Alpine)
```bash
sudo rc-service hubproxy status # Check status
sudo rc-service hubproxy restart # Restart service
sudo tail -f /var/log/hubproxy.log # Follow logs
sudo vi /etc/hubproxy/config.toml # Edit config
```
Uninstall:
```bash
sudo apk del hubproxy # Remove package
```
## Production recommendation
For public or team use, point a **custom domain** at HubProxy through Caddy / Nginx with **HTTPS enabled**, instead of exposing `http://IP:5000` long term. This provides TLS, correct client IP forwarding, and safer access. See [Reverse Proxy](/en/deployment/reverse-proxy/) and [Recommended Architecture](/en/deployment/architecture/).
@@ -0,0 +1,60 @@
---
title: Quick Start
description: Run HubProxy in minutes with Docker or the install script.
---
HubProxy supports Docker images and native package installation.
## Docker (Recommended)
```bash
docker run -d \
--name hubproxy \
-p 5000:5000 \
--restart always \
-v /path/to/config.toml:/app/config.toml:ro \
ghcr.io/sky22333/hubproxy
```
:::tip
[Docker Compose](/en/deployment/docker/) makes config mounting easier; log rotation is preconfigured in Compose.
:::
## Install Script
Auto-detects OS and architecture, downloads `.deb`, `.rpm`, or `.apk` from GitHub Releases:
```bash
curl -fsSL https://raw.githubusercontent.com/sky22333/hubproxy/main/install.sh | sh
```
The service starts automatically. Config is at `/etc/hubproxy/config.toml`.
## Verify
```bash
curl http://127.0.0.1:5000/ready # Local health check
```
Expected response:
```json
{
"ready": true,
"service": "hubproxy",
"version": "v1.x.x",
"start_time_unix": 1710000000,
"uptime_sec": 42.5,
"uptime_human": "42s"
}
```
## Next Steps
- [System Installation](/en/getting-started/install/)
- [Docker Compose](/en/deployment/docker/)
- [Docker Mirror Guide](/en/guides/docker-mirror/)
## Production recommendation
After verification, set up a **custom domain + reverse proxy + HTTPS** (Caddy / Nginx) so clients use `https://example.com` instead of bare `http://IP:5000`. See [Recommended Architecture](/en/deployment/architecture/) and [Reverse Proxy](/en/deployment/reverse-proxy/).
@@ -0,0 +1,61 @@
---
title: Transfer Capabilities
description: Transfer behavior for Docker pulls and GitHub downloads in HubProxy.
---
Based on current code in `src/handlers/docker.go` and `src/handlers/github.go`.
## Docker Image Pulls
HubProxy fetches manifests and blobs via go-containerregistry, then returns them over HTTP.
| Capability | Supported | Notes |
|------------|-----------|-------|
| Manifest cache | ✅ | GET cached; digest 24h, `latest` etc. 10m |
| Token cache | ✅ | Upstream `expires_in` driven |
| Multi-registry paths | ✅ | e.g. `example.com/ghcr.io/owner/image:tag` |
| containerd `ns` param | ✅ | Matches `[registries]` entries |
| Auth realm rewrite | ✅ | Upstream token → HubProxy `/token` |
| HTTP Range / in-layer resume | ❌ | Blobs always fetched fully; client `Range` ignored |
| Layer-level retry | ✅ | Docker/containerd retries failed layers |
:::note
Each layer costs at least one blob request against rate limits. No Range inside a layer, but clients may pull **different layers** in parallel.
:::
## GitHub / Hugging Face Downloads
The GitHub proxy (`proxyGitHubWithRedirect`) forwards **all client request headers** (including `Range`) upstream and passes through **status codes and response headers** (`Content-Range`, `Accept-Ranges`, etc.) before streaming the body.
| Capability | Supported | Notes |
|------------|-----------|-------|
| Streaming | ✅ | Direct `io.Copy` for normal files |
| HTTP Range / resume | ✅ | `wget -c`, `curl -C -`; 206 responses passed through |
| Multi-connection Range | ✅ | aria2-style parallel chunks (if upstream supports Range) |
| Redirects | ✅ | Max 20; GitHub Location URLs rewritten |
| HTML block | ✅ | `text/html` etc. → 403 |
| File size limit | ✅ | `[server].fileSize`, default 2GB |
| `.sh` / `.ps1` rewrite | ⚠️ | Full download + rewrite + chunked output; **no** Range resume |
### wget resume example
```bash
wget -c "https://example.com/https://github.com/owner/repo/releases/download/v1.0.0/large.bin"
```
See `src/handlers/github.go` lines 113118 (header forward) and 209231 (response pass-through).
## Offline image tar
| Capability | Supported |
|------------|-----------|
| Streaming tar | ✅ |
| One-time token (2 min) | ✅ |
| Range / resume | ❌ |
| `Cache-Control: no-store` | ✅ |
## Related
- [Docker Mirror](/en/guides/docker-mirror/)
- [GitHub Acceleration](/en/guides/github-proxy/)
- [Offline Images](/en/guides/offline-images/)
@@ -0,0 +1,80 @@
---
title: Development & Build
description: Extend acceleration URLs, registry mappings, and build from source.
---
## Layout
```
hubproxy/
├── src/ # Go backend
├── web/ # Vue SPA
├── Dockerfile
└── docs/
```
## Add acceleration URLs
Edit **`githubExps`** in `src/handlers/github.go`:
```go
var githubExps = []*regexp.Regexp{
regexp.MustCompile(`^(?:https?://)?github\.com/([^/]+)/([^/]+)/(?:releases|archive)/.*`),
// add new patterns here
}
```
Requirements:
1. Include `([^/]+)/([^/]+)` capture groups for `[access]` owner/repo matching
2. Run `go test ./handlers/...`
3. Routes use `NoRoute(GitHubProxyHandler)` in `main.go` — no new route needed
Access control: `src/utils/access_control.go` → `CheckGitHubAccess`.
## Add Docker Registry
`config.toml` → `[registries]`. Auth hook: `createUpstreamOptions()` in `src/handlers/docker.go`.
## Add HTTP routes
Register in `buildRouter()` in `src/main.go`.
## Local dev
```bash
cd src && CONFIG_PATH=./config.toml go run .
cd web && npm ci && npm run dev
```
Production embed:
```bash
cd web && npm ci && npm run build
# copy dist to src/dist
cd ../src && go build -o hubproxy .
```
## Docker build
```bash
docker build -t hubproxy:local --build-arg VERSION=1.0.0 .
docker buildx build --platform linux/amd64,linux/arm64 \
-t ghcr.io/your-org/hubproxy:latest \
--build-arg VERSION=1.0.0 --push .
```
| Stage | Image | Role |
|-------|-------|------|
| frontend | node:24-alpine | Vue build |
| builder | golang:1.26-alpine | Go + UPX |
| final | alpine | Runtime |
## Test & release
```bash
cd src && go test ./...
```
CI: `.github/workflows/docker-ghcr.yml`, `release.yml`.
@@ -0,0 +1,80 @@
---
title: Registry Sources
description: Configure upstream registry mappings in HubProxy.
---
HubProxy uses the `[registries]` section for third-party registry upstreams and auth endpoints. Docker Hub always proxies to `registry-1.docker.io` and is not configured here.
## Default Mappings
`config.toml` includes common registries:
```toml
[registries."ghcr.io"]
upstream = "ghcr.io"
authHost = "ghcr.io/token"
authType = "github"
enabled = true
[registries."gcr.io"]
upstream = "gcr.io"
authHost = "gcr.io/v2/token"
authType = "google"
enabled = true
[registries."quay.io"]
upstream = "quay.io"
authHost = "quay.io/v2/auth"
authType = "quay"
enabled = true
[registries."registry.k8s.io"]
upstream = "registry.k8s.io"
authHost = "registry.k8s.io"
authType = "anonymous"
enabled = true
```
## authType Values
| authType | Meaning |
|----------|---------|
| `anonymous` | Anonymous registry (e.g. registry.k8s.io) |
| `github` | GHCR auth endpoint |
| `google` | GCR auth endpoint |
| `quay` | Quay.io auth endpoint |
:::note
`authType` matches upstream token auth endpoints only. The current version does **not** inject GitHub tokens or Google service accounts — all pulls use anonymous auth and client credentials are not forwarded. **Private authenticated images cannot be pulled.**
:::
## Pull Path Format
```bash
# Docker Hub (no registry prefix; library and latest added automatically for official images)
docker pull example.com/nginx
# Third-party registry
docker pull example.com/ghcr.io/owner/image:tag
```
Also supports containerd `ns` query parameter for configured registries.
## Enable/Disable
```toml
[registries."quay.io"]
enabled = false
```
## Token and Manifest Cache
`[tokenCache]` controls upstream auth token and manifest caching:
```toml
[tokenCache]
enabled = true
defaultTTL = "20m"
```
Digest refs cache for 24 hours; common tags like `latest` cache for 10 minutes. See [config.toml Reference](/en/configuration/reference/#tokencache).
@@ -0,0 +1,71 @@
---
title: Docker Mirror
description: Configure Docker clients to pull images through HubProxy.
---
HubProxy implements Docker Registry API v2 and can accelerate Docker Hub and multiple third-party registries.
## Configure Docker Client
Edit `/etc/docker/daemon.json` (or Docker Desktop settings on Windows):
```json
{
"registry-mirrors": ["https://example.com"]
}
```
Restart Docker:
```bash
sudo systemctl restart docker
```
## Supported Registries
| Registry | Notes |
|----------|-------|
| Docker Hub | Default `registry-1.docker.io`, no path prefix needed |
| ghcr.io | GitHub Container Registry |
| gcr.io | Google Container Registry |
| quay.io | Red Hat Quay |
| registry.k8s.io | Kubernetes official images |
Extend or disable in `[registries]` — see [Registry Sources](/en/guides/docker-mirror-sources/).
## Pull Examples
```bash
# Docker Hub official image (no library prefix needed)
docker pull example.com/nginx
# Docker Hub user image
docker pull example.com/user/app:tag
# Third-party registry (path includes registry domain)
docker pull example.com/ghcr.io/owner/image:tag
```
The `library/` namespace is added automatically for official images, and `latest` is used when no tag is given — `example.com/nginx` is equivalent to `example.com/library/nginx:latest`.
## Image Search
The web UI (`enableFrontend = true`) provides Docker Hub search and tag browsing. API access:
```bash
curl "https://example.com/api/search?q=nginx"
curl "https://example.com/api/tags/library/nginx"
```
## Notes
- Each layer counts against rate limits
- Configure `[access].whiteList` for public deployments
- Ensure correct client IP behind reverse proxy — see [Reverse Proxy](/en/deployment/reverse-proxy/)
- Anonymous upstream pulls — **private authenticated images cannot be pulled through HubProxy**
## Other environments
- [Kubernetes & containerd](/en/guides/kubernetes-containerd/)
- [Synology / Feiniu NAS](/en/guides/nas/)
- [Transfer capabilities](/en/guides/capabilities/)
@@ -0,0 +1,98 @@
---
title: GitHub Acceleration
description: Accelerate GitHub Release, Raw, Clone, API, and Hugging Face downloads with HubProxy.
---
Requests that do not match `/v2`, `/token`, `/api`, or other registered routes are handled by the GitHub proxy. Prefix the **full original URL** with your HubProxy host; if the URL matches a supported pattern, the handler reconstructs it and proxies to GitHub, Hugging Face, or other upstreams — otherwise it returns "invalid input".
## How to Accelerate
Prefix your original URL with the HubProxy host. Two formats are supported.
### Full URL (Recommended)
```bash
# Release download
curl -L -O "https://example.com/https://github.com/owner/repo/releases/download/v1.0.0/app.tar.gz"
# Git clone
git clone https://example.com/https://github.com/owner/repo.git
# API
curl "https://example.com/https://api.github.com/repos/owner/repo/releases/latest"
```
### Global Git acceleration
Use `url.<base>.insteadOf` so every `https://github.com/` clone/fetch goes through HubProxy automatically:
```bash
git config --global url."https://example.com/https://github.com/".insteadOf "https://github.com/"
```
Then clone as usual:
```bash
git clone https://github.com/owner/repo.git
# actually requests https://example.com/https://github.com/owner/repo.git
```
To remove:
```bash
git config --global --unset url.https://example.com/https://github.com/.insteadOf
```
### Omit `https://`
HubProxy auto-prepends the scheme:
```bash
curl -L -O "https://example.com/github.com/owner/repo/releases/download/v1.0.0/app.tar.gz"
```
:::caution
**Not supported:** path-prefix rewrites like `https://example.com/gh/owner/repo/...` or `/raw/owner/...` — these return "invalid input".
:::
## Supported URL Types
| Type | Path pattern |
|------|-------------|
| Release / Archive | `github.com/{owner}/{repo}/releases/...`, `/archive/...` |
| Raw / Blob | `github.com/{owner}/{repo}/raw/...` (`/blob/` auto-converted to `/raw/`) |
| Git protocol | `github.com/{owner}/{repo}/info/...`, `/git-...` |
| Raw domains | `raw.githubusercontent.com/...`, `raw.github.com/...` |
| API | `api.github.com/repos/{owner}/{repo}/...` |
| Gist | `gist.github.com/...`, `gist.githubusercontent.com/...` |
| Hugging Face | `huggingface.co/...`, `cdn-lfs.hf.co/...` |
| GitHub Assets | `github.githubassets.com/...`, `opengraph.githubassets.com/...` |
## Script Nesting
HubProxy can rewrite GitHub URLs inside `.sh` / `.ps1` install scripts (max 10MB). Behind a reverse proxy, set `X-Forwarded-Host` correctly — see [Reverse Proxy](/en/deployment/reverse-proxy/).
## Limits and Blocks
| Limit | Description |
|-------|-------------|
| Web content | `text/html`, `application/xml`, etc. are blocked (403) — not for browsing GitHub pages |
| File size | `[server].fileSize` caps single-file downloads (default 2GB) |
| Repo access | `[access].whiteList` / `blackList` restrict proxied GitHub repos and Hugging Face resources |
Large Release files support `wget -c` resume — see [Transfer Capabilities](/en/guides/capabilities/).
```toml
[server]
fileSize = 2147483648
[access]
whiteList = ["trusted-org/*"]
blackList = ["*/malicious-repo"]
```
## Related
- [Hugging Face Acceleration](/en/guides/huggingface/)
- [Transfer capabilities](/en/guides/capabilities/)
- [Development: add new URLs](/en/guides/development/)
@@ -0,0 +1,35 @@
---
title: Hugging Face Acceleration
description: Accelerate Hugging Face model and LFS downloads through HubProxy.
---
Hugging Face uses the same URL-prefix proxy as GitHub — prepend your HubProxy host to the **full original URL**.
## Supported URLs
| Type | Example |
|------|---------|
| Model / dataset files | `huggingface.co/{user}/{repo}/resolve/...` |
| Spaces | `huggingface.co/spaces/{user}/{repo}/...` |
| LFS CDN | `cdn-lfs.hf.co/{user}/{repo}/...` |
Regex patterns are in `githubExps` in `src/handlers/github.go`.
## Examples
```bash
curl -L -O "https://example.com/https://huggingface.co/bert-base-uncased/resolve/main/config.json"
curl -L -O "https://example.com/huggingface.co/bert-base-uncased/resolve/main/pytorch_model.bin"
curl -L -O "https://example.com/https://cdn-lfs.hf.co/user/model-repo/abc123..."
```
## Limits
Same as [GitHub Acceleration](/en/guides/github-proxy/):
- No HTML browsing
- `[server].fileSize` cap
- Same as GitHub: **supports** `Range` resume and multi-connection downloads — see [Transfer Capabilities](/en/guides/capabilities/)
- `[access]` allow/deny lists apply
@@ -0,0 +1,83 @@
---
title: Kubernetes & containerd
description: Configure HubProxy as a registry mirror for K3s, RKE2, and containerd.
---
Kubernetes nodes typically pull images via **containerd**, not Docker `daemon.json`.
## K3s
Create `/etc/rancher/k3s/registries.yaml` on each node:
```yaml
mirrors:
docker.io:
endpoint:
- "https://example.com"
"ghcr.io":
endpoint:
- "https://example.com"
"quay.io":
endpoint:
- "https://example.com"
"registry.k8s.io":
endpoint:
- "https://example.com"
```
Restart:
```bash
sudo systemctl restart k3s # server
sudo systemctl restart k3s-agent # agent
```
Verify with **crictl** (mirrors don't apply to `ctr`):
```bash
sudo crictl pull docker.io/library/nginx:latest
```
## RKE2
Use `/etc/rancher/rke2/registries.yaml`, same format. Restart `rke2-server` or `rke2-agent`.
## Native containerd (1.5+)
```bash
sudo mkdir -p /etc/containerd/certs.d/docker.io
```
`/etc/containerd/certs.d/docker.io/hosts.toml`:
```toml
server = "https://registry-1.docker.io"
[host."https://example.com"]
capabilities = ["pull", "resolve"]
```
GHCR: `/etc/containerd/certs.d/ghcr.io/hosts.toml` with `server = "https://ghcr.io"`.
Enable in `/etc/containerd/config.toml`:
```toml
[plugins."io.containerd.grpc.v1.cri".registry]
config_path = "/etc/containerd/certs.d"
```
```bash
sudo systemctl restart containerd
```
## Path notes
- docker.io → `/v2/library/nginx/...`
- ghcr.io → `/v2/ghcr.io/owner/image/...`
- containerd `ns` query param supported
## Notes
- Configure every node; use HTTPS in production
- HubProxy pulls upstream anonymously — **private authenticated images cannot be proxied**
- See [Transfer Capabilities](/en/guides/capabilities/)
+68
View File
@@ -0,0 +1,68 @@
---
title: NAS Configuration
description: Configure HubProxy on Synology DSM and Feiniu fnOS.
---
Do **not** overwrite entire `daemon.json` with scripts — you may lose `data-root` and break existing containers.
Assumes HubProxy is exposed at `https://example.com` via reverse proxy.
## Synology DSM (Container Manager)
DSM 7.2+ renamed Docker to **Container Manager**.
### UI (Recommended)
1. **Container Manager** → **Registry** → **Settings**
2. Select **Docker Hub** → **Edit**
3. Enable **Registry mirror**, enter `https://example.com`
4. Save and restart Docker engine
### SSH (dockerd.json)
:::caution
Back up first. **Keep** `data-root`, `storage-driver`, etc. — only change `registry-mirrors`.
:::
DSM ≤7.2: `/var/packages/Docker/etc/dockerd.json`
DSM 7.3+: `/var/packages/ContainerManager/etc/dockerd.json`
```json
"registry-mirrors": ["https://example.com"]
```
Restart:
```bash
sudo systemctl restart pkgctl-Docker # DSM ≤7.2
sudo systemctl restart pkg-ContainerManager-dockerd # DSM 7.3+
```
Verify: `sudo docker info | grep -A3 "Registry Mirrors"`
---
## Feiniu fnOS
Official guidance: **do not** edit `/etc/docker/daemon.json` directly with third-party scripts — it contains `data-root`; clearing it can make images/containers disappear.
### Web UI (Recommended)
1. **Docker** → **Image Registry** → **Registry Settings** / **Mirror Settings**
2. **Add URL**: `https://example.com`
3. Move to **top** of list, save, **restart Docker**
### Manual edit (Advanced)
Back up first; only append to `registry-mirrors`, keep `data-root`, restart Docker.
### Notes
- App Center apps may use fixed registries (e.g. Aliyun) unrelated to HubProxy
- Restart Docker after changes
## Related
- [Docker Mirror](/en/guides/docker-mirror/)
- [Reverse Proxy](/en/deployment/reverse-proxy/)
@@ -0,0 +1,108 @@
---
title: Offline Images
description: Package Docker images as tar files online, with single and batch download.
---
HubProxy's web UI (`enableFrontend = true`) and API package images as tar archives without a local Docker daemon.
Downloads use a **two-step flow**: `prepare` for a one-time token, then download with that token. Tokens expire in **2 minutes** and are bound to client IP and User-Agent.
## Web UI
Visit the HubProxy homepage and use the offline image feature. Leave architecture empty to prefer `linux/amd64`; if a specified architecture is unmatched, the first available platform in the multi-arch index is used.
## Image Reference Format
Besides Docker Hub official images, enter a full registry-prefixed reference to pull from other platforms:
| Source | Example input |
|--------|---------------|
| Docker Hub | `nginx`, `redis:7` |
| GHCR | `ghcr.io/sky22333/hubproxy`, `ghcr.io/owner/app:v1.0` |
| Quay | `quay.io/coreos/etcd:latest` |
| GCR / K8s | `gcr.io/distroless/base`, `registry.k8s.io/pause:3.9` |
Missing tags default to `:latest`. Single-segment names (no `/`) get the `library/` namespace. The registry must be enabled in `[registries]` and the image must be anonymously pullable.
## Compressed Layers
The web UI and API expose a compressed-layers toggle (`compressed` / `useCompressedLayers`, **on by default**). **Keep it enabled** in most cases.
| Setting | Contents of each `layer.tar` in the archive | Size | Use case |
|---------|-----------------------------------------------|------|----------|
| On (default) | **Compressed blob** from the registry (usually gzip) | Smaller, faster download | Modern Docker Engine, `docker load` |
| Off | **Uncompressed** filesystem layer tar (classic `docker save` format) | Larger, slower to build | Older Docker Engine versions or environments that only accept uncompressed layers |
HubProxy outputs a `docker load`-compatible tar. With compression enabled, each layer keeps the upstream compressed blob instead of decompressing and re-packing on the server, which saves bandwidth and CPU. The off switch remains for **legacy Docker** (image format v1 era and early `docker load` implementations): those expect uncompressed filesystem tars in `layer.tar`, matching `docker save` output — disabling compression produces the same layer format.
## Single Image API
**Step 1: Prepare**
```bash
curl "https://example.com/api/image/download?image=library/nginx:latest&mode=prepare"
```
Response:
```json
{
"download_url": "/api/image/download?image=library/nginx%3Alatest&token=..."
}
```
**Step 2: Download tar**
```bash
curl -L -o nginx.tar "https://example.com/api/image/download?image=library/nginx:latest&token=YOUR_TOKEN"
```
Optional parameters:
| Param | Description |
|-------|-------------|
| `platform` | Target platform, e.g. `linux/arm64`; empty prefers `linux/amd64`; if specified but unmatched, uses the first available platform in the index |
| `tag` | Used when image has no tag, default `latest` |
| `compressed` | Keep registry-compressed layers in tar, default `true` (recommended — see **Compressed Layers** above) |
## Batch API
**Step 1: Prepare**
```bash
curl -X POST "https://example.com/api/image/batch?mode=prepare" \
-H "Content-Type: application/json" \
-d '{"images":["nginx:latest","ghcr.io/sky22333/hubproxy:latest"],"useCompressedLayers":true}'
```
**Step 2: Download combined tar**
```bash
curl -L -o batch.tar "https://example.com/api/image/batch?token=YOUR_TOKEN"
```
## Image Info
```bash
curl "https://example.com/api/image/info?image=library/nginx:latest"
```
## Limits
| Config / Rule | Default | Description |
|--------------|---------|-------------|
| `[download].maxImages` | `10` | Max images per batch |
| Prepare debounce (single) | 5s | Repeated prepare returns 429 |
| Prepare debounce (batch) | 60s | Same for batch |
| Token TTL | 2 min | Invalid if expired or IP/UA mismatch |
```toml
[download]
maxImages = 10
```
## Notes
- Large images take longer; streamed downloads must restart if interrupted
- Subject to `[access]` lists and IP rate limiting
- Frontend static routes (`/`, `/images`, `/search`, `/assets/*`) are not rate-limited; `/ready`, API, and proxy requests all count
+49
View File
@@ -0,0 +1,49 @@
---
title: HubProxy Docs
description: Official documentation for the Docker and GitHub acceleration proxy.
template: splash
hero:
tagline: Lightweight, self-hosted multi-service acceleration proxy
image:
file: ../../../assets/hero.svg
actions:
- text: Quick Start
link: /en/getting-started/quick-start/
icon: right-arrow
- text: GitHub
link: https://github.com/sky22333/hubproxy
icon: external
variant: minimal
---
import { Card, CardGrid } from '@astrojs/starlight/components'
## Core Features
<CardGrid stagger>
<Card title="Docker Mirror" icon="rocket">
Accelerate Docker Hub, GHCR, Quay, GCR, registry.k8s.io and other Registry API v2 registries.
</Card>
<Card title="GitHub Acceleration" icon="document">
Prefix the full original URL with your HubProxy host to accelerate Release, Raw, Clone, API, and Hugging Face downloads.
</Card>
<Card title="Offline Images" icon="seti:docker">
Package images as tar archives online, with single and batch download support.
</Card>
<Card title="Image Search" icon="magnifier">
Built-in Docker Hub image search and tag browsing.
</Card>
<Card title="Smart Rate Limiting" icon="warning">
Per-client IP rate limiting with allow/deny lists and registry access control.
</Card>
<Card title="Fully Self-Hosted" icon="approve-check">
Single-binary deployment with no dependency on third-party free CDN proxies.
</Card>
</CardGrid>
## Recommended Reading
- For public deployments: [Recommended Architecture](/en/deployment/architecture/) and [Reverse Proxy](/en/deployment/reverse-proxy/)
- K8s / NAS: [Kubernetes & containerd](/en/guides/kubernetes-containerd/), [NAS Configuration](/en/guides/nas/)
- Capabilities: [Transfer Capabilities](/en/guides/capabilities/); extending: [Development & Build](/en/guides/development/)
- [config.toml Reference](/en/configuration/reference/)
@@ -0,0 +1,50 @@
---
title: IP Trust
description: How HubProxy identifies real client IPs and prevents spoofing.
---
HubProxy uses Gin's `ClientIP()` with trusted proxy CIDRs to decide when to trust forwarding headers.
## Trusted Proxy CIDRs
Forwarding headers are only read when the TCP connection originates from:
- `127.0.0.0/8` (loopback)
- `10.0.0.0/8` (private Class A)
- `172.16.0.0/12` (private Class B)
- `192.168.0.0/16` (private Class C)
Direct public connections **never** trust forwarding headers.
## Rate Limit Keys
| Protocol | Key |
|----------|-----|
| IPv4 | Full IP address |
| IPv6 | `/64` prefix |
At most 10,000 IP entries are cached in memory.
## `[security].whiteList` vs IP Protection
`[security].whiteList` is a **rate-limit exemption** list, not an access control list:
- Whitelisted IPs skip `[rateLimit]`
- Independent from IP spoof protection and `[access]` registry control
## Correct Proxy Headers
The proxy must **overwrite** (not append) client IP:
```nginx
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
```
See [Reverse Proxy](/en/deployment/reverse-proxy/).
## Cloudflare
When users reach your proxy via Cloudflare, the reverse proxy should read `CF-Connecting-IP` and write it to forwarding headers.
HubProxy does **not** ship a Cloudflare IP trust list — it relies on the private-side proxy writing the correct IP.
@@ -0,0 +1,41 @@
---
title: Security Overview
description: HubProxy security model, risks, and best practices.
---
HubProxy is an open proxy service — improper deployment creates abuse risk. Follow these practices to reduce exposure.
## Core Mechanisms
| Mechanism | Description |
|-----------|-------------|
| IP rate limiting | Per real client IP (IPv6 uses `/64`) |
| IP allow/deny | `[security]` controls rate-limit exemption and blocking |
| Repo access control | `[access]` restricts proxied images, GitHub repos, and Hugging Face resources |
| Trusted proxies | Forward headers trusted only from private/local networks |
| File size limit | `[server].fileSize` prevents oversized file abuse |
| Offline download tokens | One-time tokens bound to IP and User-Agent, 2-minute TTL |
## Not Built In
HubProxy has **no** admin login, Basic Auth, API keys, or Prometheus `/metrics`. The web UI is a public SPA — security relies on network placement and configuration.
## Main Risks
1. **Open proxy**: Public `0.0.0.0:5000` without `[access].whiteList` lets anyone use your bandwidth
2. **IP spoofing**: Reverse proxy that appends instead of overwriting `X-Forwarded-For` bypasses rate limits
3. **Host injection**: Missing `X-Forwarded-Host` causes incorrect URLs in rewritten `.sh` scripts
## Recommendations
- Use [Recommended Architecture](/en/deployment/architecture/): CDN (optional) → reverse proxy → HubProxy
- Configure `[access].whiteList` for public services
- Overwrite `X-Forwarded-For`, `X-Real-IP`, and `X-Forwarded-Host` at the proxy
- Expose public services through a reverse proxy instead of port 5000 directly
- Review `[access].blackList` and access logs regularly
## Related Docs
- [IP Trust](/en/security/ip-trust/)
- [Reverse Proxy](/en/deployment/reverse-proxy/)
- [config.toml Reference](/en/configuration/reference/)
+92
View File
@@ -0,0 +1,92 @@
---
title: 常见问题
description: HubProxy 部署与使用中的常见问题解答。
---
<details>
<summary>HubProxy 和免费 CDN 代理有什么区别?</summary>
HubProxy 是完全自托管的代理服务,数据与带宽由你自己控制,不依赖第三方免费 CDN 的不稳定性。你可以自定义限流、仓库白名单和上游 SOCKS5 代理。
</details>
<details>
<summary>公网部署必须配置反向代理吗?</summary>
强烈建议。反代提供 TLS 终止、正确传递客户端 IP,并避免将 5000 端口直接暴露。详见 [推荐部署架构](/deployment/architecture/)。
</details>
<details>
<summary>限流白名单和仓库白名单有什么区别?</summary>
`[security].whiteList` 是限流豁免 IP,不影响可代理的仓库。`[access].whiteList` 控制允许代理哪些 Docker 镜像、GitHub 仓库与 Hugging Face 资源。两者独立配置。
</details>
<details>
<summary>GitHub 加速链接怎么写?</summary>
在**完整原始 URL** 前加上 HubProxy 域名,例如 `https://example.com/https://github.com/owner/repo/releases/download/...`。不支持 `/github/owner/...` 路径替换写法。详见 [GitHub 加速](/guides/github-proxy/)。
</details>
<details>
<summary>GitHub 加速提示「无效输入」?</summary>
常见原因:URL 格式错误(使用了 `/github/` 前缀而非完整 URL),或 Nginx 反代未正确设置 `Host` 头。参考 [GitHub 加速](/guides/github-proxy/) 与 [issue #62](https://github.com/sky22333/hubproxy/issues/62#issuecomment-3219572440)。
</details>
<details>
<summary>离线镜像 API 怎么用?</summary>
需两步:先 `GET /api/image/download?image=...&mode=prepare` 获取 token,再携带 token 下载。不存在 `/v2/offline/...` 路径。详见 [离线镜像包](/guides/offline-images/)。
</details>
<details>
<summary>使用 Cloudflare 后所有用户共用一个 IP 怎么办?</summary>
若 HubProxy 直连 Cloudflare 边缘节点,限流会按 Cloudflare IP 计数,无法区分用户。正确做法是在 Cloudflare 后面加一层私网反代,由反代写入 `CF-Connecting-IP`。
</details>
<details>
<summary>如何验证服务是否正常?</summary>
访问 `https://example.com/ready`,正常时返回 `{"ready":true,"service":"hubproxy",...}`,包含 `version` 与 `uptime_sec` 字段。该路径**会计入** IP 限流,避免高频轮询。
</details>
<details>
<summary>Docker 日志占用磁盘过大?</summary>
`docker run` 默认日志约 20MB。若使用 [Docker Compose](/deployment/docker/),已在 compose 文件中配置 `200m × 3` 日志轮转;也可自行在 compose 中调整 `logging.options`。
</details>
<details>
<summary>支持断点续传或多线程分片下载吗?</summary>
**GitHub / Hugging Face 文件下载:支持。** 代理会透传 `Range` 请求头并将上游 206 响应原样返回,`wget -c`、多线程 Range 分片均可使用(`.sh` / `.ps1` 脚本改写除外)。
**Docker 镜像拉取:不支持 layer 内 Range。** blob 整层转发,但客户端可重试失败 layer 或并行拉取不同 layer。
**离线 tar API:不支持**断点续传。详见 [传输特性](/guides/capabilities/)。
</details>
<details>
<summary>如何添加新的加速域名?</summary>
在 `src/handlers/github.go` 的 `githubExps` 数组中添加正则表达式,详见 [二次开发与构建](/guides/development/)。
</details>
<details>
<summary>authType 配置了 GitHub Token 为什么私有镜像仍拉不下来?</summary>
当前版本 `authType` 仅标识认证端点类型,尚未实现凭据注入,所有 Registry 均使用匿名拉取,且不转发客户端 `Authorization` 头。**无法**通过 HubProxy 拉取需要认证的私有镜像。
</details>
@@ -0,0 +1,56 @@
---
title: 系统安装
description: 使用 deb、rpm、apk 安装包部署 HubProxy,以及服务管理命令。
---
## 安装脚本
```bash
curl -fsSL https://raw.githubusercontent.com/sky22333/hubproxy/main/install.sh | sh
```
脚本会自动识别 `amd64` / `arm64` 与 `apt`、`dnf`、`apk` 等包管理器。
## 文件路径
| 路径 | 说明 |
|------|------|
| `/etc/hubproxy/config.toml` | 配置文件 |
| `/usr/bin/hubproxy` | 二进制文件 |
| `/lib/systemd/system/hubproxy.service` | systemd 服务(Debian/Ubuntu/RHEL 等) |
| `/etc/init.d/hubproxy` | OpenRC 服务(Alpine |
| `/var/log/hubproxy.log` | Alpine 日志文件 |
## systemd 管理
```bash
sudo systemctl status hubproxy # 查看运行状态
sudo systemctl restart hubproxy # 修改配置后重启
sudo journalctl -u hubproxy -f # 实时查看日志
sudo nano /etc/hubproxy/config.toml # 编辑配置
```
卸载:
```bash
sudo apt purge hubproxy # Debian/Ubuntu 卸载并清除配置
```
## OpenRC 管理(Alpine
```bash
sudo rc-service hubproxy status # 查看状态
sudo rc-service hubproxy restart # 重启服务
sudo tail -f /var/log/hubproxy.log # 查看日志
sudo vi /etc/hubproxy/config.toml # 编辑配置
```
卸载:
```bash
sudo apk del hubproxy # 删除安装包
```
## 生产环境建议
公网或团队使用时,建议为 HubProxy 绑定**自有域名**,通过 Caddy / Nginx 等反向代理并**开启 HTTPS**,而不是长期直接暴露 `http://IP:5000`。这样可同时获得 TLS、正确的客户端 IP 传递与更安全的访问控制。配置示例见 [反向代理](/deployment/reverse-proxy/) 与 [推荐部署架构](/deployment/architecture/)。
@@ -0,0 +1,60 @@
---
title: 快速开始
description: 使用 Docker 或安装脚本在数分钟内运行 HubProxy。
---
HubProxy 提供 Docker 镜像与系统安装包两种主流部署方式。
## Docker 部署(推荐)
```bash
docker run -d \
--name hubproxy \
-p 5000:5000 \
--restart always \
-v /path/to/config.toml:/app/config.toml:ro \
ghcr.io/sky22333/hubproxy
```
:::tip
使用 [Docker Compose](/deployment/docker/) 可更方便地挂载配置文件;日志轮转已在 Compose 文件中预置。
:::
## 脚本安装
自动识别系统与架构,从 GitHub Releases 下载 `.deb`、`.rpm` 或 `.apk`
```bash
curl -fsSL https://raw.githubusercontent.com/sky22333/hubproxy/main/install.sh | sh
```
安装完成后服务会自动启动,配置文件位于 `/etc/hubproxy/config.toml`。
## 验证服务
```bash
curl http://127.0.0.1:5000/ready # 本地健康检查
```
正常响应示例:
```json
{
"ready": true,
"service": "hubproxy",
"version": "v1.x.x",
"start_time_unix": 1710000000,
"uptime_sec": 42.5,
"uptime_human": "42秒"
}
```
## 下一步
- [系统安装与服务管理](/getting-started/install/)
- [Docker Compose 部署](/deployment/docker/)
- [Docker 镜像加速用法](/guides/docker-mirror/)
## 生产环境建议
验证通过后,建议尽快为 HubProxy 配置**自有域名 + 反向代理 + HTTPS**(如 Caddy / Nginx),客户端通过 `https://example.com` 访问,而不是长期使用裸 `http://IP:5000`。详见 [推荐部署架构](/deployment/architecture/) 与 [反向代理配置](/deployment/reverse-proxy/)。
@@ -0,0 +1,68 @@
---
title: 传输特性
description: HubProxy 在 Docker 拉取与 GitHub 下载场景下的能力边界说明。
---
本文基于当前代码实现(`src/handlers/docker.go`、`src/handlers/github.go`),说明各场景的传输行为。
## Docker 镜像拉取
HubProxy 通过 go-containerregistry 向上游拉取 manifest 与 blob,再以 HTTP 响应形式返回客户端。
| 能力 | 支持 | 说明 |
|------|------|------|
| Manifest 缓存 | ✅ | GET manifest 可缓存;digest 24h`latest` 等 10 分钟 |
| Token 缓存 | ✅ | 上游 `expires_in` 驱动 |
| 多 Registry 路径 | ✅ | 如 `example.com/ghcr.io/owner/image:tag` |
| containerd `ns` 参数 | ✅ | 识别 `[registries]` 中的 registry |
| 认证 realm 改写 | ✅ | 上游 token 地址改写到 HubProxy `/token` |
| HTTP Range / layer 内续传 | ❌ | blob 始终整层读取后转发,不读取客户端 `Range` |
| layer 级重试 | ✅ | Docker/containerd 拉取失败会重试整个 layer |
:::note
每个 layer 对应至少一次 blob 请求,均计入 IP 限流。HubProxy 对 blob **不做** Range 分片,但客户端可并行拉取**不同 layer**(多连接)。
:::
### 与标准 Registry 的差异
- blob 非透明透传,由 HubProxy 从上游完整拉取后再 `io.Copy` 转发
- 不支持 PATCH/PUT 上传(仅拉取)
- HEAD / GET manifest 支持
## GitHub / Hugging Face 下载
GitHub 代理(`proxyGitHubWithRedirect`)会将客户端**全部请求头**(含 `Range`)转发上游,并将上游**状态码与响应头**(含 `Content-Range`、`Accept-Ranges`)原样返回,再流式转发 body。
| 能力 | 支持 | 说明 |
|------|------|------|
| 流式转发 | ✅ | 普通文件直接 `io.Copy` |
| HTTP Range / 断点续传 | ✅ | `wget -c`、`curl -C -` 可用;上游返回 206 时原样透传 |
| 多连接 Range 分片 | ✅ | aria2 等多线程分片下载可用(依赖上游支持 Range) |
| 重定向跟随 | ✅ | 最多 20 次;GitHub URL 的 Location 自动改写 |
| 网页类型拦截 | ✅ | `text/html` 等返回 403 |
| 文件大小限制 | ✅ | `[server].fileSize`,默认 2GB(按响应头 `Content-Length` 校验) |
| `.sh` / `.ps1` 脚本改写 | ⚠️ | 脚本会完整下载并改写内容后 chunked 输出,**不支持** Range 续传 |
### wget 断点续传示例
```bash
# 首次下载(中断后可续传)
wget -c "https://example.com/https://github.com/owner/repo/releases/download/v1.0.0/large.bin"
```
实现位置:`src/handlers/github.go` 第 113118 行(转发请求头)、第 209–231 行(透传响应)。
## 离线镜像 tar
| 能力 | 支持 |
|------|------|
| 流式打包 tar | ✅ |
| 一次性 token2 分钟) | ✅ |
| Range / 断点续传 | ❌ |
| `Cache-Control: no-store` | ✅ |
## 相关文档
- [Docker 镜像加速](/guides/docker-mirror/)
- [GitHub 加速](/guides/github-proxy/)
- [离线镜像包](/guides/offline-images/)
@@ -0,0 +1,81 @@
---
title: 二次开发与构建
description: 扩展加速域名、Registry 映射,以及从源码构建 HubProxy。
---
## 项目结构
```
hubproxy/
├── src/ # Go 后端(main.go、handlers、config、utils
├── web/ # Vue 前端 SPA
├── Dockerfile # 多阶段构建
└── docs/ # 文档站
```
## 增加加速 URL
GitHub / Hugging Face 等 URL 前缀代理由 `src/handlers/github.go` 的 **`githubExps`** 控制:
```go
var githubExps = []*regexp.Regexp{
regexp.MustCompile(`^(?:https?://)?github\.com/([^/]+)/([^/]+)/(?:releases|archive)/.*`),
// 在此追加新正则
}
```
要求:
1. 正则需含 `([^/]+)/([^/]+)` 捕获组,供 `[access]` 匹配 owner/repo
2. 运行 `go test ./handlers/...`
3. 路由已在 `main.go` 的 `NoRoute(GitHubProxyHandler)` 注册,新域名无需加路由
访问控制逻辑:`src/utils/access_control.go` → `CheckGitHubAccess`。
## 增加 Docker Registry
`config.toml` → `[registries]`,认证扩展点在 `src/handlers/docker.go` → `createUpstreamOptions()`。
## 增加 HTTP 路由
在 `src/main.go` → `buildRouter()` 注册。
## 本地开发
```bash
cd src && CONFIG_PATH=./config.toml go run .
cd web && npm ci && npm run dev
```
生产嵌入前端:
```bash
cd web && npm ci && npm run build
# 将 dist 复制到 src/dist
cd ../src && go build -o hubproxy .
```
## Docker 构建
```bash
docker build -t hubproxy:local --build-arg VERSION=1.0.0 .
docker buildx build --platform linux/amd64,linux/arm64 \
-t ghcr.io/your-org/hubproxy:latest \
--build-arg VERSION=1.0.0 --push .
```
| 阶段 | 镜像 | 作用 |
|------|------|------|
| frontend | node:24-alpine | 构建 Vue |
| builder | golang:1.26-alpine | 编译 + UPX |
| final | alpine | 运行时 |
## 测试与发布
```bash
cd src && go test ./...
```
CI`.github/workflows/docker-ghcr.yml`(镜像)、`release.yml`(安装包)。
@@ -0,0 +1,80 @@
---
title: Docker 镜像源
description: 在 HubProxy 中配置上游 Registry 映射。
---
HubProxy 通过 `[registries]` 段配置第三方 Registry 的上游地址与认证端点。Docker Hub 固定代理到 `registry-1.docker.io`,无需在此段配置。
## 默认 Registry 映射
`config.toml` 中预置了常用 Registry
```toml
[registries."ghcr.io"]
upstream = "ghcr.io"
authHost = "ghcr.io/token"
authType = "github"
enabled = true
[registries."gcr.io"]
upstream = "gcr.io"
authHost = "gcr.io/v2/token"
authType = "google"
enabled = true
[registries."quay.io"]
upstream = "quay.io"
authHost = "quay.io/v2/auth"
authType = "quay"
enabled = true
[registries."registry.k8s.io"]
upstream = "registry.k8s.io"
authHost = "registry.k8s.io"
authType = "anonymous"
enabled = true
```
## authType 说明
| authType | 含义 |
|----------|------|
| `anonymous` | 匿名 Registry(如 registry.k8s.io |
| `github` | GHCR 认证端点 |
| `google` | GCR 认证端点 |
| `quay` | Quay.io 认证端点 |
:::note
`authType` 用于匹配上游 token 认证地址,当前版本**不会**据此注入 GitHub Token 或 Google 服务账号。所有 Registry 拉取均使用匿名认证,且不转发客户端凭据,**无法**拉取需要认证的私有镜像。
:::
## 拉取路径格式
```bash
# Docker Hub(无需前缀 registry 域名,官方镜像自动补齐 library 与 latest
docker pull example.com/nginx
# 第三方 Registry
docker pull example.com/ghcr.io/owner/image:tag
```
也支持 containerd 的 `ns` 查询参数识别已配置的 Registry。
## 启用/禁用 Registry
```toml
[registries."quay.io"]
enabled = false
```
## Token 与 Manifest 缓存
`[tokenCache]` 段控制上游认证 Token 与 Manifest 缓存:
```toml
[tokenCache]
enabled = true
defaultTTL = "20m"
```
digest 引用缓存 24 小时,`latest` 等常用 tag 缓存 10 分钟,详见 [config.toml 参考](/configuration/reference/#tokencache)。
@@ -0,0 +1,71 @@
---
title: Docker 镜像加速
description: 配置 Docker 客户端使用 HubProxy 加速镜像拉取。
---
HubProxy 兼容 Docker Registry API v2,可作为 Docker Hub 及多种第三方 Registry 的镜像加速入口。
## 配置 Docker 客户端
编辑 `/etc/docker/daemon.json`Windows 为 Docker Desktop 设置):
```json
{
"registry-mirrors": ["https://example.com"]
}
```
重启 Docker
```bash
sudo systemctl restart docker
```
## 支持的 Registry
| Registry | 说明 |
|----------|------|
| Docker Hub | 默认 `registry-1.docker.io`,无需路径前缀 |
| ghcr.io | GitHub Container Registry |
| gcr.io | Google Container Registry |
| quay.io | Red Hat Quay |
| registry.k8s.io | Kubernetes 官方镜像 |
可在 `config.toml` 的 `[registries]` 中扩展或禁用,详见 [Docker 镜像源配置](/guides/docker-mirror-sources/)。
## 拉取示例
```bash
# Docker Hub 官方镜像(无需写 library 前缀)
docker pull example.com/nginx
# Docker Hub 用户镜像
docker pull example.com/user/app:tag
# 第三方 Registry(路径含 registry 域名)
docker pull example.com/ghcr.io/owner/image:tag
```
官方镜像会自动补齐 `library/` 命名空间,未指定标签时自动使用 `latest`,即 `example.com/nginx` 等价于 `example.com/library/nginx:latest`。
## 镜像搜索
Web 界面(`enableFrontend = true`)提供 Docker Hub 搜索与标签浏览,也可直接调用 API:
```bash
curl "https://example.com/api/search?q=nginx"
curl "https://example.com/api/tags/library/nginx"
```
## 注意事项
- 拉取一个镜像会请求多个 layer,每个 HTTP 请求均计入 IP 限流配额
- 公网服务建议配置 `[access].whiteList` 限制可代理的镜像
- 配合反向代理时确保正确传递客户端 IP,详见 [反向代理配置](/deployment/reverse-proxy/)
- 当前版本对 Registry 使用匿名拉取,**无法**通过 HubProxy 拉取需要认证的私有镜像
## 其他环境
- [Kubernetes 与 containerd](/guides/kubernetes-containerd/)
- [群晖 / 飞牛 NAS](/guides/nas/)
- [传输特性(Range 续传、layer 拉取)](/guides/capabilities/)
@@ -0,0 +1,98 @@
---
title: GitHub 加速
description: 使用 HubProxy 加速 GitHub Release、Raw、Clone、API 与 Hugging Face 下载。
---
未匹配 `/v2`、`/token`、`/api` 等路由的请求,由 GitHub 代理处理器接管。使用时在**完整原始 URL** 前加上 HubProxy 域名;匹配支持的 URL 类型后,处理器还原地址并代理到 GitHub、Hugging Face 等上游,不匹配则返回「无效输入」。
## 加速方式
在原始 URL 前加上 HubProxy 域名,支持两种写法。
### 完整 URL(推荐)
```bash
# Release 下载
curl -L -O "https://example.com/https://github.com/owner/repo/releases/download/v1.0.0/app.tar.gz"
# Git Clone
git clone https://example.com/https://github.com/owner/repo.git
# API
curl "https://example.com/https://api.github.com/repos/owner/repo/releases/latest"
```
### Git 全局加速
配置 `url.<base>.insteadOf` 后,所有 `https://github.com/` 开头的 clone/fetch 会自动走 HubProxy,无需每次手动改 URL
```bash
git config --global url."https://example.com/https://github.com/".insteadOf "https://github.com/"
```
之后可直接:
```bash
git clone https://github.com/owner/repo.git
# 实际请求 https://example.com/https://github.com/owner/repo.git
```
取消配置:
```bash
git config --global --unset url.https://example.com/https://github.com/.insteadOf
```
### 省略 `https://`
HubProxy 会自动补全协议头:
```bash
curl -L -O "https://example.com/github.com/owner/repo/releases/download/v1.0.0/app.tar.gz"
```
:::caution
**不支持**将域名替换为路径前缀的写法,例如 `https://example.com/gh/owner/repo/...` 或 `/raw/owner/...` 会返回「无效输入」。
:::
## 支持的 URL 类型
| 类型 | 匹配路径示例 |
|------|-------------|
| Release / Archive | `github.com/{owner}/{repo}/releases/...`、`/archive/...` |
| Raw / Blob | `github.com/{owner}/{repo}/raw/...``/blob/` 自动转为 `/raw/` |
| Git 协议 | `github.com/{owner}/{repo}/info/...`、`/git-...` |
| Raw 域名 | `raw.githubusercontent.com/...`、`raw.github.com/...` |
| API | `api.github.com/repos/{owner}/{repo}/...` |
| Gist | `gist.github.com/...`、`gist.githubusercontent.com/...` |
| Hugging Face | `huggingface.co/...`、`cdn-lfs.hf.co/...` |
| GitHub Assets | `github.githubassets.com/...`、`opengraph.githubassets.com/...` |
## 脚本嵌套加速
HubProxy 可自动改写 `.sh` / `.ps1` 安装脚本中的 GitHub URL(脚本最大 10MB)。反代部署时需正确设置 `X-Forwarded-Host`,详见 [反向代理配置](/deployment/reverse-proxy/)。
## 限制与拒绝
| 限制 | 说明 |
|------|------|
| 网页内容 | `text/html`、`application/xml` 等网页类型会被拒绝(403),不能用来加速 GitHub 网页浏览 |
| 文件大小 | `[server].fileSize` 控制单文件上限,默认 2GB |
| 仓库访问 | `[access].whiteList` / `blackList` 限制可代理的 GitHub 仓库与 Hugging Face 资源 |
Release 等大文件支持 `wget -c` 断点续传,见 [传输特性](/guides/capabilities/)。
```toml
[server]
fileSize = 2147483648
[access]
whiteList = ["trusted-org/*"]
blackList = ["*/malicious-repo"]
```
## 相关文档
- [Hugging Face 加速](/guides/huggingface/)
- [传输特性(断点续传、分片)](/guides/capabilities/)
- [二次开发:添加新加速 URL](/guides/development/)
@@ -0,0 +1,43 @@
---
title: Hugging Face 加速
description: 通过 HubProxy 加速 Hugging Face 模型与 LFS 文件下载。
---
Hugging Face 下载与 GitHub 共用同一套 URL 代理机制,在**完整原始 URL** 前加上 HubProxy 域名即可。
## 支持的 URL
| 类型 | 示例 |
|------|------|
| 模型 / 数据集文件 | `huggingface.co/{user}/{repo}/resolve/...` |
| Spaces | `huggingface.co/spaces/{user}/{repo}/...` |
| LFS CDN | `cdn-lfs.hf.co/{user}/{repo}/...` |
正则定义位于 `src/handlers/github.go` 的 `githubExps` 数组。
## 下载示例
```bash
# 模型文件
curl -L -O "https://example.com/https://huggingface.co/bert-base-uncased/resolve/main/config.json"
# 省略 https://
curl -L -O "https://example.com/huggingface.co/bert-base-uncased/resolve/main/pytorch_model.bin"
# LFS 大文件
curl -L -O "https://example.com/https://cdn-lfs.hf.co/user/model-repo/abc123..."
```
## 限制
与 [GitHub 加速](/guides/github-proxy/) 相同:
- 不支持网页浏览(HTML 会被拒绝)
- 单文件受 `[server].fileSize` 限制
- 与 GitHub 相同,**支持** `Range` 断点续传与多连接分片(见 [传输特性](/guides/capabilities/)
- 受 `[access]` 仓库黑白名单约束
```toml
[access]
whiteList = ["trusted-user/*"]
```
@@ -0,0 +1,90 @@
---
title: Kubernetes 与 containerd
description: 在 K3s、RKE2 及原生 containerd 中配置 HubProxy 镜像加速。
---
HubProxy 兼容 OCI/Distribution Registry API v2。Kubernetes 节点通常由 **containerd** 拉取镜像,配置方式与 Docker `daemon.json` 不同。
## K3s
在每个节点创建 `/etc/rancher/k3s/registries.yaml`
```yaml
mirrors:
docker.io:
endpoint:
- "https://example.com"
"ghcr.io":
endpoint:
- "https://example.com"
"quay.io":
endpoint:
- "https://example.com"
"registry.k8s.io":
endpoint:
- "https://example.com"
```
重启服务:
```bash
sudo systemctl restart k3s # server
sudo systemctl restart k3s-agent # agent
```
验证(使用 **crictl**mirror 不对 `ctr` 生效):
```bash
sudo crictl pull docker.io/library/nginx:latest
```
## RKE2
配置 `/etc/rancher/rke2/registries.yaml`,格式同上,重启 `rke2-server` 或 `rke2-agent`。
## 原生 containerd1.5+
```bash
sudo mkdir -p /etc/containerd/certs.d/docker.io
```
`/etc/containerd/certs.d/docker.io/hosts.toml`
```toml
server = "https://registry-1.docker.io"
[host."https://example.com"]
capabilities = ["pull", "resolve"]
```
GHCR 示例:`/etc/containerd/certs.d/ghcr.io/hosts.toml`
```toml
server = "https://ghcr.io"
[host."https://example.com"]
capabilities = ["pull", "resolve"]
```
确保 `/etc/containerd/config.toml` 启用:
```toml
[plugins."io.containerd.grpc.v1.cri".registry]
config_path = "/etc/containerd/certs.d"
```
```bash
sudo systemctl restart containerd
```
## 路径说明
- docker.io`/v2/library/nginx/...`
- ghcr.io 等:`/v2/ghcr.io/owner/image/...`
- 支持 containerd `ns` 查询参数识别 `[registries]`
## 注意事项
- 每节点独立配置;生产环境 HubProxy 建议 HTTPS 反代
- HubProxy 对上游匿名拉取,**无法**代理需要认证的私有镜像
- 见 [传输特性](/guides/capabilities/)
+71
View File
@@ -0,0 +1,71 @@
---
title: NAS 配置
description: 在群晖 DSM 与飞牛 fnOS 上配置 HubProxy 镜像加速。
---
NAS 上的 Docker 通过图形界面管理,**不要**随意用脚本覆盖整个 `daemon.json`,以免丢失 `data-root` 导致容器丢失。
以下假设 HubProxy 已通过反代暴露在 `https://example.com`。
## 群晖 DSMContainer Manager
DSM 7.2+ 将「Docker」更名为 **Container Manager**。
### 图形界面(推荐)
1. **Container Manager** → **注册表** → **设置**
2. 选择 **Docker Hub** → **编辑**
3. 勾选 **启用 Registry 镜像**,填入 `https://example.com`
4. 保存并重启 Docker 引擎
### SSH 修改 dockerd.json
:::caution
备份原文件,**保留** `data-root`、`storage-driver` 等字段,仅改 `registry-mirrors`。
:::
DSM 7.2 及更早:`/var/packages/Docker/etc/dockerd.json`
DSM 7.3+`/var/packages/ContainerManager/etc/dockerd.json`
```json
"registry-mirrors": ["https://example.com"]
```
重启:
```bash
# DSM 7.2 及更早
sudo systemctl restart pkgctl-Docker
# DSM 7.3+
sudo systemctl restart pkg-ContainerManager-dockerd
```
验证:`sudo docker info | grep -A3 "Registry Mirrors"`
---
## 飞牛 fnOS
官方建议:**不要**用脚本直接改写 `/etc/docker/daemon.json`(含 `data-root`,误删会导致镜像/容器丢失)。
### Web 界面(推荐)
1. **Docker** → **镜像仓库** → **仓库设置** / **加速源设置**
2. **添加 URL**`https://example.com`
3. 拖到列表**顶部**,保存并**重启 Docker**
### 手动编辑(高级)
先备份,仅向 `registry-mirrors` 追加地址,保留 `data-root` 等字段,然后重启 Docker。
### 注意
- 应用中心部分应用走阿里云等固定 Registry,与 HubProxy 无关
- 修改后必须重启 Docker 才生效
## 相关文档
- [Docker 镜像加速](/guides/docker-mirror/)
- [反向代理](/deployment/reverse-proxy/)
@@ -0,0 +1,108 @@
---
title: 离线镜像包
description: 在线打包 Docker 镜像为 tar 文件,支持单镜像与批量下载。
---
HubProxy Web 界面(`enableFrontend = true`)与 API 支持将镜像在线打包为 tar 离线包,无需本地 Docker 环境。
下载采用**两步流程**:先 `prepare` 获取一次性 token,再携带 token 下载。Token 有效期 **2 分钟**,绑定客户端 IP 与 User-Agent。
## Web 界面
访问 HubProxy 首页,在「离线镜像」功能中输入镜像名与标签即可下载。架构选择留空时优先使用 `linux/amd64`;指定架构但匹配不到时,使用多架构索引中的第一个可用平台。
## 镜像名称格式
除 Docker Hub 官方镜像外,可直接输入带 Registry 域名的完整引用,从对应平台拉取并打包:
| 来源 | 输入示例 |
|------|---------|
| Docker Hub | `nginx`、`redis:7` |
| GHCR | `ghcr.io/sky22333/hubproxy`、`ghcr.io/owner/app:v1.0` |
| Quay | `quay.io/coreos/etcd:latest` |
| GCR / K8s | `gcr.io/distroless/base`、`registry.k8s.io/pause:3.9` |
未写 tag 时自动补 `:latest`;官方镜像(不含 `/` 的单段名称)会自动补齐 `library/` 命名空间。Registry 需在 `[registries]` 中启用,且镜像可匿名拉取。
## 压缩层
Web 界面与 API 均提供「压缩层」开关(`compressed` / `useCompressedLayers`**默认开启**)。**建议保持开启**。
| 开关 | tar 内 `layer.tar` 内容 | 体积 | 适用场景 |
|------|--------------------------|------|---------|
| 开启(默认) | Registry 中的**压缩 blob**(通常为 gzip | 更小,下载更快 | 现代 Docker Engine`docker load` 导入 |
| 关闭 | **解压后**的文件系统层 tar(与 `docker save` 经典格式一致) | 更大,打包更慢 | 较旧版本 Docker Engine 或仅支持未压缩 layer 的环境 |
HubProxy 输出的 tar 为 `docker load` 兼容格式。开启压缩层时,每层保留上游 Registry 原样压缩数据,避免 HubProxy 在服务端解压再重打包,显著减少传输体积与 CPU 开销。保留关闭选项,是为了兼容**旧版 Docker**(镜像 v1 时代及更早的 `docker load` 实现):彼时 `layer.tar` 通常为未压缩的文件系统 tar,与 `docker save` 导出结果一致;关闭后输出的 layer 格式与之相同。
## 单镜像 API
**第一步:申请下载**
```bash
curl "https://example.com/api/image/download?image=library/nginx:latest&mode=prepare"
```
响应示例:
```json
{
"download_url": "/api/image/download?image=library/nginx%3Alatest&token=..."
}
```
**第二步:下载 tar**
```bash
curl -L -o nginx.tar "https://example.com/api/image/download?image=library/nginx:latest&token=YOUR_TOKEN"
```
可选参数:
| 参数 | 说明 |
|------|------|
| `platform` | 指定平台,如 `linux/arm64`;留空时优先 `linux/amd64`;指定但匹配不到时使用索引中第一个可用平台 |
| `tag` | 镜像未含 tag 时使用,默认 `latest` |
| `compressed` | 是否保留 Registry 压缩层写入 tar,默认 `true`(建议开启,见上文「压缩层」) |
## 批量 API
**第一步:申请批量下载**
```bash
curl -X POST "https://example.com/api/image/batch?mode=prepare" \
-H "Content-Type: application/json" \
-d '{"images":["nginx:latest","ghcr.io/sky22333/hubproxy:latest"],"useCompressedLayers":true}'
```
**第二步:下载合并 tar**
```bash
curl -L -o batch.tar "https://example.com/api/image/batch?token=YOUR_TOKEN"
```
## 镜像信息
```bash
curl "https://example.com/api/image/info?image=library/nginx:latest"
```
## 限制
| 配置 / 规则 | 默认值 | 说明 |
|------------|--------|------|
| `[download].maxImages` | `10` | 单次批量镜像数量上限 |
| prepare 防抖(单镜像) | 5 秒 | 同一用户重复 prepare 会返回 429 |
| prepare 防抖(批量) | 60 秒 | 同上 |
| Token TTL | 2 分钟 | 过期或 IP/UA 不匹配则无效 |
```toml
[download]
maxImages = 10
```
## 注意事项
- 大镜像打包耗时较长,流式传输中断后需重新请求
- 受 `[access]` 黑白名单与 IP 限流约束
- 前端静态页面(`/`、`/images`、`/search`、`/assets/*`)不计入限流;`/ready`、API 与代理请求均会计入
+49
View File
@@ -0,0 +1,49 @@
---
title: HubProxy 文档
description: Docker 与 GitHub 加速代理服务的官方文档,涵盖部署、配置与安全实践。
template: splash
hero:
tagline: 轻量级、自托管的多功能加速代理
image:
file: ../../assets/hero.svg
actions:
- text: 快速开始
link: /getting-started/quick-start/
icon: right-arrow
- text: GitHub
link: https://github.com/sky22333/hubproxy
icon: external
variant: minimal
---
import { Card, CardGrid } from '@astrojs/starlight/components'
## 核心能力
<CardGrid stagger>
<Card title="Docker 镜像加速" icon="rocket">
支持 Docker Hub、GHCR、Quay、GCR、registry.k8s.io 等 Registry API v2 标准仓库。
</Card>
<Card title="GitHub 文件加速" icon="document">
在完整 URL 前加域名即可加速 Release、Raw、Clone、API 与 Hugging Face 下载。
</Card>
<Card title="离线镜像包" icon="seti:docker">
在线打包镜像为 tar,支持单镜像与批量下载。
</Card>
<Card title="镜像搜索" icon="magnifier">
内置 Docker Hub 镜像搜索与标签浏览。
</Card>
<Card title="智能限流" icon="warning">
按真实客户端 IP 限流,支持黑白名单与仓库访问控制。
</Card>
<Card title="完全自托管" icon="approve-check">
单二进制部署,不依赖第三方免费 CDN 代理服务。
</Card>
</CardGrid>
## 推荐阅读
- 公网部署请优先阅读 [推荐部署架构](/deployment/architecture/) 与 [反向代理配置](/deployment/reverse-proxy/)
- K8s / NAS 用户见 [Kubernetes 与 containerd](/guides/kubernetes-containerd/)、[NAS 配置](/guides/nas/)
- 能力边界见 [传输特性](/guides/capabilities/),扩展开发见 [二次开发与构建](/guides/development/)
- 配置项说明见 [config.toml 参考](/configuration/reference/)
@@ -0,0 +1,50 @@
---
title: IP 信任机制
description: HubProxy 如何识别真实客户端 IP 并防止伪造。
---
HubProxy 使用 Gin 的 `ClientIP()` 获取客户端地址,并配合可信代理网段判断何时信任转发头。
## 可信代理网段
仅当 TCP 连接来自以下网段时,才读取 `X-Forwarded-For` / `X-Real-IP`
- `127.0.0.0/8`(本机)
- `10.0.0.0/8`(私网 A 类)
- `172.16.0.0/12`(私网 B 类)
- `192.168.0.0/16`(私网 C 类)
直连公网 IP 的请求**不会**信任任何转发头,直接使用 TCP 远端地址。
## 限流 IP 计算
| 协议 | 限流键 |
|------|--------|
| IPv4 | 完整 IP 地址 |
| IPv6 | `/64` 网段 |
内存中最多缓存 10000 条 IP 记录,不会无限增长。
## `[security].whiteList` 与 IP 防护
`[security].whiteList` 是**限流豁免名单**,不是「允许访问的 IP 列表」:
- 白名单 IP 不受 `[rateLimit]` 限制
- 与 IP 伪造防护、仓库访问控制(`[access]`)是独立机制
## 正确反代示例
反代必须**覆盖写**(非 append)客户端 IP
```nginx
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
```
详见 [反向代理配置](/deployment/reverse-proxy/)。
## Cloudflare 场景
若用户经 Cloudflare 到达反代,反代应从 `CF-Connecting-IP` 读取真实 IP 并写入转发头,而不是让 HubProxy 直连 Cloudflare。
HubProxy **不**内置 Cloudflare IP 信任列表,依赖「反代在私网侧正确写入 IP」这一架构。
@@ -0,0 +1,41 @@
---
title: 安全概述
description: HubProxy 安全模型、风险点与最佳实践。
---
HubProxy 作为开放代理服务,部署不当可能带来滥用风险。遵循以下实践可显著降低暴露面。
## 核心安全机制
| 机制 | 说明 |
|------|------|
| IP 限流 | 按真实客户端 IP 限制请求频率(IPv6 按 `/64` |
| IP 黑白名单 | `[security]` 控制限流豁免与封禁 |
| 仓库访问控制 | `[access]` 限制可代理的镜像、GitHub 仓库与 Hugging Face 资源 |
| 可信代理 | 仅信任来自私网/本机的转发头,防止 IP 伪造 |
| 文件大小限制 | `[server].fileSize` 防止超大文件滥用 |
| 离线下载 Token | 一次性 token,绑定 IP 与 User-Agent2 分钟过期 |
## 未内置的能力
HubProxy **没有**管理后台登录、Basic Auth、API Key 或 Prometheus 指标端点。Web 界面为公开 SPA,安全依赖网络层与配置策略。
## 主要风险
1. **开放代理**:公网直连 `0.0.0.0:5000` 且无 `[access].whiteList` 时,任何人可使用你的带宽代理任意仓库
2. **IP 伪造**:反代未覆盖写 `X-Forwarded-For` 时,攻击者可绕过限流
3. **Host 注入**:反代未设置 `X-Forwarded-Host` 时,`.sh` 脚本可能生成错误加速 URL
## 推荐实践
- 使用 [推荐部署架构](/deployment/architecture/)CDN(可选)→ 反代 → HubProxy
- 公网服务配置 `[access].whiteList`
- 反代覆盖写 `X-Forwarded-For`、`X-Real-IP`、`X-Forwarded-Host`
- 公网服务建议通过反代暴露,不直接开放 `5000` 端口
- 定期审查 `[access].blackList` 与访问日志
## 相关文档
- [IP 信任机制](/security/ip-trust/)
- [反向代理配置](/deployment/reverse-proxy/)
- [config.toml 参考](/configuration/reference/)
+352
View File
@@ -0,0 +1,352 @@
/* HubProxy docs */
:root {
--sl-color-accent: #2563eb;
--sl-color-accent-high: #1d4ed8;
--sl-sidebar-width: 15rem;
--sl-toc-width: 18rem;
--hub-ease: cubic-bezier(0.4, 0, 0.2, 1);
--hub-duration-fast: 0.15s;
--hub-duration: 0.22s;
--hub-duration-slow: 0.32s;
}
html {
-webkit-tap-highlight-color: transparent;
scroll-padding-top: calc(1.5rem + var(--sl-nav-height) + var(--sl-mobile-toc-height));
}
@media (min-width: 72rem) {
html {
scroll-padding-top: calc(1.5rem + var(--sl-nav-height));
}
}
@keyframes hub-fade-up {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes hub-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes hub-scale-in {
from {
opacity: 0;
transform: scale(0.97) translateY(-6px);
}
to {
opacity: 1;
transform: none;
}
}
#starlight__sidebar {
scrollbar-width: none;
-ms-overflow-style: none;
scrollbar-gutter: auto;
}
#starlight__sidebar::-webkit-scrollbar {
display: none;
}
@media (min-width: 72rem) {
:root[data-has-toc] {
--__toc-width: var(--sl-toc-width);
}
.right-sidebar-container {
width: var(--sl-toc-width);
}
.right-sidebar-panel .sl-container {
width: calc(var(--sl-toc-width) - 2 * var(--sl-sidebar-pad-x));
max-width: calc(var(--sl-toc-width) - 2 * var(--sl-sidebar-pad-x));
}
[data-has-sidebar][data-has-toc] .main-pane {
--sl-content-margin-inline: 0;
width: min(
calc(100% - var(--sl-toc-width)),
calc(
var(--sl-content-width) +
(100% - var(--sl-content-width) - var(--sl-toc-width)) / 2
)
);
}
}
@media (max-width: 49.999rem) {
.sidebar-pane {
transform: translateX(-100%);
transition: transform var(--hub-duration) var(--hub-ease);
}
[aria-expanded='true'] ~ .sidebar-pane {
transform: translateX(0);
}
}
.sidebar-content a,
.right-sidebar-panel a,
#starlight__mobile-toc a,
.social-icons a,
.header a,
#starlight__mobile-toc,
#starlight__mobile-toc summary,
.sl-markdown-content a:not(:where(.not-content *)),
footer a {
transition:
color var(--hub-duration-fast) var(--hub-ease),
background-color var(--hub-duration-fast) var(--hub-ease),
border-color var(--hub-duration-fast) var(--hub-ease),
text-decoration-color var(--hub-duration-fast) var(--hub-ease),
opacity var(--hub-duration-fast) var(--hub-ease);
}
.sidebar-content summary,
.sidebar-content summary .caret {
transition:
color var(--hub-duration-fast) var(--hub-ease),
transform var(--hub-duration) var(--hub-ease),
opacity var(--hub-duration-fast) var(--hub-ease);
}
.site-title {
transition: opacity var(--hub-duration-fast) var(--hub-ease);
}
.site-title:hover {
opacity: 0.88;
}
footer a:hover {
opacity: 0.85;
}
site-search button,
starlight-menu-button button {
transition:
color var(--hub-duration-fast) var(--hub-ease),
background-color var(--hub-duration-fast) var(--hub-ease),
transform var(--hub-duration-fast) var(--hub-ease),
box-shadow var(--hub-duration-fast) var(--hub-ease);
}
site-search button:hover,
starlight-menu-button button:hover {
transform: scale(1.04);
}
site-search button:active,
starlight-menu-button button:active {
transform: scale(0.97);
}
starlight-theme-select label,
starlight-lang-select label {
transition:
border-color var(--hub-duration-fast) var(--hub-ease),
background-color var(--hub-duration-fast) var(--hub-ease),
color var(--hub-duration-fast) var(--hub-ease);
}
.sl-link-button,
.pagination-links a,
.card {
transition:
color var(--hub-duration-fast) var(--hub-ease),
background-color var(--hub-duration-fast) var(--hub-ease),
border-color var(--hub-duration) var(--hub-ease),
transform var(--hub-duration) var(--hub-ease),
box-shadow var(--hub-duration) var(--hub-ease);
}
/* minimal 为文本链式按钮,勿叠加卡片式 hover(首页 GitHub 按钮) */
.sl-link-button.primary:hover {
transform: translateY(-1px);
box-shadow: var(--sl-shadow-sm);
}
.sl-link-button.minimal:hover {
transform: none;
box-shadow: none;
opacity: 0.72;
}
.pagination-links a:hover {
transform: translateY(-2px);
box-shadow: var(--sl-shadow-md);
}
.sl-link-button:active,
.pagination-links a:active {
transform: translateY(0);
}
.pagination-links svg {
transition: transform var(--hub-duration) var(--hub-ease);
}
.pagination-links a[rel='prev']:hover svg {
transform: translateX(-3px);
}
.pagination-links a[rel='next']:hover svg {
transform: translateX(3px);
}
.sl-markdown-content .starlight-aside {
transition:
border-color var(--hub-duration-fast) var(--hub-ease),
box-shadow var(--hub-duration) var(--hub-ease);
}
.expressive-code .copy button {
transition:
opacity var(--hub-duration-fast) var(--hub-ease),
background-color var(--hub-duration-fast) var(--hub-ease),
transform var(--hub-duration-fast) var(--hub-ease);
}
.expressive-code .copy button:hover {
transform: scale(1.06);
}
.card:hover {
transform: translateY(-2px);
box-shadow: var(--sl-shadow-md);
}
.card-grid.stagger .card {
animation: hub-fade-up var(--hub-duration-slow) var(--hub-ease) both;
}
.card-grid.stagger .card:nth-child(1) {
animation-delay: 0.04s;
}
.card-grid.stagger .card:nth-child(2) {
animation-delay: 0.08s;
}
.card-grid.stagger .card:nth-child(3) {
animation-delay: 0.12s;
}
.card-grid.stagger .card:nth-child(4) {
animation-delay: 0.16s;
}
.card-grid.stagger .card:nth-child(5) {
animation-delay: 0.2s;
}
.card-grid.stagger .card:nth-child(6) {
animation-delay: 0.24s;
}
.hero img {
width: min(100%, 22rem);
height: auto;
border-radius: 1rem;
box-shadow: var(--sl-shadow-md);
}
@media (prefers-color-scheme: dark) {
.hero img {
box-shadow: 0 12px 40px rgb(0 0 0 / 35%);
}
}
/* 仅正文 FAQ,勿用全局 details 以免破坏移动端 TOC / 侧栏 */
.sl-markdown-content details {
margin-block: 1rem;
padding: 0.75rem 1rem;
border: 1px solid var(--sl-color-gray-5);
border-radius: 0.5rem;
transition:
background-color var(--hub-duration-fast) var(--hub-ease),
border-color var(--hub-duration-fast) var(--hub-ease);
}
.sl-markdown-content details[open] {
background: var(--sl-color-gray-6);
}
.sl-markdown-content summary {
cursor: pointer;
font-weight: 600;
transition: color var(--hub-duration-fast) var(--hub-ease);
}
.sl-markdown-content details > :not(summary) {
margin-top: 0.75rem;
}
@media (prefers-reduced-motion: no-preference) {
@view-transition {
navigation: auto;
}
.main-pane {
view-transition-name: main-content;
}
::view-transition-old(main-content),
::view-transition-new(main-content) {
animation-duration: var(--hub-duration);
animation-timing-function: var(--hub-ease);
}
html {
scroll-behavior: smooth;
}
site-search dialog[open] {
animation: hub-scale-in var(--hub-duration) var(--hub-ease) both;
}
site-search dialog[open]::backdrop {
animation: hub-fade-in var(--hub-duration) var(--hub-ease) both;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
.card:hover,
site-search button:hover,
starlight-menu-button button:hover,
.sl-link-button.primary:hover,
.pagination-links a:hover {
transform: none;
}
.card-grid.stagger .card {
animation: none;
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
}
-213
View File
@@ -1,213 +0,0 @@
#!/bin/bash
# HubProxy 一键安装脚本
# 支持自动下载最新版本或使用本地文件安装
set -e
# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# 配置
REPO="sky22333/hubproxy"
GITHUB_API="https://api.github.com/repos/${REPO}"
GITHUB_RELEASES="${GITHUB_API}/releases"
SERVICE_NAME="hubproxy"
INSTALL_DIR="/opt/hubproxy"
CONFIG_FILE="config.toml"
BINARY_NAME="hubproxy"
LOG_DIR="/var/log/hubproxy"
TEMP_DIR="/tmp/hubproxy-install"
echo -e "${BLUE}HubProxy 一键安装脚本${NC}"
echo "================================================="
# 检查是否以root权限运行
if [[ $EUID -ne 0 ]]; then
echo -e "${RED}此脚本需要root权限运行${NC}"
echo "请使用: sudo $0"
exit 1
fi
# 检测系统架构
detect_arch() {
local arch=$(uname -m)
case $arch in
x86_64)
echo "amd64"
;;
aarch64|arm64)
echo "arm64"
;;
*)
echo -e "${RED}不支持的架构: $arch${NC}"
exit 1
;;
esac
}
ARCH=$(detect_arch)
echo -e "${BLUE}检测到架构: linux-${ARCH}${NC}"
# 检查是否为本地安装模式
if [ -f "${BINARY_NAME}" ]; then
echo -e "${BLUE}发现本地文件,使用本地安装模式${NC}"
LOCAL_INSTALL=true
else
echo -e "${BLUE}本地无文件,使用自动下载模式${NC}"
LOCAL_INSTALL=false
# 检查依赖
missing_deps=()
for cmd in curl jq tar; do
if ! command -v $cmd &> /dev/null; then
missing_deps+=($cmd)
fi
done
if [ ${#missing_deps[@]} -gt 0 ]; then
echo -e "${YELLOW}检测到缺少依赖: ${missing_deps[*]}${NC}"
echo -e "${BLUE}正在自动安装依赖...${NC}"
apt update && apt install -y curl jq
if [ $? -ne 0 ]; then
echo -e "${RED}依赖安装失败${NC}"
exit 1
fi
# 重新检查依赖
for cmd in curl jq tar; do
if ! command -v $cmd &> /dev/null; then
echo -e "${RED}依赖安装后仍缺少: $cmd${NC}"
exit 1
fi
done
echo -e "${GREEN}依赖安装成功${NC}"
fi
fi
# 自动下载功能
if [ "$LOCAL_INSTALL" = false ]; then
echo -e "${BLUE}获取最新版本信息...${NC}"
LATEST_RELEASE=$(curl -s "${GITHUB_RELEASES}/latest")
if [ $? -ne 0 ]; then
echo -e "${RED}无法获取版本信息${NC}"
exit 1
fi
VERSION=$(echo "$LATEST_RELEASE" | jq -r '.tag_name')
if [ "$VERSION" = "null" ]; then
echo -e "${RED}无法解析版本信息${NC}"
exit 1
fi
echo -e "${GREEN}最新版本: ${VERSION}${NC}"
# 构造下载URL
ASSET_NAME="hubproxy-${VERSION}-linux-${ARCH}.tar.gz"
DOWNLOAD_URL="https://github.com/${REPO}/releases/download/${VERSION}/${ASSET_NAME}"
echo -e "${BLUE}下载: ${ASSET_NAME}${NC}"
# 创建临时目录并下载
rm -rf "${TEMP_DIR}"
mkdir -p "${TEMP_DIR}"
cd "${TEMP_DIR}"
curl -L -o "${ASSET_NAME}" "${DOWNLOAD_URL}"
if [ $? -ne 0 ]; then
echo -e "${RED}下载失败${NC}"
exit 1
fi
# 解压
tar -xzf "${ASSET_NAME}"
if [ $? -ne 0 ] || [ ! -d "hubproxy" ]; then
echo -e "${RED}解压失败${NC}"
exit 1
fi
cd hubproxy
echo -e "${GREEN}下载完成${NC}"
fi
echo -e "${YELLOW}开始安装 HubProxy...${NC}"
# 停止现有服务(如果存在)
if systemctl is-active --quiet ${SERVICE_NAME} 2>/dev/null; then
echo -e "${YELLOW}停止现有服务...${NC}"
systemctl stop ${SERVICE_NAME}
fi
# 备份现有配置(如果存在)
CONFIG_BACKUP_EXISTS=false
if [ -f "${INSTALL_DIR}/${CONFIG_FILE}" ]; then
echo -e "${BLUE}备份现有配置...${NC}"
cp "${INSTALL_DIR}/${CONFIG_FILE}" "${TEMP_DIR}/config.toml.backup"
CONFIG_BACKUP_EXISTS=true
fi
# 1. 创建目录结构
echo -e "${BLUE}创建目录结构${NC}"
mkdir -p ${INSTALL_DIR}
mkdir -p ${LOG_DIR}
chmod 755 ${INSTALL_DIR}
chmod 755 ${LOG_DIR}
# 2. 复制二进制文件
echo -e "${BLUE}复制二进制文件${NC}"
cp "${BINARY_NAME}" "${INSTALL_DIR}/"
chmod +x "${INSTALL_DIR}/${BINARY_NAME}"
# 3. 复制配置文件
echo -e "${BLUE}复制配置文件${NC}"
if [ -f "${CONFIG_FILE}" ]; then
if [ "$CONFIG_BACKUP_EXISTS" = false ]; then
cp "${CONFIG_FILE}" "${INSTALL_DIR}/"
echo -e "${GREEN}配置文件复制成功${NC}"
else
echo -e "${YELLOW}保留现有配置文件${NC}"
fi
else
echo -e "${YELLOW}配置文件不存在,将使用默认配置${NC}"
fi
# 5. 安装systemd服务文件
echo -e "${BLUE}安装systemd服务文件${NC}"
cp "${SERVICE_NAME}.service" "/etc/systemd/system/"
systemctl daemon-reload
# 6. 恢复配置文件(如果有备份)
if [ "$CONFIG_BACKUP_EXISTS" = true ]; then
echo -e "${BLUE}恢复配置文件...${NC}"
cp "${TEMP_DIR}/config.toml.backup" "${INSTALL_DIR}/${CONFIG_FILE}"
fi
# 7. 启用并启动服务
echo -e "${BLUE}启用并启动服务${NC}"
systemctl enable ${SERVICE_NAME}
systemctl start ${SERVICE_NAME}
# 8. 清理临时文件
if [ "$LOCAL_INSTALL" = false ]; then
echo -e "${BLUE}清理临时文件...${NC}"
cd /
rm -rf "${TEMP_DIR}"
fi
# 9. 检查服务状态
sleep 2
if systemctl is-active --quiet ${SERVICE_NAME}; then
echo ""
echo -e "${GREEN}HubProxy 安装成功!${NC}"
echo -e "${GREEN}默认运行端口: 5000${NC}"
echo -e "${GREEN}配置文件路径: ${INSTALL_DIR}/${CONFIG_FILE}${NC}"
else
echo -e "${RED}服务启动失败${NC}"
echo "查看错误日志: sudo journalctl -u ${SERVICE_NAME} -f"
exit 1
fi
+121
View File
@@ -0,0 +1,121 @@
#!/bin/sh
set -eu
REPO="${REPO:-sky22333/hubproxy}"
VERSION="${VERSION:-latest}"
TMP_DIR="${TMP_DIR:-/tmp/hubproxy-install}"
log() {
printf '%s\n' "$*"
}
fail() {
printf 'HubProxy 安装失败:%s\n' "$*" >&2
exit 1
}
need_cmd() {
command -v "$1" >/dev/null 2>&1 || fail "缺少必要命令:$1"
}
detect_arch() {
case "$(uname -m)" in
x86_64|amd64)
echo "amd64"
;;
aarch64|arm64)
echo "arm64"
;;
*)
fail "不支持的系统架构:$(uname -m)"
;;
esac
}
detect_packager() {
if command -v apk >/dev/null 2>&1; then
echo "apk"
elif command -v apt-get >/dev/null 2>&1; then
echo "deb"
elif command -v dnf >/dev/null 2>&1 || command -v yum >/dev/null 2>&1 || command -v rpm >/dev/null 2>&1; then
echo "rpm"
else
fail "不支持的系统:需要 apt、dnf、yum、rpm 或 apk"
fi
}
asset_name() {
packager="$1"
arch="$2"
case "$packager:$arch" in
deb:amd64|rpm:amd64|apk:amd64) echo "hubproxy-linux-amd64.${packager}" ;;
deb:arm64|rpm:arm64|apk:arm64) echo "hubproxy-linux-arm64.${packager}" ;;
*) fail "不支持的安装包目标:${packager}/${arch}" ;;
esac
}
asset_url() {
asset="$1"
if [ "$VERSION" = "latest" ]; then
echo "https://github.com/${REPO}/releases/latest/download/${asset}"
else
echo "https://github.com/${REPO}/releases/download/${VERSION}/${asset}"
fi
}
install_package() {
package_file="$1"
packager="$2"
case "$packager" in
deb)
apt-get install -y "$package_file"
;;
rpm)
if command -v dnf >/dev/null 2>&1; then
dnf install -y "$package_file"
elif command -v yum >/dev/null 2>&1; then
yum install -y "$package_file"
else
rpm -Uvh "$package_file"
fi
;;
apk)
apk add --allow-untrusted "$package_file"
;;
*)
fail "不支持的包管理器:$packager"
;;
esac
}
if [ "$(id -u)" -ne 0 ]; then
fail "请使用 root 权限运行"
fi
need_cmd curl
ARCH="$(detect_arch)"
PACKAGER="$(detect_packager)"
rm -rf "$TMP_DIR"
mkdir -p "$TMP_DIR"
trap 'rm -rf "$TMP_DIR"' EXIT INT TERM
log "安装 HubProxylinux/${ARCH}${PACKAGER}"
ASSET="$(asset_name "$PACKAGER" "$ARCH")"
ASSET_URL="$(asset_url "$ASSET")"
PACKAGE_FILE="${TMP_DIR}/$(basename "$ASSET_URL")"
log "下载安装包..."
curl -fL -o "$PACKAGE_FILE" "$ASSET_URL" || fail "下载安装包失败"
log "安装软件包..."
install_package "$PACKAGE_FILE" "$PACKAGER"
log "安装完成"
log "默认端口:5000"
log "配置文件:/etc/hubproxy/config.toml"
+9
View File
@@ -0,0 +1,9 @@
/var/log/hubproxy.log {
weekly
maxsize 50M
rotate 4
compress
missingok
notifempty
copytruncate
}
+18
View File
@@ -0,0 +1,18 @@
#!/sbin/openrc-run
name="hubproxy"
description="Docker and GitHub acceleration proxy server"
command="/usr/bin/hubproxy"
pidfile="/run/${RC_SVCNAME}.pid"
output_log="/var/log/hubproxy.log"
error_log="/var/log/hubproxy.log"
supervisor="supervise-daemon"
respawn_delay=5
respawn_max=0
export CONFIG_PATH="/etc/hubproxy/config.toml"
depend() {
need net
after firewall
}
@@ -7,11 +7,10 @@ Wants=network-online.target
Type=simple Type=simple
User=root User=root
Group=root Group=root
WorkingDirectory=/opt/hubproxy Environment=CONFIG_PATH=/etc/hubproxy/config.toml
ExecStart=/opt/hubproxy/hubproxy ExecStart=/usr/bin/hubproxy
Restart=always Restart=always
RestartSec=5 RestartSec=5
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
StandardOutput=journal StandardOutput=journal
StandardError=journal StandardError=journal
SyslogIdentifier=hubproxy SyslogIdentifier=hubproxy
+45
View File
@@ -0,0 +1,45 @@
name: hubproxy
arch: ${NFPM_ARCH}
platform: linux
version: ${NFPM_VERSION}
release: "1"
section: net
priority: optional
maintainer: sky22333
description: Docker and GitHub acceleration proxy server
vendor: sky22333
homepage: https://github.com/sky22333/hubproxy
license: MIT
depends:
- logrotate
contents:
- src: ./build/package-root/hubproxy
dst: /usr/bin/hubproxy
file_info:
mode: 0755
- src: ./src/config.toml
dst: /etc/hubproxy/config.toml
type: config|noreplace
file_info:
mode: 0644
- src: ./packaging/hubproxy.openrc
dst: /etc/init.d/hubproxy
file_info:
mode: 0755
- src: ./packaging/hubproxy.logrotate
dst: /etc/logrotate.d/hubproxy
file_info:
mode: 0644
scripts:
postinstall: ./packaging/postinstall.sh
preremove: ./packaging/preremove.sh
postremove: ./packaging/postremove.sh
apk:
scripts:
postupgrade: ./packaging/postinstall.sh
+34
View File
@@ -0,0 +1,34 @@
name: hubproxy
arch: ${NFPM_ARCH}
platform: linux
version: ${NFPM_VERSION}
release: "1"
section: net
priority: optional
maintainer: sky22333
description: Docker and GitHub acceleration proxy server
vendor: sky22333
homepage: https://github.com/sky22333/hubproxy
license: MIT
contents:
- src: ./build/package-root/hubproxy
dst: /usr/bin/hubproxy
file_info:
mode: 0755
- src: ./src/config.toml
dst: /etc/hubproxy/config.toml
type: config|noreplace
file_info:
mode: 0644
- src: ./packaging/hubproxy.service
dst: /lib/systemd/system/hubproxy.service
file_info:
mode: 0644
scripts:
postinstall: ./packaging/postinstall.sh
preremove: ./packaging/preremove.sh
postremove: ./packaging/postremove.sh
+27
View File
@@ -0,0 +1,27 @@
#!/bin/sh
set -e
warn() {
echo "hubproxy: $1"
}
if command -v systemctl >/dev/null 2>&1; then
systemctl daemon-reload || warn "systemd reload failed"
systemctl enable hubproxy >/dev/null 2>&1 || warn "systemd enable failed"
if [ -d /run/systemd/system ]; then
systemctl restart hubproxy || systemctl start hubproxy || {
warn "service start failed, check: journalctl -u hubproxy"
}
fi
fi
if command -v rc-update >/dev/null 2>&1; then
rc-update add hubproxy default >/dev/null 2>&1 || warn "OpenRC enable failed"
fi
if command -v rc-service >/dev/null 2>&1; then
rc-service hubproxy restart || rc-service hubproxy start || {
warn "service start failed, check: rc-service hubproxy status"
}
fi
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
set -e
if command -v systemctl >/dev/null 2>&1; then
systemctl daemon-reload >/dev/null 2>&1 || true
fi
+21
View File
@@ -0,0 +1,21 @@
#!/bin/sh
set -e
case "${1:-}" in
1|upgrade)
exit 0
;;
esac
if command -v systemctl >/dev/null 2>&1; then
systemctl stop hubproxy >/dev/null 2>&1 || true
systemctl disable hubproxy >/dev/null 2>&1 || true
fi
if command -v rc-service >/dev/null 2>&1; then
rc-service hubproxy stop >/dev/null 2>&1 || true
fi
if command -v rc-update >/dev/null 2>&1; then
rc-update del hubproxy default >/dev/null 2>&1 || true
fi
-367
View File
@@ -1,367 +0,0 @@
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)
}
+24 -22
View File
@@ -1,32 +1,36 @@
[server] [server]
# 监听地址,默认监听所有接口 # 可通过 CONFIG_PATH 环境变量指定配置文件路径,默认读取当前工作目录下的 config.toml
host = "0.0.0.0" host = "0.0.0.0"
# 监听端口 # 监听端口
port = 5000 port = 5000
# 文件大小限制(字节),默认2GB # Github文件大小限制(字节),默认2GB
fileSize = 2147483648 fileSize = 2147483648
# HTTP/2 多路复用
enableH2C = false
enableFrontend = true
[rateLimit] [rateLimit]
# 每个IP每小时允许的请求数(Docker镜像每个层为一个请求) # 每个IP每周期允许的请求数
requestLimit = 200 requestLimit = 500
# 限流周期(小时) # 限流周期(小时)
periodHours = 1.0 periodHours = 3.0
[security] [security]
# IP白名单,支持单个IP或CIDR格式 # IP白名单,支持单个IP或IP段
# 白名单中的IP不受限流限制 # 白名单中的IP不受限流限制
whiteList = [ whiteList = [
"127.0.0.1", "127.0.0.1",
"192.168.1.0/24" "127.0.0.2"
] ]
# IP黑名单,支持单个IP或CIDR格式 # IP黑名单,支持单个IP或IP段
# 黑名单中的IP将被直接拒绝访问 # 黑名单中的IP将被直接拒绝访问
blackList = [ blackList = [
"192.168.100.1" "192.168.100.1",
"192.168.100.0/24"
] ]
[proxy] [access]
# 代理服务白名单(支持GitHub仓库和Docker镜像,支持通配符) # 代理服务白名单(支持GitHub仓库和Docker镜像,支持通配符)
# 只允许访问白名单中的仓库/镜像,为空时不限制 # 只允许访问白名单中的仓库/镜像,为空时不限制
whiteList = [] whiteList = []
@@ -39,11 +43,17 @@ blackList = [
"baduser/*" "baduser/*"
] ]
# 代理配置,支持有用户名/密码认证和无认证模式
# 无认证: socks5://127.0.0.1:1080
# 有认证: socks5://username:password@127.0.0.1:1080
# 留空不使用代理
proxy = ""
[download] [download]
# 单次并发下载离线镜像数量限制 # 批量下载离线镜像数量限制
maxImages = 10 maxImages = 10
# Registry映射配置,支持多种Container Registry # Registry映射配置,支持多种镜像仓库上游
[registries] [registries]
# GitHub Container Registry # GitHub Container Registry
@@ -74,16 +84,8 @@ authHost = "registry.k8s.io"
authType = "anonymous" authType = "anonymous"
enabled = true 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] [tokenCache]
# 是否启用缓存(同时控制Token和Manifest缓存) # 是否启用缓存(同时控制Token和Manifest缓存)显著提升性能
enabled = true enabled = true
# 默认缓存时间 # 默认缓存时间(分钟)
defaultTTL = "20m" defaultTTL = "20m"
+278
View File
@@ -0,0 +1,278 @@
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()
}
func configFilePath() string {
if path := strings.TrimSpace(os.Getenv("CONFIG_PATH")); path != "" {
return path
}
return "config.toml"
}
func LoadConfig() error {
cfg := DefaultConfig()
path := configFilePath()
if data, err := os.ReadFile(path); err == nil {
if err := toml.Unmarshal(data, cfg); err != nil {
return fmt.Errorf("解析配置文件 %s 失败: %v", path, err)
}
} else {
fmt.Printf("未找到配置文件 %s,使用默认配置\n", path)
}
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, ok := os.LookupEnv("ACCESS_PROXY"); ok {
cfg.Access.Proxy = strings.TrimSpace(val)
}
if val := os.Getenv("MAX_IMAGES"); val != "" {
if maxImages, err := strconv.Atoi(val); err == nil && maxImages > 0 {
cfg.Download.MaxImages = maxImages
}
}
}
+41
View File
@@ -0,0 +1,41 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestLoadConfigUsesConfigPathAndEnvOverrides(t *testing.T) {
path := filepath.Join(t.TempDir(), "custom.toml")
data := []byte(`
[server]
host = "127.0.0.1"
port = 5999
[access]
proxy = "socks5://127.0.0.1:1080"
`)
if err := os.WriteFile(path, data, 0644); err != nil {
t.Fatal(err)
}
t.Setenv("CONFIG_PATH", path)
t.Setenv("SERVER_PORT", "6001")
t.Setenv("ACCESS_PROXY", "")
if err := LoadConfig(); err != nil {
t.Fatal(err)
}
cfg := GetConfig()
if cfg.Server.Host != "127.0.0.1" {
t.Fatalf("Server.Host = %q", cfg.Server.Host)
}
if cfg.Server.Port != 6001 {
t.Fatalf("Server.Port = %d, want 6001", cfg.Server.Port)
}
if cfg.Access.Proxy != "" {
t.Fatalf("Access.Proxy = %q, want empty override", cfg.Access.Proxy)
}
}
+32 -41
View File
@@ -1,35 +1,33 @@
module hubproxy module hubproxy
go 1.24.0 go 1.26
require ( require (
github.com/fsnotify/fsnotify v1.8.0 github.com/gin-gonic/gin v1.12.0
github.com/gin-gonic/gin v1.10.0 github.com/google/go-containerregistry v0.21.5
github.com/google/go-containerregistry v0.20.5 github.com/pelletier/go-toml/v2 v2.3.1
github.com/pelletier/go-toml/v2 v2.2.3 golang.org/x/net v0.53.0
github.com/spf13/viper v1.20.1 golang.org/x/time v0.15.0
golang.org/x/time v0.11.0
) )
require ( require (
github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect github.com/bytedance/sonic v1.15.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect github.com/containerd/stargz-snapshotter/estargz v0.18.2 // indirect
github.com/docker/cli v28.1.1+incompatible // indirect github.com/docker/cli v29.4.0+incompatible // indirect
github.com/docker/distribution v2.8.3+incompatible // indirect
github.com/docker/docker-credential-helpers v0.9.3 // indirect github.com/docker/docker-credential-helpers v0.9.3 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.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-playground/validator/v10 v10.30.1 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-yaml v1.19.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/compress v1.18.5 // 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/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect
@@ -37,25 +35,18 @@ require (
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/quic-go/qpack v0.6.0 // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect github.com/sirupsen/logrus v1.9.4 // 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/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 github.com/vbatts/tar-split v0.12.2 // indirect
go.uber.org/atomic v1.9.0 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.uber.org/multierr v1.9.0 // indirect golang.org/x/arch v0.22.0 // indirect
golang.org/x/arch v0.8.0 // indirect golang.org/x/crypto v0.50.0 // indirect
golang.org/x/crypto v0.32.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/net v0.33.0 // indirect golang.org/x/sys v0.43.0 // indirect
golang.org/x/sync v0.14.0 // indirect golang.org/x/text v0.36.0 // indirect
golang.org/x/sys v0.33.0 // indirect google.golang.org/protobuf v1.36.10 // indirect
golang.org/x/text v0.21.0 // indirect gotest.tools/v3 v3.5.2 // indirect
google.golang.org/protobuf v1.36.3 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
) )
+68 -100
View File
@@ -1,61 +1,49 @@
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= 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.18.2 h1:yXkZFYIzz3eoLwlTUZKz2iQ4MrckBxJjkmD16ynUTrw=
github.com/containerd/stargz-snapshotter/estargz v0.16.3/go.mod h1:uyr4BfYfOj3G9WBVE8cOlQmXAbPN9VEQpBBeJIuOipU= github.com/containerd/stargz-snapshotter/estargz v0.18.2/go.mod h1:XyVU5tcJ3PRpkA9XS2T5us6Eg35yM0214Y+wvrZTBrY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 v29.4.0+incompatible h1:+IjXULMetlvWJiuSI0Nbor36lcJ5BTcVpUmB21KBoVM=
github.com/docker/cli v28.1.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/cli v29.4.0+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 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8=
github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= 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/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
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/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 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/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 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 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 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 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.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 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-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.21.5 h1:KTJG9Pn/jC0VdZR6ctV3/jcN+q6/Iqlx0sTVz3ywZlM=
github.com/google/go-containerregistry v0.20.5/go.mod h1:Q14vdOOzug02bwnhMkZKD4e30pDaD9W65qzXpyzF49E= github.com/google/go-containerregistry v0.21.5/go.mod h1:ySvMuiWg+dOsRW0Hw8GYwfMwBlNRTmpYBFJPlkco5zU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 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 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 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.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
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/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= 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/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= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -71,77 +59,57 @@ 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/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 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= 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.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pelletier/go-toml/v2 v2.3.1/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
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.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.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.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.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.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.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.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 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 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 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.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= 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.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CPa4=
github.com/vbatts/tar-split v0.12.1/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 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 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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+108 -138
View File
@@ -1,4 +1,4 @@
package main package handlers
import ( import (
"context" "context"
@@ -12,6 +12,8 @@ import (
"github.com/google/go-containerregistry/pkg/authn" "github.com/google/go-containerregistry/pkg/authn"
"github.com/google/go-containerregistry/pkg/name" "github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/v1/remote" "github.com/google/go-containerregistry/pkg/v1/remote"
"hubproxy/config"
"hubproxy/utils"
) )
// DockerProxy Docker代理配置 // DockerProxy Docker代理配置
@@ -26,13 +28,18 @@ var dockerProxy *DockerProxy
type RegistryDetector struct{} type RegistryDetector struct{}
// detectRegistryDomain 检测Registry域名并返回域名和剩余路径 // detectRegistryDomain 检测Registry域名并返回域名和剩余路径
func (rd *RegistryDetector) detectRegistryDomain(path string) (string, string) { func (rd *RegistryDetector) detectRegistryDomain(c *gin.Context, path string) (string, string) {
cfg := GetConfig() cfg := config.GetConfig()
// 兼容Containerd的ns参数
if ns := c.Query("ns"); ns != "" {
if mapping, exists := cfg.Registries[ns]; exists && mapping.Enabled {
return ns, path
}
}
// 检查路径是否以已知Registry域名开头
for domain := range cfg.Registries { for domain := range cfg.Registries {
if strings.HasPrefix(path, domain+"/") { if strings.HasPrefix(path, domain+"/") {
// 找到匹配的域名,返回域名和剩余路径
remainingPath := strings.TrimPrefix(path, domain+"/") remainingPath := strings.TrimPrefix(path, domain+"/")
return domain, remainingPath return domain, remainingPath
} }
@@ -43,7 +50,7 @@ func (rd *RegistryDetector) detectRegistryDomain(path string) (string, string) {
// isRegistryEnabled 检查Registry是否启用 // isRegistryEnabled 检查Registry是否启用
func (rd *RegistryDetector) isRegistryEnabled(domain string) bool { func (rd *RegistryDetector) isRegistryEnabled(domain string) bool {
cfg := GetConfig() cfg := config.GetConfig()
if mapping, exists := cfg.Registries[domain]; exists { if mapping, exists := cfg.Registries[domain]; exists {
return mapping.Enabled return mapping.Enabled
} }
@@ -51,27 +58,26 @@ func (rd *RegistryDetector) isRegistryEnabled(domain string) bool {
} }
// getRegistryMapping 获取Registry映射配置 // getRegistryMapping 获取Registry映射配置
func (rd *RegistryDetector) getRegistryMapping(domain string) (RegistryMapping, bool) { func (rd *RegistryDetector) getRegistryMapping(domain string) (config.RegistryMapping, bool) {
cfg := GetConfig() cfg := config.GetConfig()
mapping, exists := cfg.Registries[domain] mapping, exists := cfg.Registries[domain]
return mapping, exists && mapping.Enabled return mapping, exists && mapping.Enabled
} }
var registryDetector = &RegistryDetector{} var registryDetector = &RegistryDetector{}
// 初始化Docker代理 // InitDockerProxy 初始化Docker代理
func initDockerProxy() { func InitDockerProxy() {
// 创建目标registry
registry, err := name.NewRegistry("registry-1.docker.io") registry, err := name.NewRegistry("registry-1.docker.io")
if err != nil { if err != nil {
fmt.Printf("创建Docker registry失败: %v\n", err) fmt.Printf("创建Docker registry失败: %v\n", err)
return return
} }
// 配置代理选项
options := []remote.Option{ options := []remote.Option{
remote.WithAuth(authn.Anonymous), remote.WithAuth(authn.Anonymous),
remote.WithUserAgent("hubproxy/go-containerregistry"), remote.WithUserAgent("hubproxy/go-containerregistry"),
remote.WithTransport(utils.GetGlobalHTTPClient().Transport),
} }
dockerProxy = &DockerProxy{ dockerProxy = &DockerProxy{
@@ -84,13 +90,11 @@ func initDockerProxy() {
func ProxyDockerRegistryGin(c *gin.Context) { func ProxyDockerRegistryGin(c *gin.Context) {
path := c.Request.URL.Path path := c.Request.URL.Path
// 处理 /v2/ API版本检查
if path == "/v2/" { if path == "/v2/" {
c.JSON(http.StatusOK, gin.H{}) c.JSON(http.StatusOK, gin.H{})
return return
} }
// 处理不同的API端点
if strings.HasPrefix(path, "/v2/") { if strings.HasPrefix(path, "/v2/") {
handleRegistryRequest(c, path) handleRegistryRequest(c, path)
} else { } else {
@@ -100,16 +104,10 @@ func ProxyDockerRegistryGin(c *gin.Context) {
// handleRegistryRequest 处理Registry请求 // handleRegistryRequest 处理Registry请求
func handleRegistryRequest(c *gin.Context, path string) { func handleRegistryRequest(c *gin.Context, path string) {
// 移除 /v2/ 前缀
pathWithoutV2 := strings.TrimPrefix(path, "/v2/") pathWithoutV2 := strings.TrimPrefix(path, "/v2/")
if registryDomain, remainingPath := registryDetector.detectRegistryDomain(pathWithoutV2); registryDomain != "" { if registryDomain, remainingPath := registryDetector.detectRegistryDomain(c, pathWithoutV2); registryDomain != "" {
if registryDetector.isRegistryEnabled(registryDomain) { if registryDetector.isRegistryEnabled(registryDomain) {
// 设置目标Registry信息到Context
c.Set("target_registry_domain", registryDomain)
c.Set("target_path", remainingPath)
// 处理多Registry请求
handleMultiRegistryRequest(c, registryDomain, remainingPath) handleMultiRegistryRequest(c, registryDomain, remainingPath)
return return
} }
@@ -121,19 +119,16 @@ func handleRegistryRequest(c *gin.Context, path string) {
return return
} }
// 自动处理官方镜像的library命名空间
if !strings.Contains(imageName, "/") { if !strings.Contains(imageName, "/") {
imageName = "library/" + imageName imageName = "library/" + imageName
} }
// Docker镜像访问控制检查 if allowed, reason := utils.GlobalAccessController.CheckDockerAccess(imageName); !allowed {
if allowed, reason := GlobalAccessController.CheckDockerAccess(imageName); !allowed {
fmt.Printf("Docker镜像 %s 访问被拒绝: %s\n", imageName, reason) fmt.Printf("Docker镜像 %s 访问被拒绝: %s\n", imageName, reason)
c.String(http.StatusForbidden, "镜像访问被限制") c.String(http.StatusForbidden, "镜像访问被限制")
return return
} }
// 构建完整的镜像引用
imageRef := fmt.Sprintf("%s/%s", dockerProxy.registry.Name(), imageName) imageRef := fmt.Sprintf("%s/%s", dockerProxy.registry.Name(), imageName)
switch apiType { switch apiType {
@@ -150,7 +145,6 @@ func handleRegistryRequest(c *gin.Context, path string) {
// parseRegistryPath 解析Registry路径 // parseRegistryPath 解析Registry路径
func parseRegistryPath(path string) (imageName, apiType, reference string) { func parseRegistryPath(path string) (imageName, apiType, reference string) {
// 查找API端点关键字
if idx := strings.Index(path, "/manifests/"); idx != -1 { if idx := strings.Index(path, "/manifests/"); idx != -1 {
imageName = path[:idx] imageName = path[:idx]
apiType = "manifests" apiType = "manifests"
@@ -177,13 +171,11 @@ func parseRegistryPath(path string) (imageName, apiType, reference string) {
// handleManifestRequest 处理manifest请求 // handleManifestRequest 处理manifest请求
func handleManifestRequest(c *gin.Context, imageRef, reference string) { func handleManifestRequest(c *gin.Context, imageRef, reference string) {
// Manifest缓存逻辑(仅对GET请求缓存) if utils.IsCacheEnabled() && c.Request.Method == http.MethodGet {
if isCacheEnabled() && c.Request.Method == http.MethodGet { cacheKey := utils.BuildManifestCacheKey(imageRef, reference)
cacheKey := buildManifestCacheKey(imageRef, reference)
// 优先从缓存获取 if cachedItem := utils.GlobalCache.Get(cacheKey); cachedItem != nil {
if cachedItem := globalCache.Get(cacheKey); cachedItem != nil { utils.WriteCachedResponse(c, cachedItem)
writeCachedResponse(c, cachedItem)
return return
} }
} }
@@ -191,12 +183,9 @@ func handleManifestRequest(c *gin.Context, imageRef, reference string) {
var ref name.Reference var ref name.Reference
var err error var err error
// 判断reference是digest还是tag
if strings.HasPrefix(reference, "sha256:") { if strings.HasPrefix(reference, "sha256:") {
// 是digest
ref, err = name.NewDigest(fmt.Sprintf("%s@%s", imageRef, reference)) ref, err = name.NewDigest(fmt.Sprintf("%s@%s", imageRef, reference))
} else { } else {
// 是tag
ref, err = name.NewTag(fmt.Sprintf("%s:%s", imageRef, reference)) ref, err = name.NewTag(fmt.Sprintf("%s:%s", imageRef, reference))
} }
@@ -206,9 +195,7 @@ func handleManifestRequest(c *gin.Context, imageRef, reference string) {
return return
} }
// 根据请求方法选择操作
if c.Request.Method == http.MethodHead { if c.Request.Method == http.MethodHead {
// HEAD请求,使用remote.Head
desc, err := remote.Head(ref, dockerProxy.options...) desc, err := remote.Head(ref, dockerProxy.options...)
if err != nil { if err != nil {
fmt.Printf("HEAD请求失败: %v\n", err) fmt.Printf("HEAD请求失败: %v\n", err)
@@ -216,13 +203,11 @@ func handleManifestRequest(c *gin.Context, imageRef, reference string) {
return return
} }
// 设置响应头
c.Header("Content-Type", string(desc.MediaType)) c.Header("Content-Type", string(desc.MediaType))
c.Header("Docker-Content-Digest", desc.Digest.String()) c.Header("Docker-Content-Digest", desc.Digest.String())
c.Header("Content-Length", fmt.Sprintf("%d", desc.Size)) c.Header("Content-Length", fmt.Sprintf("%d", desc.Size))
c.Status(http.StatusOK) c.Status(http.StatusOK)
} else { } else {
// GET请求,使用remote.Get
desc, err := remote.Get(ref, dockerProxy.options...) desc, err := remote.Get(ref, dockerProxy.options...)
if err != nil { if err != nil {
fmt.Printf("GET请求失败: %v\n", err) fmt.Printf("GET请求失败: %v\n", err)
@@ -230,33 +215,28 @@ func handleManifestRequest(c *gin.Context, imageRef, reference string) {
return return
} }
// 设置响应头
headers := map[string]string{ headers := map[string]string{
"Docker-Content-Digest": desc.Digest.String(), "Docker-Content-Digest": desc.Digest.String(),
"Content-Length": fmt.Sprintf("%d", len(desc.Manifest)), "Content-Length": fmt.Sprintf("%d", len(desc.Manifest)),
} }
// 缓存响应 if utils.IsCacheEnabled() {
if isCacheEnabled() { cacheKey := utils.BuildManifestCacheKey(imageRef, reference)
cacheKey := buildManifestCacheKey(imageRef, reference) ttl := utils.GetManifestTTL(reference)
ttl := getManifestTTL(reference) utils.GlobalCache.Set(cacheKey, desc.Manifest, string(desc.MediaType), headers, ttl)
globalCache.Set(cacheKey, desc.Manifest, string(desc.MediaType), headers, ttl)
} }
// 设置响应头
c.Header("Content-Type", string(desc.MediaType)) c.Header("Content-Type", string(desc.MediaType))
for key, value := range headers { for key, value := range headers {
c.Header(key, value) c.Header(key, value)
} }
// 返回manifest内容
c.Data(http.StatusOK, string(desc.MediaType), desc.Manifest) c.Data(http.StatusOK, string(desc.MediaType), desc.Manifest)
} }
} }
// handleBlobRequest 处理blob请求 // handleBlobRequest 处理blob请求
func handleBlobRequest(c *gin.Context, imageRef, digest string) { func handleBlobRequest(c *gin.Context, imageRef, digest string) {
// 构建digest引用
digestRef, err := name.NewDigest(fmt.Sprintf("%s@%s", imageRef, digest)) digestRef, err := name.NewDigest(fmt.Sprintf("%s@%s", imageRef, digest))
if err != nil { if err != nil {
fmt.Printf("解析digest引用失败: %v\n", err) fmt.Printf("解析digest引用失败: %v\n", err)
@@ -264,7 +244,6 @@ func handleBlobRequest(c *gin.Context, imageRef, digest string) {
return return
} }
// 使用remote.Layer获取layer
layer, err := remote.Layer(digestRef, dockerProxy.options...) layer, err := remote.Layer(digestRef, dockerProxy.options...)
if err != nil { if err != nil {
fmt.Printf("获取layer失败: %v\n", err) fmt.Printf("获取layer失败: %v\n", err)
@@ -272,7 +251,6 @@ func handleBlobRequest(c *gin.Context, imageRef, digest string) {
return return
} }
// 获取layer信息
size, err := layer.Size() size, err := layer.Size()
if err != nil { if err != nil {
fmt.Printf("获取layer大小失败: %v\n", err) fmt.Printf("获取layer大小失败: %v\n", err)
@@ -280,7 +258,6 @@ func handleBlobRequest(c *gin.Context, imageRef, digest string) {
return return
} }
// 获取layer内容
reader, err := layer.Compressed() reader, err := layer.Compressed()
if err != nil { if err != nil {
fmt.Printf("获取layer内容失败: %v\n", err) fmt.Printf("获取layer内容失败: %v\n", err)
@@ -289,19 +266,18 @@ func handleBlobRequest(c *gin.Context, imageRef, digest string) {
} }
defer reader.Close() defer reader.Close()
// 设置响应头
c.Header("Content-Type", "application/octet-stream") c.Header("Content-Type", "application/octet-stream")
c.Header("Content-Length", fmt.Sprintf("%d", size)) c.Header("Content-Length", fmt.Sprintf("%d", size))
c.Header("Docker-Content-Digest", digest) c.Header("Docker-Content-Digest", digest)
// 流式传输blob内容
c.Status(http.StatusOK) c.Status(http.StatusOK)
io.Copy(c.Writer, reader) if _, err := io.Copy(c.Writer, reader); err != nil {
fmt.Printf("复制layer内容失败: %v\n", err)
}
} }
// handleTagsRequest 处理tags列表请求 // handleTagsRequest 处理tags列表请求
func handleTagsRequest(c *gin.Context, imageRef string) { func handleTagsRequest(c *gin.Context, imageRef string) {
// 解析repository
repo, err := name.NewRepository(imageRef) repo, err := name.NewRepository(imageRef)
if err != nil { if err != nil {
fmt.Printf("解析repository失败: %v\n", err) fmt.Printf("解析repository失败: %v\n", err)
@@ -309,7 +285,6 @@ func handleTagsRequest(c *gin.Context, imageRef string) {
return return
} }
// 使用remote.List获取tags
tags, err := remote.List(repo, dockerProxy.options...) tags, err := remote.List(repo, dockerProxy.options...)
if err != nil { if err != nil {
fmt.Printf("获取tags失败: %v\n", err) fmt.Printf("获取tags失败: %v\n", err)
@@ -317,7 +292,6 @@ func handleTagsRequest(c *gin.Context, imageRef string) {
return return
} }
// 构建响应
response := map[string]interface{}{ response := map[string]interface{}{
"name": strings.TrimPrefix(imageRef, dockerProxy.registry.Name()+"/"), "name": strings.TrimPrefix(imageRef, dockerProxy.registry.Name()+"/"),
"tags": tags, "tags": tags,
@@ -326,10 +300,9 @@ func handleTagsRequest(c *gin.Context, imageRef string) {
c.JSON(http.StatusOK, response) c.JSON(http.StatusOK, response)
} }
// ProxyDockerAuthGin Docker认证代理(带缓存优化) // ProxyDockerAuthGin Docker认证代理
func ProxyDockerAuthGin(c *gin.Context) { func ProxyDockerAuthGin(c *gin.Context) {
// 检查是否启用token缓存 if utils.IsTokenCacheEnabled() {
if isTokenCacheEnabled() {
proxyDockerAuthWithCache(c) proxyDockerAuthWithCache(c)
} else { } else {
proxyDockerAuthOriginal(c) proxyDockerAuthOriginal(c)
@@ -338,36 +311,28 @@ func ProxyDockerAuthGin(c *gin.Context) {
// proxyDockerAuthWithCache 带缓存的认证代理 // proxyDockerAuthWithCache 带缓存的认证代理
func proxyDockerAuthWithCache(c *gin.Context) { func proxyDockerAuthWithCache(c *gin.Context) {
// 1. 构建缓存key(基于完整的查询参数) cacheKey := utils.BuildTokenCacheKey(c.Request.URL.RawQuery)
cacheKey := buildTokenCacheKey(c.Request.URL.RawQuery)
// 2. 尝试从缓存获取token if cachedToken := utils.GlobalCache.GetToken(cacheKey); cachedToken != "" {
if cachedToken := globalCache.GetToken(cacheKey); cachedToken != "" { utils.WriteTokenResponse(c, cachedToken)
writeTokenResponse(c, cachedToken)
return return
} }
// 3. 缓存未命中,创建响应记录器
recorder := &ResponseRecorder{ recorder := &ResponseRecorder{
ResponseWriter: c.Writer, ResponseWriter: c.Writer,
statusCode: 200, statusCode: 200,
} }
c.Writer = recorder c.Writer = recorder
// 4. 调用原有认证逻辑
proxyDockerAuthOriginal(c) proxyDockerAuthOriginal(c)
// 5. 如果认证成功,缓存响应
if recorder.statusCode == 200 && len(recorder.body) > 0 { if recorder.statusCode == 200 && len(recorder.body) > 0 {
ttl := extractTTLFromResponse(recorder.body) ttl := utils.ExtractTTLFromResponse(recorder.body)
globalCache.SetToken(cacheKey, string(recorder.body), ttl) utils.GlobalCache.SetToken(cacheKey, string(recorder.body), ttl)
} }
// 6. 写入实际响应(如果还没写入)
if !recorder.written {
c.Writer = recorder.ResponseWriter c.Writer = recorder.ResponseWriter
c.Data(recorder.statusCode, "application/json", recorder.body) c.Data(recorder.statusCode, "application/json", recorder.body)
}
} }
// ResponseRecorder HTTP响应记录器 // ResponseRecorder HTTP响应记录器
@@ -375,7 +340,6 @@ type ResponseRecorder struct {
gin.ResponseWriter gin.ResponseWriter
statusCode int statusCode int
body []byte body []byte
written bool
} }
func (r *ResponseRecorder) WriteHeader(code int) { func (r *ResponseRecorder) WriteHeader(code int) {
@@ -384,35 +348,17 @@ func (r *ResponseRecorder) WriteHeader(code int) {
func (r *ResponseRecorder) Write(data []byte) (int, error) { func (r *ResponseRecorder) Write(data []byte) (int, error) {
r.body = append(r.body, data...) r.body = append(r.body, data...)
r.written = true return len(data), nil
return r.ResponseWriter.Write(data)
} }
func proxyDockerAuthOriginal(c *gin.Context) { func proxyDockerAuthOriginal(c *gin.Context) {
var authURL string authURL := buildDockerAuthURL(c)
if targetDomain, exists := c.Get("target_registry_domain"); exists {
if mapping, found := registryDetector.getRegistryMapping(targetDomain.(string)); found {
// 使用Registry特定的认证服务器
authURL = "https://" + mapping.AuthHost + c.Request.URL.Path
} else {
// fallback到默认Docker认证
authURL = "https://auth.docker.io" + c.Request.URL.Path
}
} else {
// 构建默认Docker认证URL
authURL = "https://auth.docker.io" + c.Request.URL.Path
}
if c.Request.URL.RawQuery != "" {
authURL += "?" + c.Request.URL.RawQuery
}
// 创建HTTP客户端
client := &http.Client{ client := &http.Client{
Timeout: 30 * time.Second, Timeout: 30 * time.Second,
Transport: utils.GetGlobalHTTPClient().Transport,
} }
// 创建请求
req, err := http.NewRequestWithContext( req, err := http.NewRequestWithContext(
context.Background(), context.Background(),
c.Request.Method, c.Request.Method,
@@ -424,14 +370,12 @@ func proxyDockerAuthOriginal(c *gin.Context) {
return return
} }
// 复制请求头
for key, values := range c.Request.Header { for key, values := range c.Request.Header {
for _, value := range values { for _, value := range values {
req.Header.Add(key, value) req.Header.Add(key, value)
} }
} }
// 执行请求
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
c.String(http.StatusBadGateway, "Auth request failed") c.String(http.StatusBadGateway, "Auth request failed")
@@ -439,73 +383,104 @@ func proxyDockerAuthOriginal(c *gin.Context) {
} }
defer resp.Body.Close() defer resp.Body.Close()
// 获取当前代理的Host地址
proxyHost := c.Request.Host proxyHost := c.Request.Host
if proxyHost == "" { if proxyHost == "" {
// 使用配置中的服务器地址和端口 cfg := config.GetConfig()
cfg := GetConfig()
proxyHost = fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port) proxyHost = fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
if cfg.Server.Host == "0.0.0.0" { if cfg.Server.Host == "0.0.0.0" {
proxyHost = fmt.Sprintf("localhost:%d", cfg.Server.Port) proxyHost = fmt.Sprintf("localhost:%d", cfg.Server.Port)
} }
} }
// 复制响应头并重写认证URL
for key, values := range resp.Header { for key, values := range resp.Header {
for _, value := range values { for _, value := range values {
// 重写WWW-Authenticate头中的realm URL
if key == "Www-Authenticate" { if key == "Www-Authenticate" {
// 支持多Registry的URL重写
value = rewriteAuthHeader(value, proxyHost) value = rewriteAuthHeader(value, proxyHost)
} }
c.Header(key, value) c.Header(key, value)
} }
} }
// 返回响应
c.Status(resp.StatusCode) c.Status(resp.StatusCode)
io.Copy(c.Writer, resp.Body) if _, err := io.Copy(c.Writer, resp.Body); err != nil {
fmt.Printf("复制认证响应失败: %v\n", err)
}
} }
// rewriteAuthHeader 重写认证头 // buildDockerAuthURL 根据 token 请求的 service 参数选择上游认证地址。
// AuthHost 已包含路径(如 ghcr.io/token、quay.io/v2/auth),不再拼接本机 Path,避免 /token/token。
func buildDockerAuthURL(c *gin.Context) string {
authHost := resolveAuthHost(c.Query("service"))
var authURL string
if authHost != "" {
authURL = "https://" + authHost
} else {
authURL = "https://auth.docker.io" + c.Request.URL.Path
}
if c.Request.URL.RawQuery != "" {
authURL += "?" + c.Request.URL.RawQuery
}
return authURL
}
// resolveAuthHost 用 service 匹配已启用 Registry 的 AuthHostDocker Hub 返回空串走默认路径。
func resolveAuthHost(service string) string {
if service == "" || service == "registry.docker.io" || service == "docker.io" {
return ""
}
cfg := config.GetConfig()
for domain, mapping := range cfg.Registries {
if !mapping.Enabled || mapping.AuthHost == "" {
continue
}
if service == domain || service == mapping.Upstream {
return mapping.AuthHost
}
}
return ""
}
// rewriteAuthHeader 将上游认证 realm 统一改写到本机 /token,避免 quay 等变成 /v2/auth 误入 Registry 路由。
func rewriteAuthHeader(authHeader, proxyHost string) string { func rewriteAuthHeader(authHeader, proxyHost string) string {
// 重写各种Registry的认证URL proxyToken := "http://" + proxyHost + "/token"
cfg := config.GetConfig()
for _, mapping := range cfg.Registries {
if mapping.AuthHost == "" {
continue
}
authHeader = strings.ReplaceAll(authHeader, "https://"+mapping.AuthHost, proxyToken)
}
authHeader = strings.ReplaceAll(authHeader, "https://auth.docker.io/token", proxyToken)
authHeader = strings.ReplaceAll(authHeader, "https://auth.docker.io", "http://"+proxyHost) authHeader = strings.ReplaceAll(authHeader, "https://auth.docker.io", "http://"+proxyHost)
authHeader = strings.ReplaceAll(authHeader, "https://ghcr.io", "http://"+proxyHost)
authHeader = strings.ReplaceAll(authHeader, "https://gcr.io", "http://"+proxyHost)
authHeader = strings.ReplaceAll(authHeader, "https://quay.io", "http://"+proxyHost)
return authHeader return authHeader
} }
// handleMultiRegistryRequest 处理多Registry请求 // handleMultiRegistryRequest 处理多Registry请求
func handleMultiRegistryRequest(c *gin.Context, registryDomain, remainingPath string) { func handleMultiRegistryRequest(c *gin.Context, registryDomain, remainingPath string) {
// 获取Registry映射配置
mapping, exists := registryDetector.getRegistryMapping(registryDomain) mapping, exists := registryDetector.getRegistryMapping(registryDomain)
if !exists { if !exists {
c.String(http.StatusBadRequest, "Registry not configured") c.String(http.StatusBadRequest, "Registry not configured")
return return
} }
// 解析剩余路径
imageName, apiType, reference := parseRegistryPath(remainingPath) imageName, apiType, reference := parseRegistryPath(remainingPath)
if imageName == "" || apiType == "" { if imageName == "" || apiType == "" {
c.String(http.StatusBadRequest, "Invalid path format") c.String(http.StatusBadRequest, "Invalid path format")
return return
} }
// 访问控制检查(使用完整的镜像路径)
fullImageName := registryDomain + "/" + imageName fullImageName := registryDomain + "/" + imageName
if allowed, reason := GlobalAccessController.CheckDockerAccess(fullImageName); !allowed { if allowed, reason := utils.GlobalAccessController.CheckDockerAccess(fullImageName); !allowed {
fmt.Printf("镜像 %s 访问被拒绝: %s\n", fullImageName, reason) fmt.Printf("镜像 %s 访问被拒绝: %s\n", fullImageName, reason)
c.String(http.StatusForbidden, "镜像访问被限制") c.String(http.StatusForbidden, "镜像访问被限制")
return return
} }
// 构建上游Registry引用
upstreamImageRef := fmt.Sprintf("%s/%s", mapping.Upstream, imageName) upstreamImageRef := fmt.Sprintf("%s/%s", mapping.Upstream, imageName)
// 根据API类型处理请求
switch apiType { switch apiType {
case "manifests": case "manifests":
handleUpstreamManifestRequest(c, upstreamImageRef, reference, mapping) handleUpstreamManifestRequest(c, upstreamImageRef, reference, mapping)
@@ -519,14 +494,12 @@ func handleMultiRegistryRequest(c *gin.Context, registryDomain, remainingPath st
} }
// handleUpstreamManifestRequest 处理上游Registry的manifest请求 // handleUpstreamManifestRequest 处理上游Registry的manifest请求
func handleUpstreamManifestRequest(c *gin.Context, imageRef, reference string, mapping RegistryMapping) { func handleUpstreamManifestRequest(c *gin.Context, imageRef, reference string, mapping config.RegistryMapping) {
// Manifest缓存逻辑(仅对GET请求缓存) if utils.IsCacheEnabled() && c.Request.Method == http.MethodGet {
if isCacheEnabled() && c.Request.Method == http.MethodGet { cacheKey := utils.BuildManifestCacheKey(imageRef, reference)
cacheKey := buildManifestCacheKey(imageRef, reference)
// 优先从缓存获取 if cachedItem := utils.GlobalCache.Get(cacheKey); cachedItem != nil {
if cachedItem := globalCache.Get(cacheKey); cachedItem != nil { utils.WriteCachedResponse(c, cachedItem)
writeCachedResponse(c, cachedItem)
return return
} }
} }
@@ -534,7 +507,6 @@ func handleUpstreamManifestRequest(c *gin.Context, imageRef, reference string, m
var ref name.Reference var ref name.Reference
var err error var err error
// 判断reference是digest还是tag
if strings.HasPrefix(reference, "sha256:") { if strings.HasPrefix(reference, "sha256:") {
ref, err = name.NewDigest(fmt.Sprintf("%s@%s", imageRef, reference)) ref, err = name.NewDigest(fmt.Sprintf("%s@%s", imageRef, reference))
} else { } else {
@@ -547,10 +519,8 @@ func handleUpstreamManifestRequest(c *gin.Context, imageRef, reference string, m
return return
} }
// 创建针对上游Registry的选项
options := createUpstreamOptions(mapping) options := createUpstreamOptions(mapping)
// 根据请求方法选择操作
if c.Request.Method == http.MethodHead { if c.Request.Method == http.MethodHead {
desc, err := remote.Head(ref, options...) desc, err := remote.Head(ref, options...)
if err != nil { if err != nil {
@@ -571,20 +541,17 @@ func handleUpstreamManifestRequest(c *gin.Context, imageRef, reference string, m
return return
} }
// 设置响应头
headers := map[string]string{ headers := map[string]string{
"Docker-Content-Digest": desc.Digest.String(), "Docker-Content-Digest": desc.Digest.String(),
"Content-Length": fmt.Sprintf("%d", len(desc.Manifest)), "Content-Length": fmt.Sprintf("%d", len(desc.Manifest)),
} }
// 缓存响应 if utils.IsCacheEnabled() {
if isCacheEnabled() { cacheKey := utils.BuildManifestCacheKey(imageRef, reference)
cacheKey := buildManifestCacheKey(imageRef, reference) ttl := utils.GetManifestTTL(reference)
ttl := getManifestTTL(reference) utils.GlobalCache.Set(cacheKey, desc.Manifest, string(desc.MediaType), headers, ttl)
globalCache.Set(cacheKey, desc.Manifest, string(desc.MediaType), headers, ttl)
} }
// 设置响应头
c.Header("Content-Type", string(desc.MediaType)) c.Header("Content-Type", string(desc.MediaType))
for key, value := range headers { for key, value := range headers {
c.Header(key, value) c.Header(key, value)
@@ -595,7 +562,7 @@ func handleUpstreamManifestRequest(c *gin.Context, imageRef, reference string, m
} }
// handleUpstreamBlobRequest 处理上游Registry的blob请求 // handleUpstreamBlobRequest 处理上游Registry的blob请求
func handleUpstreamBlobRequest(c *gin.Context, imageRef, digest string, mapping RegistryMapping) { func handleUpstreamBlobRequest(c *gin.Context, imageRef, digest string, mapping config.RegistryMapping) {
digestRef, err := name.NewDigest(fmt.Sprintf("%s@%s", imageRef, digest)) digestRef, err := name.NewDigest(fmt.Sprintf("%s@%s", imageRef, digest))
if err != nil { if err != nil {
fmt.Printf("解析digest引用失败: %v\n", err) fmt.Printf("解析digest引用失败: %v\n", err)
@@ -631,11 +598,13 @@ func handleUpstreamBlobRequest(c *gin.Context, imageRef, digest string, mapping
c.Header("Docker-Content-Digest", digest) c.Header("Docker-Content-Digest", digest)
c.Status(http.StatusOK) c.Status(http.StatusOK)
io.Copy(c.Writer, reader) if _, err := io.Copy(c.Writer, reader); err != nil {
fmt.Printf("复制layer内容失败: %v\n", err)
}
} }
// handleUpstreamTagsRequest 处理上游Registry的tags请求 // handleUpstreamTagsRequest 处理上游Registry的tags请求
func handleUpstreamTagsRequest(c *gin.Context, imageRef string, mapping RegistryMapping) { func handleUpstreamTagsRequest(c *gin.Context, imageRef string, mapping config.RegistryMapping) {
repo, err := name.NewRepository(imageRef) repo, err := name.NewRepository(imageRef)
if err != nil { if err != nil {
fmt.Printf("解析repository失败: %v\n", err) fmt.Printf("解析repository失败: %v\n", err)
@@ -660,13 +629,14 @@ func handleUpstreamTagsRequest(c *gin.Context, imageRef string, mapping Registry
} }
// createUpstreamOptions 创建上游Registry选项 // createUpstreamOptions 创建上游Registry选项
func createUpstreamOptions(mapping RegistryMapping) []remote.Option { func createUpstreamOptions(mapping config.RegistryMapping) []remote.Option {
options := []remote.Option{ options := []remote.Option{
remote.WithAuth(authn.Anonymous), remote.WithAuth(authn.Anonymous),
remote.WithUserAgent("hubproxy/go-containerregistry"), remote.WithUserAgent("hubproxy/go-containerregistry"),
remote.WithTransport(utils.GetGlobalHTTPClient().Transport),
} }
// 根据Registry类型添加特定的认证选项(方便后续扩展 // 预留将来不同Registry的差异化认证逻辑扩展
switch mapping.AuthType { switch mapping.AuthType {
case "github": case "github":
case "google": case "google":
+155
View File
@@ -0,0 +1,155 @@
package handlers
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
"hubproxy/config"
)
func TestParseRegistryPath(t *testing.T) {
tests := []struct {
path string
image string
apiType string
reference string
}{
{"library/nginx/manifests/latest", "library/nginx", "manifests", "latest"},
{"library/nginx/blobs/sha256:abc", "library/nginx", "blobs", "sha256:abc"},
{"library/nginx/tags/list", "library/nginx", "tags", "list"},
}
for _, tt := range tests {
image, apiType, reference := parseRegistryPath(tt.path)
if image != tt.image || apiType != tt.apiType || reference != tt.reference {
t.Fatalf("parseRegistryPath(%q) = %q %q %q", tt.path, image, apiType, reference)
}
}
}
func TestParseRegistryPathInvalid(t *testing.T) {
image, apiType, reference := parseRegistryPath("library/nginx/unknown/latest")
if image != "" || apiType != "" || reference != "" {
t.Fatalf("invalid path parsed as %q %q %q", image, apiType, reference)
}
}
func TestResolveAuthHost(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.toml")
data := []byte(`
[registries."ghcr.io"]
upstream = "ghcr.io"
authHost = "ghcr.io/token"
authType = "github"
enabled = true
[registries."quay.io"]
upstream = "quay.io"
authHost = "quay.io/v2/auth"
authType = "quay"
enabled = true
`)
if err := os.WriteFile(path, data, 0644); err != nil {
t.Fatal(err)
}
t.Setenv("CONFIG_PATH", path)
if err := config.LoadConfig(); err != nil {
t.Fatal(err)
}
if got := resolveAuthHost(""); got != "" {
t.Fatalf("empty service = %q", got)
}
if got := resolveAuthHost("registry.docker.io"); got != "" {
t.Fatalf("docker hub service = %q", got)
}
if got := resolveAuthHost("ghcr.io"); got != "ghcr.io/token" {
t.Fatalf("ghcr.io = %q", got)
}
if got := resolveAuthHost("quay.io"); got != "quay.io/v2/auth" {
t.Fatalf("quay.io = %q", got)
}
}
func TestBuildDockerAuthURL(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.toml")
data := []byte(`
[registries."ghcr.io"]
upstream = "ghcr.io"
authHost = "ghcr.io/token"
enabled = true
`)
if err := os.WriteFile(path, data, 0644); err != nil {
t.Fatal(err)
}
t.Setenv("CONFIG_PATH", path)
if err := config.LoadConfig(); err != nil {
t.Fatal(err)
}
gin.SetMode(gin.TestMode)
t.Run("docker hub keeps path", func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/token?service=registry.docker.io&scope=repository:library/nginx:pull", nil)
got := buildDockerAuthURL(c)
want := "https://auth.docker.io/token?service=registry.docker.io&scope=repository:library/nginx:pull"
if got != want {
t.Fatalf("got %q want %q", got, want)
}
})
t.Run("ghcr uses AuthHost without duplicating path", func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/token?service=ghcr.io&scope=repository:foo/bar:pull", nil)
got := buildDockerAuthURL(c)
want := "https://ghcr.io/token?service=ghcr.io&scope=repository:foo/bar:pull"
if got != want {
t.Fatalf("got %q want %q", got, want)
}
})
}
func TestRewriteAuthHeader(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.toml")
data := []byte(`
[registries."quay.io"]
upstream = "quay.io"
authHost = "quay.io/v2/auth"
enabled = true
[registries."ghcr.io"]
upstream = "ghcr.io"
authHost = "ghcr.io/token"
enabled = true
`)
if err := os.WriteFile(path, data, 0644); err != nil {
t.Fatal(err)
}
t.Setenv("CONFIG_PATH", path)
if err := config.LoadConfig(); err != nil {
t.Fatal(err)
}
got := rewriteAuthHeader(`Bearer realm="https://quay.io/v2/auth",service="quay.io"`, "proxy.example.com")
want := `Bearer realm="http://proxy.example.com/token",service="quay.io"`
if got != want {
t.Fatalf("quay rewrite: got %q want %q", got, want)
}
got = rewriteAuthHeader(`Bearer realm="https://ghcr.io/token",service="ghcr.io"`, "proxy.example.com")
want = `Bearer realm="http://proxy.example.com/token",service="ghcr.io"`
if got != want {
t.Fatalf("ghcr rewrite: got %q want %q", got, want)
}
got = rewriteAuthHeader(`Bearer realm="https://auth.docker.io/token",service="registry.docker.io"`, "proxy.example.com")
want = `Bearer realm="http://proxy.example.com/token",service="registry.docker.io"`
if got != want {
t.Fatalf("docker hub rewrite: got %q want %q", got, want)
}
}
+233
View File
@@ -0,0 +1,233 @@
package handlers
import (
"fmt"
"io"
"net/http"
"regexp"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"hubproxy/config"
"hubproxy/utils"
)
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?://)?(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)
}
}
}
+35
View File
@@ -0,0 +1,35 @@
package handlers
import "testing"
func TestCheckGitHubURL(t *testing.T) {
tests := []struct {
name string
url string
user string
repo string
}{
{"release", "https://github.com/user/repo/releases/download/v1/file.tar.gz", "user", "repo"},
{"raw", "https://raw.githubusercontent.com/user/repo/main/file.sh", "user", "repo"},
{"api", "https://api.github.com/repos/user/repo/releases/latest", "user", "repo"},
{"huggingface", "https://huggingface.co/user/model/resolve/main/file", "user", "model/resolve/main/file"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := CheckGitHubURL(tt.url)
if len(got) < 2 || got[0] != tt.user || got[1] != tt.repo {
t.Fatalf("CheckGitHubURL(%q) = %#v", tt.url, got)
}
})
}
}
func TestCheckGitHubURLRejectsOtherHosts(t *testing.T) {
if got := CheckGitHubURL("https://example.com/user/repo/file"); got != nil {
t.Fatalf("unexpected match: %#v", got)
}
if got := CheckGitHubURL("https://download.docker.com/linux/static/stable/x86_64/docker.tgz"); got != nil {
t.Fatalf("download.docker.com should be rejected: %#v", got)
}
}
+359 -201
View File
@@ -1,16 +1,19 @@
package main package handlers
import ( import (
"archive/tar" "archive/tar"
"compress/gzip" "compress/gzip"
"context" "context"
"crypto/md5" "crypto/md5"
"crypto/rand"
"encoding/base64"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log" "log"
"net/http" "net/http"
"net/url"
"sort" "sort"
"strings" "strings"
"sync" "sync"
@@ -23,6 +26,8 @@ import (
"github.com/google/go-containerregistry/pkg/v1/partial" "github.com/google/go-containerregistry/pkg/v1/partial"
"github.com/google/go-containerregistry/pkg/v1/remote" "github.com/google/go-containerregistry/pkg/v1/remote"
"github.com/google/go-containerregistry/pkg/v1/types" "github.com/google/go-containerregistry/pkg/v1/types"
"hubproxy/config"
"hubproxy/utils"
) )
// DebounceEntry 防抖条目 // DebounceEntry 防抖条目
@@ -36,6 +41,7 @@ type DownloadDebouncer struct {
mu sync.RWMutex mu sync.RWMutex
entries map[string]*DebounceEntry entries map[string]*DebounceEntry
window time.Duration window time.Duration
lastCleanup time.Time
} }
// NewDownloadDebouncer 创建下载防抖器 // NewDownloadDebouncer 创建下载防抖器
@@ -43,6 +49,7 @@ func NewDownloadDebouncer(window time.Duration) *DownloadDebouncer {
return &DownloadDebouncer{ return &DownloadDebouncer{
entries: make(map[string]*DebounceEntry), entries: make(map[string]*DebounceEntry),
window: window, window: window,
lastCleanup: time.Now(),
} }
} }
@@ -56,19 +63,18 @@ func (d *DownloadDebouncer) ShouldAllow(userID, contentKey string) bool {
if entry, exists := d.entries[key]; exists { if entry, exists := d.entries[key]; exists {
if now.Sub(entry.LastRequest) < d.window { if now.Sub(entry.LastRequest) < d.window {
return false // 在防抖窗口内,拒绝请求 return false
} }
} }
// 更新或创建条目
d.entries[key] = &DebounceEntry{ d.entries[key] = &DebounceEntry{
LastRequest: now, LastRequest: now,
UserID: userID, UserID: userID,
} }
// 清理过期条目(简单策略:每100次请求清理一次) if time.Since(d.lastCleanup) > 5*time.Minute {
if len(d.entries)%100 == 0 {
d.cleanup(now) d.cleanup(now)
d.lastCleanup = now
} }
return true return true
@@ -85,53 +91,145 @@ func (d *DownloadDebouncer) cleanup(now time.Time) {
// generateContentFingerprint 生成内容指纹 // generateContentFingerprint 生成内容指纹
func generateContentFingerprint(images []string, platform string) string { func generateContentFingerprint(images []string, platform string) string {
// 对镜像列表排序确保顺序无关
sortedImages := make([]string, len(images)) sortedImages := make([]string, len(images))
copy(sortedImages, images) copy(sortedImages, images)
sort.Strings(sortedImages) sort.Strings(sortedImages)
// 组合内容:镜像列表 + 平台信息
content := strings.Join(sortedImages, "|") + ":" + platform content := strings.Join(sortedImages, "|") + ":" + platform
// 生成MD5哈希
hash := md5.Sum([]byte(content)) hash := md5.Sum([]byte(content))
return hex.EncodeToString(hash[:]) return hex.EncodeToString(hash[:])
} }
// getUserID 获取用户标识 // getUserID 获取用户标识
func getUserID(c *gin.Context) string { func getUserID(c *gin.Context) string {
// 优先使用会话Cookie
if sessionID, err := c.Cookie("session_id"); err == nil && sessionID != "" { if sessionID, err := c.Cookie("session_id"); err == nil && sessionID != "" {
return "session:" + sessionID return "session:" + sessionID
} }
// 备用方案:IP + User-Agent组合
ip := c.ClientIP() ip := c.ClientIP()
userAgent := c.GetHeader("User-Agent") userAgent := c.GetHeader("User-Agent")
if userAgent == "" { if userAgent == "" {
userAgent = "unknown" userAgent = "unknown"
} }
// 生成简短标识
combined := ip + ":" + userAgent combined := ip + ":" + userAgent
hash := md5.Sum([]byte(combined)) hash := md5.Sum([]byte(combined))
return "ip:" + hex.EncodeToString(hash[:8]) // 只取前8字节 return "ip:" + hex.EncodeToString(hash[:8])
}
func getClientIdentity(c *gin.Context) (string, string) {
ip := c.ClientIP()
userAgent := c.GetHeader("User-Agent")
if userAgent == "" {
userAgent = "unknown"
}
return ip, userAgent
} }
// 全局防抖器实例
var ( var (
singleImageDebouncer *DownloadDebouncer singleImageDebouncer *DownloadDebouncer
batchImageDebouncer *DownloadDebouncer batchImageDebouncer *DownloadDebouncer
) )
// initDebouncer 初始化防抖器 // InitDebouncer 初始化防抖器
func initDebouncer() { func InitDebouncer() {
// 单个镜像:5秒防抖窗口
singleImageDebouncer = NewDownloadDebouncer(5 * time.Second) singleImageDebouncer = NewDownloadDebouncer(5 * time.Second)
// 批量镜像:30秒防抖窗口(影响更大,需要更长保护) batchImageDebouncer = NewDownloadDebouncer(60 * time.Second)
batchImageDebouncer = NewDownloadDebouncer(30 * time.Second)
} }
type BatchDownloadRequest struct {
Images []string
Platform string
UseCompressedLayers bool
}
type SingleDownloadRequest struct {
Image string
Platform string
UseCompressedLayers bool
}
type tokenEntry[T any] struct {
Request T
ExpiresAt time.Time
IP string
UserAgent string
}
type tokenStore[T any] struct {
mu sync.RWMutex
entries map[string]tokenEntry[T]
}
const downloadTokenTTL = 2 * time.Minute
const downloadTokenMaxEntries = 2000
func newTokenStore[T any]() *tokenStore[T] {
return &tokenStore[T]{
entries: make(map[string]tokenEntry[T]),
}
}
func (s *tokenStore[T]) create(req T, ip, userAgent string) (string, error) {
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {
return "", err
}
token := base64.RawURLEncoding.EncodeToString(tokenBytes)
now := time.Now()
entry := tokenEntry[T]{
Request: req,
ExpiresAt: now.Add(downloadTokenTTL),
IP: ip,
UserAgent: userAgent,
}
s.mu.Lock()
s.cleanup(now)
if len(s.entries) >= downloadTokenMaxEntries {
s.mu.Unlock()
return "", fmt.Errorf("令牌过多,请稍后再试")
}
s.entries[token] = entry
s.mu.Unlock()
return token, nil
}
func (s *tokenStore[T]) consume(token, ip, userAgent string) (T, bool) {
var empty T
now := time.Now()
s.mu.Lock()
defer s.mu.Unlock()
entry, exists := s.entries[token]
if !exists {
return empty, false
}
if now.After(entry.ExpiresAt) {
delete(s.entries, token)
return empty, false
}
if entry.IP != ip || entry.UserAgent != userAgent {
delete(s.entries, token)
return empty, false
}
delete(s.entries, token)
return entry.Request, true
}
func (s *tokenStore[T]) cleanup(now time.Time) {
for token, entry := range s.entries {
if now.After(entry.ExpiresAt) {
delete(s.entries, token)
}
}
}
var batchDownloadTokens = newTokenStore[BatchDownloadRequest]()
var singleDownloadTokens = newTokenStore[SingleDownloadRequest]()
// ImageStreamer 镜像流式下载器 // ImageStreamer 镜像流式下载器
type ImageStreamer struct { type ImageStreamer struct {
concurrency int concurrency int
@@ -144,15 +242,15 @@ type ImageStreamerConfig struct {
} }
// NewImageStreamer 创建镜像下载器 // NewImageStreamer 创建镜像下载器
func NewImageStreamer(config *ImageStreamerConfig) *ImageStreamer { func NewImageStreamer(cfg *ImageStreamerConfig) *ImageStreamer {
if config == nil { if cfg == nil {
config = &ImageStreamerConfig{} cfg = &ImageStreamerConfig{}
} }
concurrency := config.Concurrency concurrency := cfg.Concurrency
if concurrency <= 0 { if concurrency <= 0 {
cfg := GetConfig() appCfg := config.GetConfig()
concurrency = cfg.Download.MaxImages concurrency = appCfg.Download.MaxImages
if concurrency <= 0 { if concurrency <= 0 {
concurrency = 10 concurrency = 10
} }
@@ -160,7 +258,7 @@ func NewImageStreamer(config *ImageStreamerConfig) *ImageStreamer {
remoteOptions := []remote.Option{ remoteOptions := []remote.Option{
remote.WithAuth(authn.Anonymous), remote.WithAuth(authn.Anonymous),
remote.WithTransport(GetGlobalHTTPClient().Transport), remote.WithTransport(utils.GetGlobalHTTPClient().Transport),
} }
return &ImageStreamer{ return &ImageStreamer{
@@ -173,12 +271,13 @@ func NewImageStreamer(config *ImageStreamerConfig) *ImageStreamer {
type StreamOptions struct { type StreamOptions struct {
Platform string Platform string
Compression bool Compression bool
UseCompressedLayers bool
} }
// StreamImageToWriter 流式下载镜像到Writer // StreamImageToWriter 流式下载镜像到Writer
func (is *ImageStreamer) StreamImageToWriter(ctx context.Context, imageRef string, writer io.Writer, options *StreamOptions) error { func (is *ImageStreamer) StreamImageToWriter(ctx context.Context, imageRef string, writer io.Writer, options *StreamOptions) error {
if options == nil { if options == nil {
options = &StreamOptions{} options = &StreamOptions{UseCompressedLayers: true}
} }
ref, err := name.ParseReference(imageRef) ref, err := name.ParseReference(imageRef)
@@ -190,93 +289,77 @@ func (is *ImageStreamer) StreamImageToWriter(ctx context.Context, imageRef strin
contextOptions := append(is.remoteOptions, remote.WithContext(ctx)) contextOptions := append(is.remoteOptions, remote.WithContext(ctx))
desc, err := is.getImageDescriptorWithPlatform(ref, contextOptions, options.Platform) desc, err := is.getImageDescriptor(ref, contextOptions)
if err != nil { if err != nil {
return fmt.Errorf("获取镜像描述失败: %w", err) return fmt.Errorf("获取镜像描述失败: %w", err)
} }
switch desc.MediaType { switch desc.MediaType {
case types.OCIImageIndex, types.DockerManifestList: case types.OCIImageIndex, types.DockerManifestList:
return is.streamMultiArchImage(ctx, desc, writer, options, contextOptions, imageRef) return is.streamMultiArchImage(ctx, desc, writer, options, imageRef)
case types.OCIManifestSchema1, types.DockerManifestSchema2: case types.OCIManifestSchema1, types.DockerManifestSchema2:
return is.streamSingleImage(ctx, desc, writer, options, contextOptions, imageRef) return is.streamSingleImage(ctx, desc, writer, options, imageRef)
default: default:
return is.streamSingleImage(ctx, desc, writer, options, contextOptions, imageRef) return is.streamSingleImage(ctx, desc, writer, options, imageRef)
} }
} }
// getImageDescriptor 获取镜像描述符 // getImageDescriptor 获取镜像描述符
func (is *ImageStreamer) getImageDescriptor(ref name.Reference, options []remote.Option) (*remote.Descriptor, error) { func (is *ImageStreamer) getImageDescriptor(ref name.Reference, options []remote.Option) (*remote.Descriptor, error) {
return is.getImageDescriptorWithPlatform(ref, options, "") return remote.Get(ref, options...)
} }
// getImageDescriptorWithPlatform 获取指定平台的镜像描述符 func setDownloadHeaders(c *gin.Context, filename string, compressed bool) {
func (is *ImageStreamer) getImageDescriptorWithPlatform(ref name.Reference, options []remote.Option, platform string) (*remote.Descriptor, error) { c.Header("Content-Type", "application/octet-stream")
if isCacheEnabled() { c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
var reference string c.Header("Cache-Control", "no-store")
if tagged, ok := ref.(name.Tag); ok { c.Header("Pragma", "no-cache")
reference = tagged.TagStr() c.Header("Expires", "0")
} else if digested, ok := ref.(name.Digest); ok { if compressed {
reference = digested.DigestStr() c.Header("Content-Encoding", "gzip")
} }
}
if reference != "" { // writeDownloadError 仅在尚未写出响应体时返回 JSON;流已开始则只记日志,避免损坏 tar。
cacheKey := buildManifestCacheKeyWithPlatform(ref.Context().String(), reference, platform) func writeDownloadError(c *gin.Context, err error, message string) {
if cachedItem := globalCache.Get(cacheKey); cachedItem != nil { if c.Writer.Written() {
desc := &remote.Descriptor{ log.Printf("%s: %v", message, err)
Manifest: cachedItem.Data, return
} }
log.Printf("使用缓存的manifest: %s (平台: %s)", ref.String(), platform) c.JSON(http.StatusInternalServerError, gin.H{"error": message + ": " + err.Error()})
return desc, nil
}
}
}
desc, err := remote.Get(ref, options...)
if err != nil {
return nil, err
}
if isCacheEnabled() {
var reference string
if tagged, ok := ref.(name.Tag); ok {
reference = tagged.TagStr()
} else if digested, ok := ref.(name.Digest); ok {
reference = digested.DigestStr()
}
if reference != "" {
cacheKey := buildManifestCacheKeyWithPlatform(ref.Context().String(), reference, platform)
ttl := getManifestTTL(reference)
headers := map[string]string{
"Docker-Content-Digest": desc.Digest.String(),
}
globalCache.Set(cacheKey, desc.Manifest, string(desc.MediaType), headers, ttl)
log.Printf("缓存manifest: %s (平台: %s, TTL: %v)", ref.String(), platform, ttl)
}
}
return desc, nil
} }
// StreamImageToGin 流式响应到Gin // StreamImageToGin 流式响应到Gin
func (is *ImageStreamer) StreamImageToGin(ctx context.Context, imageRef string, c *gin.Context, options *StreamOptions) error { func (is *ImageStreamer) StreamImageToGin(ctx context.Context, imageRef string, c *gin.Context, options *StreamOptions) error {
if options == nil { if options == nil {
options = &StreamOptions{} options = &StreamOptions{UseCompressedLayers: true}
}
ref, err := name.ParseReference(imageRef)
if err != nil {
return fmt.Errorf("解析镜像引用失败: %w", err)
}
contextOptions := append(is.remoteOptions, remote.WithContext(ctx))
desc, err := is.getImageDescriptor(ref, contextOptions)
if err != nil {
return fmt.Errorf("获取镜像描述失败: %w", err)
} }
filename := strings.ReplaceAll(imageRef, "/", "_") + ".tar" filename := strings.ReplaceAll(imageRef, "/", "_") + ".tar"
c.Header("Content-Type", "application/octet-stream") setDownloadHeaders(c, filename, options.Compression)
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
if options.Compression { switch desc.MediaType {
c.Header("Content-Encoding", "gzip") case types.OCIImageIndex, types.DockerManifestList:
return is.streamMultiArchImage(ctx, desc, c.Writer, options, imageRef)
case types.OCIManifestSchema1, types.DockerManifestSchema2:
return is.streamSingleImage(ctx, desc, c.Writer, options, imageRef)
default:
return is.streamSingleImage(ctx, desc, c.Writer, options, imageRef)
} }
return is.StreamImageToWriter(ctx, imageRef, c.Writer, options)
} }
// streamMultiArchImage 处理多架构镜像 // streamMultiArchImage 处理多架构镜像
func (is *ImageStreamer) streamMultiArchImage(ctx context.Context, desc *remote.Descriptor, writer io.Writer, options *StreamOptions, remoteOptions []remote.Option, imageRef string) error { func (is *ImageStreamer) streamMultiArchImage(ctx context.Context, desc *remote.Descriptor, writer io.Writer, options *StreamOptions, imageRef string) error {
img, err := is.selectPlatformImage(desc, options) img, err := is.selectPlatformImage(desc, options)
if err != nil { if err != nil {
return err return err
@@ -286,7 +369,7 @@ func (is *ImageStreamer) streamMultiArchImage(ctx context.Context, desc *remote.
} }
// streamSingleImage 处理单架构镜像 // streamSingleImage 处理单架构镜像
func (is *ImageStreamer) streamSingleImage(ctx context.Context, desc *remote.Descriptor, writer io.Writer, options *StreamOptions, remoteOptions []remote.Option, imageRef string) error { func (is *ImageStreamer) streamSingleImage(ctx context.Context, desc *remote.Descriptor, writer io.Writer, options *StreamOptions, imageRef string) error {
img, err := desc.Image() img, err := desc.Image()
if err != nil { if err != nil {
return fmt.Errorf("获取镜像失败: %w", err) return fmt.Errorf("获取镜像失败: %w", err)
@@ -320,16 +403,16 @@ func (is *ImageStreamer) streamImageLayers(ctx context.Context, img v1.Image, wr
log.Printf("镜像包含 %d 层", len(layers)) log.Printf("镜像包含 %d 层", len(layers))
return is.streamDockerFormat(ctx, tarWriter, img, layers, configFile, imageRef) return is.streamDockerFormat(ctx, tarWriter, img, layers, configFile, imageRef, options)
} }
// streamDockerFormat 生成Docker格式 // streamDockerFormat 生成Docker格式
func (is *ImageStreamer) streamDockerFormat(ctx context.Context, tarWriter *tar.Writer, img v1.Image, layers []v1.Layer, configFile *v1.ConfigFile, imageRef string) error { func (is *ImageStreamer) streamDockerFormat(ctx context.Context, tarWriter *tar.Writer, img v1.Image, layers []v1.Layer, configFile *v1.ConfigFile, imageRef string, options *StreamOptions) error {
return is.streamDockerFormatWithReturn(ctx, tarWriter, img, layers, configFile, imageRef, nil, nil) return is.streamDockerFormatWithReturn(ctx, tarWriter, img, layers, configFile, imageRef, nil, nil, options)
} }
// streamDockerFormatWithReturn 生成Docker格式并返回manifest和repositories信息 // streamDockerFormatWithReturn 生成Docker格式并返回manifest和repositories信息
func (is *ImageStreamer) streamDockerFormatWithReturn(ctx context.Context, tarWriter *tar.Writer, img v1.Image, layers []v1.Layer, configFile *v1.ConfigFile, imageRef string, manifestOut *map[string]interface{}, repositoriesOut *map[string]map[string]string) error { func (is *ImageStreamer) streamDockerFormatWithReturn(ctx context.Context, tarWriter *tar.Writer, img v1.Image, layers []v1.Layer, configFile *v1.ConfigFile, imageRef string, manifestOut *map[string]interface{}, repositoriesOut *map[string]map[string]string, options *StreamOptions) error {
configDigest, err := img.ConfigName() configDigest, err := img.ConfigName()
if err != nil { if err != nil {
return err return err
@@ -379,12 +462,23 @@ func (is *ImageStreamer) streamDockerFormatWithReturn(ctx context.Context, tarWr
return err return err
} }
uncompressedSize, err := partial.UncompressedSize(layer) var layerSize int64
var layerReader io.ReadCloser
if options != nil && options.UseCompressedLayers {
layerSize, err = layer.Size()
if err != nil { if err != nil {
return err return err
} }
layerReader, err = layer.Compressed()
} else {
layerSize, err = partial.UncompressedSize(layer)
if err != nil {
return err
}
layerReader, err = layer.Uncompressed()
}
layerReader, err := layer.Uncompressed()
if err != nil { if err != nil {
return err return err
} }
@@ -392,7 +486,7 @@ func (is *ImageStreamer) streamDockerFormatWithReturn(ctx context.Context, tarWr
layerTarHeader := &tar.Header{ layerTarHeader := &tar.Header{
Name: layerDir + "/layer.tar", Name: layerDir + "/layer.tar",
Size: uncompressedSize, Size: layerSize,
Mode: 0644, Mode: 0644,
} }
@@ -412,8 +506,6 @@ func (is *ImageStreamer) streamDockerFormatWithReturn(ctx context.Context, tarWr
log.Printf("已处理层 %d/%d", i+1, len(layers)) log.Printf("已处理层 %d/%d", i+1, len(layers))
} }
// 构建单个镜像的manifest信息
singleManifest := map[string]interface{}{ singleManifest := map[string]interface{}{
"Config": configDigest.String() + ".json", "Config": configDigest.String() + ".json",
"RepoTags": []string{imageRef}, "RepoTags": []string{imageRef},
@@ -426,7 +518,6 @@ func (is *ImageStreamer) streamDockerFormatWithReturn(ctx context.Context, tarWr
}(), }(),
} }
// 构建repositories信息
repositories := make(map[string]map[string]string) repositories := make(map[string]map[string]string)
parts := strings.Split(imageRef, ":") parts := strings.Split(imageRef, ":")
if len(parts) == 2 { if len(parts) == 2 {
@@ -435,14 +526,12 @@ func (is *ImageStreamer) streamDockerFormatWithReturn(ctx context.Context, tarWr
repositories[repoName] = map[string]string{tag: configDigest.String()} repositories[repoName] = map[string]string{tag: configDigest.String()}
} }
// 如果是批量下载,返回信息而不写入文件
if manifestOut != nil && repositoriesOut != nil { if manifestOut != nil && repositoriesOut != nil {
*manifestOut = singleManifest *manifestOut = singleManifest
*repositoriesOut = repositories *repositoriesOut = repositories
return nil return nil
} }
// 单镜像下载,直接写入manifest.json
manifest := []map[string]interface{}{singleManifest} manifest := []map[string]interface{}{singleManifest}
manifestData, err := json.Marshal(manifest) manifestData, err := json.Marshal(manifest)
@@ -464,7 +553,6 @@ func (is *ImageStreamer) streamDockerFormatWithReturn(ctx context.Context, tarWr
return err return err
} }
// 写入repositories文件
repositoriesData, err := json.Marshal(repositories) repositoriesData, err := json.Marshal(repositories)
if err != nil { if err != nil {
return err return err
@@ -484,7 +572,31 @@ func (is *ImageStreamer) streamDockerFormatWithReturn(ctx context.Context, tarWr
return err return err
} }
// streamSingleImageForBatch 为批量下载流式处理单个镜像 // processImageForBatch 处理镜像的公共逻辑
func (is *ImageStreamer) processImageForBatch(ctx context.Context, img v1.Image, tarWriter *tar.Writer, imageRef string, options *StreamOptions) (map[string]interface{}, map[string]map[string]string, error) {
layers, err := img.Layers()
if err != nil {
return nil, nil, fmt.Errorf("获取镜像层失败: %w", err)
}
configFile, err := img.ConfigFile()
if err != nil {
return nil, nil, fmt.Errorf("获取镜像配置失败: %w", err)
}
log.Printf("镜像包含 %d 层", len(layers))
var manifest map[string]interface{}
var repositories map[string]map[string]string
err = is.streamDockerFormatWithReturn(ctx, tarWriter, img, layers, configFile, imageRef, &manifest, &repositories, options)
if err != nil {
return nil, nil, err
}
return manifest, repositories, nil
}
func (is *ImageStreamer) streamSingleImageForBatch(ctx context.Context, tarWriter *tar.Writer, imageRef string, options *StreamOptions) (map[string]interface{}, map[string]map[string]string, error) { func (is *ImageStreamer) streamSingleImageForBatch(ctx context.Context, tarWriter *tar.Writer, imageRef string, options *StreamOptions) (map[string]interface{}, map[string]map[string]string, error) {
ref, err := name.ParseReference(imageRef) ref, err := name.ParseReference(imageRef)
if err != nil { if err != nil {
@@ -493,85 +605,32 @@ func (is *ImageStreamer) streamSingleImageForBatch(ctx context.Context, tarWrite
contextOptions := append(is.remoteOptions, remote.WithContext(ctx)) contextOptions := append(is.remoteOptions, remote.WithContext(ctx))
desc, err := is.getImageDescriptorWithPlatform(ref, contextOptions, options.Platform) desc, err := is.getImageDescriptor(ref, contextOptions)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("获取镜像描述失败: %w", err) return nil, nil, fmt.Errorf("获取镜像描述失败: %w", err)
} }
var manifest map[string]interface{} var img v1.Image
var repositories map[string]map[string]string
switch desc.MediaType { switch desc.MediaType {
case types.OCIImageIndex, types.DockerManifestList: case types.OCIImageIndex, types.DockerManifestList:
// 处理多架构镜像,复用单个下载的逻辑 img, err = is.selectPlatformImage(desc, options)
img, err := is.selectPlatformImage(desc, options)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("选择平台镜像失败: %w", err) return nil, nil, fmt.Errorf("选择平台镜像失败: %w", err)
} }
layers, err := img.Layers()
if err != nil {
return nil, nil, fmt.Errorf("获取镜像层失败: %w", err)
}
configFile, err := img.ConfigFile()
if err != nil {
return nil, nil, fmt.Errorf("获取镜像配置失败: %w", err)
}
log.Printf("镜像包含 %d 层", len(layers))
err = is.streamDockerFormatWithReturn(ctx, tarWriter, img, layers, configFile, imageRef, &manifest, &repositories)
if err != nil {
return nil, nil, err
}
case types.OCIManifestSchema1, types.DockerManifestSchema2: case types.OCIManifestSchema1, types.DockerManifestSchema2:
img, err := desc.Image() img, err = desc.Image()
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("获取镜像失败: %w", err) return nil, nil, fmt.Errorf("获取镜像失败: %w", err)
} }
layers, err := img.Layers()
if err != nil {
return nil, nil, fmt.Errorf("获取镜像层失败: %w", err)
}
configFile, err := img.ConfigFile()
if err != nil {
return nil, nil, fmt.Errorf("获取镜像配置失败: %w", err)
}
log.Printf("镜像包含 %d 层", len(layers))
err = is.streamDockerFormatWithReturn(ctx, tarWriter, img, layers, configFile, imageRef, &manifest, &repositories)
if err != nil {
return nil, nil, err
}
default: default:
img, err := desc.Image() img, err = desc.Image()
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("获取镜像失败: %w", err) return nil, nil, fmt.Errorf("获取镜像失败: %w", err)
} }
layers, err := img.Layers()
if err != nil {
return nil, nil, fmt.Errorf("获取镜像层失败: %w", err)
} }
configFile, err := img.ConfigFile() return is.processImageForBatch(ctx, img, tarWriter, imageRef, options)
if err != nil {
return nil, nil, fmt.Errorf("获取镜像配置失败: %w", err)
}
log.Printf("镜像包含 %d 层", len(layers))
err = is.streamDockerFormatWithReturn(ctx, tarWriter, img, layers, configFile, imageRef, &manifest, &repositories)
if err != nil {
return nil, nil, err
}
}
return manifest, repositories, nil
} }
// selectPlatformImage 从多架构镜像中选择合适的平台镜像 // selectPlatformImage 从多架构镜像中选择合适的平台镜像
@@ -586,7 +645,6 @@ func (is *ImageStreamer) selectPlatformImage(desc *remote.Descriptor, options *S
return nil, fmt.Errorf("获取索引清单失败: %w", err) return nil, fmt.Errorf("获取索引清单失败: %w", err)
} }
// 选择合适的平台
var selectedDesc *v1.Descriptor var selectedDesc *v1.Descriptor
for _, m := range manifest.Manifests { for _, m := range manifest.Manifests {
if m.Platform == nil { if m.Platform == nil {
@@ -634,10 +692,9 @@ func (is *ImageStreamer) selectPlatformImage(desc *remote.Descriptor, options *S
var globalImageStreamer *ImageStreamer var globalImageStreamer *ImageStreamer
// initImageStreamer 初始化镜像下载器 // InitImageStreamer 初始化镜像下载器
func initImageStreamer() { func InitImageStreamer() {
globalImageStreamer = NewImageStreamer(nil) globalImageStreamer = NewImageStreamer(nil)
// 镜像下载器初始化完成
} }
// formatPlatformText 格式化平台文本 // formatPlatformText 格式化平台文本
@@ -648,27 +705,33 @@ func formatPlatformText(platform string) string {
return platform return platform
} }
// initImageTarRoutes 初始化镜像下载路由 // InitImageTarRoutes 初始化镜像下载路由
func initImageTarRoutes(router *gin.Engine) { func InitImageTarRoutes(router *gin.Engine) {
imageAPI := router.Group("/api/image") imageAPI := router.Group("/api/image")
{ {
imageAPI.GET("/download/:image", RateLimitMiddleware(globalLimiter), handleDirectImageDownload) imageAPI.GET("/download", handleDirectImageDownload)
imageAPI.GET("/info/:image", RateLimitMiddleware(globalLimiter), handleImageInfo) imageAPI.GET("/info", handleImageInfo)
imageAPI.POST("/batch", RateLimitMiddleware(globalLimiter), handleSimpleBatchDownload) imageAPI.GET("/batch", handleSimpleBatchDownload)
imageAPI.POST("/batch", handleSimpleBatchDownload)
} }
} }
// resolveImageRef 从 query image 读取镜像引用,避免 path 段用 _ 代替 / 导致下划线歧义。
func resolveImageRef(c *gin.Context) string {
return strings.TrimSpace(c.Query("image"))
}
// handleDirectImageDownload 处理单镜像下载 // handleDirectImageDownload 处理单镜像下载
func handleDirectImageDownload(c *gin.Context) { func handleDirectImageDownload(c *gin.Context) {
imageParam := c.Param("image") imageRef := resolveImageRef(c)
if imageParam == "" { if imageRef == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少镜像参数"}) c.JSON(http.StatusBadRequest, gin.H{"error": "缺少镜像参数"})
return return
} }
imageRef := strings.ReplaceAll(imageParam, "_", "/")
platform := c.Query("platform") platform := c.Query("platform")
tag := c.DefaultQuery("tag", "") tag := c.DefaultQuery("tag", "")
useCompressed := c.DefaultQuery("compressed", "true") == "true"
if tag != "" && !strings.Contains(imageRef, ":") && !strings.Contains(imageRef, "@") { if tag != "" && !strings.Contains(imageRef, ":") && !strings.Contains(imageRef, "@") {
imageRef = imageRef + ":" + tag imageRef = imageRef + ":" + tag
@@ -680,8 +743,12 @@ func handleDirectImageDownload(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "镜像引用格式错误: " + err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": "镜像引用格式错误: " + err.Error()})
return return
} }
if allowed, reason := utils.GlobalAccessController.CheckDockerAccess(imageRef); !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": reason})
return
}
// 防抖检查 if c.Query("mode") == "prepare" {
userID := getUserID(c) userID := getUserID(c)
contentKey := generateContentFingerprint([]string{imageRef}, platform) contentKey := generateContentFingerprint([]string{imageRef}, platform)
@@ -693,26 +760,109 @@ func handleDirectImageDownload(c *gin.Context) {
return return
} }
options := &StreamOptions{ ip, userAgent := getClientIdentity(c)
token, err := singleDownloadTokens.create(SingleDownloadRequest{
Image: imageRef,
Platform: platform, Platform: platform,
UseCompressedLayers: useCompressed,
}, ip, userAgent)
if err != nil {
c.JSON(http.StatusTooManyRequests, gin.H{"error": err.Error()})
return
}
q := url.Values{}
q.Set("image", imageRef)
q.Set("token", token)
c.JSON(http.StatusOK, gin.H{"download_url": "/api/image/download?" + q.Encode()})
return
}
token := c.Query("token")
if token == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少下载令牌"})
return
}
ip, userAgent := getClientIdentity(c)
req, ok := singleDownloadTokens.consume(token, ip, userAgent)
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效或过期的下载令牌"})
return
}
if req.Image != imageRef {
c.JSON(http.StatusBadRequest, gin.H{"error": "下载令牌与镜像不匹配"})
return
}
if allowed, reason := utils.GlobalAccessController.CheckDockerAccess(req.Image); !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": reason})
return
}
options := &StreamOptions{
Platform: req.Platform,
Compression: false, Compression: false,
UseCompressedLayers: req.UseCompressedLayers,
} }
ctx := c.Request.Context() ctx := c.Request.Context()
log.Printf("下载镜像: %s (平台: %s)", imageRef, formatPlatformText(platform)) log.Printf("下载镜像: %s (平台: %s)", req.Image, formatPlatformText(req.Platform))
if err := globalImageStreamer.StreamImageToGin(ctx, imageRef, c, options); err != nil { if err := globalImageStreamer.StreamImageToGin(ctx, req.Image, c, options); err != nil {
log.Printf("镜像下载失败: %v", err) writeDownloadError(c, err, "镜像下载失败")
c.JSON(http.StatusInternalServerError, gin.H{"error": "镜像下载失败: " + err.Error()})
return return
} }
} }
// handleSimpleBatchDownload 处理批量下载 // handleSimpleBatchDownload 处理批量下载
func handleSimpleBatchDownload(c *gin.Context) { func handleSimpleBatchDownload(c *gin.Context) {
if c.Request.Method == http.MethodGet {
token := c.Query("token")
if token == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少下载令牌"})
return
}
ip, userAgent := getClientIdentity(c)
req, ok := batchDownloadTokens.consume(token, ip, userAgent)
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效或过期的下载令牌"})
return
}
if len(req.Images) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "镜像列表不能为空"})
return
}
options := &StreamOptions{
Platform: req.Platform,
Compression: false,
UseCompressedLayers: req.UseCompressedLayers,
}
ctx := c.Request.Context()
log.Printf("批量下载 %d 个镜像 (平台: %s)", len(req.Images), formatPlatformText(req.Platform))
filename := fmt.Sprintf("batch_%d_images.tar", len(req.Images))
setDownloadHeaders(c, filename, options.Compression)
if err := globalImageStreamer.StreamMultipleImages(ctx, req.Images, c.Writer, options); err != nil {
writeDownloadError(c, err, "批量镜像下载失败")
return
}
return
}
if c.Query("mode") != "prepare" {
c.JSON(http.StatusBadRequest, gin.H{"error": "只支持prepare模式"})
return
}
var req struct { var req struct {
Images []string `json:"images" binding:"required"` Images []string `json:"images" binding:"required"`
Platform string `json:"platform"` Platform string `json:"platform"`
UseCompressedLayers *bool `json:"useCompressedLayers"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@@ -724,14 +874,26 @@ func handleSimpleBatchDownload(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "镜像列表不能为空"}) c.JSON(http.StatusBadRequest, gin.H{"error": "镜像列表不能为空"})
return return
} }
for _, imageRef := range req.Images {
if allowed, reason := utils.GlobalAccessController.CheckDockerAccess(imageRef); !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": reason})
return
}
}
for i, imageRef := range req.Images { for i, imageRef := range req.Images {
if !strings.Contains(imageRef, ":") && !strings.Contains(imageRef, "@") { if !strings.Contains(imageRef, ":") && !strings.Contains(imageRef, "@") {
req.Images[i] = imageRef + ":latest" req.Images[i] = imageRef + ":latest"
} }
} }
for _, imageRef := range req.Images {
if allowed, reason := utils.GlobalAccessController.CheckDockerAccess(imageRef); !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": reason})
return
}
}
cfg := GetConfig() cfg := config.GetConfig()
if len(req.Images) > cfg.Download.MaxImages { if len(req.Images) > cfg.Download.MaxImages {
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("镜像数量超过限制,最大允许: %d", cfg.Download.MaxImages), "error": fmt.Sprintf("镜像数量超过限制,最大允许: %d", cfg.Download.MaxImages),
@@ -739,47 +901,45 @@ func handleSimpleBatchDownload(c *gin.Context) {
return return
} }
// 批量下载防抖检查
userID := getUserID(c) userID := getUserID(c)
contentKey := generateContentFingerprint(req.Images, req.Platform) contentKey := generateContentFingerprint(req.Images, req.Platform)
if !batchImageDebouncer.ShouldAllow(userID, contentKey) { if !batchImageDebouncer.ShouldAllow(userID, contentKey) {
c.JSON(http.StatusTooManyRequests, gin.H{ c.JSON(http.StatusTooManyRequests, gin.H{
"error": "批量下载请求过于频繁,请稍后再试", "error": "批量下载请求过于频繁,请稍后再试",
"retry_after": 30, "retry_after": 60,
}) })
return return
} }
options := &StreamOptions{ useCompressed := true
Platform: req.Platform, if req.UseCompressedLayers != nil {
Compression: false, useCompressed = *req.UseCompressedLayers
} }
ctx := c.Request.Context() batchReq := BatchDownloadRequest{
log.Printf("批量下载 %d 个镜像 (平台: %s)", len(req.Images), formatPlatformText(req.Platform)) Images: req.Images,
Platform: req.Platform,
UseCompressedLayers: useCompressed,
}
filename := fmt.Sprintf("batch_%d_images.tar", len(req.Images)) ip, userAgent := getClientIdentity(c)
token, err := batchDownloadTokens.create(batchReq, ip, userAgent)
c.Header("Content-Type", "application/octet-stream") if err != nil {
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) c.JSON(http.StatusTooManyRequests, gin.H{"error": err.Error()})
if err := globalImageStreamer.StreamMultipleImages(ctx, req.Images, c.Writer, options); err != nil {
log.Printf("批量镜像下载失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "批量镜像下载失败: " + err.Error()})
return return
} }
c.JSON(http.StatusOK, gin.H{"download_url": fmt.Sprintf("/api/image/batch?token=%s", token)})
} }
// handleImageInfo 处理镜像信息查询 // handleImageInfo 处理镜像信息查询
func handleImageInfo(c *gin.Context) { func handleImageInfo(c *gin.Context) {
imageParam := c.Param("image") imageRef := resolveImageRef(c)
if imageParam == "" { if imageRef == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少镜像参数"}) c.JSON(http.StatusBadRequest, gin.H{"error": "缺少镜像参数"})
return return
} }
imageRef := strings.ReplaceAll(imageParam, "_", "/")
tag := c.DefaultQuery("tag", "latest") tag := c.DefaultQuery("tag", "latest")
if !strings.Contains(imageRef, ":") && !strings.Contains(imageRef, "@") { if !strings.Contains(imageRef, ":") && !strings.Contains(imageRef, "@") {
@@ -791,6 +951,10 @@ func handleImageInfo(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "镜像引用格式错误: " + err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": "镜像引用格式错误: " + err.Error()})
return return
} }
if allowed, reason := utils.GlobalAccessController.CheckDockerAccess(imageRef); !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": reason})
return
}
ctx := c.Request.Context() ctx := c.Request.Context()
contextOptions := append(globalImageStreamer.remoteOptions, remote.WithContext(ctx)) contextOptions := append(globalImageStreamer.remoteOptions, remote.WithContext(ctx))
@@ -833,7 +997,7 @@ func handleImageInfo(c *gin.Context) {
// StreamMultipleImages 批量下载多个镜像 // StreamMultipleImages 批量下载多个镜像
func (is *ImageStreamer) StreamMultipleImages(ctx context.Context, imageRefs []string, writer io.Writer, options *StreamOptions) error { func (is *ImageStreamer) StreamMultipleImages(ctx context.Context, imageRefs []string, writer io.Writer, options *StreamOptions) error {
if options == nil { if options == nil {
options = &StreamOptions{} options = &StreamOptions{UseCompressedLayers: true}
} }
var finalWriter io.Writer = writer var finalWriter io.Writer = writer
@@ -849,7 +1013,6 @@ func (is *ImageStreamer) StreamMultipleImages(ctx context.Context, imageRefs []s
var allManifests []map[string]interface{} var allManifests []map[string]interface{}
var allRepositories = make(map[string]map[string]string) var allRepositories = make(map[string]map[string]string)
// 流式处理每个镜像
for i, imageRef := range imageRefs { for i, imageRef := range imageRefs {
select { select {
case <-ctx.Done(): case <-ctx.Done():
@@ -859,7 +1022,6 @@ func (is *ImageStreamer) StreamMultipleImages(ctx context.Context, imageRefs []s
log.Printf("处理镜像 %d/%d: %s", i+1, len(imageRefs), imageRef) log.Printf("处理镜像 %d/%d: %s", i+1, len(imageRefs), imageRef)
// 防止单个镜像处理时间过长
timeoutCtx, cancel := context.WithTimeout(ctx, 15*time.Minute) timeoutCtx, cancel := context.WithTimeout(ctx, 15*time.Minute)
manifest, repositories, err := is.streamSingleImageForBatch(timeoutCtx, tarWriter, imageRef, options) manifest, repositories, err := is.streamSingleImageForBatch(timeoutCtx, tarWriter, imageRef, options)
cancel() cancel()
@@ -873,10 +1035,8 @@ func (is *ImageStreamer) StreamMultipleImages(ctx context.Context, imageRefs []s
return fmt.Errorf("镜像 %s manifest数据为空", imageRef) return fmt.Errorf("镜像 %s manifest数据为空", imageRef)
} }
// 收集manifest信息
allManifests = append(allManifests, manifest) allManifests = append(allManifests, manifest)
// 合并repositories信息
for repo, tags := range repositories { for repo, tags := range repositories {
if allRepositories[repo] == nil { if allRepositories[repo] == nil {
allRepositories[repo] = make(map[string]string) allRepositories[repo] = make(map[string]string)
@@ -887,7 +1047,6 @@ func (is *ImageStreamer) StreamMultipleImages(ctx context.Context, imageRefs []s
} }
} }
// 写入合并的manifest.json
manifestData, err := json.Marshal(allManifests) manifestData, err := json.Marshal(allManifests)
if err != nil { if err != nil {
return fmt.Errorf("序列化manifest失败: %w", err) return fmt.Errorf("序列化manifest失败: %w", err)
@@ -907,7 +1066,6 @@ func (is *ImageStreamer) StreamMultipleImages(ctx context.Context, imageRefs []s
return fmt.Errorf("写入manifest数据失败: %w", err) return fmt.Errorf("写入manifest数据失败: %w", err)
} }
// 写入合并的repositories文件
repositoriesData, err := json.Marshal(allRepositories) repositoriesData, err := json.Marshal(allRepositories)
if err != nil { if err != nil {
return fmt.Errorf("序列化repositories失败: %w", err) return fmt.Errorf("序列化repositories失败: %w", err)
+115
View File
@@ -0,0 +1,115 @@
package handlers
import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
)
func TestDownloadDebouncer(t *testing.T) {
d := NewDownloadDebouncer(time.Minute)
if !d.ShouldAllow("user", "content") {
t.Fatal("first request denied")
}
if d.ShouldAllow("user", "content") {
t.Fatal("duplicate request allowed")
}
if !d.ShouldAllow("other", "content") {
t.Fatal("different user denied")
}
}
func TestTokenStoreCreateConsume(t *testing.T) {
store := newTokenStore[SingleDownloadRequest]()
req := SingleDownloadRequest{Image: "nginx:latest", Platform: "linux/amd64", UseCompressedLayers: true}
token, err := store.create(req, "127.0.0.1", "ua")
if err != nil {
t.Fatal(err)
}
got, ok := store.consume(token, "127.0.0.1", "ua")
if !ok {
t.Fatal("token not consumed")
}
if got != req {
t.Fatalf("request = %#v, want %#v", got, req)
}
if _, ok := store.consume(token, "127.0.0.1", "ua"); ok {
t.Fatal("token consumed twice")
}
}
func TestTokenStoreRejectsDifferentClient(t *testing.T) {
store := newTokenStore[SingleDownloadRequest]()
token, err := store.create(SingleDownloadRequest{Image: "nginx:latest"}, "127.0.0.1", "ua")
if err != nil {
t.Fatal(err)
}
if _, ok := store.consume(token, "127.0.0.2", "ua"); ok {
t.Fatal("token accepted for different IP")
}
}
func TestGenerateContentFingerprintStable(t *testing.T) {
a := generateContentFingerprint([]string{"b:1", "a:1"}, "linux/amd64")
b := generateContentFingerprint([]string{"a:1", "b:1"}, "linux/amd64")
c := generateContentFingerprint([]string{"a:1", "b:1"}, "linux/arm64")
if a != b || a == c {
t.Fatalf("unexpected fingerprints: %q %q %q", a, b, c)
}
}
func TestResolveImageRef(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Run("query preserves underscores", func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/api/image/download?image=user/my_app:v1", nil)
if got := resolveImageRef(c); got != "user/my_app:v1" {
t.Fatalf("got %q", got)
}
})
t.Run("missing image is empty", func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/api/image/download", nil)
if got := resolveImageRef(c); got != "" {
t.Fatalf("got %q", got)
}
})
}
func TestWriteDownloadErrorSkipsJSONAfterBodyStarted(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Run("before write returns json", func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
writeDownloadError(c, errors.New("boom"), "镜像下载失败")
if w.Code != http.StatusInternalServerError {
t.Fatalf("status = %d", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "镜像下载失败") || !strings.Contains(body, "boom") {
t.Fatalf("body = %q", body)
}
})
t.Run("after write skips json", func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
if _, err := c.Writer.Write([]byte("tar-bytes")); err != nil {
t.Fatal(err)
}
writeDownloadError(c, errors.New("boom"), "镜像下载失败")
if got := w.Body.String(); got != "tar-bytes" {
t.Fatalf("body corrupted: %q", got)
}
})
}
+511
View File
@@ -0,0 +1,511 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"hubproxy/utils"
)
// 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 注册搜索与标签相关 API 路由。
func RegisterSearchRoute(r *gin.Engine) {
r.GET("/api/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("/api/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
}
c.JSON(http.StatusOK, TagPageResult{
Tags: tags,
HasMore: hasMore,
})
})
}
+45
View File
@@ -0,0 +1,45 @@
package handlers
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
)
func TestNormalizeRepository(t *testing.T) {
official := &Repository{Name: "nginx", IsOfficial: true}
normalizeRepository(official)
if official.Namespace != "library" || official.Name != "library/nginx" {
t.Fatalf("official normalized to %#v", official)
}
userRepo := &Repository{Name: "owner/app", RepoOwner: "owner"}
normalizeRepository(userRepo)
if userRepo.Namespace != "owner" || userRepo.Name != "app" {
t.Fatalf("user repo normalized to %#v", userRepo)
}
}
func TestParsePaginationParams(t *testing.T) {
gin.SetMode(gin.TestMode)
req := httptest.NewRequest(http.MethodGet, "/?page=3&page_size=50", nil)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = req
page, pageSize := parsePaginationParams(c, 25)
if page != 3 || pageSize != 50 {
t.Fatalf("pagination = %d %d", page, pageSize)
}
}
func TestSearchCacheExpires(t *testing.T) {
cache := &Cache{data: make(map[string]cacheEntry), maxSize: 10}
cache.SetWithTTL("k", "v", -time.Second)
if got, ok := cache.Get("k"); ok || got != nil {
t.Fatalf("expired cache returned: %#v", got)
}
}
+146 -328
View File
@@ -3,379 +3,197 @@ package main
import ( import (
"embed" "embed"
"fmt" "fmt"
"io"
"log" "log"
"mime"
"net/http" "net/http"
"regexp" "path"
"strconv"
"strings" "strings"
"time" "time"
"github.com/gin-gonic/gin" "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/* //go:embed all:dist
var staticFiles embed.FS 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 ( var (
exps = []*regexp.Regexp{ globalLimiter *utils.IPRateLimiter
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() serviceStartTime = time.Now()
) )
func main() { var Version = "dev"
// 加载配置
if err := LoadConfig(); err != nil { func init() {
fmt.Printf("配置加载失败: %v\n", err) for ext, typ := range map[string]string{
".js": "application/javascript; charset=utf-8",
".mjs": "application/javascript; charset=utf-8",
".woff": "font/woff",
".woff2": "font/woff2",
".map": "application/json",
} {
_ = mime.AddExtensionType(ext, typ)
}
}
func contentTypeFor(filename string) string {
if ct := mime.TypeByExtension(path.Ext(filename)); ct != "" {
return ct
}
return "application/octet-stream"
}
func serveEmbedFile(c *gin.Context, filename string) {
data, err := staticFiles.ReadFile(filename)
if err != nil {
c.Status(http.StatusNotFound)
return
}
c.Data(http.StatusOK, contentTypeFor(filename), data)
}
func serveSPA(c *gin.Context) {
serveEmbedFile(c, "dist/index.html")
}
func registerFrontendRoutes(router *gin.Engine, enabled bool) {
if !enabled {
notFound := func(c *gin.Context) { c.Status(http.StatusNotFound) }
router.GET("/", notFound)
router.GET("/images", notFound)
router.GET("/search", notFound)
router.GET("/assets/*filepath", notFound)
router.GET("/favicon.ico", notFound)
return return
} }
// 初始化HTTP客户端 router.GET("/", serveSPA)
initHTTPClients() router.GET("/images", serveSPA)
router.GET("/search", serveSPA)
// 初始化限流器 router.GET("/favicon.ico", func(c *gin.Context) {
initLimiter() serveEmbedFile(c, "dist/favicon.ico")
})
// 初始化Docker流式代理 router.GET("/assets/*filepath", func(c *gin.Context) {
initDockerProxy() filepath := strings.TrimPrefix(c.Param("filepath"), "/")
if filepath == "" || strings.Contains(filepath, "..") {
// 初始化镜像流式下载器 c.Status(http.StatusNotFound)
initImageStreamer() return
}
// 初始化防抖器 serveEmbedFile(c, path.Join("dist/assets", filepath))
initDebouncer() })
}
func buildRouter(cfg *config.AppConfig) *gin.Engine {
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
router := gin.Default() router := gin.Default()
utils.ConfigureTrustedProxies(router)
// 全局Panic恢复保护
router.Use(gin.CustomRecovery(func(c *gin.Context, recovered interface{}) { router.Use(gin.CustomRecovery(func(c *gin.Context, recovered interface{}) {
log.Printf("🚨 Panic recovered: %v", recovered) log.Printf("Panic 已恢复: %v", recovered)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"error": "Internal server error", "error": "Internal server error",
"code": "INTERNAL_ERROR", "code": "INTERNAL_ERROR",
}) })
})) }))
// 初始化监控端点 router.Use(utils.RateLimitMiddleware(globalLimiter))
initHealthRoutes(router) initHealthRoutes(router)
handlers.InitImageTarRoutes(router)
registerFrontendRoutes(router, cfg.Server.EnableFrontend)
handlers.RegisterSearchRoute(router)
// 初始化镜像tar下载路由 router.Any("/token", handlers.ProxyDockerAuthGin)
initImageTarRoutes(router) router.Any("/token/*path", handlers.ProxyDockerAuthGin)
router.Any("/v2/*path", handlers.ProxyDockerRegistryGin)
router.NoRoute(handlers.GitHubProxyHandler)
// 静态文件路由 return router
router.GET("/", func(c *gin.Context) { }
serveEmbedFile(c, "public/index.html")
}) func main() {
router.GET("/public/*filepath", func(c *gin.Context) { if err := config.LoadConfig(); err != nil {
filepath := strings.TrimPrefix(c.Param("filepath"), "/") fmt.Printf("配置加载失败: %v\n", err)
serveEmbedFile(c, "public/"+filepath) return
}
utils.InitHTTPClients()
globalLimiter = utils.InitGlobalLimiter()
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)
if cfg.Server.EnableH2C {
fmt.Printf("H2c: 已启用\n")
}
fmt.Printf("版本号: %s\n", Version)
fmt.Printf("项目地址: https://github.com/sky22333/hubproxy\n")
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,
}
if cfg.Server.EnableH2C {
server.Handler = h2c.NewHandler(router, &http2.Server{
MaxConcurrentStreams: 250,
IdleTimeout: 300 * time.Second,
MaxReadFrameSize: 4 << 20,
MaxUploadBufferPerConnection: 8 << 20,
MaxUploadBufferPerStream: 2 << 20,
}) })
} else {
server.Handler = router
}
router.GET("/images.html", func(c *gin.Context) { if err := server.ListenAndServe(); err != nil {
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) fmt.Printf("启动服务失败: %v\n", err)
} }
} }
func handler(c *gin.Context) { func formatDuration(d time.Duration) string {
rawPath := strings.TrimPrefix(c.Request.URL.RequestURI(), "/") if d < time.Minute {
return fmt.Sprintf("%d秒", int(d.Seconds()))
for strings.HasPrefix(rawPath, "/") { }
rawPath = strings.TrimPrefix(rawPath, "/") if d < time.Hour {
return fmt.Sprintf("%d分钟%d秒", int(d.Minutes()), int(d.Seconds())%60)
}
if d < 24*time.Hour {
return fmt.Sprintf("%d小时%d分钟", int(d.Hours()), int(d.Minutes())%60)
} }
if !strings.HasPrefix(rawPath, "http") { days := int(d.Hours()) / 24
c.String(http.StatusForbidden, "无效输入") hours := int(d.Hours()) % 24
return return fmt.Sprintf("%d天%d小时", days, hours)
}
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 getUptimeInfo() (time.Duration, float64, string) {
func proxy(c *gin.Context, u string) { uptime := time.Since(serviceStartTime)
proxyWithRedirect(c, u, 0) return uptime, uptime.Seconds(), formatDuration(uptime)
} }
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) { 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) { router.GET("/ready", func(c *gin.Context) {
checks := make(map[string]string) _, uptimeSec, uptimeHuman := getUptimeInfo()
allReady := true c.JSON(http.StatusOK, gin.H{
"ready": true,
if GetConfig() != nil { "service": "hubproxy",
checks["config"] = "ok" "version": Version,
} else { "start_time_unix": serviceStartTime.Unix(),
checks["config"] = "failed" "uptime_sec": uptimeSec,
allReady = false "uptime_human": uptimeHuman,
}
// 检查全局缓存状态
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(),
}) })
}) })
} }
+196
View File
@@ -0,0 +1,196 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gin-gonic/gin"
"hubproxy/config"
"hubproxy/handlers"
"hubproxy/utils"
)
func newTestRouter(t *testing.T, configBody string) *gin.Engine {
t.Helper()
path := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(path, []byte(configBody), 0644); err != nil {
t.Fatal(err)
}
t.Setenv("CONFIG_PATH", path)
if err := config.LoadConfig(); err != nil {
t.Fatal(err)
}
utils.InitHTTPClients()
globalLimiter = utils.InitGlobalLimiter()
handlers.InitDockerProxy()
handlers.InitImageStreamer()
handlers.InitDebouncer()
return buildRouter(config.GetConfig())
}
func performRequest(router http.Handler, method, path, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, strings.NewReader(body))
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("User-Agent", "hubproxy-test")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
func TestReadyRoute(t *testing.T) {
router := newTestRouter(t, "")
w := performRequest(router, http.MethodGet, "/ready", "")
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
}
var got map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got["ready"] != true || got["service"] != "hubproxy" {
t.Fatalf("unexpected ready response: %#v", got)
}
}
func TestFrontendDisabledRoutesReturnNotFound(t *testing.T) {
router := newTestRouter(t, `
[server]
enableFrontend = false
`)
for _, path := range []string{"/", "/images", "/search", "/favicon.ico"} {
w := performRequest(router, http.MethodGet, path, "")
if w.Code != http.StatusNotFound {
t.Fatalf("%s status = %d, want 404", path, w.Code)
}
}
}
func TestSingleImageDownloadPrepareReturnsURL(t *testing.T) {
router := newTestRouter(t, "")
w := performRequest(router, http.MethodGet, "/api/image/download?image=nginx&mode=prepare", "")
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
}
var got struct {
DownloadURL string `json:"download_url"`
}
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if !strings.Contains(got.DownloadURL, "image=nginx") || !strings.Contains(got.DownloadURL, "token=") {
t.Fatalf("download_url = %q", got.DownloadURL)
}
if !strings.HasPrefix(got.DownloadURL, "/api/image/download?") {
t.Fatalf("download_url = %q", got.DownloadURL)
}
}
func TestBatchImageDownloadPrepareReturnsURL(t *testing.T) {
router := newTestRouter(t, "")
body := `{"images":["nginx"],"useCompressedLayers":true}`
w := performRequest(router, http.MethodPost, "/api/image/batch?mode=prepare", body)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
}
var got struct {
DownloadURL string `json:"download_url"`
}
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(got.DownloadURL, "/api/image/batch?token=") {
t.Fatalf("download_url = %q", got.DownloadURL)
}
}
func TestBatchImageDownloadRejectsTooManyImages(t *testing.T) {
router := newTestRouter(t, `
[download]
maxImages = 1
`)
body := `{"images":["nginx","redis"],"useCompressedLayers":true}`
w := performRequest(router, http.MethodPost, "/api/image/batch?mode=prepare", body)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", w.Code, w.Body.String())
}
}
func TestGitHubNoRouteRejectsUnsupportedHost(t *testing.T) {
router := newTestRouter(t, "")
w := performRequest(router, http.MethodGet, "/https://example.com/file.zip", "")
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403; body=%s", w.Code, w.Body.String())
}
}
func TestDockerV2PingAndInvalidPath(t *testing.T) {
router := newTestRouter(t, "")
w := performRequest(router, http.MethodGet, "/v2/", "")
if w.Code != http.StatusOK {
t.Fatalf("/v2/ status = %d, want 200; body=%s", w.Code, w.Body.String())
}
w = performRequest(router, http.MethodGet, "/v2/library/nginx/unknown/latest", "")
if w.Code != http.StatusBadRequest {
t.Fatalf("invalid v2 status = %d, want 400; body=%s", w.Code, w.Body.String())
}
}
func TestSearchAPIRejectsMissingQuery(t *testing.T) {
router := newTestRouter(t, `
[server]
enableFrontend = false
`)
w := performRequest(router, http.MethodGet, "/api/search", "")
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", w.Code, w.Body.String())
}
var got map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got["error"] == "" {
t.Fatalf("missing error response: %#v", got)
}
}
func TestSearchServesSPAWhenFrontendEnabled(t *testing.T) {
router := newTestRouter(t, `
[server]
enableFrontend = true
`)
w := performRequest(router, http.MethodGet, "/search?q=nginx", "")
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Header().Get("Content-Type"), "text/html") {
t.Fatalf("content-type = %q, want text/html", w.Header().Get("Content-Type"))
}
if !strings.Contains(w.Body.String(), `<div id="app">`) {
t.Fatalf("SPA shell missing: %s", w.Body.String())
}
}
-95
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
}
-789
View File
@@ -1,789 +0,0 @@
<!DOCTYPE html>
<html lang="zh">
<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="color-scheme" content="dark light">
<title>Docker离线镜像下载</title>
<link rel="icon" href="./favicon.ico">
<style>
:root {
--background: #ffffff;
--foreground: #0f172a;
--card: #ffffff;
--card-foreground: #0f172a;
--primary: #2563eb;
--primary-foreground: #f8fafc;
--secondary: #f1f5f9;
--secondary-foreground: #0f172a;
--muted: #f1f5f9;
--muted-foreground: #64748b;
--accent: #f1f5f9;
--accent-foreground: #0f172a;
--border: #e2e8f0;
--input: #ffffff;
--ring: #2563eb;
--radius: 0.5rem;
--success: #10b981;
--warning: #f59e0b;
--error: #ef4444;
}
.dark {
--background: #0f172a;
--foreground: #f8fafc;
--card: #1e293b;
--card-foreground: #f8fafc;
--primary: #3b82f6;
--primary-foreground: #f8fafc;
--secondary: #1e293b;
--secondary-foreground: #f8fafc;
--muted: #1e293b;
--muted-foreground: #94a3b8;
--accent: #1e293b;
--accent-foreground: #f8fafc;
--border: #334155;
--input: #1e293b;
--ring: #3b82f6;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0f172a;
--foreground: #f8fafc;
--card: #1e293b;
--card-foreground: #f8fafc;
--primary: #3b82f6;
--primary-foreground: #f8fafc;
--secondary: #1e293b;
--secondary-foreground: #f8fafc;
--muted: #1e293b;
--muted-foreground: #94a3b8;
--accent: #1e293b;
--accent-foreground: #f8fafc;
--border: #334155;
--input: #1e293b;
--ring: #3b82f6;
}
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
background-color: var(--background);
color: var(--foreground);
line-height: 1.5;
min-height: 100vh;
display: flex;
flex-direction: column;
transition: background-color 0.3s, color 0.3s;
}
/* 导航栏 */
.navbar {
position: sticky;
top: 0;
z-index: 50;
width: 100%;
border-bottom: 1px solid var(--border);
background-color: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(8px);
padding: 0;
}
.dark .navbar {
background-color: rgba(15, 23, 42, 0.95);
}
.navbar-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
display: flex;
align-items: center;
justify-content: space-between;
height: 4rem;
}
.logo {
display: flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
color: var(--foreground);
font-weight: 600;
font-size: 1.125rem;
}
.logo-icon {
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
background: linear-gradient(135deg, var(--primary), #3b82f6);
display: flex;
align-items: center;
justify-content: center;
color: white;
}
.nav-links {
display: flex;
align-items: center;
gap: 0.5rem;
}
.nav-link {
padding: 0.5rem 1rem;
border-radius: var(--radius);
text-decoration: none;
color: var(--muted-foreground);
transition: all 0.2s;
font-weight: 500;
}
.nav-link:hover,
.nav-link.active {
color: var(--foreground);
background-color: var(--muted);
}
.theme-toggle {
padding: 0.5rem;
border: none;
border-radius: var(--radius);
background-color: transparent;
color: var(--muted-foreground);
cursor: pointer;
transition: all 0.2s;
}
.theme-toggle:hover {
background-color: var(--muted);
color: var(--foreground);
}
/* 主要内容 */
.main {
flex: 1;
padding: 2rem 1rem;
}
.container {
max-width: 800px;
margin: 0 auto;
}
.header {
text-align: center;
margin-bottom: 3rem;
}
.title {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 1rem;
background: linear-gradient(135deg, var(--primary), #3b82f6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.subtitle {
font-size: 1.125rem;
color: var(--muted-foreground);
margin-bottom: 2rem;
}
.features {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
margin-top: 2rem;
}
.feature {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 1rem;
background-color: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
font-weight: 500;
}
.feature-icon {
font-size: 1.25rem;
}
/* 下载区域 */
.download-section,
.batch-section {
background-color: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 2rem;
margin-bottom: 2rem;
}
.section-title {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 1.5rem;
color: var(--foreground);
}
.form-group {
margin-bottom: 1.5rem;
}
.form-label {
display: block;
font-weight: 500;
margin-bottom: 0.5rem;
color: var(--foreground);
}
.form-input,
.form-select,
.textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background-color: var(--input);
color: var(--foreground);
font-size: 1rem;
transition: all 0.2s;
}
.form-input:focus,
.form-select:focus,
.textarea:focus {
outline: none;
border-color: var(--ring);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.textarea {
min-height: 120px;
resize: vertical;
font-family: monospace;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
border: none;
border-radius: var(--radius);
font-weight: 500;
font-size: 1rem;
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
}
.btn-primary {
background-color: var(--primary);
color: var(--primary-foreground);
}
.btn-primary:hover:not(:disabled) {
background-color: #1d4ed8;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-full {
width: 100%;
}
.status {
padding: 1rem;
border-radius: var(--radius);
margin-bottom: 1rem;
font-weight: 500;
}
.status-success {
background-color: rgba(16, 185, 129, 0.1);
color: var(--success);
border: 1px solid rgba(16, 185, 129, 0.2);
}
.status-error {
background-color: rgba(239, 68, 68, 0.1);
color: var(--error);
border: 1px solid rgba(239, 68, 68, 0.2);
}
.status-warning {
background-color: rgba(245, 158, 11, 0.1);
color: var(--warning);
border: 1px solid rgba(245, 158, 11, 0.2);
}
.help-text {
font-size: 0.875rem;
color: var(--muted-foreground);
margin-top: 0.25rem;
}
@media (max-width: 768px) {
.navbar-container {
padding: 0 0.5rem;
}
.nav-links {
gap: 0.25rem;
}
.nav-link {
padding: 0.5rem;
font-size: 0.875rem;
}
.main {
padding: 1rem 0.5rem;
}
.download-section,
.batch-section {
padding: 1.5rem;
}
.form-row {
grid-template-columns: 1fr;
}
.features {
grid-template-columns: 1fr;
}
.title {
font-size: 2rem;
}
}
.loading {
display: inline-block;
width: 1rem;
height: 1rem;
border: 2px solid transparent;
border-top: 2px solid currentColor;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.hidden {
display: none;
}
.mobile-menu-toggle {
display: none;
}
@media (max-width: 768px) {
.navbar-container {
padding: 0 0.5rem;
}
.nav-links {
position: fixed;
top: 70px;
left: 0;
right: 0;
background: var(--background);
border: 1px solid var(--border);
border-top: none;
border-radius: 0 0 12px 12px;
padding: 1rem;
flex-direction: column;
gap: 0.5rem;
z-index: 1000;
transform: translateY(-100vh);
transition: transform 0.3s ease;
}
.nav-links.active {
transform: translateY(0);
}
.mobile-menu-toggle {
display: block !important;
background: none;
border: none;
color: var(--foreground);
font-size: 1.5rem;
cursor: pointer;
padding: 0.5rem;
border-radius: var(--radius);
transition: background-color 0.2s;
}
.mobile-menu-toggle:hover {
background-color: var(--muted);
}
.navbar-container {
justify-content: space-between !important;
}
.main {
padding: 1rem 0.5rem;
}
.download-section,
.batch-section {
padding: 1.5rem;
}
.form-row {
grid-template-columns: 1fr;
}
.features {
grid-template-columns: 1fr;
}
.title {
font-size: 2rem;
}
}
</style>
</head>
<body>
<nav class="navbar">
<div class="navbar-container">
<a href="/" class="logo">
<div class="logo-icon">
</div>
加速服务
</a>
<button class="mobile-menu-toggle" id="mobileMenuToggle">
</button>
<div class="nav-links" id="navLinks">
<a href="/" class="nav-link">🚀 GitHub加速</a>
<a href="/images.html" class="nav-link active">🐳 离线镜像下载</a>
<a href="/search.html" class="nav-link">🔍 镜像搜索</a>
<a href="https://gitee.com/if-the-wind/github-hosts/raw/main/hosts" target="_blank" class="nav-link">📄 Hosts</a>
<button class="theme-toggle" id="themeToggle">
🌙
</button>
</div>
</div>
</nav>
<main class="main">
<div class="container">
<div class="header">
<h1 class="title">Docker离线镜像下载</h1>
<p class="subtitle">即点即下,无需等待打包,完全符合docker load加载标准</p>
<div class="features">
<div class="feature">
<span class="feature-icon"></span>
<span>即时下载</span>
</div>
<div class="feature">
<span class="feature-icon">🔄</span>
<span>流式传输</span>
</div>
<div class="feature">
<span class="feature-icon">💾</span>
<span>无需打包</span>
</div>
<div class="feature">
<span class="feature-icon">🏗️</span>
<span>多架构支持</span>
</div>
</div>
</div>
<div class="download-section">
<h2 class="section-title">单镜像下载</h2>
<div id="singleStatus"></div>
<form id="singleForm">
<div class="form-group">
<label class="form-label" for="imageInput">镜像名称</label>
<input
type="text"
id="imageInput"
class="form-input"
placeholder="例如: nginx:alpine"
>
</div>
<div class="form-group">
<label class="form-label" for="platformInput">目标架构(可选)</label>
<input
type="text"
id="platformInput"
class="form-input"
placeholder="linux/amd64"
value="linux/amd64"
>
<div class="help-text">
常用平台: linux/amd64, linux/arm64, linux/arm/v7
</div>
</div>
<button type="submit" class="btn btn-primary btn-full" id="downloadBtn">
<span id="downloadText">立即下载</span>
<span id="downloadLoading" class="loading hidden"></span>
</button>
</form>
</div>
<div class="batch-section">
<h2 class="section-title">多个镜像批量下载</h2>
<div id="batchStatus"></div>
<form id="batchForm">
<div class="form-group">
<label class="form-label" for="imagesTextarea">镜像列表,每行一个,会将多个镜像自动合并,符合官方标准,完全兼容docker load</label>
<textarea
id="imagesTextarea"
class="textarea"
placeholder="alpine&#10;redis:alpine&#10;stilleshan/frpc:0.62.1"
></textarea>
</div>
<div class="form-group">
<label class="form-label" for="batchPlatformInput">目标架构(可选)</label>
<input
type="text"
id="batchPlatformInput"
class="form-input"
placeholder="linux/amd64"
value="linux/amd64"
>
<div class="help-text">
所有镜像将使用相同的目标架构
</div>
</div>
<button type="submit" class="btn btn-primary btn-full" id="batchDownloadBtn">
<span id="batchDownloadText">开始下载</span>
<span id="batchDownloadLoading" class="loading hidden"></span>
</button>
</form>
</div>
</div>
</main>
<script>
function initTheme() {
const themeToggle = document.getElementById('themeToggle');
const html = document.documentElement;
const savedTheme = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
html.classList.add('dark');
themeToggle.textContent = '☀️';
}
themeToggle.addEventListener('click', () => {
html.classList.toggle('dark');
const isDark = html.classList.contains('dark');
themeToggle.textContent = isDark ? '☀️' : '🌙';
localStorage.setItem('theme', isDark ? 'dark' : 'light');
});
}
function showStatus(elementId, message, type = 'success') {
const element = document.getElementById(elementId);
element.className = `status status-${type}`;
element.textContent = message;
element.classList.remove('hidden');
}
function hideStatus(elementId) {
document.getElementById(elementId).classList.add('hidden');
}
function setButtonLoading(btnId, textId, loadingId, loading) {
const btn = document.getElementById(btnId);
const text = document.getElementById(textId);
const loadingSpinner = document.getElementById(loadingId);
btn.disabled = loading;
if (loading) {
text.classList.add('hidden');
loadingSpinner.classList.remove('hidden');
} else {
text.classList.remove('hidden');
loadingSpinner.classList.add('hidden');
}
}
function buildDownloadUrl(imageName, platform = '') {
const encodedImage = imageName.replace(/\//g, '_');
let url = `/api/image/download/${encodedImage}`;
if (platform && platform.trim()) {
url += `?platform=${encodeURIComponent(platform.trim())}`;
}
return url;
}
document.getElementById('singleForm').addEventListener('submit', function(e) {
e.preventDefault();
const imageName = document.getElementById('imageInput').value.trim();
if (!imageName) {
showStatus('singleStatus', '请输入镜像名称', 'error');
return;
}
const platform = document.getElementById('platformInput').value.trim();
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);
});
document.getElementById('batchForm').addEventListener('submit', async function(e) {
e.preventDefault();
const imagesText = document.getElementById('imagesTextarea').value.trim();
if (!imagesText) {
showStatus('batchStatus', '请输入镜像列表', 'error');
return;
}
const images = imagesText.split('\n')
.map(line => line.trim())
.filter(line => line && !line.startsWith('#'));
if (images.length === 0) {
showStatus('batchStatus', '镜像列表为空', 'error');
return;
}
const platform = document.getElementById('batchPlatformInput').value.trim();
const options = {
images: images
};
if (platform) {
options.platform = platform;
}
hideStatus('batchStatus');
setButtonLoading('batchDownloadBtn', 'batchDownloadText', 'batchDownloadLoading', true);
try {
const response = await fetch('/api/image/batch', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(options)
});
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 blob = await response.blob();
const url = window.URL.createObjectURL(blob);
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');
} else {
const error = await response.json();
showStatus('batchStatus', error.error || '下载失败', 'error');
}
} catch (error) {
showStatus('batchStatus', '网络错误: ' + error.message, 'error');
} finally {
setButtonLoading('batchDownloadBtn', 'batchDownloadText', 'batchDownloadLoading', false);
}
});
function initMobileMenu() {
const mobileMenuToggle = document.getElementById('mobileMenuToggle');
const navLinks = document.getElementById('navLinks');
if (mobileMenuToggle && navLinks) {
mobileMenuToggle.addEventListener('click', () => {
navLinks.classList.toggle('active');
});
navLinks.addEventListener('click', (e) => {
if (e.target.classList.contains('nav-link')) {
navLinks.classList.remove('active');
}
});
}
}
initTheme();
initMobileMenu();
</script>
</body>
</html>
-833
View File
@@ -1,833 +0,0 @@
<!DOCTYPE html>
<html lang="zh">
<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="color-scheme" content="dark light">
<title>Github文件加速</title>
<link rel="icon" href="./favicon.ico">
<style>
:root {
--background: #ffffff;
--foreground: #0f172a;
--card: #ffffff;
--card-foreground: #0f172a;
--primary: #2563eb;
--primary-foreground: #f8fafc;
--secondary: #f1f5f9;
--secondary-foreground: #0f172a;
--muted: #f1f5f9;
--muted-foreground: #64748b;
--accent: #f1f5f9;
--accent-foreground: #0f172a;
--border: #e2e8f0;
--input: #ffffff;
--ring: #2563eb;
--radius: 0.5rem;
}
.dark {
--background: #0f172a;
--foreground: #f8fafc;
--card: #1e293b;
--card-foreground: #f8fafc;
--primary: #3b82f6;
--primary-foreground: #f8fafc;
--secondary: #1e293b;
--secondary-foreground: #f8fafc;
--muted: #1e293b;
--muted-foreground: #94a3b8;
--accent: #1e293b;
--accent-foreground: #f8fafc;
--border: #334155;
--input: #1e293b;
--ring: #3b82f6;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0f172a;
--foreground: #f8fafc;
--card: #1e293b;
--card-foreground: #f8fafc;
--primary: #3b82f6;
--primary-foreground: #f8fafc;
--secondary: #1e293b;
--secondary-foreground: #f8fafc;
--muted: #1e293b;
--muted-foreground: #94a3b8;
--accent: #1e293b;
--accent-foreground: #f8fafc;
--border: #334155;
--input: #1e293b;
--ring: #3b82f6;
}
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
background-color: var(--background);
color: var(--foreground);
line-height: 1.5;
min-height: 100vh;
display: flex;
flex-direction: column;
transition: background-color 0.3s, color 0.3s;
}
.navbar {
position: sticky;
top: 0;
z-index: 50;
width: 100%;
border-bottom: 1px solid var(--border);
background-color: var(--background);
backdrop-filter: blur(8px);
background-color: rgba(255, 255, 255, 0.95);
}
.dark .navbar {
background-color: rgba(15, 23, 42, 0.95);
}
.navbar-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
display: flex;
align-items: center;
justify-content: space-between;
height: 4rem;
}
.logo {
display: flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
color: var(--foreground);
font-weight: 600;
font-size: 1.125rem;
}
.logo-icon {
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
background: linear-gradient(135deg, var(--primary), #3b82f6);
display: flex;
align-items: center;
justify-content: center;
color: white;
}
.nav-links {
display: flex;
align-items: center;
gap: 0.5rem;
}
.nav-link {
padding: 0.5rem 1rem;
border-radius: var(--radius);
text-decoration: none;
color: var(--muted-foreground);
transition: all 0.2s;
font-weight: 500;
}
.nav-link:hover,
.nav-link.active {
color: var(--foreground);
background-color: var(--muted);
}
.theme-toggle {
padding: 0.5rem;
border: none;
border-radius: var(--radius);
background-color: transparent;
color: var(--muted-foreground);
cursor: pointer;
transition: all 0.2s;
}
.theme-toggle:hover {
background-color: var(--muted);
color: var(--foreground);
}
.main {
flex: 1;
padding: 2rem 1rem;
}
.container {
max-width: 1000px;
margin: 0 auto;
}
.hero {
text-align: center;
margin-bottom: 3rem;
opacity: 0;
transform: translateY(20px);
animation: fadeInUp 0.6s ease-out forwards;
}
.hero-title {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 1rem;
background: linear-gradient(135deg, var(--primary), #3b82f6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.hero-subtitle {
font-size: 1.125rem;
color: var(--muted-foreground);
max-width: 600px;
margin: 0 auto;
}
.card {
background-color: var(--card);
border: 1px solid var(--border);
border-radius: 0.75rem;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
margin-bottom: 2rem;
opacity: 0;
transform: translateY(20px);
animation: fadeInUp 0.6s ease-out 0.2s forwards;
}
.card-header {
margin-bottom: 1.5rem;
}
.card-title {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 0.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.card-description {
color: var(--muted-foreground);
font-size: 0.875rem;
}
.form-group {
margin-bottom: 1rem;
}
.input-container {
position: relative;
display: flex;
gap: 0.75rem;
}
.input {
flex: 1;
padding: 0.75rem 1rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background-color: var(--input);
color: var(--foreground);
font-size: 1rem;
transition: all 0.2s;
}
.input:focus {
outline: none;
border-color: var(--ring);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2);
}
.input::placeholder {
color: var(--muted-foreground);
}
.button {
padding: 0.75rem 1.5rem;
border: none;
border-radius: var(--radius);
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.button-primary {
background-color: var(--primary);
color: var(--primary-foreground);
}
.button-primary:hover {
background-color: #1d4ed8;
transform: translateY(-1px);
}
.button-secondary {
background-color: var(--secondary);
color: var(--secondary-foreground);
}
.button-secondary:hover {
background-color: var(--muted);
}
.output-container {
margin-top: 1.5rem;
display: none;
opacity: 0;
transform: translateY(10px);
transition: all 0.3s;
}
.output-container.show {
display: block;
opacity: 1;
transform: translateY(0);
}
.success-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
color: #059669;
}
.output-box {
background-color: var(--muted);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.875rem;
word-break: break-all;
position: relative;
margin-bottom: 1rem;
}
.output-actions {
display: flex;
gap: 0.5rem;
}
.docker-info {
opacity: 0;
transform: translateY(20px);
animation: fadeInUp 0.6s ease-out 0.4s forwards;
}
.docker-button {
width: 100%;
padding: 1rem;
background: linear-gradient(135deg, #f1f5f9, #e2e8f0);
border: 1px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
transition: all 0.3s;
font-size: 1rem;
font-weight: 500;
color: var(--foreground);
}
.docker-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.dark .docker-button {
background: linear-gradient(135deg, #374151, #4b5563);
}
.dark .docker-button:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: none;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(4px);
}
.modal-content {
background-color: var(--card);
border-radius: 0.75rem;
padding: 2rem;
max-width: 600px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
position: relative;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
}
.modal-header {
text-align: center;
margin-bottom: 2rem;
}
.modal-title {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.close-button {
position: absolute;
top: 1rem;
right: 1rem;
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--muted-foreground);
padding: 0.25rem;
border-radius: var(--radius);
}
.close-button:hover {
background-color: var(--muted);
}
.domain-examples {
background-color: var(--muted);
border-radius: var(--radius);
padding: 1rem;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.875rem;
line-height: 1.6;
}
.domain-examples strong {
color: var(--foreground);
display: block;
margin: 1rem 0 0.5rem 0;
}
.domain-examples strong:first-child {
margin-top: 0;
}
.toast {
position: fixed;
top: 1rem;
right: 1rem;
background-color: var(--primary);
color: var(--primary-foreground);
padding: 1rem 1.5rem;
border-radius: var(--radius);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1001;
display: none;
opacity: 0;
transform: translateX(100%);
transition: all 0.3s ease;
}
.toast.show {
display: block;
opacity: 1;
transform: translateX(0);
}
.footer {
padding: 2rem 1rem;
text-align: center;
border-top: 1px solid var(--border);
}
.github-link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: var(--muted-foreground);
text-decoration: none;
transition: color 0.2s;
}
.github-link:hover {
color: var(--foreground);
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 768px) {
.hero-title {
font-size: 2rem;
}
.nav-links {
position: fixed;
top: 70px;
left: 0;
right: 0;
background: var(--background);
border: 1px solid var(--border);
border-top: none;
border-radius: 0 0 12px 12px;
padding: 1rem;
flex-direction: column;
gap: 0.5rem;
z-index: 1000;
transform: translateY(-100vh);
transition: transform 0.3s ease;
}
.nav-links.active {
transform: translateY(0);
}
.mobile-menu-toggle {
display: block !important;
background: none;
border: none;
color: var(--foreground);
font-size: 1.5rem;
cursor: pointer;
padding: 0.5rem;
border-radius: var(--radius);
transition: background-color 0.2s;
}
.mobile-menu-toggle:hover {
background-color: var(--muted);
}
.navbar-container {
justify-content: space-between !important;
}
.container {
padding: 0 1rem;
}
.modal-content {
padding: 1.5rem;
}
.input-container {
flex-direction: column;
gap: 1rem;
}
.input {
font-size: 16px;
}
.button {
width: 100%;
justify-content: center;
padding: 0.875rem 1.5rem;
}
.output-actions {
flex-direction: column;
gap: 0.75rem;
}
}
.mobile-menu-toggle {
display: none;
}
</style>
</head>
<body>
<nav class="navbar">
<div class="navbar-container">
<a href="/" class="logo">
<div class="logo-icon">
</div>
加速服务
</a>
<button class="mobile-menu-toggle" id="mobileMenuToggle">
</button>
<div class="nav-links" id="navLinks">
<a href="/" class="nav-link active">🚀 GitHub加速</a>
<a href="/images.html" class="nav-link">🐳 离线镜像下载</a>
<a href="/search.html" class="nav-link">🔍 镜像搜索</a>
<a href="https://gitee.com/if-the-wind/github-hosts/raw/main/hosts" target="_blank" class="nav-link">📄 Hosts</a>
<button class="theme-toggle" id="themeToggle">
🌙
</button>
</div>
</div>
</nav>
<main class="main">
<div class="container">
<div class="hero">
<h1 class="hero-title">GitHub 文件加速</h1>
<p class="hero-subtitle">
快速下载GitHub上的文件和仓库,解决国内访问GitHub速度慢的问题,支持AI模型库Hugging Face
</p>
</div>
<div class="card">
<div class="card-header">
<h2 class="card-title">
⚡ 快速生成加速链接
</h2>
<p class="card-description">
输入GitHub文件或仓库链接,自动转换加速链接,可以直接在Github域名前面加上本站域名使用。
</p>
</div>
<div class="form-group">
<div class="input-container">
<input
type="text"
class="input"
id="githubLinkInput"
placeholder="请输入GitHub链接,例如:https://github.com/user/repo/releases/download/..."
>
<button class="button button-primary" id="formatButton">
获取加速链接
</button>
</div>
</div>
<div class="output-container" id="outputBlock">
<div class="success-header">
<span></span>
<strong>加速链接已生成</strong>
</div>
<div class="output-box" id="formattedLinkOutput"></div>
<div class="output-actions">
<button class="button button-secondary" id="copyButton">
📋 复制链接
</button>
<button class="button button-secondary" id="redirButton">
🔗 打开链接
</button>
</div>
</div>
</div>
<div class="card docker-info">
<div class="card-header">
<h3 class="card-title">
🐳 Docker 镜像加速
</h3>
<p class="card-description">
支持多种Registry,在镜像名前添加本站域名即可加速下载。
</p>
</div>
<button class="docker-button" id="dockerButton">
查看 Docker 镜像加速配置
</button>
</div>
</div>
</main>
<div id="dockerModal" class="modal">
<div class="modal-content">
<button class="close-button" id="closeModal">&times;</button>
<div class="modal-header">
<h2 class="modal-title">Docker 镜像加速</h2>
<p>支持多种Registry,在镜像名前添加本站域名即可加速下载。</p>
</div>
<div class="domain-examples">
<strong>Docker Hub 官方镜像:</strong>
docker pull <span class="domain-base"></span>/nginx
<strong>Docker Hub 第三方镜像:</strong>
docker pull <span class="domain-base"></span>/user/image
<strong>GitHub Container Registry</strong>
docker pull <span class="domain-base"></span>/ghcr.io/user/image
<strong>Quay.io Registry</strong>
docker pull <span class="domain-base"></span>/quay.io/org/image
<strong>Kubernetes Registry</strong>
docker pull <span class="domain-base"></span>/registry.k8s.io/pause:3.8
</div>
</div>
</div>
<div id="toast" class="toast">
链接已复制到剪贴板
</div>
<footer class="footer">
<a href="https://github.com/sky22333/hubproxy" 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>
GitHub
</a>
</footer>
<script>
const themeToggle = document.getElementById('themeToggle');
const html = document.documentElement;
const savedTheme = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
html.classList.add('dark');
themeToggle.textContent = '☀️';
}
themeToggle.addEventListener('click', () => {
html.classList.toggle('dark');
const isDark = html.classList.contains('dark');
themeToggle.textContent = isDark ? '☀️' : '🌙';
localStorage.setItem('theme', isDark ? 'dark' : 'light');
});
document.addEventListener('DOMContentLoaded', function() {
const fullDomain = window.location.host;
document.querySelectorAll('.domain-base').forEach(span => {
span.textContent = fullDomain;
});
const modal = document.getElementById('dockerModal');
const dockerButton = document.getElementById('dockerButton');
const closeButton = document.getElementById('closeModal');
dockerButton.onclick = () => modal.style.display = "flex";
closeButton.onclick = () => modal.style.display = "none";
window.onclick = (event) => {
if (event.target == modal) modal.style.display = "none";
};
});
function formatGithubLink() {
const githubLinkInput = document.getElementById('githubLinkInput');
const currentHost = window.location.host;
let formattedLink = "";
const link = githubLinkInput.value.trim();
if (link.startsWith("https://") || link.startsWith("http://")) {
formattedLink = "https://" + currentHost + "/" + link;
} else if (
link.startsWith("github.com/") ||
link.startsWith("raw.githubusercontent.com/") ||
link.startsWith("gist.githubusercontent.com/") ||
link.startsWith("huggingface.co/") ||
link.startsWith("cdn-lfs.hf.co/") ||
link.startsWith("download.docker.com/")
) {
formattedLink = "https://" + currentHost + "/https://" + link;
} else {
showToast('请输入有效的链接');
return;
}
const formattedLinkOutput = document.getElementById('formattedLinkOutput');
formattedLinkOutput.textContent = formattedLink;
const outputBlock = document.getElementById('outputBlock');
outputBlock.classList.add('show');
}
function copyToClipboard() {
const output = document.getElementById('formattedLinkOutput');
const text = output.textContent;
if (navigator.clipboard) {
navigator.clipboard.writeText(text).then(() => {
showToast('链接已复制到剪贴板');
});
} else {
const range = document.createRange();
range.selectNode(output);
window.getSelection().removeAllRanges();
window.getSelection().addRange(range);
document.execCommand('copy');
window.getSelection().removeAllRanges();
showToast('链接已复制到剪贴板');
}
}
function openLink() {
const formattedLinkOutput = document.getElementById('formattedLinkOutput');
window.open(formattedLinkOutput.textContent);
}
function showToast(message) {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.classList.add('show');
setTimeout(() => {
toast.classList.remove('show');
}, 3000);
}
document.getElementById('formatButton').addEventListener('click', formatGithubLink);
document.getElementById('copyButton').addEventListener('click', copyToClipboard);
document.getElementById('redirButton').addEventListener('click', openLink);
document.getElementById('githubLinkInput').addEventListener('keyup', function(event) {
if (event.key === 'Enter') {
formatGithubLink();
}
});
const mobileMenuToggle = document.getElementById('mobileMenuToggle');
const navLinks = document.getElementById('navLinks');
mobileMenuToggle.addEventListener('click', () => {
navLinks.classList.toggle('active');
mobileMenuToggle.textContent = navLinks.classList.contains('active') ? '✕' : '☰';
});
document.addEventListener('click', (e) => {
if (!e.target.closest('.navbar') && navLinks.classList.contains('active')) {
navLinks.classList.remove('active');
mobileMenuToggle.textContent = '☰';
}
});
</script>
</body>
</html>
-1316
View File
File diff suppressed because it is too large Load Diff
-299
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)
}
}
-500
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)
})
}
-108
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)
}
}
}
@@ -1,8 +1,9 @@
package main package utils
import ( import (
"strings" "strings"
"sync"
"hubproxy/config"
) )
// ResourceType 资源类型 // ResourceType 资源类型
@@ -15,7 +16,6 @@ const (
// AccessController 统一访问控制器 // AccessController 统一访问控制器
type AccessController struct { type AccessController struct {
mu sync.RWMutex
} }
// DockerImageInfo Docker镜像信息 // DockerImageInfo Docker镜像信息
@@ -26,7 +26,7 @@ type DockerImageInfo struct {
FullName string FullName string
} }
// 全局访问控制器实例 // GlobalAccessController 全局访问控制器实例
var GlobalAccessController = &AccessController{} var GlobalAccessController = &AccessController{}
// ParseDockerImage 解析Docker镜像名称 // ParseDockerImage 解析Docker镜像名称
@@ -79,21 +79,18 @@ func (ac *AccessController) ParseDockerImage(image string) DockerImageInfo {
// CheckDockerAccess 检查Docker镜像访问权限 // CheckDockerAccess 检查Docker镜像访问权限
func (ac *AccessController) CheckDockerAccess(image string) (allowed bool, reason string) { func (ac *AccessController) CheckDockerAccess(image string) (allowed bool, reason string) {
cfg := GetConfig() cfg := config.GetConfig()
// 解析镜像名称
imageInfo := ac.ParseDockerImage(image) imageInfo := ac.ParseDockerImage(image)
// 检查白名单(如果配置了白名单,则只允许白名单中的镜像) if len(cfg.Access.WhiteList) > 0 {
if len(cfg.Proxy.WhiteList) > 0 { if !ac.matchImageInList(imageInfo, cfg.Access.WhiteList) {
if !ac.matchImageInList(imageInfo, cfg.Proxy.WhiteList) {
return false, "不在Docker镜像白名单内" return false, "不在Docker镜像白名单内"
} }
} }
// 检查黑名单 if len(cfg.Access.BlackList) > 0 {
if len(cfg.Proxy.BlackList) > 0 { if ac.matchImageInList(imageInfo, cfg.Access.BlackList) {
if ac.matchImageInList(imageInfo, cfg.Proxy.BlackList) {
return false, "Docker镜像在黑名单内" return false, "Docker镜像在黑名单内"
} }
} }
@@ -107,15 +104,13 @@ func (ac *AccessController) CheckGitHubAccess(matches []string) (allowed bool, r
return false, "无效的GitHub仓库格式" return false, "无效的GitHub仓库格式"
} }
cfg := GetConfig() cfg := config.GetConfig()
// 检查白名单 if len(cfg.Access.WhiteList) > 0 && !ac.checkList(matches, cfg.Access.WhiteList) {
if len(cfg.Proxy.WhiteList) > 0 && !ac.checkList(matches, cfg.Proxy.WhiteList) {
return false, "不在GitHub仓库白名单内" return false, "不在GitHub仓库白名单内"
} }
// 检查黑名单 if len(cfg.Access.BlackList) > 0 && ac.checkList(matches, cfg.Access.BlackList) {
if len(cfg.Proxy.BlackList) > 0 && ac.checkList(matches, cfg.Proxy.BlackList) {
return false, "GitHub仓库在黑名单内" return false, "GitHub仓库在黑名单内"
} }
@@ -185,17 +180,14 @@ func (ac *AccessController) checkList(matches, list []string) bool {
continue continue
} }
// 支持多种匹配模式
if fullRepo == item { if fullRepo == item {
return true return true
} }
// 用户级匹配
if item == username || item == username+"/*" { if item == username || item == username+"/*" {
return true return true
} }
// 前缀匹配(支持通配符)
if strings.HasSuffix(item, "*") { if strings.HasSuffix(item, "*") {
prefix := strings.TrimSuffix(item, "*") prefix := strings.TrimSuffix(item, "*")
if strings.HasPrefix(fullRepo, prefix) { if strings.HasPrefix(fullRepo, prefix) {
@@ -203,18 +195,16 @@ func (ac *AccessController) checkList(matches, list []string) bool {
} }
} }
// 子仓库匹配(防止 user/repo 匹配到 user/repo-fork
if strings.HasPrefix(fullRepo, item+"/") { if strings.HasPrefix(fullRepo, item+"/") {
return true 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 return false
} }
// Reload 热重载访问控制规则
func (ac *AccessController) Reload() {
ac.mu.Lock()
defer ac.mu.Unlock()
// 访问控制器本身不缓存配置
}
+86
View File
@@ -0,0 +1,86 @@
package utils
import (
"os"
"path/filepath"
"testing"
"hubproxy/config"
)
func TestParseDockerImage(t *testing.T) {
tests := []struct {
name string
image string
namespace string
repository string
tag string
fullName string
}{
{"official", "nginx", "library", "nginx", "latest", "library/nginx"},
{"tagged", "redis:7", "library", "redis", "7", "library/redis"},
{"namespaced", "user/app:v1", "user", "app", "v1", "user/app"},
{"registry", "ghcr.io/user/app:v2", "user", "app", "v2", "user/app"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := GlobalAccessController.ParseDockerImage(tt.image)
if got.Namespace != tt.namespace || got.Repository != tt.repository || got.Tag != tt.tag || got.FullName != tt.fullName {
t.Fatalf("ParseDockerImage(%q) = %#v", tt.image, got)
}
})
}
}
func TestDockerAccessLists(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.toml")
data := []byte(`
[access]
whiteList = ["library/*", "good/*"]
blackList = ["good/bad"]
`)
if err := os.WriteFile(path, data, 0644); err != nil {
t.Fatal(err)
}
t.Setenv("CONFIG_PATH", path)
if err := config.LoadConfig(); err != nil {
t.Fatal(err)
}
if allowed, reason := GlobalAccessController.CheckDockerAccess("nginx"); !allowed {
t.Fatalf("nginx denied: %s", reason)
}
if allowed, _ := GlobalAccessController.CheckDockerAccess("good/bad:latest"); allowed {
t.Fatal("blacklisted image allowed")
}
if allowed, _ := GlobalAccessController.CheckDockerAccess("other/app"); allowed {
t.Fatal("image outside whitelist allowed")
}
}
func TestGitHubAccessLists(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.toml")
data := []byte(`
[access]
whiteList = ["allowed/*"]
blackList = ["allowed/blocked"]
`)
if err := os.WriteFile(path, data, 0644); err != nil {
t.Fatal(err)
}
t.Setenv("CONFIG_PATH", path)
if err := config.LoadConfig(); err != nil {
t.Fatal(err)
}
if allowed, reason := GlobalAccessController.CheckGitHubAccess([]string{"allowed", "repo"}); !allowed {
t.Fatalf("allowed/repo denied: %s", reason)
}
if allowed, _ := GlobalAccessController.CheckGitHubAccess([]string{"allowed", "blocked"}); allowed {
t.Fatal("blacklisted repo allowed")
}
if allowed, _ := GlobalAccessController.CheckGitHubAccess([]string{"other", "repo"}); allowed {
t.Fatal("repo outside whitelist allowed")
}
}
+51 -40
View File
@@ -1,4 +1,4 @@
package main package utils
import ( import (
"crypto/md5" "crypto/md5"
@@ -9,22 +9,23 @@ import (
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"hubproxy/config"
) )
// CachedItem 通用缓存项,支持Token和Manifest // CachedItem 通用缓存项
type CachedItem struct { type CachedItem struct {
Data []byte // 缓存数据(token字符串或manifest字节) Data []byte
ContentType string // 内容类型 ContentType string
Headers map[string]string // 额外的响应头 Headers map[string]string
ExpiresAt time.Time // 过期时间 ExpiresAt time.Time
} }
// UniversalCache 通用缓存,支持Token和Manifest // UniversalCache 通用缓存
type UniversalCache struct { type UniversalCache struct {
cache sync.Map cache sync.Map
} }
var globalCache = &UniversalCache{} var GlobalCache = &UniversalCache{}
// Get 获取缓存项 // Get 获取缓存项
func (c *UniversalCache) Get(key string) *CachedItem { func (c *UniversalCache) Get(key string) *CachedItem {
@@ -57,30 +58,22 @@ func (c *UniversalCache) SetToken(key, token string, ttl time.Duration) {
c.Set(key, []byte(token), "application/json", nil, ttl) c.Set(key, []byte(token), "application/json", nil, ttl)
} }
// buildCacheKey 构建稳定的缓存key // BuildCacheKey 构建稳定的缓存key
func buildCacheKey(prefix, query string) string { func BuildCacheKey(prefix, query string) string {
return fmt.Sprintf("%s:%x", prefix, md5.Sum([]byte(query))) return fmt.Sprintf("%s:%x", prefix, md5.Sum([]byte(query)))
} }
func buildTokenCacheKey(query string) string { func BuildTokenCacheKey(query string) string {
return buildCacheKey("token", query) return BuildCacheKey("token", query)
} }
func buildManifestCacheKey(imageRef, reference string) string { func BuildManifestCacheKey(imageRef, reference string) string {
key := fmt.Sprintf("%s:%s", imageRef, reference) key := fmt.Sprintf("%s:%s", imageRef, reference)
return buildCacheKey("manifest", key) return BuildCacheKey("manifest", key)
} }
func buildManifestCacheKeyWithPlatform(imageRef, reference, platform string) string { func GetManifestTTL(reference string) time.Duration {
if platform == "" { cfg := config.GetConfig()
platform = "default"
}
key := fmt.Sprintf("%s:%s@%s", imageRef, reference, platform)
return buildCacheKey("manifest", key)
}
func getManifestTTL(reference string) time.Duration {
cfg := GetConfig()
defaultTTL := 30 * time.Minute defaultTTL := 30 * time.Minute
if cfg.TokenCache.DefaultTTL != "" { if cfg.TokenCache.DefaultTTL != "" {
if parsed, err := time.ParseDuration(cfg.TokenCache.DefaultTTL); err == nil { if parsed, err := time.ParseDuration(cfg.TokenCache.DefaultTTL); err == nil {
@@ -92,24 +85,20 @@ func getManifestTTL(reference string) time.Duration {
return 24 * time.Hour return 24 * time.Hour
} }
// mutable tag的智能判断
if reference == "latest" || reference == "main" || reference == "master" || if reference == "latest" || reference == "main" || reference == "master" ||
reference == "dev" || reference == "develop" { reference == "dev" || reference == "develop" {
// 热门可变标签: 短期缓存
return 10 * time.Minute return 10 * time.Minute
} }
// 普通tag: 中等缓存时间
return defaultTTL return defaultTTL
} }
// extractTTLFromResponse 从响应中智能提取TTL // ExtractTTLFromResponse 从响应中智能提取TTL
func extractTTLFromResponse(responseBody []byte) time.Duration { func ExtractTTLFromResponse(responseBody []byte) time.Duration {
var tokenResp struct { var tokenResp struct {
ExpiresIn int `json:"expires_in"` ExpiresIn int `json:"expires_in"`
} }
// 默认30分钟TTL,确保稳定性
defaultTTL := 30 * time.Minute defaultTTL := 30 * time.Minute
if json.Unmarshal(responseBody, &tokenResp) == nil && tokenResp.ExpiresIn > 0 { if json.Unmarshal(responseBody, &tokenResp) == nil && tokenResp.ExpiresIn > 0 {
@@ -122,32 +111,54 @@ func extractTTLFromResponse(responseBody []byte) time.Duration {
return defaultTTL return defaultTTL
} }
func writeTokenResponse(c *gin.Context, cachedBody string) { func WriteTokenResponse(c *gin.Context, cachedBody string) {
c.Header("Content-Type", "application/json") c.Header("Content-Type", "application/json")
c.String(200, cachedBody) c.String(200, cachedBody)
} }
func writeCachedResponse(c *gin.Context, item *CachedItem) { func WriteCachedResponse(c *gin.Context, item *CachedItem) {
if item.ContentType != "" { if item.ContentType != "" {
c.Header("Content-Type", item.ContentType) c.Header("Content-Type", item.ContentType)
} }
// 设置额外的响应头
for key, value := range item.Headers { for key, value := range item.Headers {
c.Header(key, value) c.Header(key, value)
} }
// 返回数据
c.Data(200, item.ContentType, item.Data) c.Data(200, item.ContentType, item.Data)
} }
// isCacheEnabled 检查缓存是否启用 // IsCacheEnabled 检查缓存是否启用
func isCacheEnabled() bool { func IsCacheEnabled() bool {
cfg := GetConfig() cfg := config.GetConfig()
return cfg.TokenCache.Enabled return cfg.TokenCache.Enabled
} }
// isTokenCacheEnabled 检查token缓存是否启用(向后兼容) // IsTokenCacheEnabled 检查token缓存是否启用
func isTokenCacheEnabled() bool { func IsTokenCacheEnabled() bool {
return isCacheEnabled() 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)
}
}
}()
} }
+49
View File
@@ -0,0 +1,49 @@
package utils
import (
"testing"
"time"
)
func TestUniversalCacheSetGetAndExpire(t *testing.T) {
cache := &UniversalCache{}
cache.Set("k", []byte("v"), "text/plain", map[string]string{"X-Test": "1"}, time.Minute)
if got := cache.Get("k"); got == nil || string(got.Data) != "v" || got.Headers["X-Test"] != "1" {
t.Fatalf("cache hit mismatch: %#v", got)
}
cache.Set("expired", []byte("v"), "", nil, -time.Second)
if got := cache.Get("expired"); got != nil {
t.Fatalf("expired item returned: %#v", got)
}
}
func TestTokenCacheHelpers(t *testing.T) {
cache := &UniversalCache{}
cache.SetToken("token", `{"token":"abc"}`, time.Minute)
if got := cache.GetToken("token"); got != `{"token":"abc"}` {
t.Fatalf("GetToken = %q", got)
}
}
func TestExtractTTLFromResponse(t *testing.T) {
ttl := ExtractTTLFromResponse([]byte(`{"expires_in":3600}`))
if ttl != 55*time.Minute {
t.Fatalf("TTL = %s, want 55m", ttl)
}
if ttl := ExtractTTLFromResponse([]byte(`{}`)); ttl != 30*time.Minute {
t.Fatalf("default TTL = %s", ttl)
}
}
func TestBuildCacheKeyStable(t *testing.T) {
a := BuildCacheKey("p", "query")
b := BuildCacheKey("p", "query")
c := BuildCacheKey("p", "other")
if a != b || a == c {
t.Fatalf("unexpected keys: %q %q %q", a, b, c)
}
}
@@ -1,23 +1,31 @@
package main package utils
import ( import (
"net" "net"
"net/http" "net/http"
"os"
"time" "time"
"hubproxy/config"
) )
var ( var (
// 全局HTTP客户端 - 用于代理请求(长超时)
globalHTTPClient *http.Client globalHTTPClient *http.Client
// 搜索HTTP客户端 - 用于API请求(短超时)
searchHTTPClient *http.Client searchHTTPClient *http.Client
) )
// initHTTPClients 初始化HTTP客户端 // InitHTTPClients 初始化HTTP客户端
func initHTTPClients() { func InitHTTPClients() {
// 代理客户端配置 - 适用于大文件传输 cfg := config.GetConfig()
if p := cfg.Access.Proxy; p != "" {
os.Setenv("HTTP_PROXY", p)
os.Setenv("HTTPS_PROXY", p)
}
globalHTTPClient = &http.Client{ globalHTTPClient = &http.Client{
Transport: &http.Transport{ Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{ DialContext: (&net.Dialer{
Timeout: 30 * time.Second, Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second, KeepAlive: 30 * time.Second,
@@ -31,10 +39,10 @@ func initHTTPClients() {
}, },
} }
// 搜索客户端配置 - 适用于API调用
searchHTTPClient = &http.Client{ searchHTTPClient = &http.Client{
Timeout: 10 * time.Second, Timeout: 10 * time.Second,
Transport: &http.Transport{ Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{ DialContext: (&net.Dialer{
Timeout: 5 * time.Second, Timeout: 5 * time.Second,
KeepAlive: 30 * time.Second, KeepAlive: 30 * time.Second,
@@ -48,12 +56,12 @@ func initHTTPClients() {
} }
} }
// GetGlobalHTTPClient 获取全局HTTP客户端(用于代理) // GetGlobalHTTPClient 获取全局HTTP客户端
func GetGlobalHTTPClient() *http.Client { func GetGlobalHTTPClient() *http.Client {
return globalHTTPClient return globalHTTPClient
} }
// GetSearchHTTPClient 获取搜索HTTP客户端(用于API调用) // GetSearchHTTPClient 获取搜索HTTP客户端
func GetSearchHTTPClient() *http.Client { func GetSearchHTTPClient() *http.Client {
return searchHTTPClient return searchHTTPClient
} }
+107
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
}

Some files were not shown because too many files have changed in this diff Show More