mirror of
https://github.com/sky22333/hubproxy.git
synced 2026-06-24 17:14:26 +08:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba83a44492 | ||
|
|
c7a7f3d146 | ||
|
|
e4d4f33ea1 | ||
|
|
6e91fe9925 | ||
|
|
d0b3c657cc | ||
|
|
f5bc86ef79 | ||
|
|
23dd077f5d | ||
|
|
3917b2503a | ||
|
|
bb61eb5025 | ||
|
|
11c34459ca | ||
|
|
6659e977ae | ||
|
|
f77d951500 | ||
|
|
685388fff9 | ||
|
|
c6d95e683f | ||
|
|
f8828ccb74 | ||
|
|
fdc156adad | ||
|
|
80b0173d7c |
21
.github/workflows/docker-ghcr.yml
vendored
21
.github/workflows/docker-ghcr.yml
vendored
@@ -3,9 +3,9 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version number'
|
||||
description: '版本号 (例如: v1.0.0)'
|
||||
required: true
|
||||
default: 'latest'
|
||||
default: 'v1.0.0'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -15,13 +15,13 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Cache Docker layers
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: /tmp/.buildx-cache
|
||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||
@@ -29,14 +29,19 @@ jobs:
|
||||
${{ runner.os }}-buildx-
|
||||
|
||||
- name: Log in to GitHub Docker Registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set version from input
|
||||
run: echo "VERSION=${{ github.event.inputs.version }}" >> $GITHUB_ENV
|
||||
run: |
|
||||
VERSION=${{ github.event.inputs.version }}
|
||||
if [[ $VERSION == v* ]]; then
|
||||
VERSION=${VERSION:1}
|
||||
fi
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
|
||||
- name: Convert repository name to lowercase
|
||||
run: |
|
||||
@@ -53,4 +58,4 @@ jobs:
|
||||
--build-arg VERSION=${{ env.VERSION }} \
|
||||
-f Dockerfile .
|
||||
env:
|
||||
GHCR_PUBLIC: true # 将镜像设置为公开
|
||||
GHCR_PUBLIC: true
|
||||
|
||||
101
.github/workflows/release.yml
vendored
101
.github/workflows/release.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: 发布二进制文件
|
||||
|
||||
on:
|
||||
workflow_dispatch: # 手动触发
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: '版本号 (例如: v1.0.0)'
|
||||
@@ -16,12 +16,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 检出代码
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0 # 获取完整历史,用于生成变更日志
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 设置Go环境
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: "src/go.mod"
|
||||
cache-dependency-path: "src/go.sum"
|
||||
@@ -55,42 +55,33 @@ jobs:
|
||||
mkdir -p build/hubproxy
|
||||
|
||||
- name: 安装 UPX
|
||||
uses: crazy-max/ghaction-upx@v3
|
||||
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: 编译二进制文件
|
||||
run: |
|
||||
cd src
|
||||
VERSION=${{ steps.version.outputs.version }}
|
||||
|
||||
# Linux AMD64
|
||||
CGO_ENABLED=0 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
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o ../build/hubproxy/hubproxy-linux-arm64 .
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w -X main.Version=${VERSION}" -o ../build/hubproxy/hubproxy-linux-arm64 .
|
||||
|
||||
# 压缩二进制文件
|
||||
upx -9 ../build/hubproxy/hubproxy-linux-amd64
|
||||
upx -9 ../build/hubproxy/hubproxy-linux-arm64
|
||||
|
||||
- name: 复制配置文件
|
||||
- name: 准备压缩包文件
|
||||
run: |
|
||||
# 复制配置文件
|
||||
cp src/config.toml build/hubproxy/
|
||||
|
||||
# 复制systemd服务文件
|
||||
cp hubproxy.service build/hubproxy/
|
||||
|
||||
# 复制安装脚本
|
||||
cp install.sh build/hubproxy/
|
||||
|
||||
# 创建README文件
|
||||
cat > build/hubproxy/README.md << 'EOF'
|
||||
# HubProxy
|
||||
|
||||
项目地址:https://github.com/sky22333/hubproxy
|
||||
EOF
|
||||
|
||||
- name: 创建压缩包
|
||||
run: |
|
||||
cd build
|
||||
@@ -98,26 +89,64 @@ jobs:
|
||||
# Linux AMD64 包
|
||||
mkdir -p linux-amd64/hubproxy
|
||||
cp hubproxy/hubproxy-linux-amd64 linux-amd64/hubproxy/hubproxy
|
||||
cp hubproxy/config.toml hubproxy/hubproxy.service hubproxy/install.sh hubproxy/README.md linux-amd64/hubproxy/
|
||||
tar -czf hubproxy-${{ steps.version.outputs.version }}-linux-amd64.tar.gz -C linux-amd64 hubproxy
|
||||
cp hubproxy/config.toml linux-amd64/hubproxy/
|
||||
tar -czf hubproxy-linux-amd64.tar.gz -C linux-amd64 hubproxy
|
||||
|
||||
# Linux ARM64 包
|
||||
mkdir -p linux-arm64/hubproxy
|
||||
cp hubproxy/hubproxy-linux-arm64 linux-arm64/hubproxy/hubproxy
|
||||
cp hubproxy/config.toml hubproxy/hubproxy.service hubproxy/install.sh hubproxy/README.md linux-arm64/hubproxy/
|
||||
tar -czf hubproxy-${{ steps.version.outputs.version }}-linux-arm64.tar.gz -C linux-arm64 hubproxy
|
||||
cp hubproxy/config.toml linux-arm64/hubproxy/
|
||||
tar -czf hubproxy-linux-arm64.tar.gz -C linux-arm64 hubproxy
|
||||
|
||||
# 列出生成的文件
|
||||
ls -la *.tar.gz
|
||||
|
||||
- name: 计算文件校验和
|
||||
|
||||
- name: 创建Linux发行版安装包
|
||||
run: |
|
||||
cd build
|
||||
sha256sum *.tar.gz > checksums.txt
|
||||
cat checksums.txt
|
||||
mkdir -p build/packages
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
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
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
tag_name: ${{ steps.version.outputs.version }}
|
||||
name: "HubProxy ${{ steps.version.outputs.version }}"
|
||||
@@ -127,12 +156,16 @@ jobs:
|
||||
|
||||
## 下载文件
|
||||
|
||||
- **Linux AMD64**: `hubproxy-${{ steps.version.outputs.version }}-linux-amd64.tar.gz`
|
||||
- **Linux ARM64**: `hubproxy-${{ steps.version.outputs.version }}-linux-arm64.tar.gz`
|
||||
- **Linux AMD64**: `hubproxy-linux-amd64.tar.gz`
|
||||
- **Linux ARM64**: `hubproxy-linux-arm64.tar.gz`
|
||||
- **Debian/Ubuntu**: `.deb`
|
||||
- **RHEL/CentOS/Fedora**: `.rpm`
|
||||
- **Alpine Linux**: `.apk`
|
||||
|
||||
files: |
|
||||
build/*.tar.gz
|
||||
build/checksums.txt
|
||||
build/packages/*
|
||||
overwrite_files: true
|
||||
draft: false
|
||||
prerelease: false
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,5 +1,5 @@
|
||||
.idea
|
||||
.vscode
|
||||
.DS_Store
|
||||
hubproxy*
|
||||
!hubproxy.service
|
||||
/hubproxy*
|
||||
*.exe
|
||||
|
||||
11
Dockerfile
11
Dockerfile
@@ -1,20 +1,21 @@
|
||||
FROM golang:1.25-alpine AS builder
|
||||
FROM golang:1.26-alpine AS builder
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG VERSION=dev
|
||||
|
||||
WORKDIR /app
|
||||
COPY src/go.mod src/go.sum ./
|
||||
RUN go mod download && apk add upx
|
||||
RUN apk add --no-cache upx && go mod download
|
||||
|
||||
COPY src/ .
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -ldflags="-s -w" -trimpath -o hubproxy . && upx -9 hubproxy
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -ldflags="-s -w -X main.Version=${VERSION}" -trimpath -o hubproxy . && upx -9 hubproxy
|
||||
|
||||
FROM alpine
|
||||
|
||||
WORKDIR /root/
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/hubproxy .
|
||||
COPY --from=builder /app/config.toml .
|
||||
|
||||
CMD ["./hubproxy"]
|
||||
CMD ["./hubproxy"]
|
||||
|
||||
88
README.md
88
README.md
@@ -40,19 +40,70 @@ docker run -d \
|
||||
ghcr.io/sky22333/hubproxy
|
||||
```
|
||||
|
||||
### 一键脚本安装
|
||||
### 脚本安装
|
||||
|
||||
自动识别系统与架构,从 GitHub Releases 下载对应的 `.deb`、`.rpm` 或 `.apk` 安装包:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/sky22333/hubproxy/main/install.sh | sudo bash
|
||||
curl -fsSL https://raw.githubusercontent.com/sky22333/hubproxy/main/install.sh | sh
|
||||
```
|
||||
|
||||
支持单个二进制文件直接启动,无需其他配置,内置默认配置,支持所有功能。
|
||||
安装包会自动安装并启动 `hubproxy` 服务。
|
||||
|
||||
这个脚本会:
|
||||
- 自动检测系统架构(AMD64/ARM64)
|
||||
- 从 GitHub Releases 下载最新版本
|
||||
- 自动配置系统服务
|
||||
- 保留现有配置(升级时)
|
||||
<details>
|
||||
<summary>服务管理命令</summary>
|
||||
|
||||
#### systemd(Debian / Ubuntu / RHEL / CentOS / Fedora)
|
||||
|
||||
```bash
|
||||
# 查看状态
|
||||
sudo systemctl status hubproxy
|
||||
|
||||
# 重启服务
|
||||
sudo systemctl restart hubproxy
|
||||
|
||||
# 查看实时日志
|
||||
sudo journalctl -u hubproxy -f
|
||||
|
||||
# 编辑配置文件
|
||||
sudo nano /etc/hubproxy/config.toml
|
||||
|
||||
# 卸载服务
|
||||
sudo apt remove hubproxy
|
||||
|
||||
# 连配置一起清理
|
||||
sudo apt purge hubproxy
|
||||
```
|
||||
|
||||
#### OpenRC(Alpine Linux)
|
||||
|
||||
```bash
|
||||
# 查看状态
|
||||
sudo rc-service hubproxy status
|
||||
|
||||
# 重启服务
|
||||
sudo rc-service hubproxy restart
|
||||
|
||||
# 查看实时日志
|
||||
sudo tail -f /var/log/hubproxy.log
|
||||
|
||||
# 编辑配置文件
|
||||
sudo vi /etc/hubproxy/config.toml
|
||||
|
||||
# 卸载
|
||||
sudo apk del hubproxy
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### 文件路径
|
||||
|
||||
- Linux 安装包配置文件:`/etc/hubproxy/config.toml`
|
||||
- Linux 安装包二进制文件:`/usr/bin/hubproxy`
|
||||
- systemd 服务文件:`/lib/systemd/system/hubproxy.service`
|
||||
- Alpine OpenRC 服务文件:`/etc/init.d/hubproxy`
|
||||
- Alpine 日志文件:`/var/log/hubproxy.log`
|
||||
- Alpine 日志轮转配置:`/etc/logrotate.d/hubproxy`
|
||||
|
||||
## 使用方法
|
||||
|
||||
@@ -114,6 +165,8 @@ port = 5000
|
||||
fileSize = 2147483648
|
||||
# HTTP/2 多路复用,提升下载速度
|
||||
enableH2C = false
|
||||
# 是否启用前端静态页面
|
||||
enableFrontend = true
|
||||
|
||||
[rateLimit]
|
||||
# 每个IP每周期允许的请求数(注意Docker镜像会有多个层,会消耗多个次数)
|
||||
@@ -200,9 +253,24 @@ defaultTTL = "20m"
|
||||
|
||||
</details>
|
||||
|
||||
容器内的配置文件位于 `/root/config.toml`
|
||||
### 环境变量(可选)
|
||||
|
||||
脚本部署配置文件位于 `/opt/hubproxy/config.toml`
|
||||
支持通过环境变量覆盖部分配置,优先级高于`config.toml`,以下是默认值:
|
||||
|
||||
```
|
||||
CONFIG_PATH=config.toml # 配置文件路径
|
||||
SERVER_HOST=0.0.0.0 # 监听地址
|
||||
SERVER_PORT=5000 # 监听端口
|
||||
ENABLE_H2C=false # 是否启用 H2C
|
||||
ENABLE_FRONTEND=true # 是否启用前端静态页面
|
||||
MAX_FILE_SIZE=2147483648 # GitHub 文件大小限制(字节)
|
||||
RATE_LIMIT=500 # 每周期请求数
|
||||
RATE_PERIOD_HOURS=3 # 限流周期(小时)
|
||||
IP_WHITELIST=127.0.0.1,192.168.1.0/24 # IP 白名单(逗号分隔)
|
||||
IP_BLACKLIST=192.168.100.1,192.168.100.0/24 # IP 黑名单(逗号分隔)
|
||||
MAX_IMAGES=10 # 批量下载镜像数量限制
|
||||
ACCESS_PROXY= # 代理配置,例如 socks5://127.0.0.1:1080
|
||||
```
|
||||
|
||||
为了IP限流能够正常运行,反向代理需要传递IP头用来获取访客真实IP,以caddy为例:
|
||||
```
|
||||
|
||||
@@ -6,7 +6,7 @@ services:
|
||||
ports:
|
||||
- "5000:5000"
|
||||
volumes:
|
||||
- ./src/config.toml:/root/config.toml
|
||||
- ./src/config.toml:/app/config.toml:ro
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
|
||||
286
install.sh
286
install.sh
@@ -1,213 +1,121 @@
|
||||
#!/bin/bash
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# HubProxy 一键安装脚本
|
||||
# 支持自动下载最新版本或使用本地文件安装
|
||||
set -e
|
||||
REPO="${REPO:-sky22333/hubproxy}"
|
||||
VERSION="${VERSION:-latest}"
|
||||
TMP_DIR="${TMP_DIR:-/tmp/hubproxy-install}"
|
||||
|
||||
# 颜色定义
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
log() {
|
||||
printf '%s\n' "$*"
|
||||
}
|
||||
|
||||
# 配置
|
||||
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"
|
||||
fail() {
|
||||
printf 'HubProxy 安装失败:%s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo -e "${BLUE}HubProxy 一键安装脚本${NC}"
|
||||
echo "================================================="
|
||||
need_cmd() {
|
||||
command -v "$1" >/dev/null 2>&1 || fail "缺少必要命令:$1"
|
||||
}
|
||||
|
||||
# 检查是否以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)
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64)
|
||||
echo "amd64"
|
||||
;;
|
||||
aarch64|arm64)
|
||||
echo "arm64"
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}不支持的架构: $arch${NC}"
|
||||
exit 1
|
||||
fail "不支持的系统架构:$(uname -m)"
|
||||
;;
|
||||
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}"
|
||||
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
|
||||
echo -e "${YELLOW}保留现有配置文件${NC}"
|
||||
fail "不支持的系统:需要 apt、dnf、yum、rpm 或 apk"
|
||||
fi
|
||||
else
|
||||
echo -e "${YELLOW}配置文件不存在,将使用默认配置${NC}"
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
# 5. 安装systemd服务文件
|
||||
echo -e "${BLUE}安装systemd服务文件${NC}"
|
||||
cp "${SERVICE_NAME}.service" "/etc/systemd/system/"
|
||||
systemctl daemon-reload
|
||||
need_cmd curl
|
||||
|
||||
# 6. 恢复配置文件(如果有备份)
|
||||
if [ "$CONFIG_BACKUP_EXISTS" = true ]; then
|
||||
echo -e "${BLUE}恢复配置文件...${NC}"
|
||||
cp "${TEMP_DIR}/config.toml.backup" "${INSTALL_DIR}/${CONFIG_FILE}"
|
||||
fi
|
||||
ARCH="$(detect_arch)"
|
||||
PACKAGER="$(detect_packager)"
|
||||
|
||||
# 7. 启用并启动服务
|
||||
echo -e "${BLUE}启用并启动服务${NC}"
|
||||
systemctl enable ${SERVICE_NAME}
|
||||
systemctl start ${SERVICE_NAME}
|
||||
rm -rf "$TMP_DIR"
|
||||
mkdir -p "$TMP_DIR"
|
||||
trap 'rm -rf "$TMP_DIR"' EXIT INT TERM
|
||||
|
||||
# 8. 清理临时文件
|
||||
if [ "$LOCAL_INSTALL" = false ]; then
|
||||
echo -e "${BLUE}清理临时文件...${NC}"
|
||||
cd /
|
||||
rm -rf "${TEMP_DIR}"
|
||||
fi
|
||||
log "安装 HubProxy:linux/${ARCH}(${PACKAGER})"
|
||||
|
||||
# 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
|
||||
ASSET="$(asset_name "$PACKAGER" "$ARCH")"
|
||||
ASSET_URL="$(asset_url "$ASSET")"
|
||||
|
||||
PACKAGE_FILE="${TMP_DIR}/$(basename "$ASSET_URL")"
|
||||
log "下载安装包..."
|
||||
curl -fL -o "$PACKAGE_FILE" "$ASSET_URL" || fail "下载安装包失败"
|
||||
|
||||
log "安装软件包..."
|
||||
install_package "$PACKAGE_FILE" "$PACKAGER"
|
||||
|
||||
log "安装完成"
|
||||
log "默认端口:5000"
|
||||
log "配置文件:/etc/hubproxy/config.toml"
|
||||
|
||||
9
packaging/hubproxy.logrotate
Normal file
9
packaging/hubproxy.logrotate
Normal file
@@ -0,0 +1,9 @@
|
||||
/var/log/hubproxy.log {
|
||||
weekly
|
||||
maxsize 50M
|
||||
rotate 4
|
||||
compress
|
||||
missingok
|
||||
notifempty
|
||||
copytruncate
|
||||
}
|
||||
18
packaging/hubproxy.openrc
Normal file
18
packaging/hubproxy.openrc
Normal file
@@ -0,0 +1,18 @@
|
||||
#!/sbin/openrc-run
|
||||
|
||||
name="hubproxy"
|
||||
description="Docker and GitHub acceleration proxy server"
|
||||
command="/usr/bin/hubproxy"
|
||||
pidfile="/run/${RC_SVCNAME}.pid"
|
||||
output_log="/var/log/hubproxy.log"
|
||||
error_log="/var/log/hubproxy.log"
|
||||
supervisor="supervise-daemon"
|
||||
respawn_delay=5
|
||||
respawn_max=0
|
||||
|
||||
export CONFIG_PATH="/etc/hubproxy/config.toml"
|
||||
|
||||
depend() {
|
||||
need net
|
||||
after firewall
|
||||
}
|
||||
@@ -7,11 +7,10 @@ Wants=network-online.target
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
WorkingDirectory=/opt/hubproxy
|
||||
ExecStart=/opt/hubproxy/hubproxy
|
||||
Environment=CONFIG_PATH=/etc/hubproxy/config.toml
|
||||
ExecStart=/usr/bin/hubproxy
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=hubproxy
|
||||
45
packaging/nfpm.apk.yaml
Normal file
45
packaging/nfpm.apk.yaml
Normal file
@@ -0,0 +1,45 @@
|
||||
name: hubproxy
|
||||
arch: ${NFPM_ARCH}
|
||||
platform: linux
|
||||
version: ${NFPM_VERSION}
|
||||
release: "1"
|
||||
section: net
|
||||
priority: optional
|
||||
maintainer: sky22333
|
||||
description: Docker and GitHub acceleration proxy server
|
||||
vendor: sky22333
|
||||
homepage: https://github.com/sky22333/hubproxy
|
||||
license: MIT
|
||||
depends:
|
||||
- logrotate
|
||||
|
||||
contents:
|
||||
- src: ./build/package-root/hubproxy
|
||||
dst: /usr/bin/hubproxy
|
||||
file_info:
|
||||
mode: 0755
|
||||
|
||||
- src: ./src/config.toml
|
||||
dst: /etc/hubproxy/config.toml
|
||||
type: config|noreplace
|
||||
file_info:
|
||||
mode: 0644
|
||||
|
||||
- src: ./packaging/hubproxy.openrc
|
||||
dst: /etc/init.d/hubproxy
|
||||
file_info:
|
||||
mode: 0755
|
||||
|
||||
- src: ./packaging/hubproxy.logrotate
|
||||
dst: /etc/logrotate.d/hubproxy
|
||||
file_info:
|
||||
mode: 0644
|
||||
|
||||
scripts:
|
||||
postinstall: ./packaging/postinstall.sh
|
||||
preremove: ./packaging/preremove.sh
|
||||
postremove: ./packaging/postremove.sh
|
||||
|
||||
apk:
|
||||
scripts:
|
||||
postupgrade: ./packaging/postinstall.sh
|
||||
34
packaging/nfpm.deb-rpm.yaml
Normal file
34
packaging/nfpm.deb-rpm.yaml
Normal file
@@ -0,0 +1,34 @@
|
||||
name: hubproxy
|
||||
arch: ${NFPM_ARCH}
|
||||
platform: linux
|
||||
version: ${NFPM_VERSION}
|
||||
release: "1"
|
||||
section: net
|
||||
priority: optional
|
||||
maintainer: sky22333
|
||||
description: Docker and GitHub acceleration proxy server
|
||||
vendor: sky22333
|
||||
homepage: https://github.com/sky22333/hubproxy
|
||||
license: MIT
|
||||
|
||||
contents:
|
||||
- src: ./build/package-root/hubproxy
|
||||
dst: /usr/bin/hubproxy
|
||||
file_info:
|
||||
mode: 0755
|
||||
|
||||
- src: ./src/config.toml
|
||||
dst: /etc/hubproxy/config.toml
|
||||
type: config|noreplace
|
||||
file_info:
|
||||
mode: 0644
|
||||
|
||||
- src: ./packaging/hubproxy.service
|
||||
dst: /lib/systemd/system/hubproxy.service
|
||||
file_info:
|
||||
mode: 0644
|
||||
|
||||
scripts:
|
||||
postinstall: ./packaging/postinstall.sh
|
||||
preremove: ./packaging/preremove.sh
|
||||
postremove: ./packaging/postremove.sh
|
||||
27
packaging/postinstall.sh
Normal file
27
packaging/postinstall.sh
Normal file
@@ -0,0 +1,27 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
warn() {
|
||||
echo "hubproxy: $1"
|
||||
}
|
||||
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl daemon-reload || warn "systemd reload failed"
|
||||
systemctl enable hubproxy >/dev/null 2>&1 || warn "systemd enable failed"
|
||||
|
||||
if [ -d /run/systemd/system ]; then
|
||||
systemctl restart hubproxy || systemctl start hubproxy || {
|
||||
warn "service start failed, check: journalctl -u hubproxy"
|
||||
}
|
||||
fi
|
||||
fi
|
||||
|
||||
if command -v rc-update >/dev/null 2>&1; then
|
||||
rc-update add hubproxy default >/dev/null 2>&1 || warn "OpenRC enable failed"
|
||||
fi
|
||||
|
||||
if command -v rc-service >/dev/null 2>&1; then
|
||||
rc-service hubproxy restart || rc-service hubproxy start || {
|
||||
warn "service start failed, check: rc-service hubproxy status"
|
||||
}
|
||||
fi
|
||||
6
packaging/postremove.sh
Normal file
6
packaging/postremove.sh
Normal file
@@ -0,0 +1,6 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||
fi
|
||||
21
packaging/preremove.sh
Normal file
21
packaging/preremove.sh
Normal file
@@ -0,0 +1,21 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
case "${1:-}" in
|
||||
1|upgrade)
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl stop hubproxy >/dev/null 2>&1 || true
|
||||
systemctl disable hubproxy >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
if command -v rc-service >/dev/null 2>&1; then
|
||||
rc-service hubproxy stop >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
if command -v rc-update >/dev/null 2>&1; then
|
||||
rc-update del hubproxy default >/dev/null 2>&1 || true
|
||||
fi
|
||||
@@ -1,4 +1,5 @@
|
||||
[server]
|
||||
# 可通过 CONFIG_PATH 环境变量指定配置文件路径,默认读取当前工作目录下的 config.toml
|
||||
host = "0.0.0.0"
|
||||
# 监听端口
|
||||
port = 5000
|
||||
@@ -6,6 +7,7 @@ port = 5000
|
||||
fileSize = 2147483648
|
||||
# HTTP/2 多路复用
|
||||
enableH2C = false
|
||||
enableFrontend = true
|
||||
|
||||
[rateLimit]
|
||||
# 每个IP每周期允许的请求数
|
||||
|
||||
@@ -22,10 +22,11 @@ type RegistryMapping struct {
|
||||
// AppConfig 应用配置结构体
|
||||
type AppConfig struct {
|
||||
Server struct {
|
||||
Host string `toml:"host"`
|
||||
Port int `toml:"port"`
|
||||
FileSize int64 `toml:"fileSize"`
|
||||
EnableH2C bool `toml:"enableH2C"`
|
||||
Host string `toml:"host"`
|
||||
Port int `toml:"port"`
|
||||
FileSize int64 `toml:"fileSize"`
|
||||
EnableH2C bool `toml:"enableH2C"`
|
||||
EnableFrontend bool `toml:"enableFrontend"`
|
||||
} `toml:"server"`
|
||||
|
||||
RateLimit struct {
|
||||
@@ -70,15 +71,17 @@ var (
|
||||
func DefaultConfig() *AppConfig {
|
||||
return &AppConfig{
|
||||
Server: struct {
|
||||
Host string `toml:"host"`
|
||||
Port int `toml:"port"`
|
||||
FileSize int64 `toml:"fileSize"`
|
||||
EnableH2C bool `toml:"enableH2C"`
|
||||
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, // 2GB
|
||||
EnableH2C: false, // 默认关闭H2C
|
||||
Host: "0.0.0.0",
|
||||
Port: 5000,
|
||||
FileSize: 2 * 1024 * 1024 * 1024,
|
||||
EnableH2C: false,
|
||||
EnableFrontend: true,
|
||||
},
|
||||
RateLimit: struct {
|
||||
RequestLimit int `toml:"requestLimit"`
|
||||
@@ -194,16 +197,23 @@ func setConfig(cfg *AppConfig) {
|
||||
configCacheMutex.Unlock()
|
||||
}
|
||||
|
||||
// LoadConfig 加载配置文件
|
||||
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("config.toml"); err == nil {
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
if err := toml.Unmarshal(data, cfg); err != nil {
|
||||
return fmt.Errorf("解析配置文件失败: %v", err)
|
||||
return fmt.Errorf("解析配置文件 %s 失败: %v", path, err)
|
||||
}
|
||||
} else {
|
||||
fmt.Println("未找到config.toml,使用默认配置")
|
||||
fmt.Printf("未找到配置文件 %s,使用默认配置\n", path)
|
||||
}
|
||||
|
||||
overrideFromEnv(cfg)
|
||||
@@ -227,6 +237,11 @@ func overrideFromEnv(cfg *AppConfig) {
|
||||
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
|
||||
@@ -251,6 +266,10 @@ func overrideFromEnv(cfg *AppConfig) {
|
||||
cfg.Security.BlackList = append(cfg.Security.BlackList, strings.Split(val, ",")...)
|
||||
}
|
||||
|
||||
if val, ok := os.LookupEnv("ACCESS_PROXY"); ok {
|
||||
cfg.Access.Proxy = strings.TrimSpace(val)
|
||||
}
|
||||
|
||||
if val := os.Getenv("MAX_IMAGES"); val != "" {
|
||||
if maxImages, err := strconv.Atoi(val); err == nil && maxImages > 0 {
|
||||
cfg.Download.MaxImages = maxImages
|
||||
|
||||
41
src/config/config_test.go
Normal file
41
src/config/config_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadConfigUsesConfigPathAndEnvOverrides(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "custom.toml")
|
||||
data := []byte(`
|
||||
[server]
|
||||
host = "127.0.0.1"
|
||||
port = 5999
|
||||
|
||||
[access]
|
||||
proxy = "socks5://127.0.0.1:1080"
|
||||
`)
|
||||
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Setenv("CONFIG_PATH", path)
|
||||
t.Setenv("SERVER_PORT", "6001")
|
||||
t.Setenv("ACCESS_PROXY", "")
|
||||
|
||||
if err := LoadConfig(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg := GetConfig()
|
||||
if cfg.Server.Host != "127.0.0.1" {
|
||||
t.Fatalf("Server.Host = %q", cfg.Server.Host)
|
||||
}
|
||||
if cfg.Server.Port != 6001 {
|
||||
t.Fatalf("Server.Port = %d, want 6001", cfg.Server.Port)
|
||||
}
|
||||
if cfg.Access.Proxy != "" {
|
||||
t.Fatalf("Access.Proxy = %q, want empty override", cfg.Access.Proxy)
|
||||
}
|
||||
}
|
||||
62
src/go.mod
62
src/go.mod
@@ -1,33 +1,33 @@
|
||||
module hubproxy
|
||||
|
||||
go 1.25
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.10.1
|
||||
github.com/google/go-containerregistry v0.20.6
|
||||
github.com/pelletier/go-toml/v2 v2.2.4
|
||||
golang.org/x/net v0.43.0
|
||||
golang.org/x/time v0.12.0
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/google/go-containerregistry v0.21.5
|
||||
github.com/pelletier/go-toml/v2 v2.3.1
|
||||
golang.org/x/net v0.53.0
|
||||
golang.org/x/time v0.15.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect
|
||||
github.com/docker/cli v28.2.2+incompatible // indirect
|
||||
github.com/docker/distribution v2.8.3+incompatible // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/containerd/stargz-snapshotter/estargz v0.18.2 // indirect
|
||||
github.com/docker/cli v29.4.0+incompatible // indirect
|
||||
github.com/docker/docker-credential-helpers v0.9.3 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
@@ -35,16 +35,18 @@ require (
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/vbatts/tar-split v0.12.1 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/crypto v0.41.0 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
google.golang.org/protobuf v1.36.3 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
github.com/vbatts/tar-split v0.12.2 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/crypto v0.50.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gotest.tools/v3 v3.5.2 // indirect
|
||||
)
|
||||
|
||||
139
src/go.sum
139
src/go.sum
@@ -1,51 +1,49 @@
|
||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/containerd/stargz-snapshotter/estargz v0.16.3 h1:7evrXtoh1mSbGj/pfRccTampEyKpjpOnS3CyiV1Ebr8=
|
||||
github.com/containerd/stargz-snapshotter/estargz v0.16.3/go.mod h1:uyr4BfYfOj3G9WBVE8cOlQmXAbPN9VEQpBBeJIuOipU=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/containerd/stargz-snapshotter/estargz v0.18.2 h1:yXkZFYIzz3eoLwlTUZKz2iQ4MrckBxJjkmD16ynUTrw=
|
||||
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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/docker/cli v28.2.2+incompatible h1:qzx5BNUDFqlvyq4AHzdNB7gSyVTmU4cgsyN9SdInc1A=
|
||||
github.com/docker/cli v28.2.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||
github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk=
|
||||
github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
|
||||
github.com/docker/cli v29.4.0+incompatible h1:+IjXULMetlvWJiuSI0Nbor36lcJ5BTcVpUmB21KBoVM=
|
||||
github.com/docker/cli v29.4.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||
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/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
||||
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
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/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-containerregistry v0.20.6 h1:cvWX87UxxLgaH76b4hIvya6Dzz9qHB31qAwjAohdSTU=
|
||||
github.com/google/go-containerregistry v0.20.6/go.mod h1:T0x8MuoAoKX/873bkeSfLD2FAkwCDf9/HZgsFJ02E2Y=
|
||||
github.com/google/go-containerregistry v0.21.5 h1:KTJG9Pn/jC0VdZR6ctV3/jcN+q6/Iqlx0sTVz3ywZlM=
|
||||
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/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
@@ -61,56 +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/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
|
||||
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
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/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/vbatts/tar-split v0.12.1 h1:CqKoORW7BUWBe7UL/iqTVvkTBOF8UvOMKOIZykxnnbo=
|
||||
github.com/vbatts/tar-split v0.12.1/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.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=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CPa4=
|
||||
github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU=
|
||||
google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0=
|
||||
gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
|
||||
@@ -28,9 +28,16 @@ var dockerProxy *DockerProxy
|
||||
type RegistryDetector struct{}
|
||||
|
||||
// detectRegistryDomain 检测Registry域名并返回域名和剩余路径
|
||||
func (rd *RegistryDetector) detectRegistryDomain(path string) (string, string) {
|
||||
func (rd *RegistryDetector) detectRegistryDomain(c *gin.Context, path string) (string, string) {
|
||||
cfg := config.GetConfig()
|
||||
|
||||
// 兼容Containerd的ns参数
|
||||
if ns := c.Query("ns"); ns != "" {
|
||||
if mapping, exists := cfg.Registries[ns]; exists && mapping.Enabled {
|
||||
return ns, path
|
||||
}
|
||||
}
|
||||
|
||||
for domain := range cfg.Registries {
|
||||
if strings.HasPrefix(path, domain+"/") {
|
||||
remainingPath := strings.TrimPrefix(path, domain+"/")
|
||||
@@ -99,7 +106,7 @@ func ProxyDockerRegistryGin(c *gin.Context) {
|
||||
func handleRegistryRequest(c *gin.Context, path string) {
|
||||
pathWithoutV2 := strings.TrimPrefix(path, "/v2/")
|
||||
|
||||
if registryDomain, remainingPath := registryDetector.detectRegistryDomain(pathWithoutV2); registryDomain != "" {
|
||||
if registryDomain, remainingPath := registryDetector.detectRegistryDomain(c, pathWithoutV2); registryDomain != "" {
|
||||
if registryDetector.isRegistryEnabled(registryDomain) {
|
||||
c.Set("target_registry_domain", registryDomain)
|
||||
c.Set("target_path", remainingPath)
|
||||
@@ -267,7 +274,9 @@ func handleBlobRequest(c *gin.Context, imageRef, digest string) {
|
||||
c.Header("Docker-Content-Digest", digest)
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
io.Copy(c.Writer, reader)
|
||||
if _, err := io.Copy(c.Writer, reader); err != nil {
|
||||
fmt.Printf("复制layer内容失败: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleTagsRequest 处理tags列表请求
|
||||
@@ -409,7 +418,9 @@ func proxyDockerAuthOriginal(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.Status(resp.StatusCode)
|
||||
io.Copy(c.Writer, resp.Body)
|
||||
if _, err := io.Copy(c.Writer, resp.Body); err != nil {
|
||||
fmt.Printf("复制认证响应失败: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// rewriteAuthHeader 重写认证头
|
||||
@@ -562,7 +573,9 @@ func handleUpstreamBlobRequest(c *gin.Context, imageRef, digest string, mapping
|
||||
c.Header("Docker-Content-Digest", digest)
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
io.Copy(c.Writer, reader)
|
||||
if _, err := io.Copy(c.Writer, reader); err != nil {
|
||||
fmt.Printf("复制layer内容失败: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleUpstreamTagsRequest 处理上游Registry的tags请求
|
||||
|
||||
30
src/handlers/docker_test.go
Normal file
30
src/handlers/docker_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package handlers
|
||||
|
||||
import "testing"
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -129,7 +129,7 @@ func proxyGitHubWithRedirect(c *gin.Context, u string, redirectCount int) {
|
||||
fmt.Printf("关闭响应体失败: %v\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
// 检查并处理被阻止的内容类型
|
||||
if c.Request.Method == "GET" {
|
||||
if contentType := resp.Header.Get("Content-Type"); blockedContentTypes[strings.ToLower(strings.Split(contentType, ";")[0])] {
|
||||
@@ -171,9 +171,9 @@ func proxyGitHubWithRedirect(c *gin.Context, u string, redirectCount int) {
|
||||
|
||||
processedBody, processedSize, err := utils.ProcessSmart(resp.Body, isGzipCompressed, realHost)
|
||||
if err != nil {
|
||||
fmt.Printf("智能处理失败,回退到直接代理: %v\n", err)
|
||||
processedBody = resp.Body
|
||||
processedSize = 0
|
||||
fmt.Printf("脚本处理失败: %v\n", err)
|
||||
c.String(http.StatusBadGateway, "Script processing failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 智能设置响应头
|
||||
@@ -227,6 +227,8 @@ func proxyGitHubWithRedirect(c *gin.Context, u string, redirectCount int) {
|
||||
c.Status(resp.StatusCode)
|
||||
|
||||
// 直接流式转发
|
||||
io.Copy(c.Writer, resp.Body)
|
||||
if _, err := io.Copy(c.Writer, resp.Body); err != nil {
|
||||
fmt.Printf("转发响应体失败: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
32
src/handlers/github_test.go
Normal file
32
src/handlers/github_test.go
Normal file
@@ -0,0 +1,32 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,15 @@ import (
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -115,6 +118,15 @@ func getUserID(c *gin.Context) string {
|
||||
return "ip:" + hex.EncodeToString(hash[:8])
|
||||
}
|
||||
|
||||
func getClientIdentity(c *gin.Context) (string, string) {
|
||||
ip := c.ClientIP()
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
if userAgent == "" {
|
||||
userAgent = "unknown"
|
||||
}
|
||||
return ip, userAgent
|
||||
}
|
||||
|
||||
var (
|
||||
singleImageDebouncer *DownloadDebouncer
|
||||
batchImageDebouncer *DownloadDebouncer
|
||||
@@ -126,6 +138,98 @@ func InitDebouncer() {
|
||||
batchImageDebouncer = NewDownloadDebouncer(60 * time.Second)
|
||||
}
|
||||
|
||||
type BatchDownloadRequest struct {
|
||||
Images []string
|
||||
Platform string
|
||||
UseCompressedLayers bool
|
||||
}
|
||||
|
||||
type SingleDownloadRequest struct {
|
||||
Image string
|
||||
Platform string
|
||||
UseCompressedLayers bool
|
||||
}
|
||||
|
||||
type tokenEntry[T any] struct {
|
||||
Request T
|
||||
ExpiresAt time.Time
|
||||
IP string
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
type tokenStore[T any] struct {
|
||||
mu sync.RWMutex
|
||||
entries map[string]tokenEntry[T]
|
||||
}
|
||||
|
||||
const downloadTokenTTL = 2 * time.Minute
|
||||
const downloadTokenMaxEntries = 2000
|
||||
|
||||
func newTokenStore[T any]() *tokenStore[T] {
|
||||
return &tokenStore[T]{
|
||||
entries: make(map[string]tokenEntry[T]),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *tokenStore[T]) create(req T, ip, userAgent string) (string, error) {
|
||||
tokenBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(tokenBytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
token := base64.RawURLEncoding.EncodeToString(tokenBytes)
|
||||
now := time.Now()
|
||||
entry := tokenEntry[T]{
|
||||
Request: req,
|
||||
ExpiresAt: now.Add(downloadTokenTTL),
|
||||
IP: ip,
|
||||
UserAgent: userAgent,
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.cleanup(now)
|
||||
if len(s.entries) >= downloadTokenMaxEntries {
|
||||
s.mu.Unlock()
|
||||
return "", fmt.Errorf("令牌过多,请稍后再试")
|
||||
}
|
||||
s.entries[token] = entry
|
||||
s.mu.Unlock()
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (s *tokenStore[T]) consume(token, ip, userAgent string) (T, bool) {
|
||||
var empty T
|
||||
now := time.Now()
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
entry, exists := s.entries[token]
|
||||
if !exists {
|
||||
return empty, false
|
||||
}
|
||||
if now.After(entry.ExpiresAt) {
|
||||
delete(s.entries, token)
|
||||
return empty, false
|
||||
}
|
||||
if entry.IP != ip || entry.UserAgent != userAgent {
|
||||
delete(s.entries, token)
|
||||
return empty, false
|
||||
}
|
||||
delete(s.entries, token)
|
||||
return entry.Request, true
|
||||
}
|
||||
|
||||
func (s *tokenStore[T]) cleanup(now time.Time) {
|
||||
for token, entry := range s.entries {
|
||||
if now.After(entry.ExpiresAt) {
|
||||
delete(s.entries, token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var batchDownloadTokens = newTokenStore[BatchDownloadRequest]()
|
||||
var singleDownloadTokens = newTokenStore[SingleDownloadRequest]()
|
||||
|
||||
// ImageStreamer 镜像流式下载器
|
||||
type ImageStreamer struct {
|
||||
concurrency int
|
||||
@@ -209,6 +313,17 @@ func (is *ImageStreamer) getImageDescriptorWithPlatform(ref name.Reference, opti
|
||||
return remote.Get(ref, options...)
|
||||
}
|
||||
|
||||
func setDownloadHeaders(c *gin.Context, filename string, compressed bool) {
|
||||
c.Header("Content-Type", "application/octet-stream")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.Header("Pragma", "no-cache")
|
||||
c.Header("Expires", "0")
|
||||
if compressed {
|
||||
c.Header("Content-Encoding", "gzip")
|
||||
}
|
||||
}
|
||||
|
||||
// StreamImageToGin 流式响应到Gin
|
||||
func (is *ImageStreamer) StreamImageToGin(ctx context.Context, imageRef string, c *gin.Context, options *StreamOptions) error {
|
||||
if options == nil {
|
||||
@@ -216,12 +331,7 @@ func (is *ImageStreamer) StreamImageToGin(ctx context.Context, imageRef string,
|
||||
}
|
||||
|
||||
filename := strings.ReplaceAll(imageRef, "/", "_") + ".tar"
|
||||
c.Header("Content-Type", "application/octet-stream")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||
|
||||
if options.Compression {
|
||||
c.Header("Content-Encoding", "gzip")
|
||||
}
|
||||
setDownloadHeaders(c, filename, options.Compression)
|
||||
|
||||
return is.StreamImageToWriter(ctx, imageRef, c.Writer, options)
|
||||
}
|
||||
@@ -579,6 +689,7 @@ func InitImageTarRoutes(router *gin.Engine) {
|
||||
{
|
||||
imageAPI.GET("/download/:image", handleDirectImageDownload)
|
||||
imageAPI.GET("/info/:image", handleImageInfo)
|
||||
imageAPI.GET("/batch", handleSimpleBatchDownload)
|
||||
imageAPI.POST("/batch", handleSimpleBatchDownload)
|
||||
}
|
||||
}
|
||||
@@ -606,28 +717,73 @@ func handleDirectImageDownload(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "镜像引用格式错误: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if allowed, reason := utils.GlobalAccessController.CheckDockerAccess(imageRef); !allowed {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": reason})
|
||||
return
|
||||
}
|
||||
|
||||
userID := getUserID(c)
|
||||
contentKey := generateContentFingerprint([]string{imageRef}, platform)
|
||||
if c.Query("mode") == "prepare" {
|
||||
userID := getUserID(c)
|
||||
contentKey := generateContentFingerprint([]string{imageRef}, platform)
|
||||
|
||||
if !singleImageDebouncer.ShouldAllow(userID, contentKey) {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||
"error": "请求过于频繁,请稍后再试",
|
||||
"retry_after": 5,
|
||||
})
|
||||
if !singleImageDebouncer.ShouldAllow(userID, contentKey) {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||
"error": "请求过于频繁,请稍后再试",
|
||||
"retry_after": 5,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ip, userAgent := getClientIdentity(c)
|
||||
token, err := singleDownloadTokens.create(SingleDownloadRequest{
|
||||
Image: imageRef,
|
||||
Platform: platform,
|
||||
UseCompressedLayers: useCompressed,
|
||||
}, ip, userAgent)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
downloadURL := fmt.Sprintf("/api/image/download/%s?token=%s", imageParam, token)
|
||||
if tag != "" {
|
||||
downloadURL = downloadURL + "&tag=" + url.QueryEscape(tag)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"download_url": downloadURL})
|
||||
return
|
||||
}
|
||||
|
||||
token := c.Query("token")
|
||||
if token == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少下载令牌"})
|
||||
return
|
||||
}
|
||||
|
||||
ip, userAgent := getClientIdentity(c)
|
||||
req, ok := singleDownloadTokens.consume(token, ip, userAgent)
|
||||
if !ok {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效或过期的下载令牌"})
|
||||
return
|
||||
}
|
||||
if req.Image != imageRef {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "下载令牌与镜像不匹配"})
|
||||
return
|
||||
}
|
||||
if allowed, reason := utils.GlobalAccessController.CheckDockerAccess(req.Image); !allowed {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": reason})
|
||||
return
|
||||
}
|
||||
|
||||
options := &StreamOptions{
|
||||
Platform: platform,
|
||||
Platform: req.Platform,
|
||||
Compression: false,
|
||||
UseCompressedLayers: useCompressed,
|
||||
UseCompressedLayers: req.UseCompressedLayers,
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
log.Printf("下载镜像: %s (平台: %s)", imageRef, formatPlatformText(platform))
|
||||
log.Printf("下载镜像: %s (平台: %s)", req.Image, formatPlatformText(req.Platform))
|
||||
|
||||
if err := globalImageStreamer.StreamImageToGin(ctx, imageRef, c, options); err != nil {
|
||||
if err := globalImageStreamer.StreamImageToGin(ctx, req.Image, c, options); err != nil {
|
||||
log.Printf("镜像下载失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "镜像下载失败: " + err.Error()})
|
||||
return
|
||||
@@ -636,6 +792,51 @@ func handleDirectImageDownload(c *gin.Context) {
|
||||
|
||||
// handleSimpleBatchDownload 处理批量下载
|
||||
func handleSimpleBatchDownload(c *gin.Context) {
|
||||
if c.Request.Method == http.MethodGet {
|
||||
token := c.Query("token")
|
||||
if token == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少下载令牌"})
|
||||
return
|
||||
}
|
||||
|
||||
ip, userAgent := getClientIdentity(c)
|
||||
req, ok := batchDownloadTokens.consume(token, ip, userAgent)
|
||||
if !ok {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效或过期的下载令牌"})
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Images) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "镜像列表不能为空"})
|
||||
return
|
||||
}
|
||||
|
||||
options := &StreamOptions{
|
||||
Platform: req.Platform,
|
||||
Compression: false,
|
||||
UseCompressedLayers: req.UseCompressedLayers,
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
log.Printf("批量下载 %d 个镜像 (平台: %s)", len(req.Images), formatPlatformText(req.Platform))
|
||||
|
||||
filename := fmt.Sprintf("batch_%d_images.tar", len(req.Images))
|
||||
|
||||
setDownloadHeaders(c, filename, options.Compression)
|
||||
|
||||
if err := globalImageStreamer.StreamMultipleImages(ctx, req.Images, c.Writer, options); err != nil {
|
||||
log.Printf("批量镜像下载失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "批量镜像下载失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if c.Query("mode") != "prepare" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "只支持prepare模式"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Images []string `json:"images" binding:"required"`
|
||||
Platform string `json:"platform"`
|
||||
@@ -651,12 +852,24 @@ func handleSimpleBatchDownload(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "镜像列表不能为空"})
|
||||
return
|
||||
}
|
||||
for _, imageRef := range req.Images {
|
||||
if allowed, reason := utils.GlobalAccessController.CheckDockerAccess(imageRef); !allowed {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": reason})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
for i, imageRef := range req.Images {
|
||||
if !strings.Contains(imageRef, ":") && !strings.Contains(imageRef, "@") {
|
||||
req.Images[i] = imageRef + ":latest"
|
||||
}
|
||||
}
|
||||
for _, imageRef := range req.Images {
|
||||
if allowed, reason := utils.GlobalAccessController.CheckDockerAccess(imageRef); !allowed {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": reason})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
cfg := config.GetConfig()
|
||||
if len(req.Images) > cfg.Download.MaxImages {
|
||||
@@ -682,25 +895,19 @@ func handleSimpleBatchDownload(c *gin.Context) {
|
||||
useCompressed = *req.UseCompressedLayers
|
||||
}
|
||||
|
||||
options := &StreamOptions{
|
||||
batchReq := BatchDownloadRequest{
|
||||
Images: req.Images,
|
||||
Platform: req.Platform,
|
||||
Compression: false,
|
||||
UseCompressedLayers: useCompressed,
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
log.Printf("批量下载 %d 个镜像 (平台: %s)", len(req.Images), formatPlatformText(req.Platform))
|
||||
|
||||
filename := fmt.Sprintf("batch_%d_images.tar", len(req.Images))
|
||||
|
||||
c.Header("Content-Type", "application/octet-stream")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||
|
||||
if err := globalImageStreamer.StreamMultipleImages(ctx, req.Images, c.Writer, options); err != nil {
|
||||
log.Printf("批量镜像下载失败: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "批量镜像下载失败: " + err.Error()})
|
||||
ip, userAgent := getClientIdentity(c)
|
||||
token, err := batchDownloadTokens.create(batchReq, ip, userAgent)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"download_url": fmt.Sprintf("/api/image/batch?token=%s", token)})
|
||||
}
|
||||
|
||||
// handleImageInfo 处理镜像信息查询
|
||||
@@ -723,6 +930,10 @@ func handleImageInfo(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "镜像引用格式错误: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if allowed, reason := utils.GlobalAccessController.CheckDockerAccess(imageRef); !allowed {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": reason})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
contextOptions := append(globalImageStreamer.remoteOptions, remote.WithContext(ctx))
|
||||
|
||||
60
src/handlers/imagetar_test.go
Normal file
60
src/handlers/imagetar_test.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -160,51 +159,6 @@ func init() {
|
||||
}()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// normalizeRepository 统一规范化仓库信息
|
||||
func normalizeRepository(repo *Repository) {
|
||||
if repo.IsOfficial {
|
||||
@@ -297,7 +251,7 @@ func searchDockerHubWithDepth(ctx context.Context, query string, page, pageSize
|
||||
}
|
||||
return nil, fmt.Errorf("未找到相关镜像")
|
||||
case http.StatusBadGateway, http.StatusServiceUnavailable:
|
||||
return nil, fmt.Errorf("Docker Hub服务暂时不可用,请稍后重试")
|
||||
return nil, fmt.Errorf("docker hub 服务暂时不可用,请稍后重试")
|
||||
default:
|
||||
return nil, fmt.Errorf("请求失败: 状态码=%d, 响应=%s", resp.StatusCode, string(body))
|
||||
}
|
||||
@@ -487,10 +441,14 @@ func parsePaginationParams(c *gin.Context, defaultPageSize int) (page, pageSize
|
||||
pageSize = defaultPageSize
|
||||
|
||||
if p := c.Query("page"); p != "" {
|
||||
fmt.Sscanf(p, "%d", &page)
|
||||
if _, err := fmt.Sscanf(p, "%d", &page); err != nil {
|
||||
fmt.Printf("解析page参数失败: %v\n", err)
|
||||
}
|
||||
}
|
||||
if ps := c.Query("page_size"); ps != "" {
|
||||
fmt.Sscanf(ps, "%d", &pageSize)
|
||||
if _, err := fmt.Sscanf(ps, "%d", &pageSize); err != nil {
|
||||
fmt.Printf("解析page_size参数失败: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
return page, pageSize
|
||||
|
||||
45
src/handlers/search_test.go
Normal file
45
src/handlers/search_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestNormalizeRepository(t *testing.T) {
|
||||
official := &Repository{Name: "nginx", IsOfficial: true}
|
||||
normalizeRepository(official)
|
||||
if official.Namespace != "library" || official.Name != "library/nginx" {
|
||||
t.Fatalf("official normalized to %#v", official)
|
||||
}
|
||||
|
||||
userRepo := &Repository{Name: "owner/app", RepoOwner: "owner"}
|
||||
normalizeRepository(userRepo)
|
||||
if userRepo.Namespace != "owner" || userRepo.Name != "app" {
|
||||
t.Fatalf("user repo normalized to %#v", userRepo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePaginationParams(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
req := httptest.NewRequest(http.MethodGet, "/?page=3&page_size=50", nil)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = req
|
||||
|
||||
page, pageSize := parsePaginationParams(c, 25)
|
||||
if page != 3 || pageSize != 50 {
|
||||
t.Fatalf("pagination = %d %d", page, pageSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchCacheExpires(t *testing.T) {
|
||||
cache := &Cache{data: make(map[string]cacheEntry), maxSize: 10}
|
||||
cache.SetWithTTL("k", "v", -time.Second)
|
||||
|
||||
if got, ok := cache.Get("k"); ok || got != nil {
|
||||
t.Fatalf("expired cache returned: %#v", got)
|
||||
}
|
||||
}
|
||||
148
src/main.go
148
src/main.go
@@ -19,116 +19,103 @@ import (
|
||||
//go:embed public/*
|
||||
var staticFiles embed.FS
|
||||
|
||||
// 服务嵌入的静态文件
|
||||
var (
|
||||
globalLimiter *utils.IPRateLimiter
|
||||
serviceStartTime = time.Now()
|
||||
)
|
||||
|
||||
var Version = "dev"
|
||||
|
||||
func serveEmbedFile(c *gin.Context, filename string) {
|
||||
data, err := staticFiles.ReadFile(filename)
|
||||
if err != nil {
|
||||
c.Status(404)
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
contentType := "text/html; charset=utf-8"
|
||||
if strings.HasSuffix(filename, ".ico") {
|
||||
contentType = "image/x-icon"
|
||||
}
|
||||
c.Data(200, contentType, data)
|
||||
c.Data(http.StatusOK, contentType, data)
|
||||
}
|
||||
|
||||
var (
|
||||
globalLimiter *utils.IPRateLimiter
|
||||
|
||||
// 服务启动时间
|
||||
serviceStartTime = time.Now()
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 加载配置
|
||||
if err := config.LoadConfig(); err != nil {
|
||||
fmt.Printf("配置加载失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 初始化HTTP客户端
|
||||
utils.InitHTTPClients()
|
||||
|
||||
// 初始化限流器
|
||||
globalLimiter = utils.InitGlobalLimiter()
|
||||
|
||||
// 初始化Docker流式代理
|
||||
handlers.InitDockerProxy()
|
||||
|
||||
// 初始化镜像流式下载器
|
||||
handlers.InitImageStreamer()
|
||||
|
||||
// 初始化防抖器
|
||||
handlers.InitDebouncer()
|
||||
|
||||
func buildRouter(cfg *config.AppConfig) *gin.Engine {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
router := gin.Default()
|
||||
|
||||
// 全局Panic恢复保护
|
||||
router.Use(gin.CustomRecovery(func(c *gin.Context, recovered interface{}) {
|
||||
log.Printf("🚨 Panic recovered: %v", recovered)
|
||||
log.Printf("Panic 已恢复: %v", recovered)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Internal server error",
|
||||
"code": "INTERNAL_ERROR",
|
||||
})
|
||||
}))
|
||||
|
||||
// 全局限流中间件
|
||||
router.Use(utils.RateLimitMiddleware(globalLimiter))
|
||||
|
||||
// 初始化监控端点
|
||||
initHealthRoutes(router)
|
||||
|
||||
// 初始化镜像tar下载路由
|
||||
handlers.InitImageTarRoutes(router)
|
||||
|
||||
// 静态文件路由
|
||||
router.GET("/", func(c *gin.Context) {
|
||||
serveEmbedFile(c, "public/index.html")
|
||||
})
|
||||
router.GET("/public/*filepath", func(c *gin.Context) {
|
||||
filepath := strings.TrimPrefix(c.Param("filepath"), "/")
|
||||
serveEmbedFile(c, "public/"+filepath)
|
||||
})
|
||||
if cfg.Server.EnableFrontend {
|
||||
router.GET("/", func(c *gin.Context) {
|
||||
serveEmbedFile(c, "public/index.html")
|
||||
})
|
||||
router.GET("/public/*filepath", func(c *gin.Context) {
|
||||
filepath := strings.TrimPrefix(c.Param("filepath"), "/")
|
||||
serveEmbedFile(c, "public/"+filepath)
|
||||
})
|
||||
router.GET("/images.html", func(c *gin.Context) {
|
||||
serveEmbedFile(c, "public/images.html")
|
||||
})
|
||||
router.GET("/search.html", func(c *gin.Context) {
|
||||
serveEmbedFile(c, "public/search.html")
|
||||
})
|
||||
router.GET("/favicon.ico", func(c *gin.Context) {
|
||||
serveEmbedFile(c, "public/favicon.ico")
|
||||
})
|
||||
} else {
|
||||
router.GET("/", func(c *gin.Context) { c.Status(http.StatusNotFound) })
|
||||
router.GET("/public/*filepath", func(c *gin.Context) { c.Status(http.StatusNotFound) })
|
||||
router.GET("/images.html", func(c *gin.Context) { c.Status(http.StatusNotFound) })
|
||||
router.GET("/search.html", func(c *gin.Context) { c.Status(http.StatusNotFound) })
|
||||
router.GET("/favicon.ico", func(c *gin.Context) { c.Status(http.StatusNotFound) })
|
||||
}
|
||||
|
||||
router.GET("/images.html", func(c *gin.Context) {
|
||||
serveEmbedFile(c, "public/images.html")
|
||||
})
|
||||
router.GET("/search.html", func(c *gin.Context) {
|
||||
serveEmbedFile(c, "public/search.html")
|
||||
})
|
||||
router.GET("/favicon.ico", func(c *gin.Context) {
|
||||
serveEmbedFile(c, "public/favicon.ico")
|
||||
})
|
||||
|
||||
// 注册dockerhub搜索路由
|
||||
handlers.RegisterSearchRoute(router)
|
||||
|
||||
// 注册Docker认证路由
|
||||
router.Any("/token", handlers.ProxyDockerAuthGin)
|
||||
router.Any("/token/*path", handlers.ProxyDockerAuthGin)
|
||||
|
||||
// 注册Docker Registry代理路由
|
||||
router.Any("/v2/*path", handlers.ProxyDockerRegistryGin)
|
||||
|
||||
// 注册GitHub代理路由(NoRoute处理器)
|
||||
router.NoRoute(handlers.GitHubProxyHandler)
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := config.LoadConfig(); err != nil {
|
||||
fmt.Printf("配置加载失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.InitHTTPClients()
|
||||
globalLimiter = utils.InitGlobalLimiter()
|
||||
handlers.InitDockerProxy()
|
||||
handlers.InitImageStreamer()
|
||||
handlers.InitDebouncer()
|
||||
|
||||
cfg := config.GetConfig()
|
||||
router := buildRouter(cfg)
|
||||
|
||||
fmt.Printf("HubProxy 启动成功\n")
|
||||
fmt.Printf("监听地址: %s:%d\n", cfg.Server.Host, cfg.Server.Port)
|
||||
fmt.Printf("限流配置: %d请求/%g小时\n", cfg.RateLimit.RequestLimit, cfg.RateLimit.PeriodHours)
|
||||
|
||||
// 显示HTTP/2支持状态
|
||||
if cfg.Server.EnableH2C {
|
||||
fmt.Printf("H2c: 已启用\n")
|
||||
}
|
||||
|
||||
fmt.Printf("版本号: v1.2.0\n")
|
||||
fmt.Printf("版本号: %s\n", Version)
|
||||
fmt.Printf("项目地址: https://github.com/sky22333/hubproxy\n")
|
||||
|
||||
// 创建HTTP2服务器
|
||||
server := &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port),
|
||||
ReadTimeout: 60 * time.Second,
|
||||
@@ -136,39 +123,37 @@ func main() {
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
// 根据配置决定是否启用H2C
|
||||
if cfg.Server.EnableH2C {
|
||||
h2cHandler := h2c.NewHandler(router, &http2.Server{
|
||||
server.Handler = h2c.NewHandler(router, &http2.Server{
|
||||
MaxConcurrentStreams: 250,
|
||||
IdleTimeout: 300 * time.Second,
|
||||
MaxReadFrameSize: 4 << 20,
|
||||
MaxUploadBufferPerConnection: 8 << 20,
|
||||
MaxUploadBufferPerStream: 2 << 20,
|
||||
})
|
||||
server.Handler = h2cHandler
|
||||
} else {
|
||||
server.Handler = router
|
||||
}
|
||||
|
||||
err := server.ListenAndServe()
|
||||
if err != nil {
|
||||
if err := server.ListenAndServe(); err != nil {
|
||||
fmt.Printf("启动服务失败: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 简单的健康检查
|
||||
func formatDuration(d time.Duration) string {
|
||||
if d < time.Minute {
|
||||
return fmt.Sprintf("%d秒", int(d.Seconds()))
|
||||
} else if d < time.Hour {
|
||||
return fmt.Sprintf("%d分钟%d秒", int(d.Minutes()), int(d.Seconds())%60)
|
||||
} else if d < 24*time.Hour {
|
||||
return fmt.Sprintf("%d小时%d分钟", int(d.Hours()), int(d.Minutes())%60)
|
||||
} else {
|
||||
days := int(d.Hours()) / 24
|
||||
hours := int(d.Hours()) % 24
|
||||
return fmt.Sprintf("%d天%d小时", days, hours)
|
||||
}
|
||||
if d < time.Hour {
|
||||
return fmt.Sprintf("%d分钟%d秒", int(d.Minutes()), int(d.Seconds())%60)
|
||||
}
|
||||
if d < 24*time.Hour {
|
||||
return fmt.Sprintf("%d小时%d分钟", int(d.Hours()), int(d.Minutes())%60)
|
||||
}
|
||||
|
||||
days := int(d.Hours()) / 24
|
||||
hours := int(d.Hours()) % 24
|
||||
return fmt.Sprintf("%d天%d小时", days, hours)
|
||||
}
|
||||
|
||||
func getUptimeInfo() (time.Duration, float64, string) {
|
||||
@@ -182,6 +167,7 @@ func initHealthRoutes(router *gin.Engine) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ready": true,
|
||||
"service": "hubproxy",
|
||||
"version": Version,
|
||||
"start_time_unix": serviceStartTime.Unix(),
|
||||
"uptime_sec": uptimeSec,
|
||||
"uptime_human": uptimeHuman,
|
||||
|
||||
172
src/main_test.go
Normal file
172
src/main_test.go
Normal file
@@ -0,0 +1,172 @@
|
||||
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.html", "/search.html", "/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/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.HasPrefix(got.DownloadURL, "/api/image/download/nginx?token=") {
|
||||
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 TestSearchRouteRejectsMissingQuery(t *testing.T) {
|
||||
router := newTestRouter(t, "")
|
||||
|
||||
w := performRequest(router, http.MethodGet, "/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)
|
||||
}
|
||||
}
|
||||
151
src/public/images.html
vendored
151
src/public/images.html
vendored
@@ -728,7 +728,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
function buildDownloadUrl(imageName, platform = '', useCompressed = true) {
|
||||
function buildDownloadUrl(imageName, platform = '', useCompressed = true, mode = '') {
|
||||
const encodedImage = imageName.replace(/\//g, '_');
|
||||
let url = `/api/image/download/${encodedImage}`;
|
||||
|
||||
@@ -737,6 +737,9 @@
|
||||
params.append('platform', platform.trim());
|
||||
}
|
||||
params.append('compressed', useCompressed.toString());
|
||||
if (mode) {
|
||||
params.append('mode', mode);
|
||||
}
|
||||
|
||||
if (params.toString()) {
|
||||
url += '?' + params.toString();
|
||||
@@ -745,7 +748,65 @@
|
||||
return url;
|
||||
}
|
||||
|
||||
document.getElementById('singleForm').addEventListener('submit', function(e) {
|
||||
function buildInfoUrl(imageName) {
|
||||
const encodedImage = imageName.replace(/\//g, '_');
|
||||
return `/api/image/info/${encodedImage}`;
|
||||
}
|
||||
|
||||
async function preflightImageDownload(imageName) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 8000);
|
||||
try {
|
||||
const response = await fetch(buildInfoUrl(imageName), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
cache: 'no-store',
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
const contentType = response.headers.get('Content-Type') || '';
|
||||
let payload = null;
|
||||
if (contentType.includes('application/json')) {
|
||||
payload = await response.json();
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return { ok: false, error: (payload && payload.error) ? payload.error : '镜像预检失败' };
|
||||
}
|
||||
|
||||
if (payload && payload.success === false) {
|
||||
return { ok: false, error: payload.error || '镜像预检失败' };
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
return { ok: false, error: '预检超时,请稍后重试' };
|
||||
}
|
||||
return { ok: false, error: '网络错误: ' + error.message };
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
async function preflightImages(images) {
|
||||
const uniqueImages = Array.from(new Set(images));
|
||||
const results = await Promise.allSettled(uniqueImages.map((imageName) => preflightImageDownload(imageName)));
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const result = results[i];
|
||||
if (result.status === 'rejected') {
|
||||
return { ok: false, error: '预检失败,请稍后重试' };
|
||||
}
|
||||
if (!result.value.ok) {
|
||||
return { ok: false, error: result.value.error || '预检失败' };
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
document.getElementById('singleForm').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const imageName = document.getElementById('imageInput').value.trim();
|
||||
@@ -760,20 +821,50 @@
|
||||
hideStatus('singleStatus');
|
||||
setButtonLoading('downloadBtn', 'downloadText', 'downloadLoading', true);
|
||||
|
||||
const downloadUrl = buildDownloadUrl(imageName, platform, useCompressed);
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = downloadUrl;
|
||||
link.download = '';
|
||||
link.style.display = 'none';
|
||||
document.body.appendChild(link);
|
||||
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
const platformText = platform ? ` (${platform})` : '';
|
||||
showStatus('singleStatus', `开始下载 ${imageName}${platformText}`, 'success');
|
||||
setButtonLoading('downloadBtn', 'downloadText', 'downloadLoading', false);
|
||||
showStatus('singleStatus', '正在准备下载...', 'success');
|
||||
const preflightResult = await preflightImages([imageName]);
|
||||
if (!preflightResult.ok) {
|
||||
showStatus('singleStatus', preflightResult.error, 'error');
|
||||
setButtonLoading('downloadBtn', 'downloadText', 'downloadLoading', false);
|
||||
return;
|
||||
}
|
||||
|
||||
const prepareUrl = buildDownloadUrl(imageName, platform, useCompressed, 'prepare');
|
||||
try {
|
||||
const response = await fetch(prepareUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
cache: 'no-store'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (!data || !data.download_url) {
|
||||
showStatus('singleStatus', '下载地址生成失败', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = data.download_url;
|
||||
link.download = '';
|
||||
link.style.display = 'none';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
const platformText = platform ? ` (${platform})` : '';
|
||||
showStatus('singleStatus', `开始下载 ${imageName}${platformText}`, 'success');
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showStatus('singleStatus', error.error || '下载失败', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showStatus('singleStatus', '网络错误: ' + error.message, 'error');
|
||||
} finally {
|
||||
setButtonLoading('downloadBtn', 'downloadText', 'downloadLoading', false);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('batchForm').addEventListener('submit', async function(e) {
|
||||
@@ -808,9 +899,16 @@
|
||||
|
||||
hideStatus('batchStatus');
|
||||
setButtonLoading('batchDownloadBtn', 'batchDownloadText', 'batchDownloadLoading', true);
|
||||
showStatus('batchStatus', '正在准备下载...', 'success');
|
||||
const preflightResult = await preflightImages(images);
|
||||
if (!preflightResult.ok) {
|
||||
showStatus('batchStatus', preflightResult.error, 'error');
|
||||
setButtonLoading('batchDownloadBtn', 'batchDownloadText', 'batchDownloadLoading', false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/image/batch', {
|
||||
const response = await fetch('/api/image/batch?mode=prepare', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -819,24 +917,19 @@
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const contentDisposition = response.headers.get('Content-Disposition');
|
||||
let filename = `batch_${images.length}_images.tar`;
|
||||
|
||||
if (contentDisposition) {
|
||||
const matches = contentDisposition.match(/filename="(.+)"/);
|
||||
if (matches) filename = matches[1];
|
||||
const data = await response.json();
|
||||
if (!data || !data.download_url) {
|
||||
showStatus('batchStatus', '下载地址生成失败', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
|
||||
const url = data.download_url;
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.style.display = 'none';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
const platformText = platform ? ` (${platform})` : '';
|
||||
showStatus('batchStatus', `开始下载 ${images.length} 个镜像${platformText}`, 'success');
|
||||
@@ -872,4 +965,4 @@
|
||||
initMobileMenu();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
14
src/public/search.html
vendored
14
src/public/search.html
vendored
@@ -778,16 +778,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tag-list" id="tagList">
|
||||
<div class="pagination" id="tagPagination" style="display: none;">
|
||||
<button id="tagPrevPage" disabled>上一页</button>
|
||||
<button id="tagNextPage" disabled>下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tag-list" id="tagList"></div>
|
||||
</div>
|
||||
|
||||
<div id="toast"></div>
|
||||
|
||||
<script>
|
||||
const formatUtils = {
|
||||
formatNumber(num) {
|
||||
@@ -1047,7 +1041,6 @@
|
||||
}
|
||||
|
||||
// 分页更新函数
|
||||
const updateSearchPagination = () => updatePagination();
|
||||
const updateTagPagination = () => updatePagination({
|
||||
currentPage: currentTagPage,
|
||||
totalPages: totalTagPages,
|
||||
@@ -1217,9 +1210,6 @@
|
||||
const currentNamespace = namespace || repoInfo.namespace;
|
||||
const currentName = name || repoInfo.name;
|
||||
|
||||
// 调试日志
|
||||
console.log(`loadTagPage: namespace=${currentNamespace}, name=${currentName}, page=${currentTagPage}`);
|
||||
|
||||
if (!currentNamespace || !currentName) {
|
||||
showToast('命名空间和镜像名称不能为空');
|
||||
return;
|
||||
@@ -1488,4 +1478,4 @@
|
||||
</script>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -2,7 +2,6 @@ package utils
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"hubproxy/config"
|
||||
)
|
||||
@@ -17,7 +16,6 @@ const (
|
||||
|
||||
// AccessController 统一访问控制器
|
||||
type AccessController struct {
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// DockerImageInfo Docker镜像信息
|
||||
@@ -200,6 +198,13 @@ func (ac *AccessController) checkList(matches, list []string) bool {
|
||||
if strings.HasPrefix(fullRepo, item+"/") {
|
||||
return true
|
||||
}
|
||||
|
||||
if strings.HasPrefix(item, "*/") {
|
||||
p := item[2:]
|
||||
if p == repoName || (strings.HasSuffix(p, "*") && strings.HasPrefix(repoName, p[:len(p)-1])) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
86
src/utils/access_control_test.go
Normal file
86
src/utils/access_control_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"hubproxy/config"
|
||||
)
|
||||
|
||||
func TestParseDockerImage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
image string
|
||||
namespace string
|
||||
repository string
|
||||
tag string
|
||||
fullName string
|
||||
}{
|
||||
{"official", "nginx", "library", "nginx", "latest", "library/nginx"},
|
||||
{"tagged", "redis:7", "library", "redis", "7", "library/redis"},
|
||||
{"namespaced", "user/app:v1", "user", "app", "v1", "user/app"},
|
||||
{"registry", "ghcr.io/user/app:v2", "user", "app", "v2", "user/app"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := GlobalAccessController.ParseDockerImage(tt.image)
|
||||
if got.Namespace != tt.namespace || got.Repository != tt.repository || got.Tag != tt.tag || got.FullName != tt.fullName {
|
||||
t.Fatalf("ParseDockerImage(%q) = %#v", tt.image, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerAccessLists(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.toml")
|
||||
data := []byte(`
|
||||
[access]
|
||||
whiteList = ["library/*", "good/*"]
|
||||
blackList = ["good/bad"]
|
||||
`)
|
||||
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("CONFIG_PATH", path)
|
||||
if err := config.LoadConfig(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if allowed, reason := GlobalAccessController.CheckDockerAccess("nginx"); !allowed {
|
||||
t.Fatalf("nginx denied: %s", reason)
|
||||
}
|
||||
if allowed, _ := GlobalAccessController.CheckDockerAccess("good/bad:latest"); allowed {
|
||||
t.Fatal("blacklisted image allowed")
|
||||
}
|
||||
if allowed, _ := GlobalAccessController.CheckDockerAccess("other/app"); allowed {
|
||||
t.Fatal("image outside whitelist allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitHubAccessLists(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.toml")
|
||||
data := []byte(`
|
||||
[access]
|
||||
whiteList = ["allowed/*"]
|
||||
blackList = ["allowed/blocked"]
|
||||
`)
|
||||
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("CONFIG_PATH", path)
|
||||
if err := config.LoadConfig(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if allowed, reason := GlobalAccessController.CheckGitHubAccess([]string{"allowed", "repo"}); !allowed {
|
||||
t.Fatalf("allowed/repo denied: %s", reason)
|
||||
}
|
||||
if allowed, _ := GlobalAccessController.CheckGitHubAccess([]string{"allowed", "blocked"}); allowed {
|
||||
t.Fatal("blacklisted repo allowed")
|
||||
}
|
||||
if allowed, _ := GlobalAccessController.CheckGitHubAccess([]string{"other", "repo"}); allowed {
|
||||
t.Fatal("repo outside whitelist allowed")
|
||||
}
|
||||
}
|
||||
49
src/utils/cache_test.go
Normal file
49
src/utils/cache_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestUniversalCacheSetGetAndExpire(t *testing.T) {
|
||||
cache := &UniversalCache{}
|
||||
|
||||
cache.Set("k", []byte("v"), "text/plain", map[string]string{"X-Test": "1"}, time.Minute)
|
||||
if got := cache.Get("k"); got == nil || string(got.Data) != "v" || got.Headers["X-Test"] != "1" {
|
||||
t.Fatalf("cache hit mismatch: %#v", got)
|
||||
}
|
||||
|
||||
cache.Set("expired", []byte("v"), "", nil, -time.Second)
|
||||
if got := cache.Get("expired"); got != nil {
|
||||
t.Fatalf("expired item returned: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenCacheHelpers(t *testing.T) {
|
||||
cache := &UniversalCache{}
|
||||
cache.SetToken("token", `{"token":"abc"}`, time.Minute)
|
||||
|
||||
if got := cache.GetToken("token"); got != `{"token":"abc"}` {
|
||||
t.Fatalf("GetToken = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractTTLFromResponse(t *testing.T) {
|
||||
ttl := ExtractTTLFromResponse([]byte(`{"expires_in":3600}`))
|
||||
if ttl != 55*time.Minute {
|
||||
t.Fatalf("TTL = %s, want 55m", ttl)
|
||||
}
|
||||
|
||||
if ttl := ExtractTTLFromResponse([]byte(`{}`)); ttl != 30*time.Minute {
|
||||
t.Fatalf("default TTL = %s", ttl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCacheKeyStable(t *testing.T) {
|
||||
a := BuildCacheKey("p", "query")
|
||||
b := BuildCacheKey("p", "query")
|
||||
c := BuildCacheKey("p", "other")
|
||||
if a != b || a == c {
|
||||
t.Fatalf("unexpected keys: %q %q %q", a, b, c)
|
||||
}
|
||||
}
|
||||
@@ -12,47 +12,44 @@ import (
|
||||
// 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'")]*`)
|
||||
|
||||
// ProcessSmart Shell脚本智能处理函数
|
||||
func ProcessSmart(input io.ReadCloser, isCompressed bool, host string) (io.Reader, int64, error) {
|
||||
defer input.Close()
|
||||
// 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, fmt.Errorf("内容读取失败: %v", err)
|
||||
return nil, 0, 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 !bytes.Contains(content, []byte("github.com")) && !bytes.Contains(content, []byte("githubusercontent.com")) {
|
||||
return bytes.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)
|
||||
processed := processGitHubURLs(string(content), host)
|
||||
|
||||
return strings.NewReader(processed), int64(len(processed)), nil
|
||||
}
|
||||
|
||||
func readShellContent(input io.ReadCloser, isCompressed bool) (string, error) {
|
||||
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 "", fmt.Errorf("读取数据失败: %v", err)
|
||||
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 "", fmt.Errorf("gzip解压失败: %v", err)
|
||||
return nil, fmt.Errorf("gzip解压失败: %v", err)
|
||||
}
|
||||
defer gzReader.Close()
|
||||
reader = gzReader
|
||||
@@ -61,12 +58,19 @@ func readShellContent(input io.ReadCloser, isCompressed bool) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
limit := int64(MaxShellSize + 1)
|
||||
limitedReader := io.LimitReader(reader, limit)
|
||||
|
||||
data, err := io.ReadAll(limitedReader)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取内容失败: %v", err)
|
||||
return nil, fmt.Errorf("读取内容失败: %v", err)
|
||||
}
|
||||
|
||||
return string(data), nil
|
||||
if int64(len(data)) > MaxShellSize {
|
||||
return nil, fmt.Errorf("脚本文件过大,超过 %d MB 限制", MaxShellSize/1024/1024)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func processGitHubURLs(content, host string) string {
|
||||
@@ -100,4 +104,4 @@ func transformURL(url, host string) string {
|
||||
host = strings.TrimSuffix(host, "/")
|
||||
|
||||
return host + "/" + url
|
||||
}
|
||||
}
|
||||
|
||||
69
src/utils/proxy_shell_test.go
Normal file
69
src/utils/proxy_shell_test.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProcessSmartRewritesGitHubURLs(t *testing.T) {
|
||||
input := `curl -L https://github.com/user/repo/releases/download/v1/file.sh`
|
||||
reader, size, err := ProcessSmart(strings.NewReader(input), false, "proxy.example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
buf := new(strings.Builder)
|
||||
if _, err := io.Copy(buf, reader); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want := "https://proxy.example.com/https://github.com/user/repo/releases/download/v1/file.sh"
|
||||
if !strings.Contains(buf.String(), want) {
|
||||
t.Fatalf("processed script = %q, want contains %q", buf.String(), want)
|
||||
}
|
||||
if size != int64(len(buf.String())) {
|
||||
t.Fatalf("size = %d, want %d", size, len(buf.String()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessSmartKeepsNonGitHubContent(t *testing.T) {
|
||||
input := "echo hello"
|
||||
reader, _, err := ProcessSmart(strings.NewReader(input), false, "proxy.example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
buf := new(strings.Builder)
|
||||
if _, err := io.Copy(buf, reader); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if buf.String() != input {
|
||||
t.Fatalf("content changed: %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadShellContentGzip(t *testing.T) {
|
||||
var compressed strings.Builder
|
||||
gz := gzip.NewWriter(&compressed)
|
||||
if _, err := gz.Write([]byte("echo https://github.com/u/r")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := gz.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reader, _, err := ProcessSmart(strings.NewReader(compressed.String()), true, "proxy.example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
buf := new(strings.Builder)
|
||||
if _, err := io.Copy(buf, reader); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "https://proxy.example.com/https://github.com/u/r") {
|
||||
t.Fatalf("gzip content not rewritten: %q", buf.String())
|
||||
}
|
||||
}
|
||||
@@ -176,8 +176,9 @@ func (i *IPRateLimiter) GetLimiter(ip string) (*rate.Limiter, bool) {
|
||||
|
||||
now := time.Now()
|
||||
|
||||
var entry *rateLimiterEntry
|
||||
i.mu.RLock()
|
||||
entry, exists := i.ips[normalizedIP]
|
||||
_, exists := i.ips[normalizedIP]
|
||||
i.mu.RUnlock()
|
||||
|
||||
if exists {
|
||||
|
||||
21
src/utils/ratelimiter_test.go
Normal file
21
src/utils/ratelimiter_test.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package utils
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestExtractIPFromAddress(t *testing.T) {
|
||||
if got := extractIPFromAddress("127.0.0.1:5000"); got != "127.0.0.1" {
|
||||
t.Fatalf("extract IPv4 = %q", got)
|
||||
}
|
||||
if got := extractIPFromAddress("[2001:db8::1]:5000"); got != "2001:db8::1" {
|
||||
t.Fatalf("extract IPv6 = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeIPv6ForRateLimit(t *testing.T) {
|
||||
if got := normalizeIPForRateLimit("192.168.1.2"); got != "192.168.1.2" {
|
||||
t.Fatalf("IPv4 normalized = %q", got)
|
||||
}
|
||||
if got := normalizeIPForRateLimit("2001:db8::1"); got != "2001:db8::/64" {
|
||||
t.Fatalf("IPv6 normalized = %q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user