mirror of
https://github.com/sky22333/hubproxy.git
synced 2026-08-09 21:52:11 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77ece220ee | ||
|
|
644a225733 | ||
|
|
28b55fb231 | ||
|
|
d9eaae4a07 | ||
|
|
ffea3da482 | ||
|
|
f101c7f29b | ||
|
|
4da7f90adc | ||
|
|
5b1c2f549b | ||
|
|
5b86de8860 | ||
|
|
5edfcc5a59 | ||
|
|
c559f6cbb3 | ||
|
|
dcc496bca3 | ||
|
|
aeb045a9e3 | ||
|
|
293c352518 | ||
|
|
e6381d52d2 | ||
|
|
8382d9c553 | ||
|
|
320df5def5 | ||
|
|
7e0b82f8f0 | ||
|
|
79f23d13ad | ||
|
|
26b45c98bf | ||
|
|
587c1f2144 | ||
|
|
8100bcea0b | ||
|
|
ba83a44492 | ||
|
|
c7a7f3d146 | ||
|
|
e4d4f33ea1 | ||
|
|
6e91fe9925 | ||
|
|
d0b3c657cc | ||
|
|
f5bc86ef79 | ||
|
|
23dd077f5d | ||
|
|
3917b2503a | ||
|
|
bb61eb5025 | ||
|
|
11c34459ca | ||
|
|
6659e977ae | ||
|
|
f77d951500 | ||
|
|
685388fff9 | ||
|
|
c6d95e683f | ||
|
|
f8828ccb74 | ||
|
|
fdc156adad | ||
|
|
80b0173d7c | ||
|
|
31f62fde35 | ||
|
|
8d7619c7e4 | ||
|
|
a09db34787 | ||
|
|
31a3b67ab0 | ||
|
|
3590c7c073 | ||
|
|
3f614e8011 | ||
|
|
198a18508b | ||
|
|
780ac14a8f | ||
|
|
62b3cb6b70 | ||
|
|
714224bd29 | ||
|
|
7f6c46f0c8 | ||
|
|
fd9b0cf829 | ||
|
|
42ddfaab9d | ||
|
|
6144883a6e | ||
|
|
c704923b64 | ||
|
|
dcb502d3c8 | ||
|
|
a011d560c6 | ||
|
|
53060d50db | ||
|
|
68868388d3 | ||
|
|
75833b937b | ||
|
|
45b4acc31f | ||
|
|
0cd5a7334d | ||
|
|
40f5b597ab | ||
|
|
30bc88ed93 | ||
|
|
737a522afc | ||
|
|
eee0a3220c | ||
|
|
9d5d3012a5 | ||
|
|
e2413fc30d | ||
|
|
6193a07837 | ||
|
|
bb2f7bcda6 | ||
|
|
4ec36da9b5 | ||
|
|
83a1211067 | ||
|
|
367038a4b5 | ||
|
|
a0df3b1a54 | ||
|
|
70bf552daf | ||
|
|
d5e2abdcff | ||
|
|
07a926902a | ||
|
|
1881b5b1ba | ||
|
|
75e37158ef | ||
|
|
506de49586 | ||
|
|
dd704dc499 | ||
|
|
9a8b850bce | ||
|
|
187e842445 | ||
|
|
badafd2899 | ||
|
|
4bf075fcaf | ||
|
|
208a239af3 | ||
|
|
1fb97b5347 | ||
|
|
95c2e4fd68 | ||
|
|
79fa21321f | ||
|
|
c4c5993bd1 | ||
|
|
d46fd3fec4 | ||
|
|
279b48d432 | ||
|
|
61f09192bb | ||
|
|
d876809086 | ||
|
|
fe9156f878 | ||
|
|
35651e214f | ||
|
|
d373e0104d | ||
|
|
207a03a511 | ||
|
|
5bd32cd6c1 | ||
|
|
8c127a795b | ||
|
|
2567652a7d | ||
|
|
c023e6a9c4 | ||
|
|
44c6e4cd7b | ||
|
|
c22bd0637a | ||
|
|
a94b476726 | ||
|
|
4c6751b862 | ||
|
|
acc63d7b68 | ||
|
|
d0b1ea8582 | ||
|
|
c607061dae | ||
|
|
143de7b254 | ||
|
|
51ace73b78 | ||
|
|
fa9e9210ab | ||
|
|
f308410920 | ||
|
|
252dc319c6 | ||
|
|
29ceeef45b | ||
|
|
182dced403 | ||
|
|
aea36939a3 | ||
|
|
4240c1452a | ||
|
|
212c8e529d | ||
|
|
3fd630159b | ||
|
|
17d827f50b | ||
|
|
7dcbc839c6 | ||
|
|
45ffebc820 | ||
|
|
3027b1f218 | ||
|
|
3d2c419ebe | ||
|
|
b529fbfdd2 | ||
|
|
737c1dbf46 | ||
|
|
a67ef6c52c | ||
|
|
0adf11099e | ||
|
|
dbb9432eb0 |
@@ -0,0 +1,2 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
|
*.html linguist-vendored
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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 .
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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/)
|
||||||
|
|
||||||
|
|
||||||
|
## 界面预览
|
||||||
|
|
||||||
## ⚙️ 提示
|

|
||||||
|
|
||||||
主配置文件位于 `/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>
|
|
||||||
+13
-7
@@ -1,8 +1,14 @@
|
|||||||
services:
|
services:
|
||||||
ghproxy:
|
hubproxy:
|
||||||
build: .
|
image: ghcr.io/sky22333/hubproxy
|
||||||
restart: always
|
container_name: hubproxy
|
||||||
ports:
|
restart: always
|
||||||
- '5000:5000'
|
ports:
|
||||||
volumes:
|
- "5000:5000"
|
||||||
- ./src/config.toml:/root/config.toml
|
volumes:
|
||||||
|
- ./src/config.toml:/app/config.toml:ro
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "200m"
|
||||||
|
max-file: "3"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.astro/
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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'],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})
|
||||||
Generated
+6883
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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.
|
||||||
@@ -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 113–118 (header forward) and 209–231 (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/)
|
||||||
@@ -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
|
||||||
@@ -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/)
|
||||||
@@ -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` 第 113–118 行(转发请求头)、第 209–231 行(透传响应)。
|
||||||
|
|
||||||
|
## 离线镜像 tar
|
||||||
|
|
||||||
|
| 能力 | 支持 |
|
||||||
|
|------|------|
|
||||||
|
| 流式打包 tar | ✅ |
|
||||||
|
| 一次性 token(2 分钟) | ✅ |
|
||||||
|
| 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`。
|
||||||
|
|
||||||
|
## 原生 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`:
|
||||||
|
|
||||||
|
```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/)
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
---
|
||||||
|
title: NAS 配置
|
||||||
|
description: 在群晖 DSM 与飞牛 fnOS 上配置 HubProxy 镜像加速。
|
||||||
|
---
|
||||||
|
|
||||||
|
NAS 上的 Docker 通过图形界面管理,**不要**随意用脚本覆盖整个 `daemon.json`,以免丢失 `data-root` 导致容器丢失。
|
||||||
|
|
||||||
|
以下假设 HubProxy 已通过反代暴露在 `https://example.com`。
|
||||||
|
|
||||||
|
## 群晖 DSM(Container 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 与代理请求均会计入
|
||||||
@@ -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-Agent,2 分钟过期 |
|
||||||
|
|
||||||
|
## 未内置的能力
|
||||||
|
|
||||||
|
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/)
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "astro/tsconfigs/strict",
|
||||||
|
"include": [".astro/types.d.ts", "**/*"],
|
||||||
|
"exclude": ["dist"]
|
||||||
|
}
|
||||||
@@ -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
@@ -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 "安装 HubProxy:linux/${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"
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/var/log/hubproxy.log {
|
||||||
|
weekly
|
||||||
|
maxsize 50M
|
||||||
|
rotate 4
|
||||||
|
compress
|
||||||
|
missingok
|
||||||
|
notifempty
|
||||||
|
copytruncate
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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)
|
|
||||||
}
|
|
||||||
+91
-89
@@ -1,89 +1,91 @@
|
|||||||
[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 多路复用
|
||||||
[rateLimit]
|
enableH2C = false
|
||||||
# 每个IP每小时允许的请求数(Docker镜像每个层为一个请求)
|
enableFrontend = true
|
||||||
requestLimit = 200
|
|
||||||
# 限流周期(小时)
|
[rateLimit]
|
||||||
periodHours = 1.0
|
# 每个IP每周期允许的请求数
|
||||||
|
requestLimit = 500
|
||||||
[security]
|
# 限流周期(小时)
|
||||||
# IP白名单,支持单个IP或CIDR格式
|
periodHours = 3.0
|
||||||
# 白名单中的IP不受限流限制
|
|
||||||
whiteList = [
|
[security]
|
||||||
"127.0.0.1",
|
# IP白名单,支持单个IP或IP段
|
||||||
"192.168.1.0/24"
|
# 白名单中的IP不受限流限制
|
||||||
]
|
whiteList = [
|
||||||
|
"127.0.0.1",
|
||||||
# IP黑名单,支持单个IP或CIDR格式
|
"127.0.0.2"
|
||||||
# 黑名单中的IP将被直接拒绝访问
|
]
|
||||||
blackList = [
|
|
||||||
"192.168.100.1"
|
# IP黑名单,支持单个IP或IP段
|
||||||
]
|
# 黑名单中的IP将被直接拒绝访问
|
||||||
|
blackList = [
|
||||||
[proxy]
|
"192.168.100.1",
|
||||||
# 代理服务白名单(支持GitHub仓库和Docker镜像,支持通配符)
|
"192.168.100.0/24"
|
||||||
# 只允许访问白名单中的仓库/镜像,为空时不限制
|
]
|
||||||
whiteList = []
|
|
||||||
|
[access]
|
||||||
# 代理服务黑名单(支持GitHub仓库和Docker镜像,支持通配符)
|
# 代理服务白名单(支持GitHub仓库和Docker镜像,支持通配符)
|
||||||
# 禁止访问黑名单中的仓库/镜像
|
# 只允许访问白名单中的仓库/镜像,为空时不限制
|
||||||
blackList = [
|
whiteList = []
|
||||||
"baduser/malicious-repo",
|
|
||||||
"*/malicious-repo",
|
# 代理服务黑名单(支持GitHub仓库和Docker镜像,支持通配符)
|
||||||
"baduser/*"
|
# 禁止访问黑名单中的仓库/镜像
|
||||||
]
|
blackList = [
|
||||||
|
"baduser/malicious-repo",
|
||||||
[download]
|
"*/malicious-repo",
|
||||||
# 单次并发下载离线镜像数量限制
|
"baduser/*"
|
||||||
maxImages = 10
|
]
|
||||||
|
|
||||||
# Registry映射配置,支持多种Container Registry
|
# 代理配置,支持有用户名/密码认证和无认证模式
|
||||||
[registries]
|
# 无认证: socks5://127.0.0.1:1080
|
||||||
|
# 有认证: socks5://username:password@127.0.0.1:1080
|
||||||
# GitHub Container Registry
|
# 留空不使用代理
|
||||||
[registries."ghcr.io"]
|
proxy = ""
|
||||||
upstream = "ghcr.io"
|
|
||||||
authHost = "ghcr.io/token"
|
[download]
|
||||||
authType = "github"
|
# 批量下载离线镜像数量限制
|
||||||
enabled = true
|
maxImages = 10
|
||||||
|
|
||||||
# Google Container Registry
|
# Registry映射配置,支持多种镜像仓库上游
|
||||||
[registries."gcr.io"]
|
[registries]
|
||||||
upstream = "gcr.io"
|
|
||||||
authHost = "gcr.io/v2/token"
|
# GitHub Container Registry
|
||||||
authType = "google"
|
[registries."ghcr.io"]
|
||||||
enabled = true
|
upstream = "ghcr.io"
|
||||||
|
authHost = "ghcr.io/token"
|
||||||
# Quay.io Container Registry
|
authType = "github"
|
||||||
[registries."quay.io"]
|
enabled = true
|
||||||
upstream = "quay.io"
|
|
||||||
authHost = "quay.io/v2/auth"
|
# Google Container Registry
|
||||||
authType = "quay"
|
[registries."gcr.io"]
|
||||||
enabled = true
|
upstream = "gcr.io"
|
||||||
|
authHost = "gcr.io/v2/token"
|
||||||
# Kubernetes Container Registry
|
authType = "google"
|
||||||
[registries."registry.k8s.io"]
|
enabled = true
|
||||||
upstream = "registry.k8s.io"
|
|
||||||
authHost = "registry.k8s.io"
|
# Quay.io Container Registry
|
||||||
authType = "anonymous"
|
[registries."quay.io"]
|
||||||
enabled = true
|
upstream = "quay.io"
|
||||||
|
authHost = "quay.io/v2/auth"
|
||||||
# 私有Registry示例(默认禁用)
|
authType = "quay"
|
||||||
# [registries."harbor.company.com"]
|
enabled = true
|
||||||
# upstream = "harbor.company.com"
|
|
||||||
# authHost = "harbor.company.com/service/token"
|
# Kubernetes Container Registry
|
||||||
# authType = "basic"
|
[registries."registry.k8s.io"]
|
||||||
# enabled = false
|
upstream = "registry.k8s.io"
|
||||||
|
authHost = "registry.k8s.io"
|
||||||
# 缓存配置:Docker临时Token和Manifest统一管理,显著提升性能
|
authType = "anonymous"
|
||||||
[tokenCache]
|
enabled = true
|
||||||
# 是否启用缓存(同时控制Token和Manifest缓存)
|
|
||||||
enabled = true
|
[tokenCache]
|
||||||
# 默认缓存时间
|
# 是否启用缓存(同时控制Token和Manifest缓存)显著提升性能
|
||||||
defaultTTL = "20m"
|
enabled = true
|
||||||
|
# 默认缓存时间(分钟)
|
||||||
|
defaultTTL = "20m"
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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=
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+199
-381
@@ -1,381 +1,199 @@
|
|||||||
package main
|
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"
|
||||||
//go:embed public/*
|
"hubproxy/handlers"
|
||||||
var staticFiles embed.FS
|
"hubproxy/utils"
|
||||||
|
)
|
||||||
// 服务嵌入的静态文件
|
|
||||||
func serveEmbedFile(c *gin.Context, filename string) {
|
//go:embed all:dist
|
||||||
data, err := staticFiles.ReadFile(filename)
|
var staticFiles embed.FS
|
||||||
if err != nil {
|
|
||||||
c.Status(404)
|
var (
|
||||||
return
|
globalLimiter *utils.IPRateLimiter
|
||||||
}
|
serviceStartTime = time.Now()
|
||||||
contentType := "text/html; charset=utf-8"
|
)
|
||||||
if strings.HasSuffix(filename, ".ico") {
|
|
||||||
contentType = "image/x-icon"
|
var Version = "dev"
|
||||||
}
|
|
||||||
c.Data(200, contentType, data)
|
func init() {
|
||||||
}
|
for ext, typ := range map[string]string{
|
||||||
|
".js": "application/javascript; charset=utf-8",
|
||||||
var (
|
".mjs": "application/javascript; charset=utf-8",
|
||||||
exps = []*regexp.Regexp{
|
".woff": "font/woff",
|
||||||
regexp.MustCompile(`^(?:https?://)?github\.com/([^/]+)/([^/]+)/(?:releases|archive)/.*$`),
|
".woff2": "font/woff2",
|
||||||
regexp.MustCompile(`^(?:https?://)?github\.com/([^/]+)/([^/]+)/(?:blob|raw)/.*$`),
|
".map": "application/json",
|
||||||
regexp.MustCompile(`^(?:https?://)?github\.com/([^/]+)/([^/]+)/(?:info|git-).*$`),
|
} {
|
||||||
regexp.MustCompile(`^(?:https?://)?raw\.github(?:usercontent|)\.com/([^/]+)/([^/]+)/.+?/.+$`),
|
_ = mime.AddExtensionType(ext, typ)
|
||||||
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)?/([^/]+)/([^/]+)(?:/(.*))?$`),
|
func contentTypeFor(filename string) string {
|
||||||
regexp.MustCompile(`^(?:https?://)?download\.docker\.com/([^/]+)/.*\.(tgz|zip)$`),
|
if ct := mime.TypeByExtension(path.Ext(filename)); ct != "" {
|
||||||
regexp.MustCompile(`^(?:https?://)?(github|opengraph)\.githubassets\.com/([^/]+)/.+?$`),
|
return ct
|
||||||
}
|
}
|
||||||
globalLimiter *IPRateLimiter
|
return "application/octet-stream"
|
||||||
|
}
|
||||||
// 服务启动时间
|
|
||||||
serviceStartTime = time.Now()
|
func serveEmbedFile(c *gin.Context, filename string) {
|
||||||
)
|
data, err := staticFiles.ReadFile(filename)
|
||||||
|
if err != nil {
|
||||||
func main() {
|
c.Status(http.StatusNotFound)
|
||||||
// 加载配置
|
return
|
||||||
if err := LoadConfig(); err != nil {
|
}
|
||||||
fmt.Printf("配置加载失败: %v\n", err)
|
c.Data(http.StatusOK, contentTypeFor(filename), data)
|
||||||
return
|
}
|
||||||
}
|
|
||||||
|
func serveSPA(c *gin.Context) {
|
||||||
// 初始化HTTP客户端
|
serveEmbedFile(c, "dist/index.html")
|
||||||
initHTTPClients()
|
}
|
||||||
|
|
||||||
// 初始化限流器
|
func registerFrontendRoutes(router *gin.Engine, enabled bool) {
|
||||||
initLimiter()
|
if !enabled {
|
||||||
|
notFound := func(c *gin.Context) { c.Status(http.StatusNotFound) }
|
||||||
// 初始化Docker流式代理
|
router.GET("/", notFound)
|
||||||
initDockerProxy()
|
router.GET("/images", notFound)
|
||||||
|
router.GET("/search", notFound)
|
||||||
// 初始化镜像流式下载器
|
router.GET("/assets/*filepath", notFound)
|
||||||
initImageStreamer()
|
router.GET("/favicon.ico", notFound)
|
||||||
|
return
|
||||||
// 初始化防抖器
|
}
|
||||||
initDebouncer()
|
|
||||||
|
router.GET("/", serveSPA)
|
||||||
gin.SetMode(gin.ReleaseMode)
|
router.GET("/images", serveSPA)
|
||||||
router := gin.Default()
|
router.GET("/search", serveSPA)
|
||||||
|
router.GET("/favicon.ico", func(c *gin.Context) {
|
||||||
// 全局Panic恢复保护
|
serveEmbedFile(c, "dist/favicon.ico")
|
||||||
router.Use(gin.CustomRecovery(func(c *gin.Context, recovered interface{}) {
|
})
|
||||||
log.Printf("🚨 Panic recovered: %v", recovered)
|
router.GET("/assets/*filepath", func(c *gin.Context) {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
filepath := strings.TrimPrefix(c.Param("filepath"), "/")
|
||||||
"error": "Internal server error",
|
if filepath == "" || strings.Contains(filepath, "..") {
|
||||||
"code": "INTERNAL_ERROR",
|
c.Status(http.StatusNotFound)
|
||||||
})
|
return
|
||||||
}))
|
}
|
||||||
|
serveEmbedFile(c, path.Join("dist/assets", filepath))
|
||||||
// 初始化监控端点
|
})
|
||||||
initHealthRoutes(router)
|
}
|
||||||
|
|
||||||
// 初始化镜像tar下载路由
|
func buildRouter(cfg *config.AppConfig) *gin.Engine {
|
||||||
initImageTarRoutes(router)
|
gin.SetMode(gin.ReleaseMode)
|
||||||
|
router := gin.Default()
|
||||||
// 静态文件路由
|
utils.ConfigureTrustedProxies(router)
|
||||||
router.GET("/", func(c *gin.Context) {
|
|
||||||
serveEmbedFile(c, "public/index.html")
|
router.Use(gin.CustomRecovery(func(c *gin.Context, recovered interface{}) {
|
||||||
})
|
log.Printf("Panic 已恢复: %v", recovered)
|
||||||
router.GET("/public/*filepath", func(c *gin.Context) {
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
filepath := strings.TrimPrefix(c.Param("filepath"), "/")
|
"error": "Internal server error",
|
||||||
serveEmbedFile(c, "public/"+filepath)
|
"code": "INTERNAL_ERROR",
|
||||||
})
|
})
|
||||||
|
}))
|
||||||
router.GET("/images.html", func(c *gin.Context) {
|
|
||||||
serveEmbedFile(c, "public/images.html")
|
router.Use(utils.RateLimitMiddleware(globalLimiter))
|
||||||
})
|
|
||||||
router.GET("/search.html", func(c *gin.Context) {
|
initHealthRoutes(router)
|
||||||
serveEmbedFile(c, "public/search.html")
|
handlers.InitImageTarRoutes(router)
|
||||||
})
|
registerFrontendRoutes(router, cfg.Server.EnableFrontend)
|
||||||
router.GET("/favicon.ico", func(c *gin.Context) {
|
handlers.RegisterSearchRoute(router)
|
||||||
serveEmbedFile(c, "public/favicon.ico")
|
|
||||||
})
|
router.Any("/token", handlers.ProxyDockerAuthGin)
|
||||||
|
router.Any("/token/*path", handlers.ProxyDockerAuthGin)
|
||||||
// 注册dockerhub搜索路由
|
router.Any("/v2/*path", handlers.ProxyDockerRegistryGin)
|
||||||
RegisterSearchRoute(router)
|
router.NoRoute(handlers.GitHubProxyHandler)
|
||||||
|
|
||||||
// 注册Docker认证路由(/token*)
|
return router
|
||||||
router.Any("/token", RateLimitMiddleware(globalLimiter), ProxyDockerAuthGin)
|
}
|
||||||
router.Any("/token/*path", RateLimitMiddleware(globalLimiter), ProxyDockerAuthGin)
|
|
||||||
|
func main() {
|
||||||
// 注册Docker Registry代理路由
|
if err := config.LoadConfig(); err != nil {
|
||||||
router.Any("/v2/*path", RateLimitMiddleware(globalLimiter), ProxyDockerRegistryGin)
|
fmt.Printf("配置加载失败: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
// 注册NoRoute处理器
|
|
||||||
router.NoRoute(RateLimitMiddleware(globalLimiter), handler)
|
utils.InitHTTPClients()
|
||||||
|
globalLimiter = utils.InitGlobalLimiter()
|
||||||
cfg := GetConfig()
|
handlers.InitDockerProxy()
|
||||||
fmt.Printf("🚀 HubProxy 启动成功\n")
|
handlers.InitImageStreamer()
|
||||||
fmt.Printf("📡 监听地址: %s:%d\n", cfg.Server.Host, cfg.Server.Port)
|
handlers.InitDebouncer()
|
||||||
fmt.Printf("⚡ 限流配置: %d请求/%g小时\n", cfg.RateLimit.RequestLimit, cfg.RateLimit.PeriodHours)
|
|
||||||
fmt.Printf("🔗 项目地址: https://github.com/sky22333/hubproxy\n")
|
cfg := config.GetConfig()
|
||||||
|
router := buildRouter(cfg)
|
||||||
err := router.Run(fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port))
|
|
||||||
if err != nil {
|
fmt.Printf("HubProxy 启动成功\n")
|
||||||
fmt.Printf("启动服务失败: %v\n", err)
|
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")
|
||||||
func handler(c *gin.Context) {
|
}
|
||||||
rawPath := strings.TrimPrefix(c.Request.URL.RequestURI(), "/")
|
fmt.Printf("版本号: %s\n", Version)
|
||||||
|
fmt.Printf("项目地址: https://github.com/sky22333/hubproxy\n")
|
||||||
for strings.HasPrefix(rawPath, "/") {
|
|
||||||
rawPath = strings.TrimPrefix(rawPath, "/")
|
server := &http.Server{
|
||||||
}
|
Addr: fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port),
|
||||||
|
ReadTimeout: 60 * time.Second,
|
||||||
if !strings.HasPrefix(rawPath, "http") {
|
WriteTimeout: 30 * time.Minute,
|
||||||
c.String(http.StatusForbidden, "无效输入")
|
IdleTimeout: 120 * time.Second,
|
||||||
return
|
}
|
||||||
}
|
|
||||||
|
if cfg.Server.EnableH2C {
|
||||||
matches := checkURL(rawPath)
|
server.Handler = h2c.NewHandler(router, &http2.Server{
|
||||||
if matches != nil {
|
MaxConcurrentStreams: 250,
|
||||||
// GitHub仓库访问控制检查
|
IdleTimeout: 300 * time.Second,
|
||||||
if allowed, reason := GlobalAccessController.CheckGitHubAccess(matches); !allowed {
|
MaxReadFrameSize: 4 << 20,
|
||||||
// 构建仓库名用于日志
|
MaxUploadBufferPerConnection: 8 << 20,
|
||||||
var repoPath string
|
MaxUploadBufferPerStream: 2 << 20,
|
||||||
if len(matches) >= 2 {
|
})
|
||||||
username := matches[0]
|
} else {
|
||||||
repoName := strings.TrimSuffix(matches[1], ".git")
|
server.Handler = router
|
||||||
repoPath = username + "/" + repoName
|
}
|
||||||
}
|
|
||||||
fmt.Printf("GitHub仓库 %s 访问被拒绝: %s\n", repoPath, reason)
|
if err := server.ListenAndServe(); err != nil {
|
||||||
c.String(http.StatusForbidden, reason)
|
fmt.Printf("启动服务失败: %v\n", err)
|
||||||
return
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
c.String(http.StatusForbidden, "无效输入")
|
func formatDuration(d time.Duration) string {
|
||||||
return
|
if d < time.Minute {
|
||||||
}
|
return fmt.Sprintf("%d秒", int(d.Seconds()))
|
||||||
|
}
|
||||||
if exps[1].MatchString(rawPath) {
|
if d < time.Hour {
|
||||||
rawPath = strings.Replace(rawPath, "/blob/", "/raw/", 1)
|
return fmt.Sprintf("%d分钟%d秒", int(d.Minutes()), int(d.Seconds())%60)
|
||||||
}
|
}
|
||||||
|
if d < 24*time.Hour {
|
||||||
proxy(c, rawPath)
|
return fmt.Sprintf("%d小时%d分钟", int(d.Hours()), int(d.Minutes())%60)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
days := int(d.Hours()) / 24
|
||||||
func proxy(c *gin.Context, u string) {
|
hours := int(d.Hours()) % 24
|
||||||
proxyWithRedirect(c, u, 0)
|
return fmt.Sprintf("%d天%d小时", days, hours)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getUptimeInfo() (time.Duration, float64, string) {
|
||||||
func proxyWithRedirect(c *gin.Context, u string, redirectCount int) {
|
uptime := time.Since(serviceStartTime)
|
||||||
// 限制最大重定向次数,防止无限递归
|
return uptime, uptime.Seconds(), formatDuration(uptime)
|
||||||
const maxRedirects = 20
|
}
|
||||||
if redirectCount > maxRedirects {
|
|
||||||
c.String(http.StatusLoopDetected, "重定向次数过多,可能存在循环重定向")
|
func initHealthRoutes(router *gin.Engine) {
|
||||||
return
|
router.GET("/ready", func(c *gin.Context) {
|
||||||
}
|
_, uptimeSec, uptimeHuman := getUptimeInfo()
|
||||||
req, err := http.NewRequest(c.Request.Method, u, c.Request.Body)
|
c.JSON(http.StatusOK, gin.H{
|
||||||
if err != nil {
|
"ready": true,
|
||||||
c.String(http.StatusInternalServerError, fmt.Sprintf("server error %v", err))
|
"service": "hubproxy",
|
||||||
return
|
"version": Version,
|
||||||
}
|
"start_time_unix": serviceStartTime.Unix(),
|
||||||
|
"uptime_sec": uptimeSec,
|
||||||
for key, values := range c.Request.Header {
|
"uptime_human": uptimeHuman,
|
||||||
for _, value := range values {
|
})
|
||||||
req.Header.Add(key, value)
|
})
|
||||||
}
|
}
|
||||||
}
|
|
||||||
req.Header.Del("Host")
|
|
||||||
|
|
||||||
resp, err := GetGlobalHTTPClient().Do(req)
|
|
||||||
if err != nil {
|
|
||||||
c.String(http.StatusInternalServerError, fmt.Sprintf("server error %v", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
if err := resp.Body.Close(); err != nil {
|
|
||||||
fmt.Printf("关闭响应体失败: %v\n", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// 检查文件大小限制
|
|
||||||
cfg := GetConfig()
|
|
||||||
if contentLength := resp.Header.Get("Content-Length"); contentLength != "" {
|
|
||||||
if size, err := strconv.ParseInt(contentLength, 10, 64); err == nil && size > cfg.Server.FileSize {
|
|
||||||
c.String(http.StatusRequestEntityTooLarge,
|
|
||||||
fmt.Sprintf("文件过大,限制大小: %d MB", cfg.Server.FileSize/(1024*1024)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清理安全相关的头
|
|
||||||
resp.Header.Del("Content-Security-Policy")
|
|
||||||
resp.Header.Del("Referrer-Policy")
|
|
||||||
resp.Header.Del("Strict-Transport-Security")
|
|
||||||
|
|
||||||
// 获取真实域名
|
|
||||||
realHost := c.Request.Header.Get("X-Forwarded-Host")
|
|
||||||
if realHost == "" {
|
|
||||||
realHost = c.Request.Host
|
|
||||||
}
|
|
||||||
// 如果域名中没有协议前缀,添加https://
|
|
||||||
if !strings.HasPrefix(realHost, "http://") && !strings.HasPrefix(realHost, "https://") {
|
|
||||||
realHost = "https://" + realHost
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasSuffix(strings.ToLower(u), ".sh") {
|
|
||||||
isGzipCompressed := resp.Header.Get("Content-Encoding") == "gzip"
|
|
||||||
|
|
||||||
processedBody, processedSize, err := ProcessSmart(resp.Body, isGzipCompressed, realHost)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("智能处理失败,回退到直接代理: %v\n", err)
|
|
||||||
processedBody = resp.Body
|
|
||||||
processedSize = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// 智能设置响应头
|
|
||||||
if processedSize > 0 {
|
|
||||||
resp.Header.Del("Content-Length")
|
|
||||||
resp.Header.Del("Content-Encoding")
|
|
||||||
resp.Header.Set("Transfer-Encoding", "chunked")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 复制其他响应头
|
|
||||||
for key, values := range resp.Header {
|
|
||||||
for _, value := range values {
|
|
||||||
c.Header(key, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if location := resp.Header.Get("Location"); location != "" {
|
|
||||||
if checkURL(location) != nil {
|
|
||||||
c.Header("Location", "/"+location)
|
|
||||||
} else {
|
|
||||||
proxyWithRedirect(c, location, redirectCount+1)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Status(resp.StatusCode)
|
|
||||||
|
|
||||||
// 输出处理后的内容
|
|
||||||
if _, err := io.Copy(c.Writer, processedBody); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for key, values := range resp.Header {
|
|
||||||
for _, value := range values {
|
|
||||||
c.Header(key, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理重定向
|
|
||||||
if location := resp.Header.Get("Location"); location != "" {
|
|
||||||
if checkURL(location) != nil {
|
|
||||||
c.Header("Location", "/"+location)
|
|
||||||
} else {
|
|
||||||
proxyWithRedirect(c, location, redirectCount+1)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Status(resp.StatusCode)
|
|
||||||
|
|
||||||
// 直接流式转发
|
|
||||||
if _, err := io.Copy(c.Writer, resp.Body); err != nil {
|
|
||||||
fmt.Printf("直接代理失败: %v\n", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkURL(u string) []string {
|
|
||||||
for _, exp := range exps {
|
|
||||||
if matches := exp.FindStringSubmatch(u); matches != nil {
|
|
||||||
return matches[1:]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化健康监控路由
|
|
||||||
func initHealthRoutes(router *gin.Engine) {
|
|
||||||
// 健康检查端点
|
|
||||||
router.GET("/health", func(c *gin.Context) {
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"status": "healthy",
|
|
||||||
"timestamp": time.Now().Unix(),
|
|
||||||
"uptime": time.Since(serviceStartTime).Seconds(),
|
|
||||||
"service": "hubproxy",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// 就绪检查端点
|
|
||||||
router.GET("/ready", func(c *gin.Context) {
|
|
||||||
checks := make(map[string]string)
|
|
||||||
allReady := true
|
|
||||||
|
|
||||||
if GetConfig() != nil {
|
|
||||||
checks["config"] = "ok"
|
|
||||||
} else {
|
|
||||||
checks["config"] = "failed"
|
|
||||||
allReady = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查全局缓存状态
|
|
||||||
if globalCache != nil {
|
|
||||||
checks["cache"] = "ok"
|
|
||||||
} else {
|
|
||||||
checks["cache"] = "failed"
|
|
||||||
allReady = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查限流器状态
|
|
||||||
if globalLimiter != nil {
|
|
||||||
checks["ratelimiter"] = "ok"
|
|
||||||
} else {
|
|
||||||
checks["ratelimiter"] = "failed"
|
|
||||||
allReady = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查镜像下载器状态
|
|
||||||
if globalImageStreamer != nil {
|
|
||||||
checks["imagestreamer"] = "ok"
|
|
||||||
} else {
|
|
||||||
checks["imagestreamer"] = "failed"
|
|
||||||
allReady = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查HTTP客户端状态
|
|
||||||
if GetGlobalHTTPClient() != nil {
|
|
||||||
checks["httpclient"] = "ok"
|
|
||||||
} else {
|
|
||||||
checks["httpclient"] = "failed"
|
|
||||||
allReady = false
|
|
||||||
}
|
|
||||||
|
|
||||||
status := http.StatusOK
|
|
||||||
if !allReady {
|
|
||||||
status = http.StatusServiceUnavailable
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(status, gin.H{
|
|
||||||
"ready": allReady,
|
|
||||||
"checks": checks,
|
|
||||||
"timestamp": time.Now().Unix(),
|
|
||||||
"uptime": time.Since(serviceStartTime).Seconds(),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
Vendored
-789
@@ -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 redis:alpine 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>
|
|
||||||
Vendored
-833
@@ -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">×</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>
|
|
||||||
Vendored
-1316
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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,220 +1,210 @@
|
|||||||
package main
|
package utils
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
)
|
"hubproxy/config"
|
||||||
|
)
|
||||||
// ResourceType 资源类型
|
|
||||||
type ResourceType string
|
// ResourceType 资源类型
|
||||||
|
type ResourceType string
|
||||||
const (
|
|
||||||
ResourceTypeGitHub ResourceType = "github"
|
const (
|
||||||
ResourceTypeDocker ResourceType = "docker"
|
ResourceTypeGitHub ResourceType = "github"
|
||||||
)
|
ResourceTypeDocker ResourceType = "docker"
|
||||||
|
)
|
||||||
// AccessController 统一访问控制器
|
|
||||||
type AccessController struct {
|
// AccessController 统一访问控制器
|
||||||
mu sync.RWMutex
|
type AccessController struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// DockerImageInfo Docker镜像信息
|
// DockerImageInfo Docker镜像信息
|
||||||
type DockerImageInfo struct {
|
type DockerImageInfo struct {
|
||||||
Namespace string
|
Namespace string
|
||||||
Repository string
|
Repository string
|
||||||
Tag string
|
Tag string
|
||||||
FullName string
|
FullName string
|
||||||
}
|
}
|
||||||
|
|
||||||
// 全局访问控制器实例
|
// GlobalAccessController 全局访问控制器实例
|
||||||
var GlobalAccessController = &AccessController{}
|
var GlobalAccessController = &AccessController{}
|
||||||
|
|
||||||
// ParseDockerImage 解析Docker镜像名称
|
// ParseDockerImage 解析Docker镜像名称
|
||||||
func (ac *AccessController) ParseDockerImage(image string) DockerImageInfo {
|
func (ac *AccessController) ParseDockerImage(image string) DockerImageInfo {
|
||||||
image = strings.TrimPrefix(image, "docker://")
|
image = strings.TrimPrefix(image, "docker://")
|
||||||
|
|
||||||
var tag string
|
var tag string
|
||||||
if idx := strings.LastIndex(image, ":"); idx != -1 {
|
if idx := strings.LastIndex(image, ":"); idx != -1 {
|
||||||
part := image[idx+1:]
|
part := image[idx+1:]
|
||||||
if !strings.Contains(part, "/") {
|
if !strings.Contains(part, "/") {
|
||||||
tag = part
|
tag = part
|
||||||
image = image[:idx]
|
image = image[:idx]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if tag == "" {
|
if tag == "" {
|
||||||
tag = "latest"
|
tag = "latest"
|
||||||
}
|
}
|
||||||
|
|
||||||
var namespace, repository string
|
var namespace, repository string
|
||||||
if strings.Contains(image, "/") {
|
if strings.Contains(image, "/") {
|
||||||
parts := strings.Split(image, "/")
|
parts := strings.Split(image, "/")
|
||||||
if len(parts) >= 2 {
|
if len(parts) >= 2 {
|
||||||
if strings.Contains(parts[0], ".") {
|
if strings.Contains(parts[0], ".") {
|
||||||
if len(parts) >= 3 {
|
if len(parts) >= 3 {
|
||||||
namespace = parts[1]
|
namespace = parts[1]
|
||||||
repository = parts[2]
|
repository = parts[2]
|
||||||
} else {
|
} else {
|
||||||
namespace = "library"
|
namespace = "library"
|
||||||
repository = parts[1]
|
repository = parts[1]
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
namespace = parts[0]
|
namespace = parts[0]
|
||||||
repository = parts[1]
|
repository = parts[1]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
namespace = "library"
|
namespace = "library"
|
||||||
repository = image
|
repository = image
|
||||||
}
|
}
|
||||||
|
|
||||||
fullName := namespace + "/" + repository
|
fullName := namespace + "/" + repository
|
||||||
|
|
||||||
return DockerImageInfo{
|
return DockerImageInfo{
|
||||||
Namespace: namespace,
|
Namespace: namespace,
|
||||||
Repository: repository,
|
Repository: repository,
|
||||||
Tag: tag,
|
Tag: tag,
|
||||||
FullName: fullName,
|
FullName: fullName,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 !ac.matchImageInList(imageInfo, cfg.Access.WhiteList) {
|
||||||
if len(cfg.Proxy.WhiteList) > 0 {
|
return false, "不在Docker镜像白名单内"
|
||||||
if !ac.matchImageInList(imageInfo, cfg.Proxy.WhiteList) {
|
}
|
||||||
return false, "不在Docker镜像白名单内"
|
}
|
||||||
}
|
|
||||||
}
|
if len(cfg.Access.BlackList) > 0 {
|
||||||
|
if ac.matchImageInList(imageInfo, cfg.Access.BlackList) {
|
||||||
// 检查黑名单
|
return false, "Docker镜像在黑名单内"
|
||||||
if len(cfg.Proxy.BlackList) > 0 {
|
}
|
||||||
if ac.matchImageInList(imageInfo, cfg.Proxy.BlackList) {
|
}
|
||||||
return false, "Docker镜像在黑名单内"
|
|
||||||
}
|
return true, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
return true, ""
|
// CheckGitHubAccess 检查GitHub仓库访问权限
|
||||||
}
|
func (ac *AccessController) CheckGitHubAccess(matches []string) (allowed bool, reason string) {
|
||||||
|
if len(matches) < 2 {
|
||||||
// CheckGitHubAccess 检查GitHub仓库访问权限
|
return false, "无效的GitHub仓库格式"
|
||||||
func (ac *AccessController) CheckGitHubAccess(matches []string) (allowed bool, reason string) {
|
}
|
||||||
if len(matches) < 2 {
|
|
||||||
return false, "无效的GitHub仓库格式"
|
cfg := config.GetConfig()
|
||||||
}
|
|
||||||
|
if len(cfg.Access.WhiteList) > 0 && !ac.checkList(matches, cfg.Access.WhiteList) {
|
||||||
cfg := GetConfig()
|
return false, "不在GitHub仓库白名单内"
|
||||||
|
}
|
||||||
// 检查白名单
|
|
||||||
if len(cfg.Proxy.WhiteList) > 0 && !ac.checkList(matches, cfg.Proxy.WhiteList) {
|
if len(cfg.Access.BlackList) > 0 && ac.checkList(matches, cfg.Access.BlackList) {
|
||||||
return false, "不在GitHub仓库白名单内"
|
return false, "GitHub仓库在黑名单内"
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查黑名单
|
return true, ""
|
||||||
if len(cfg.Proxy.BlackList) > 0 && ac.checkList(matches, cfg.Proxy.BlackList) {
|
}
|
||||||
return false, "GitHub仓库在黑名单内"
|
|
||||||
}
|
// matchImageInList 检查Docker镜像是否在指定列表中
|
||||||
|
func (ac *AccessController) matchImageInList(imageInfo DockerImageInfo, list []string) bool {
|
||||||
return true, ""
|
fullName := strings.ToLower(imageInfo.FullName)
|
||||||
}
|
namespace := strings.ToLower(imageInfo.Namespace)
|
||||||
|
|
||||||
// matchImageInList 检查Docker镜像是否在指定列表中
|
for _, item := range list {
|
||||||
func (ac *AccessController) matchImageInList(imageInfo DockerImageInfo, list []string) bool {
|
item = strings.ToLower(strings.TrimSpace(item))
|
||||||
fullName := strings.ToLower(imageInfo.FullName)
|
if item == "" {
|
||||||
namespace := strings.ToLower(imageInfo.Namespace)
|
continue
|
||||||
|
}
|
||||||
for _, item := range list {
|
|
||||||
item = strings.ToLower(strings.TrimSpace(item))
|
if fullName == item {
|
||||||
if item == "" {
|
return true
|
||||||
continue
|
}
|
||||||
}
|
|
||||||
|
if item == namespace || item == namespace+"/*" {
|
||||||
if fullName == item {
|
return true
|
||||||
return true
|
}
|
||||||
}
|
|
||||||
|
if strings.HasSuffix(item, "*") {
|
||||||
if item == namespace || item == namespace+"/*" {
|
prefix := strings.TrimSuffix(item, "*")
|
||||||
return true
|
if strings.HasPrefix(fullName, prefix) {
|
||||||
}
|
return true
|
||||||
|
}
|
||||||
if strings.HasSuffix(item, "*") {
|
}
|
||||||
prefix := strings.TrimSuffix(item, "*")
|
|
||||||
if strings.HasPrefix(fullName, prefix) {
|
if strings.HasPrefix(item, "*/") {
|
||||||
return true
|
repoPattern := strings.TrimPrefix(item, "*/")
|
||||||
}
|
if strings.HasSuffix(repoPattern, "*") {
|
||||||
}
|
repoPrefix := strings.TrimSuffix(repoPattern, "*")
|
||||||
|
if strings.HasPrefix(imageInfo.Repository, repoPrefix) {
|
||||||
if strings.HasPrefix(item, "*/") {
|
return true
|
||||||
repoPattern := strings.TrimPrefix(item, "*/")
|
}
|
||||||
if strings.HasSuffix(repoPattern, "*") {
|
} else {
|
||||||
repoPrefix := strings.TrimSuffix(repoPattern, "*")
|
if strings.ToLower(imageInfo.Repository) == repoPattern {
|
||||||
if strings.HasPrefix(imageInfo.Repository, repoPrefix) {
|
return true
|
||||||
return true
|
}
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
if strings.ToLower(imageInfo.Repository) == repoPattern {
|
|
||||||
return true
|
if strings.HasPrefix(fullName, item+"/") {
|
||||||
}
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
if strings.HasPrefix(fullName, item+"/") {
|
}
|
||||||
return true
|
|
||||||
}
|
// checkList GitHub仓库检查逻辑
|
||||||
}
|
func (ac *AccessController) checkList(matches, list []string) bool {
|
||||||
return false
|
if len(matches) < 2 {
|
||||||
}
|
return false
|
||||||
|
}
|
||||||
// checkList GitHub仓库检查逻辑
|
|
||||||
func (ac *AccessController) checkList(matches, list []string) bool {
|
username := strings.ToLower(strings.TrimSpace(matches[0]))
|
||||||
if len(matches) < 2 {
|
repoName := strings.ToLower(strings.TrimSpace(strings.TrimSuffix(matches[1], ".git")))
|
||||||
return false
|
fullRepo := username + "/" + repoName
|
||||||
}
|
|
||||||
|
for _, item := range list {
|
||||||
username := strings.ToLower(strings.TrimSpace(matches[0]))
|
item = strings.ToLower(strings.TrimSpace(item))
|
||||||
repoName := strings.ToLower(strings.TrimSpace(strings.TrimSuffix(matches[1], ".git")))
|
if item == "" {
|
||||||
fullRepo := username + "/" + repoName
|
continue
|
||||||
|
}
|
||||||
for _, item := range list {
|
|
||||||
item = strings.ToLower(strings.TrimSpace(item))
|
if fullRepo == item {
|
||||||
if item == "" {
|
return true
|
||||||
continue
|
}
|
||||||
}
|
|
||||||
|
if item == username || item == username+"/*" {
|
||||||
// 支持多种匹配模式
|
return true
|
||||||
if fullRepo == item {
|
}
|
||||||
return true
|
|
||||||
}
|
if strings.HasSuffix(item, "*") {
|
||||||
|
prefix := strings.TrimSuffix(item, "*")
|
||||||
// 用户级匹配
|
if strings.HasPrefix(fullRepo, prefix) {
|
||||||
if item == username || item == username+"/*" {
|
return true
|
||||||
return true
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 前缀匹配(支持通配符)
|
if strings.HasPrefix(fullRepo, item+"/") {
|
||||||
if strings.HasSuffix(item, "*") {
|
return true
|
||||||
prefix := strings.TrimSuffix(item, "*")
|
}
|
||||||
if strings.HasPrefix(fullRepo, prefix) {
|
|
||||||
return true
|
if strings.HasPrefix(item, "*/") {
|
||||||
}
|
p := item[2:]
|
||||||
}
|
if p == repoName || (strings.HasSuffix(p, "*") && strings.HasPrefix(repoName, p[:len(p)-1])) {
|
||||||
|
return true
|
||||||
// 子仓库匹配(防止 user/repo 匹配到 user/repo-fork)
|
}
|
||||||
if strings.HasPrefix(fullRepo, item+"/") {
|
}
|
||||||
return true
|
}
|
||||||
}
|
return false
|
||||||
}
|
}
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reload 热重载访问控制规则
|
|
||||||
func (ac *AccessController) Reload() {
|
|
||||||
ac.mu.Lock()
|
|
||||||
defer ac.mu.Unlock()
|
|
||||||
|
|
||||||
// 访问控制器本身不缓存配置
|
|
||||||
}
|
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,97 +58,107 @@ 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 {
|
||||||
defaultTTL = parsed
|
defaultTTL = parsed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(reference, "sha256:") {
|
if strings.HasPrefix(reference, "sha256:") {
|
||||||
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 {
|
||||||
safeTTL := time.Duration(tokenResp.ExpiresIn-300) * time.Second
|
safeTTL := time.Duration(tokenResp.ExpiresIn-300) * time.Second
|
||||||
if safeTTL > 5*time.Minute {
|
if safeTTL > 5*time.Minute {
|
||||||
return safeTTL
|
return safeTTL
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -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,59 +1,67 @@
|
|||||||
package main
|
package utils
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"os"
|
||||||
)
|
"time"
|
||||||
|
|
||||||
var (
|
"hubproxy/config"
|
||||||
// 全局HTTP客户端 - 用于代理请求(长超时)
|
)
|
||||||
globalHTTPClient *http.Client
|
|
||||||
// 搜索HTTP客户端 - 用于API请求(短超时)
|
var (
|
||||||
searchHTTPClient *http.Client
|
globalHTTPClient *http.Client
|
||||||
)
|
searchHTTPClient *http.Client
|
||||||
|
)
|
||||||
// initHTTPClients 初始化HTTP客户端
|
|
||||||
func initHTTPClients() {
|
// InitHTTPClients 初始化HTTP客户端
|
||||||
// 代理客户端配置 - 适用于大文件传输
|
func InitHTTPClients() {
|
||||||
globalHTTPClient = &http.Client{
|
cfg := config.GetConfig()
|
||||||
Transport: &http.Transport{
|
|
||||||
DialContext: (&net.Dialer{
|
if p := cfg.Access.Proxy; p != "" {
|
||||||
Timeout: 30 * time.Second,
|
os.Setenv("HTTP_PROXY", p)
|
||||||
KeepAlive: 30 * time.Second,
|
os.Setenv("HTTPS_PROXY", p)
|
||||||
}).DialContext,
|
}
|
||||||
MaxIdleConns: 1000,
|
|
||||||
MaxIdleConnsPerHost: 1000,
|
globalHTTPClient = &http.Client{
|
||||||
IdleConnTimeout: 90 * time.Second,
|
Transport: &http.Transport{
|
||||||
TLSHandshakeTimeout: 10 * time.Second,
|
Proxy: http.ProxyFromEnvironment,
|
||||||
ExpectContinueTimeout: 1 * time.Second,
|
DialContext: (&net.Dialer{
|
||||||
ResponseHeaderTimeout: 300 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
},
|
KeepAlive: 30 * time.Second,
|
||||||
}
|
}).DialContext,
|
||||||
|
MaxIdleConns: 1000,
|
||||||
// 搜索客户端配置 - 适用于API调用
|
MaxIdleConnsPerHost: 1000,
|
||||||
searchHTTPClient = &http.Client{
|
IdleConnTimeout: 90 * time.Second,
|
||||||
Timeout: 10 * time.Second,
|
TLSHandshakeTimeout: 10 * time.Second,
|
||||||
Transport: &http.Transport{
|
ExpectContinueTimeout: 1 * time.Second,
|
||||||
DialContext: (&net.Dialer{
|
ResponseHeaderTimeout: 300 * time.Second,
|
||||||
Timeout: 5 * time.Second,
|
},
|
||||||
KeepAlive: 30 * time.Second,
|
}
|
||||||
}).DialContext,
|
|
||||||
MaxIdleConns: 100,
|
searchHTTPClient = &http.Client{
|
||||||
MaxIdleConnsPerHost: 10,
|
Timeout: 10 * time.Second,
|
||||||
IdleConnTimeout: 90 * time.Second,
|
Transport: &http.Transport{
|
||||||
TLSHandshakeTimeout: 5 * time.Second,
|
Proxy: http.ProxyFromEnvironment,
|
||||||
DisableCompression: false,
|
DialContext: (&net.Dialer{
|
||||||
},
|
Timeout: 5 * time.Second,
|
||||||
}
|
KeepAlive: 30 * time.Second,
|
||||||
}
|
}).DialContext,
|
||||||
|
MaxIdleConns: 100,
|
||||||
// GetGlobalHTTPClient 获取全局HTTP客户端(用于代理)
|
MaxIdleConnsPerHost: 10,
|
||||||
func GetGlobalHTTPClient() *http.Client {
|
IdleConnTimeout: 90 * time.Second,
|
||||||
return globalHTTPClient
|
TLSHandshakeTimeout: 5 * time.Second,
|
||||||
}
|
DisableCompression: false,
|
||||||
|
},
|
||||||
// GetSearchHTTPClient 获取搜索HTTP客户端(用于API调用)
|
}
|
||||||
func GetSearchHTTPClient() *http.Client {
|
}
|
||||||
return searchHTTPClient
|
|
||||||
}
|
// GetGlobalHTTPClient 获取全局HTTP客户端
|
||||||
|
func GetGlobalHTTPClient() *http.Client {
|
||||||
|
return globalHTTPClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSearchHTTPClient 获取搜索HTTP客户端
|
||||||
|
func GetSearchHTTPClient() *http.Client {
|
||||||
|
return searchHTTPClient
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user