17 Commits
v1.2.0 ... main

Author SHA1 Message Date
sky22333
ba83a44492 fix 2026-05-11 23:14:21 +08:00
sky22333
c7a7f3d146 fix 2026-05-11 21:52:02 +08:00
sky22333
e4d4f33ea1 fix 2026-05-11 21:28:44 +08:00
sky22333
6e91fe9925 优化构建和打包 2026-05-11 21:16:24 +08:00
sky22333
d0b3c657cc 更新构建配置并补充测试 2026-05-06 19:16:27 +08:00
user123
f5bc86ef79 补齐访问控制 2026-02-02 09:53:45 +08:00
user123
23dd077f5d 优化离线下载镜像的实现 2026-02-02 06:12:31 +08:00
user123
3917b2503a 版本注入 2026-01-26 23:49:53 +08:00
user123
bb61eb5025 更新文档 2026-01-26 23:27:58 +08:00
user123
11c34459ca 支持禁用前端静态文件路由 2026-01-26 23:06:05 +08:00
user123
6659e977ae 优化代码质量 2026-01-25 14:03:21 +08:00
starry
f77d951500 Merge pull request #93 from sky22333/registry-alpha
shell OOM
2026-01-10 23:11:02 +08:00
user123
685388fff9 shell OOM 2026-01-10 23:04:16 +08:00
user123
c6d95e683f update 2026-01-10 21:23:38 +08:00
user123
f8828ccb74 v1.2.1 2026-01-10 21:06:02 +08:00
user123
fdc156adad 修复GitHub用户名通配符 2026-01-10 20:54:45 +08:00
user123
80b0173d7c 兼容Containerd的ns参数 2026-01-10 20:29:42 +08:00
39 changed files with 1664 additions and 600 deletions

View File

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

View File

@@ -1,7 +1,7 @@
name: 发布二进制文件 name: 发布二进制文件
on: on:
workflow_dispatch: # 手动触发 workflow_dispatch:
inputs: inputs:
version: version:
description: '版本号 (例如: v1.0.0)' description: '版本号 (例如: v1.0.0)'
@@ -16,12 +16,12 @@ jobs:
steps: steps:
- name: 检出代码 - name: 检出代码
uses: actions/checkout@v4 uses: actions/checkout@v6
with: with:
fetch-depth: 0 # 获取完整历史,用于生成变更日志 fetch-depth: 0
- name: 设置Go环境 - name: 设置Go环境
uses: actions/setup-go@v5 uses: actions/setup-go@v6
with: with:
go-version-file: "src/go.mod" go-version-file: "src/go.mod"
cache-dependency-path: "src/go.sum" cache-dependency-path: "src/go.sum"
@@ -55,42 +55,33 @@ jobs:
mkdir -p build/hubproxy mkdir -p build/hubproxy
- name: 安装 UPX - name: 安装 UPX
uses: crazy-max/ghaction-upx@v3 uses: crazy-max/ghaction-upx@v4
with: with:
install-only: true install-only: true
- name: 安装 nFPM
run: go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.46.3
- name: 编译二进制文件 - name: 编译二进制文件
run: | run: |
cd src cd src
VERSION=${{ steps.version.outputs.version }}
# Linux AMD64 # Linux AMD64
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 # 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-amd64
upx -9 ../build/hubproxy/hubproxy-linux-arm64 upx -9 ../build/hubproxy/hubproxy-linux-arm64
- name: 复制配置文件 - name: 准备压缩包文件
run: | run: |
# 复制配置文件 # 复制配置文件
cp src/config.toml build/hubproxy/ 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: 创建压缩包 - name: 创建压缩包
run: | run: |
cd build cd build
@@ -98,26 +89,64 @@ jobs:
# Linux AMD64 包 # Linux AMD64 包
mkdir -p linux-amd64/hubproxy mkdir -p linux-amd64/hubproxy
cp hubproxy/hubproxy-linux-amd64 linux-amd64/hubproxy/hubproxy cp hubproxy/hubproxy-linux-amd64 linux-amd64/hubproxy/hubproxy
cp hubproxy/config.toml hubproxy/hubproxy.service hubproxy/install.sh hubproxy/README.md linux-amd64/hubproxy/ cp hubproxy/config.toml linux-amd64/hubproxy/
tar -czf hubproxy-${{ steps.version.outputs.version }}-linux-amd64.tar.gz -C linux-amd64 hubproxy tar -czf hubproxy-linux-amd64.tar.gz -C linux-amd64 hubproxy
# Linux ARM64 包 # Linux ARM64 包
mkdir -p linux-arm64/hubproxy mkdir -p linux-arm64/hubproxy
cp hubproxy/hubproxy-linux-arm64 linux-arm64/hubproxy/hubproxy cp hubproxy/hubproxy-linux-arm64 linux-arm64/hubproxy/hubproxy
cp hubproxy/config.toml hubproxy/hubproxy.service hubproxy/install.sh hubproxy/README.md linux-arm64/hubproxy/ cp hubproxy/config.toml linux-arm64/hubproxy/
tar -czf hubproxy-${{ steps.version.outputs.version }}-linux-arm64.tar.gz -C linux-arm64 hubproxy tar -czf hubproxy-linux-arm64.tar.gz -C linux-arm64 hubproxy
# 列出生成的文件 # 列出生成的文件
ls -la *.tar.gz ls -la *.tar.gz
- name: 计算文件校验和 - name: 创建Linux发行版安装包
run: | run: |
cd build mkdir -p build/packages
sha256sum *.tar.gz > checksums.txt VERSION="${{ steps.version.outputs.version }}"
cat checksums.txt NFPM_VERSION="${VERSION#v}"
package() {
hubproxy_arch="$1"
nfpm_arch="$2"
packager="$3"
config="$4"
target="build/packages/hubproxy-linux-${hubproxy_arch}.${packager}"
temp_dir="build/packages/${hubproxy_arch}-${packager}"
binary="./build/hubproxy/hubproxy-linux-${hubproxy_arch}"
rm -rf "${temp_dir}"
mkdir -p "${temp_dir}"
rm -rf build/package-root
mkdir -p build/package-root
cp "${binary}" build/package-root/hubproxy
NFPM_ARCH="${nfpm_arch}" NFPM_VERSION="${NFPM_VERSION}" nfpm package --config "${config}" --packager "${packager}" --target "${temp_dir}/"
mv "${temp_dir}"/*.${packager} "${target}"
rm -rf "${temp_dir}"
rm -rf build/package-root
}
# AMD64 包
package amd64 amd64 deb packaging/nfpm.deb-rpm.yaml
package amd64 amd64 rpm packaging/nfpm.deb-rpm.yaml
package amd64 amd64 apk packaging/nfpm.apk.yaml
# ARM64 包
package arm64 arm64 deb packaging/nfpm.deb-rpm.yaml
package arm64 arm64 rpm packaging/nfpm.deb-rpm.yaml
package arm64 arm64 apk packaging/nfpm.apk.yaml
ls -la build/packages
- name: 检查安装包内容
run: |
dpkg-deb -c build/packages/hubproxy-linux-amd64.deb
rpm -qpl build/packages/hubproxy-linux-amd64.rpm
tar -tf build/packages/hubproxy-linux-amd64.apk
- name: 创建或更新Release - name: 创建或更新Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v3
with: with:
tag_name: ${{ steps.version.outputs.version }} tag_name: ${{ steps.version.outputs.version }}
name: "HubProxy ${{ steps.version.outputs.version }}" name: "HubProxy ${{ steps.version.outputs.version }}"
@@ -127,12 +156,16 @@ jobs:
## 下载文件 ## 下载文件
- **Linux AMD64**: `hubproxy-${{ steps.version.outputs.version }}-linux-amd64.tar.gz` - **Linux AMD64**: `hubproxy-linux-amd64.tar.gz`
- **Linux ARM64**: `hubproxy-${{ steps.version.outputs.version }}-linux-arm64.tar.gz` - **Linux ARM64**: `hubproxy-linux-arm64.tar.gz`
- **Debian/Ubuntu**: `.deb`
- **RHEL/CentOS/Fedora**: `.rpm`
- **Alpine Linux**: `.apk`
files: | files: |
build/*.tar.gz build/*.tar.gz
build/checksums.txt build/packages/*
overwrite_files: true
draft: false draft: false
prerelease: false prerelease: false
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}

4
.gitignore vendored
View File

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

View File

@@ -1,20 +1,21 @@
FROM golang:1.25-alpine AS builder FROM golang:1.26-alpine AS builder
ARG TARGETARCH ARG TARGETARCH
ARG VERSION=dev
WORKDIR /app WORKDIR /app
COPY src/go.mod src/go.sum ./ COPY src/go.mod src/go.sum ./
RUN go mod download && apk add upx RUN apk add --no-cache upx && go mod download
COPY src/ . 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 FROM alpine
WORKDIR /root/ WORKDIR /app
COPY --from=builder /app/hubproxy . COPY --from=builder /app/hubproxy .
COPY --from=builder /app/config.toml . COPY --from=builder /app/config.toml .
CMD ["./hubproxy"] CMD ["./hubproxy"]

View File

@@ -40,19 +40,70 @@ docker run -d \
ghcr.io/sky22333/hubproxy ghcr.io/sky22333/hubproxy
``` ```
### 一键脚本安装 ### 脚本安装
自动识别系统与架构,从 GitHub Releases 下载对应的 `.deb``.rpm``.apk` 安装包:
```bash ```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` 服务
这个脚本会: <details>
- 自动检测系统架构AMD64/ARM64 <summary>服务管理命令</summary>
- 从 GitHub Releases 下载最新版本
- 自动配置系统服务 #### systemdDebian / 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
```
#### OpenRCAlpine 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 fileSize = 2147483648
# HTTP/2 多路复用,提升下载速度 # HTTP/2 多路复用,提升下载速度
enableH2C = false enableH2C = false
# 是否启用前端静态页面
enableFrontend = true
[rateLimit] [rateLimit]
# 每个IP每周期允许的请求数(注意Docker镜像会有多个层会消耗多个次数) # 每个IP每周期允许的请求数(注意Docker镜像会有多个层会消耗多个次数)
@@ -200,9 +253,24 @@ defaultTTL = "20m"
</details> </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为例 为了IP限流能够正常运行反向代理需要传递IP头用来获取访客真实IP以caddy为例
``` ```

View File

@@ -6,7 +6,7 @@ services:
ports: ports:
- "5000:5000" - "5000:5000"
volumes: volumes:
- ./src/config.toml:/root/config.toml - ./src/config.toml:/app/config.toml:ro
logging: logging:
driver: json-file driver: json-file
options: options:

View File

@@ -1,213 +1,121 @@
#!/bin/bash #!/bin/sh
set -eu
# HubProxy 一键安装脚本 REPO="${REPO:-sky22333/hubproxy}"
# 支持自动下载最新版本或使用本地文件安装 VERSION="${VERSION:-latest}"
set -e TMP_DIR="${TMP_DIR:-/tmp/hubproxy-install}"
# 颜色定义 log() {
RED='\033[0;31m' printf '%s\n' "$*"
GREEN='\033[0;32m' }
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# 配置 fail() {
REPO="sky22333/hubproxy" printf 'HubProxy 安装失败:%s\n' "$*" >&2
GITHUB_API="https://api.github.com/repos/${REPO}" exit 1
GITHUB_RELEASES="${GITHUB_API}/releases" }
SERVICE_NAME="hubproxy"
INSTALL_DIR="/opt/hubproxy"
CONFIG_FILE="config.toml"
BINARY_NAME="hubproxy"
LOG_DIR="/var/log/hubproxy"
TEMP_DIR="/tmp/hubproxy-install"
echo -e "${BLUE}HubProxy 一键安装脚本${NC}" need_cmd() {
echo "=================================================" 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() { detect_arch() {
local arch=$(uname -m) case "$(uname -m)" in
case $arch in x86_64|amd64)
x86_64)
echo "amd64" echo "amd64"
;; ;;
aarch64|arm64) aarch64|arm64)
echo "arm64" echo "arm64"
;; ;;
*) *)
echo -e "${RED}不支持的架构: $arch${NC}" fail "不支持的系统架构:$(uname -m)"
exit 1
;; ;;
esac esac
} }
ARCH=$(detect_arch) detect_packager() {
echo -e "${BLUE}检测到架构: linux-${ARCH}${NC}" if command -v apk >/dev/null 2>&1; then
echo "apk"
# 检查是否为本地安装模式 elif command -v apt-get >/dev/null 2>&1; then
if [ -f "${BINARY_NAME}" ]; then echo "deb"
echo -e "${BLUE}发现本地文件,使用本地安装模式${NC}" elif command -v dnf >/dev/null 2>&1 || command -v yum >/dev/null 2>&1 || command -v rpm >/dev/null 2>&1; then
LOCAL_INSTALL=true echo "rpm"
else
echo -e "${BLUE}本地无文件,使用自动下载模式${NC}"
LOCAL_INSTALL=false
# 检查依赖
missing_deps=()
for cmd in curl jq tar; do
if ! command -v $cmd &> /dev/null; then
missing_deps+=($cmd)
fi
done
if [ ${#missing_deps[@]} -gt 0 ]; then
echo -e "${YELLOW}检测到缺少依赖: ${missing_deps[*]}${NC}"
echo -e "${BLUE}正在自动安装依赖...${NC}"
apt update && apt install -y curl jq
if [ $? -ne 0 ]; then
echo -e "${RED}依赖安装失败${NC}"
exit 1
fi
# 重新检查依赖
for cmd in curl jq tar; do
if ! command -v $cmd &> /dev/null; then
echo -e "${RED}依赖安装后仍缺少: $cmd${NC}"
exit 1
fi
done
echo -e "${GREEN}依赖安装成功${NC}"
fi
fi
# 自动下载功能
if [ "$LOCAL_INSTALL" = false ]; then
echo -e "${BLUE}获取最新版本信息...${NC}"
LATEST_RELEASE=$(curl -s "${GITHUB_RELEASES}/latest")
if [ $? -ne 0 ]; then
echo -e "${RED}无法获取版本信息${NC}"
exit 1
fi
VERSION=$(echo "$LATEST_RELEASE" | jq -r '.tag_name')
if [ "$VERSION" = "null" ]; then
echo -e "${RED}无法解析版本信息${NC}"
exit 1
fi
echo -e "${GREEN}最新版本: ${VERSION}${NC}"
# 构造下载URL
ASSET_NAME="hubproxy-${VERSION}-linux-${ARCH}.tar.gz"
DOWNLOAD_URL="https://github.com/${REPO}/releases/download/${VERSION}/${ASSET_NAME}"
echo -e "${BLUE}下载: ${ASSET_NAME}${NC}"
# 创建临时目录并下载
rm -rf "${TEMP_DIR}"
mkdir -p "${TEMP_DIR}"
cd "${TEMP_DIR}"
curl -L -o "${ASSET_NAME}" "${DOWNLOAD_URL}"
if [ $? -ne 0 ]; then
echo -e "${RED}下载失败${NC}"
exit 1
fi
# 解压
tar -xzf "${ASSET_NAME}"
if [ $? -ne 0 ] || [ ! -d "hubproxy" ]; then
echo -e "${RED}解压失败${NC}"
exit 1
fi
cd hubproxy
echo -e "${GREEN}下载完成${NC}"
fi
echo -e "${YELLOW}开始安装 HubProxy...${NC}"
# 停止现有服务(如果存在)
if systemctl is-active --quiet ${SERVICE_NAME} 2>/dev/null; then
echo -e "${YELLOW}停止现有服务...${NC}"
systemctl stop ${SERVICE_NAME}
fi
# 备份现有配置(如果存在)
CONFIG_BACKUP_EXISTS=false
if [ -f "${INSTALL_DIR}/${CONFIG_FILE}" ]; then
echo -e "${BLUE}备份现有配置...${NC}"
cp "${INSTALL_DIR}/${CONFIG_FILE}" "${TEMP_DIR}/config.toml.backup"
CONFIG_BACKUP_EXISTS=true
fi
# 1. 创建目录结构
echo -e "${BLUE}创建目录结构${NC}"
mkdir -p ${INSTALL_DIR}
mkdir -p ${LOG_DIR}
chmod 755 ${INSTALL_DIR}
chmod 755 ${LOG_DIR}
# 2. 复制二进制文件
echo -e "${BLUE}复制二进制文件${NC}"
cp "${BINARY_NAME}" "${INSTALL_DIR}/"
chmod +x "${INSTALL_DIR}/${BINARY_NAME}"
# 3. 复制配置文件
echo -e "${BLUE}复制配置文件${NC}"
if [ -f "${CONFIG_FILE}" ]; then
if [ "$CONFIG_BACKUP_EXISTS" = false ]; then
cp "${CONFIG_FILE}" "${INSTALL_DIR}/"
echo -e "${GREEN}配置文件复制成功${NC}"
else else
echo -e "${YELLOW}保留现有配置文件${NC}" fail "不支持的系统:需要 apt、dnf、yum、rpm 或 apk"
fi 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 fi
# 5. 安装systemd服务文件 need_cmd curl
echo -e "${BLUE}安装systemd服务文件${NC}"
cp "${SERVICE_NAME}.service" "/etc/systemd/system/"
systemctl daemon-reload
# 6. 恢复配置文件(如果有备份) ARCH="$(detect_arch)"
if [ "$CONFIG_BACKUP_EXISTS" = true ]; then PACKAGER="$(detect_packager)"
echo -e "${BLUE}恢复配置文件...${NC}"
cp "${TEMP_DIR}/config.toml.backup" "${INSTALL_DIR}/${CONFIG_FILE}"
fi
# 7. 启用并启动服务 rm -rf "$TMP_DIR"
echo -e "${BLUE}启用并启动服务${NC}" mkdir -p "$TMP_DIR"
systemctl enable ${SERVICE_NAME} trap 'rm -rf "$TMP_DIR"' EXIT INT TERM
systemctl start ${SERVICE_NAME}
# 8. 清理临时文件 log "安装 HubProxylinux/${ARCH}${PACKAGER}"
if [ "$LOCAL_INSTALL" = false ]; then
echo -e "${BLUE}清理临时文件...${NC}"
cd /
rm -rf "${TEMP_DIR}"
fi
# 9. 检查服务状态 ASSET="$(asset_name "$PACKAGER" "$ARCH")"
sleep 2 ASSET_URL="$(asset_url "$ASSET")"
if systemctl is-active --quiet ${SERVICE_NAME}; then
echo "" PACKAGE_FILE="${TMP_DIR}/$(basename "$ASSET_URL")"
echo -e "${GREEN}HubProxy 安装成功!${NC}" log "下载安装包..."
echo -e "${GREEN}默认运行端口: 5000${NC}" curl -fL -o "$PACKAGE_FILE" "$ASSET_URL" || fail "下载安装包失败"
echo -e "${GREEN}配置文件路径: ${INSTALL_DIR}/${CONFIG_FILE}${NC}"
else log "安装软件包..."
echo -e "${RED}服务启动失败${NC}" install_package "$PACKAGE_FILE" "$PACKAGER"
echo "查看错误日志: sudo journalctl -u ${SERVICE_NAME} -f"
exit 1 log "安装完成"
fi log "默认端口5000"
log "配置文件:/etc/hubproxy/config.toml"

View File

@@ -0,0 +1,9 @@
/var/log/hubproxy.log {
weekly
maxsize 50M
rotate 4
compress
missingok
notifempty
copytruncate
}

18
packaging/hubproxy.openrc Normal file
View File

@@ -0,0 +1,18 @@
#!/sbin/openrc-run
name="hubproxy"
description="Docker and GitHub acceleration proxy server"
command="/usr/bin/hubproxy"
pidfile="/run/${RC_SVCNAME}.pid"
output_log="/var/log/hubproxy.log"
error_log="/var/log/hubproxy.log"
supervisor="supervise-daemon"
respawn_delay=5
respawn_max=0
export CONFIG_PATH="/etc/hubproxy/config.toml"
depend() {
need net
after firewall
}

View File

@@ -7,11 +7,10 @@ Wants=network-online.target
Type=simple Type=simple
User=root User=root
Group=root Group=root
WorkingDirectory=/opt/hubproxy Environment=CONFIG_PATH=/etc/hubproxy/config.toml
ExecStart=/opt/hubproxy/hubproxy ExecStart=/usr/bin/hubproxy
Restart=always Restart=always
RestartSec=5 RestartSec=5
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
StandardOutput=journal StandardOutput=journal
StandardError=journal StandardError=journal
SyslogIdentifier=hubproxy SyslogIdentifier=hubproxy

45
packaging/nfpm.apk.yaml Normal file
View File

@@ -0,0 +1,45 @@
name: hubproxy
arch: ${NFPM_ARCH}
platform: linux
version: ${NFPM_VERSION}
release: "1"
section: net
priority: optional
maintainer: sky22333
description: Docker and GitHub acceleration proxy server
vendor: sky22333
homepage: https://github.com/sky22333/hubproxy
license: MIT
depends:
- logrotate
contents:
- src: ./build/package-root/hubproxy
dst: /usr/bin/hubproxy
file_info:
mode: 0755
- src: ./src/config.toml
dst: /etc/hubproxy/config.toml
type: config|noreplace
file_info:
mode: 0644
- src: ./packaging/hubproxy.openrc
dst: /etc/init.d/hubproxy
file_info:
mode: 0755
- src: ./packaging/hubproxy.logrotate
dst: /etc/logrotate.d/hubproxy
file_info:
mode: 0644
scripts:
postinstall: ./packaging/postinstall.sh
preremove: ./packaging/preremove.sh
postremove: ./packaging/postremove.sh
apk:
scripts:
postupgrade: ./packaging/postinstall.sh

View File

@@ -0,0 +1,34 @@
name: hubproxy
arch: ${NFPM_ARCH}
platform: linux
version: ${NFPM_VERSION}
release: "1"
section: net
priority: optional
maintainer: sky22333
description: Docker and GitHub acceleration proxy server
vendor: sky22333
homepage: https://github.com/sky22333/hubproxy
license: MIT
contents:
- src: ./build/package-root/hubproxy
dst: /usr/bin/hubproxy
file_info:
mode: 0755
- src: ./src/config.toml
dst: /etc/hubproxy/config.toml
type: config|noreplace
file_info:
mode: 0644
- src: ./packaging/hubproxy.service
dst: /lib/systemd/system/hubproxy.service
file_info:
mode: 0644
scripts:
postinstall: ./packaging/postinstall.sh
preremove: ./packaging/preremove.sh
postremove: ./packaging/postremove.sh

27
packaging/postinstall.sh Normal file
View File

@@ -0,0 +1,27 @@
#!/bin/sh
set -e
warn() {
echo "hubproxy: $1"
}
if command -v systemctl >/dev/null 2>&1; then
systemctl daemon-reload || warn "systemd reload failed"
systemctl enable hubproxy >/dev/null 2>&1 || warn "systemd enable failed"
if [ -d /run/systemd/system ]; then
systemctl restart hubproxy || systemctl start hubproxy || {
warn "service start failed, check: journalctl -u hubproxy"
}
fi
fi
if command -v rc-update >/dev/null 2>&1; then
rc-update add hubproxy default >/dev/null 2>&1 || warn "OpenRC enable failed"
fi
if command -v rc-service >/dev/null 2>&1; then
rc-service hubproxy restart || rc-service hubproxy start || {
warn "service start failed, check: rc-service hubproxy status"
}
fi

6
packaging/postremove.sh Normal file
View File

@@ -0,0 +1,6 @@
#!/bin/sh
set -e
if command -v systemctl >/dev/null 2>&1; then
systemctl daemon-reload >/dev/null 2>&1 || true
fi

21
packaging/preremove.sh Normal file
View File

@@ -0,0 +1,21 @@
#!/bin/sh
set -e
case "${1:-}" in
1|upgrade)
exit 0
;;
esac
if command -v systemctl >/dev/null 2>&1; then
systemctl stop hubproxy >/dev/null 2>&1 || true
systemctl disable hubproxy >/dev/null 2>&1 || true
fi
if command -v rc-service >/dev/null 2>&1; then
rc-service hubproxy stop >/dev/null 2>&1 || true
fi
if command -v rc-update >/dev/null 2>&1; then
rc-update del hubproxy default >/dev/null 2>&1 || true
fi

View File

@@ -1,4 +1,5 @@
[server] [server]
# 可通过 CONFIG_PATH 环境变量指定配置文件路径,默认读取当前工作目录下的 config.toml
host = "0.0.0.0" host = "0.0.0.0"
# 监听端口 # 监听端口
port = 5000 port = 5000
@@ -6,6 +7,7 @@ port = 5000
fileSize = 2147483648 fileSize = 2147483648
# HTTP/2 多路复用 # HTTP/2 多路复用
enableH2C = false enableH2C = false
enableFrontend = true
[rateLimit] [rateLimit]
# 每个IP每周期允许的请求数 # 每个IP每周期允许的请求数

View File

@@ -22,10 +22,11 @@ type RegistryMapping struct {
// AppConfig 应用配置结构体 // AppConfig 应用配置结构体
type AppConfig struct { type AppConfig struct {
Server struct { Server struct {
Host string `toml:"host"` Host string `toml:"host"`
Port int `toml:"port"` Port int `toml:"port"`
FileSize int64 `toml:"fileSize"` FileSize int64 `toml:"fileSize"`
EnableH2C bool `toml:"enableH2C"` EnableH2C bool `toml:"enableH2C"`
EnableFrontend bool `toml:"enableFrontend"`
} `toml:"server"` } `toml:"server"`
RateLimit struct { RateLimit struct {
@@ -70,15 +71,17 @@ var (
func DefaultConfig() *AppConfig { func DefaultConfig() *AppConfig {
return &AppConfig{ return &AppConfig{
Server: struct { Server: struct {
Host string `toml:"host"` Host string `toml:"host"`
Port int `toml:"port"` Port int `toml:"port"`
FileSize int64 `toml:"fileSize"` FileSize int64 `toml:"fileSize"`
EnableH2C bool `toml:"enableH2C"` EnableH2C bool `toml:"enableH2C"`
EnableFrontend bool `toml:"enableFrontend"`
}{ }{
Host: "0.0.0.0", Host: "0.0.0.0",
Port: 5000, Port: 5000,
FileSize: 2 * 1024 * 1024 * 1024, // 2GB FileSize: 2 * 1024 * 1024 * 1024,
EnableH2C: false, // 默认关闭H2C EnableH2C: false,
EnableFrontend: true,
}, },
RateLimit: struct { RateLimit: struct {
RequestLimit int `toml:"requestLimit"` RequestLimit int `toml:"requestLimit"`
@@ -194,16 +197,23 @@ func setConfig(cfg *AppConfig) {
configCacheMutex.Unlock() configCacheMutex.Unlock()
} }
// LoadConfig 加载配置文件 func configFilePath() string {
if path := strings.TrimSpace(os.Getenv("CONFIG_PATH")); path != "" {
return path
}
return "config.toml"
}
func LoadConfig() error { func LoadConfig() error {
cfg := DefaultConfig() 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 { if err := toml.Unmarshal(data, cfg); err != nil {
return fmt.Errorf("解析配置文件失败: %v", err) return fmt.Errorf("解析配置文件 %s 失败: %v", path, err)
} }
} else { } else {
fmt.Println("未找到config.toml使用默认配置") fmt.Printf("未找到配置文件 %s使用默认配置\n", path)
} }
overrideFromEnv(cfg) overrideFromEnv(cfg)
@@ -227,6 +237,11 @@ func overrideFromEnv(cfg *AppConfig) {
cfg.Server.EnableH2C = enable 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 val := os.Getenv("MAX_FILE_SIZE"); val != "" {
if size, err := strconv.ParseInt(val, 10, 64); err == nil && size > 0 { if size, err := strconv.ParseInt(val, 10, 64); err == nil && size > 0 {
cfg.Server.FileSize = size cfg.Server.FileSize = size
@@ -251,6 +266,10 @@ func overrideFromEnv(cfg *AppConfig) {
cfg.Security.BlackList = append(cfg.Security.BlackList, strings.Split(val, ",")...) cfg.Security.BlackList = append(cfg.Security.BlackList, strings.Split(val, ",")...)
} }
if val, ok := os.LookupEnv("ACCESS_PROXY"); ok {
cfg.Access.Proxy = strings.TrimSpace(val)
}
if val := os.Getenv("MAX_IMAGES"); val != "" { if val := os.Getenv("MAX_IMAGES"); val != "" {
if maxImages, err := strconv.Atoi(val); err == nil && maxImages > 0 { if maxImages, err := strconv.Atoi(val); err == nil && maxImages > 0 {
cfg.Download.MaxImages = maxImages cfg.Download.MaxImages = maxImages

41
src/config/config_test.go Normal file
View File

@@ -0,0 +1,41 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestLoadConfigUsesConfigPathAndEnvOverrides(t *testing.T) {
path := filepath.Join(t.TempDir(), "custom.toml")
data := []byte(`
[server]
host = "127.0.0.1"
port = 5999
[access]
proxy = "socks5://127.0.0.1:1080"
`)
if err := os.WriteFile(path, data, 0644); err != nil {
t.Fatal(err)
}
t.Setenv("CONFIG_PATH", path)
t.Setenv("SERVER_PORT", "6001")
t.Setenv("ACCESS_PROXY", "")
if err := LoadConfig(); err != nil {
t.Fatal(err)
}
cfg := GetConfig()
if cfg.Server.Host != "127.0.0.1" {
t.Fatalf("Server.Host = %q", cfg.Server.Host)
}
if cfg.Server.Port != 6001 {
t.Fatalf("Server.Port = %d, want 6001", cfg.Server.Port)
}
if cfg.Access.Proxy != "" {
t.Fatalf("Access.Proxy = %q, want empty override", cfg.Access.Proxy)
}
}

View File

@@ -1,33 +1,33 @@
module hubproxy module hubproxy
go 1.25 go 1.26
require ( require (
github.com/gin-gonic/gin v1.10.1 github.com/gin-gonic/gin v1.12.0
github.com/google/go-containerregistry v0.20.6 github.com/google/go-containerregistry v0.21.5
github.com/pelletier/go-toml/v2 v2.2.4 github.com/pelletier/go-toml/v2 v2.3.1
golang.org/x/net v0.43.0 golang.org/x/net v0.53.0
golang.org/x/time v0.12.0 golang.org/x/time v0.15.0
) )
require ( require (
github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect github.com/bytedance/sonic v1.15.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect github.com/containerd/stargz-snapshotter/estargz v0.18.2 // indirect
github.com/docker/cli v28.2.2+incompatible // indirect github.com/docker/cli v29.4.0+incompatible // indirect
github.com/docker/distribution v2.8.3+incompatible // indirect
github.com/docker/docker-credential-helpers v0.9.3 // indirect github.com/docker/docker-credential-helpers v0.9.3 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.2 // 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/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect
@@ -35,16 +35,18 @@ require (
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/quic-go/qpack v0.6.0 // indirect
github.com/sirupsen/logrus v1.9.3 // 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/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect github.com/ugorji/go/codec v1.3.1 // indirect
github.com/vbatts/tar-split v0.12.1 // indirect github.com/vbatts/tar-split v0.12.2 // indirect
golang.org/x/arch v0.8.0 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
golang.org/x/crypto v0.41.0 // indirect golang.org/x/arch v0.22.0 // indirect
golang.org/x/sync v0.16.0 // indirect golang.org/x/crypto v0.50.0 // indirect
golang.org/x/sys v0.35.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.28.0 // indirect golang.org/x/sys v0.43.0 // indirect
google.golang.org/protobuf v1.36.3 // indirect golang.org/x/text v0.36.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect google.golang.org/protobuf v1.36.10 // indirect
gotest.tools/v3 v3.5.2 // indirect
) )

View File

@@ -1,51 +1,49 @@
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/containerd/stargz-snapshotter/estargz v0.16.3 h1:7evrXtoh1mSbGj/pfRccTampEyKpjpOnS3CyiV1Ebr8= github.com/containerd/stargz-snapshotter/estargz v0.18.2 h1:yXkZFYIzz3eoLwlTUZKz2iQ4MrckBxJjkmD16ynUTrw=
github.com/containerd/stargz-snapshotter/estargz v0.16.3/go.mod h1:uyr4BfYfOj3G9WBVE8cOlQmXAbPN9VEQpBBeJIuOipU= github.com/containerd/stargz-snapshotter/estargz v0.18.2/go.mod h1:XyVU5tcJ3PRpkA9XS2T5us6Eg35yM0214Y+wvrZTBrY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/docker/cli v28.2.2+incompatible h1:qzx5BNUDFqlvyq4AHzdNB7gSyVTmU4cgsyN9SdInc1A= github.com/docker/cli v29.4.0+incompatible h1:+IjXULMetlvWJiuSI0Nbor36lcJ5BTcVpUmB21KBoVM=
github.com/docker/cli v28.2.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/cli v29.4.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk=
github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8=
github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= 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 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= 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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-containerregistry v0.20.6 h1:cvWX87UxxLgaH76b4hIvya6Dzz9qHB31qAwjAohdSTU= github.com/google/go-containerregistry v0.21.5 h1:KTJG9Pn/jC0VdZR6ctV3/jcN+q6/Iqlx0sTVz3ywZlM=
github.com/google/go-containerregistry v0.20.6/go.mod h1:T0x8MuoAoKX/873bkeSfLD2FAkwCDf9/HZgsFJ02E2Y= github.com/google/go-containerregistry v0.21.5/go.mod h1:ySvMuiWg+dOsRW0Hw8GYwfMwBlNRTmpYBFJPlkco5zU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -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/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 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.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.9.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 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/vbatts/tar-split v0.12.1 h1:CqKoORW7BUWBe7UL/iqTVvkTBOF8UvOMKOIZykxnnbo= github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CPa4=
github.com/vbatts/tar-split v0.12.1/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

View File

@@ -28,9 +28,16 @@ var dockerProxy *DockerProxy
type RegistryDetector struct{} type RegistryDetector struct{}
// detectRegistryDomain 检测Registry域名并返回域名和剩余路径 // detectRegistryDomain 检测Registry域名并返回域名和剩余路径
func (rd *RegistryDetector) detectRegistryDomain(path string) (string, string) { func (rd *RegistryDetector) detectRegistryDomain(c *gin.Context, path string) (string, string) {
cfg := config.GetConfig() 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 { for domain := range cfg.Registries {
if strings.HasPrefix(path, domain+"/") { if strings.HasPrefix(path, domain+"/") {
remainingPath := strings.TrimPrefix(path, domain+"/") remainingPath := strings.TrimPrefix(path, domain+"/")
@@ -99,7 +106,7 @@ func ProxyDockerRegistryGin(c *gin.Context) {
func handleRegistryRequest(c *gin.Context, path string) { func handleRegistryRequest(c *gin.Context, path string) {
pathWithoutV2 := strings.TrimPrefix(path, "/v2/") pathWithoutV2 := strings.TrimPrefix(path, "/v2/")
if registryDomain, remainingPath := registryDetector.detectRegistryDomain(pathWithoutV2); registryDomain != "" { if registryDomain, remainingPath := registryDetector.detectRegistryDomain(c, pathWithoutV2); registryDomain != "" {
if registryDetector.isRegistryEnabled(registryDomain) { if registryDetector.isRegistryEnabled(registryDomain) {
c.Set("target_registry_domain", registryDomain) c.Set("target_registry_domain", registryDomain)
c.Set("target_path", remainingPath) c.Set("target_path", remainingPath)
@@ -267,7 +274,9 @@ func handleBlobRequest(c *gin.Context, imageRef, digest string) {
c.Header("Docker-Content-Digest", digest) c.Header("Docker-Content-Digest", digest)
c.Status(http.StatusOK) c.Status(http.StatusOK)
io.Copy(c.Writer, reader) if _, err := io.Copy(c.Writer, reader); err != nil {
fmt.Printf("复制layer内容失败: %v\n", err)
}
} }
// handleTagsRequest 处理tags列表请求 // handleTagsRequest 处理tags列表请求
@@ -409,7 +418,9 @@ func proxyDockerAuthOriginal(c *gin.Context) {
} }
c.Status(resp.StatusCode) c.Status(resp.StatusCode)
io.Copy(c.Writer, resp.Body) if _, err := io.Copy(c.Writer, resp.Body); err != nil {
fmt.Printf("复制认证响应失败: %v\n", err)
}
} }
// rewriteAuthHeader 重写认证头 // rewriteAuthHeader 重写认证头
@@ -562,7 +573,9 @@ func handleUpstreamBlobRequest(c *gin.Context, imageRef, digest string, mapping
c.Header("Docker-Content-Digest", digest) c.Header("Docker-Content-Digest", digest)
c.Status(http.StatusOK) c.Status(http.StatusOK)
io.Copy(c.Writer, reader) if _, err := io.Copy(c.Writer, reader); err != nil {
fmt.Printf("复制layer内容失败: %v\n", err)
}
} }
// handleUpstreamTagsRequest 处理上游Registry的tags请求 // handleUpstreamTagsRequest 处理上游Registry的tags请求

View 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)
}
}

View File

@@ -129,7 +129,7 @@ func proxyGitHubWithRedirect(c *gin.Context, u string, redirectCount int) {
fmt.Printf("关闭响应体失败: %v\n", err) fmt.Printf("关闭响应体失败: %v\n", err)
} }
}() }()
// 检查并处理被阻止的内容类型 // 检查并处理被阻止的内容类型
if c.Request.Method == "GET" { if c.Request.Method == "GET" {
if contentType := resp.Header.Get("Content-Type"); blockedContentTypes[strings.ToLower(strings.Split(contentType, ";")[0])] { 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) processedBody, processedSize, err := utils.ProcessSmart(resp.Body, isGzipCompressed, realHost)
if err != nil { if err != nil {
fmt.Printf("智能处理失败,回退到直接代理: %v\n", err) fmt.Printf("脚本处理失败: %v\n", err)
processedBody = resp.Body c.String(http.StatusBadGateway, "Script processing failed: %v", err)
processedSize = 0 return
} }
// 智能设置响应头 // 智能设置响应头
@@ -227,6 +227,8 @@ func proxyGitHubWithRedirect(c *gin.Context, u string, redirectCount int) {
c.Status(resp.StatusCode) c.Status(resp.StatusCode)
// 直接流式转发 // 直接流式转发
io.Copy(c.Writer, resp.Body) if _, err := io.Copy(c.Writer, resp.Body); err != nil {
fmt.Printf("转发响应体失败: %v\n", err)
}
} }
} }

View 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)
}
}

View File

@@ -5,12 +5,15 @@ import (
"compress/gzip" "compress/gzip"
"context" "context"
"crypto/md5" "crypto/md5"
"crypto/rand"
"encoding/base64"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log" "log"
"net/http" "net/http"
"net/url"
"sort" "sort"
"strings" "strings"
"sync" "sync"
@@ -115,6 +118,15 @@ func getUserID(c *gin.Context) string {
return "ip:" + hex.EncodeToString(hash[:8]) return "ip:" + hex.EncodeToString(hash[:8])
} }
func getClientIdentity(c *gin.Context) (string, string) {
ip := c.ClientIP()
userAgent := c.GetHeader("User-Agent")
if userAgent == "" {
userAgent = "unknown"
}
return ip, userAgent
}
var ( var (
singleImageDebouncer *DownloadDebouncer singleImageDebouncer *DownloadDebouncer
batchImageDebouncer *DownloadDebouncer batchImageDebouncer *DownloadDebouncer
@@ -126,6 +138,98 @@ func InitDebouncer() {
batchImageDebouncer = NewDownloadDebouncer(60 * time.Second) 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 镜像流式下载器 // ImageStreamer 镜像流式下载器
type ImageStreamer struct { type ImageStreamer struct {
concurrency int concurrency int
@@ -209,6 +313,17 @@ func (is *ImageStreamer) getImageDescriptorWithPlatform(ref name.Reference, opti
return remote.Get(ref, options...) 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 // StreamImageToGin 流式响应到Gin
func (is *ImageStreamer) StreamImageToGin(ctx context.Context, imageRef string, c *gin.Context, options *StreamOptions) error { func (is *ImageStreamer) StreamImageToGin(ctx context.Context, imageRef string, c *gin.Context, options *StreamOptions) error {
if options == nil { if options == nil {
@@ -216,12 +331,7 @@ func (is *ImageStreamer) StreamImageToGin(ctx context.Context, imageRef string,
} }
filename := strings.ReplaceAll(imageRef, "/", "_") + ".tar" filename := strings.ReplaceAll(imageRef, "/", "_") + ".tar"
c.Header("Content-Type", "application/octet-stream") setDownloadHeaders(c, filename, options.Compression)
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
if options.Compression {
c.Header("Content-Encoding", "gzip")
}
return is.StreamImageToWriter(ctx, imageRef, c.Writer, options) return is.StreamImageToWriter(ctx, imageRef, c.Writer, options)
} }
@@ -579,6 +689,7 @@ func InitImageTarRoutes(router *gin.Engine) {
{ {
imageAPI.GET("/download/:image", handleDirectImageDownload) imageAPI.GET("/download/:image", handleDirectImageDownload)
imageAPI.GET("/info/:image", handleImageInfo) imageAPI.GET("/info/:image", handleImageInfo)
imageAPI.GET("/batch", handleSimpleBatchDownload)
imageAPI.POST("/batch", handleSimpleBatchDownload) imageAPI.POST("/batch", handleSimpleBatchDownload)
} }
} }
@@ -606,28 +717,73 @@ func handleDirectImageDownload(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "镜像引用格式错误: " + err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": "镜像引用格式错误: " + err.Error()})
return return
} }
if allowed, reason := utils.GlobalAccessController.CheckDockerAccess(imageRef); !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": reason})
return
}
userID := getUserID(c) if c.Query("mode") == "prepare" {
contentKey := generateContentFingerprint([]string{imageRef}, platform) userID := getUserID(c)
contentKey := generateContentFingerprint([]string{imageRef}, platform)
if !singleImageDebouncer.ShouldAllow(userID, contentKey) { if !singleImageDebouncer.ShouldAllow(userID, contentKey) {
c.JSON(http.StatusTooManyRequests, gin.H{ c.JSON(http.StatusTooManyRequests, gin.H{
"error": "请求过于频繁,请稍后再试", "error": "请求过于频繁,请稍后再试",
"retry_after": 5, "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 return
} }
options := &StreamOptions{ options := &StreamOptions{
Platform: platform, Platform: req.Platform,
Compression: false, Compression: false,
UseCompressedLayers: useCompressed, UseCompressedLayers: req.UseCompressedLayers,
} }
ctx := c.Request.Context() ctx := c.Request.Context()
log.Printf("下载镜像: %s (平台: %s)", imageRef, formatPlatformText(platform)) log.Printf("下载镜像: %s (平台: %s)", req.Image, formatPlatformText(req.Platform))
if err := globalImageStreamer.StreamImageToGin(ctx, imageRef, c, options); err != nil { if err := globalImageStreamer.StreamImageToGin(ctx, req.Image, c, options); err != nil {
log.Printf("镜像下载失败: %v", err) log.Printf("镜像下载失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "镜像下载失败: " + err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": "镜像下载失败: " + err.Error()})
return return
@@ -636,6 +792,51 @@ func handleDirectImageDownload(c *gin.Context) {
// handleSimpleBatchDownload 处理批量下载 // handleSimpleBatchDownload 处理批量下载
func handleSimpleBatchDownload(c *gin.Context) { func handleSimpleBatchDownload(c *gin.Context) {
if c.Request.Method == http.MethodGet {
token := c.Query("token")
if token == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少下载令牌"})
return
}
ip, userAgent := getClientIdentity(c)
req, ok := batchDownloadTokens.consume(token, ip, userAgent)
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效或过期的下载令牌"})
return
}
if len(req.Images) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "镜像列表不能为空"})
return
}
options := &StreamOptions{
Platform: req.Platform,
Compression: false,
UseCompressedLayers: req.UseCompressedLayers,
}
ctx := c.Request.Context()
log.Printf("批量下载 %d 个镜像 (平台: %s)", len(req.Images), formatPlatformText(req.Platform))
filename := fmt.Sprintf("batch_%d_images.tar", len(req.Images))
setDownloadHeaders(c, filename, options.Compression)
if err := globalImageStreamer.StreamMultipleImages(ctx, req.Images, c.Writer, options); err != nil {
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 { var req struct {
Images []string `json:"images" binding:"required"` Images []string `json:"images" binding:"required"`
Platform string `json:"platform"` Platform string `json:"platform"`
@@ -651,12 +852,24 @@ func handleSimpleBatchDownload(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "镜像列表不能为空"}) c.JSON(http.StatusBadRequest, gin.H{"error": "镜像列表不能为空"})
return return
} }
for _, imageRef := range req.Images {
if allowed, reason := utils.GlobalAccessController.CheckDockerAccess(imageRef); !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": reason})
return
}
}
for i, imageRef := range req.Images { for i, imageRef := range req.Images {
if !strings.Contains(imageRef, ":") && !strings.Contains(imageRef, "@") { if !strings.Contains(imageRef, ":") && !strings.Contains(imageRef, "@") {
req.Images[i] = imageRef + ":latest" req.Images[i] = imageRef + ":latest"
} }
} }
for _, imageRef := range req.Images {
if allowed, reason := utils.GlobalAccessController.CheckDockerAccess(imageRef); !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": reason})
return
}
}
cfg := config.GetConfig() cfg := config.GetConfig()
if len(req.Images) > cfg.Download.MaxImages { if len(req.Images) > cfg.Download.MaxImages {
@@ -682,25 +895,19 @@ func handleSimpleBatchDownload(c *gin.Context) {
useCompressed = *req.UseCompressedLayers useCompressed = *req.UseCompressedLayers
} }
options := &StreamOptions{ batchReq := BatchDownloadRequest{
Images: req.Images,
Platform: req.Platform, Platform: req.Platform,
Compression: false,
UseCompressedLayers: useCompressed, UseCompressedLayers: useCompressed,
} }
ctx := c.Request.Context() ip, userAgent := getClientIdentity(c)
log.Printf("批量下载 %d 个镜像 (平台: %s)", len(req.Images), formatPlatformText(req.Platform)) token, err := batchDownloadTokens.create(batchReq, ip, userAgent)
if err != nil {
filename := fmt.Sprintf("batch_%d_images.tar", len(req.Images)) c.JSON(http.StatusTooManyRequests, gin.H{"error": err.Error()})
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()})
return return
} }
c.JSON(http.StatusOK, gin.H{"download_url": fmt.Sprintf("/api/image/batch?token=%s", token)})
} }
// handleImageInfo 处理镜像信息查询 // handleImageInfo 处理镜像信息查询
@@ -723,6 +930,10 @@ func handleImageInfo(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "镜像引用格式错误: " + err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": "镜像引用格式错误: " + err.Error()})
return return
} }
if allowed, reason := utils.GlobalAccessController.CheckDockerAccess(imageRef); !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": reason})
return
}
ctx := c.Request.Context() ctx := c.Request.Context()
contextOptions := append(globalImageStreamer.remoteOptions, remote.WithContext(ctx)) contextOptions := append(globalImageStreamer.remoteOptions, remote.WithContext(ctx))

View 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)
}
}

View File

@@ -7,7 +7,6 @@ import (
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"sort"
"strings" "strings"
"sync" "sync"
"time" "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 统一规范化仓库信息 // normalizeRepository 统一规范化仓库信息
func normalizeRepository(repo *Repository) { func normalizeRepository(repo *Repository) {
if repo.IsOfficial { if repo.IsOfficial {
@@ -297,7 +251,7 @@ func searchDockerHubWithDepth(ctx context.Context, query string, page, pageSize
} }
return nil, fmt.Errorf("未找到相关镜像") return nil, fmt.Errorf("未找到相关镜像")
case http.StatusBadGateway, http.StatusServiceUnavailable: case http.StatusBadGateway, http.StatusServiceUnavailable:
return nil, fmt.Errorf("Docker Hub服务暂时不可用请稍后重试") return nil, fmt.Errorf("docker hub 服务暂时不可用,请稍后重试")
default: default:
return nil, fmt.Errorf("请求失败: 状态码=%d, 响应=%s", resp.StatusCode, string(body)) 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 pageSize = defaultPageSize
if p := c.Query("page"); p != "" { 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 != "" { 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 return page, pageSize

View File

@@ -0,0 +1,45 @@
package handlers
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
)
func TestNormalizeRepository(t *testing.T) {
official := &Repository{Name: "nginx", IsOfficial: true}
normalizeRepository(official)
if official.Namespace != "library" || official.Name != "library/nginx" {
t.Fatalf("official normalized to %#v", official)
}
userRepo := &Repository{Name: "owner/app", RepoOwner: "owner"}
normalizeRepository(userRepo)
if userRepo.Namespace != "owner" || userRepo.Name != "app" {
t.Fatalf("user repo normalized to %#v", userRepo)
}
}
func TestParsePaginationParams(t *testing.T) {
gin.SetMode(gin.TestMode)
req := httptest.NewRequest(http.MethodGet, "/?page=3&page_size=50", nil)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = req
page, pageSize := parsePaginationParams(c, 25)
if page != 3 || pageSize != 50 {
t.Fatalf("pagination = %d %d", page, pageSize)
}
}
func TestSearchCacheExpires(t *testing.T) {
cache := &Cache{data: make(map[string]cacheEntry), maxSize: 10}
cache.SetWithTTL("k", "v", -time.Second)
if got, ok := cache.Get("k"); ok || got != nil {
t.Fatalf("expired cache returned: %#v", got)
}
}

View File

@@ -19,116 +19,103 @@ import (
//go:embed public/* //go:embed public/*
var staticFiles embed.FS var staticFiles embed.FS
// 服务嵌入的静态文件 var (
globalLimiter *utils.IPRateLimiter
serviceStartTime = time.Now()
)
var Version = "dev"
func serveEmbedFile(c *gin.Context, filename string) { func serveEmbedFile(c *gin.Context, filename string) {
data, err := staticFiles.ReadFile(filename) data, err := staticFiles.ReadFile(filename)
if err != nil { if err != nil {
c.Status(404) c.Status(http.StatusNotFound)
return return
} }
contentType := "text/html; charset=utf-8" contentType := "text/html; charset=utf-8"
if strings.HasSuffix(filename, ".ico") { if strings.HasSuffix(filename, ".ico") {
contentType = "image/x-icon" contentType = "image/x-icon"
} }
c.Data(200, contentType, data) c.Data(http.StatusOK, contentType, data)
} }
var ( func buildRouter(cfg *config.AppConfig) *gin.Engine {
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()
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
router := gin.Default() router := gin.Default()
// 全局Panic恢复保护
router.Use(gin.CustomRecovery(func(c *gin.Context, recovered interface{}) { router.Use(gin.CustomRecovery(func(c *gin.Context, recovered interface{}) {
log.Printf("🚨 Panic recovered: %v", recovered) log.Printf("Panic 已恢复: %v", recovered)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"error": "Internal server error", "error": "Internal server error",
"code": "INTERNAL_ERROR", "code": "INTERNAL_ERROR",
}) })
})) }))
// 全局限流中间件
router.Use(utils.RateLimitMiddleware(globalLimiter)) router.Use(utils.RateLimitMiddleware(globalLimiter))
// 初始化监控端点
initHealthRoutes(router) initHealthRoutes(router)
// 初始化镜像tar下载路由
handlers.InitImageTarRoutes(router) handlers.InitImageTarRoutes(router)
// 静态文件路由 if cfg.Server.EnableFrontend {
router.GET("/", func(c *gin.Context) { router.GET("/", func(c *gin.Context) {
serveEmbedFile(c, "public/index.html") serveEmbedFile(c, "public/index.html")
}) })
router.GET("/public/*filepath", func(c *gin.Context) { router.GET("/public/*filepath", func(c *gin.Context) {
filepath := strings.TrimPrefix(c.Param("filepath"), "/") filepath := strings.TrimPrefix(c.Param("filepath"), "/")
serveEmbedFile(c, "public/"+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) handlers.RegisterSearchRoute(router)
// 注册Docker认证路由
router.Any("/token", handlers.ProxyDockerAuthGin) router.Any("/token", handlers.ProxyDockerAuthGin)
router.Any("/token/*path", handlers.ProxyDockerAuthGin) router.Any("/token/*path", handlers.ProxyDockerAuthGin)
// 注册Docker Registry代理路由
router.Any("/v2/*path", handlers.ProxyDockerRegistryGin) router.Any("/v2/*path", handlers.ProxyDockerRegistryGin)
// 注册GitHub代理路由NoRoute处理器
router.NoRoute(handlers.GitHubProxyHandler) 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() cfg := config.GetConfig()
router := buildRouter(cfg)
fmt.Printf("HubProxy 启动成功\n") fmt.Printf("HubProxy 启动成功\n")
fmt.Printf("监听地址: %s:%d\n", cfg.Server.Host, cfg.Server.Port) fmt.Printf("监听地址: %s:%d\n", cfg.Server.Host, cfg.Server.Port)
fmt.Printf("限流配置: %d请求/%g小时\n", cfg.RateLimit.RequestLimit, cfg.RateLimit.PeriodHours) fmt.Printf("限流配置: %d请求/%g小时\n", cfg.RateLimit.RequestLimit, cfg.RateLimit.PeriodHours)
// 显示HTTP/2支持状态
if cfg.Server.EnableH2C { if cfg.Server.EnableH2C {
fmt.Printf("H2c: 已启用\n") fmt.Printf("H2c: 已启用\n")
} }
fmt.Printf("版本号: %s\n", Version)
fmt.Printf("版本号: v1.2.0\n")
fmt.Printf("项目地址: https://github.com/sky22333/hubproxy\n") fmt.Printf("项目地址: https://github.com/sky22333/hubproxy\n")
// 创建HTTP2服务器
server := &http.Server{ server := &http.Server{
Addr: fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port), Addr: fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port),
ReadTimeout: 60 * time.Second, ReadTimeout: 60 * time.Second,
@@ -136,39 +123,37 @@ func main() {
IdleTimeout: 120 * time.Second, IdleTimeout: 120 * time.Second,
} }
// 根据配置决定是否启用H2C
if cfg.Server.EnableH2C { if cfg.Server.EnableH2C {
h2cHandler := h2c.NewHandler(router, &http2.Server{ server.Handler = h2c.NewHandler(router, &http2.Server{
MaxConcurrentStreams: 250, MaxConcurrentStreams: 250,
IdleTimeout: 300 * time.Second, IdleTimeout: 300 * time.Second,
MaxReadFrameSize: 4 << 20, MaxReadFrameSize: 4 << 20,
MaxUploadBufferPerConnection: 8 << 20, MaxUploadBufferPerConnection: 8 << 20,
MaxUploadBufferPerStream: 2 << 20, MaxUploadBufferPerStream: 2 << 20,
}) })
server.Handler = h2cHandler
} else { } else {
server.Handler = router server.Handler = router
} }
err := server.ListenAndServe() if err := server.ListenAndServe(); err != nil {
if err != nil {
fmt.Printf("启动服务失败: %v\n", err) fmt.Printf("启动服务失败: %v\n", err)
} }
} }
// 简单的健康检查
func formatDuration(d time.Duration) string { func formatDuration(d time.Duration) string {
if d < time.Minute { if d < time.Minute {
return fmt.Sprintf("%d秒", int(d.Seconds())) 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) { func getUptimeInfo() (time.Duration, float64, string) {
@@ -182,6 +167,7 @@ func initHealthRoutes(router *gin.Engine) {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"ready": true, "ready": true,
"service": "hubproxy", "service": "hubproxy",
"version": Version,
"start_time_unix": serviceStartTime.Unix(), "start_time_unix": serviceStartTime.Unix(),
"uptime_sec": uptimeSec, "uptime_sec": uptimeSec,
"uptime_human": uptimeHuman, "uptime_human": uptimeHuman,

172
src/main_test.go Normal file
View 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
View File

@@ -728,7 +728,7 @@
} }
} }
function buildDownloadUrl(imageName, platform = '', useCompressed = true) { function buildDownloadUrl(imageName, platform = '', useCompressed = true, mode = '') {
const encodedImage = imageName.replace(/\//g, '_'); const encodedImage = imageName.replace(/\//g, '_');
let url = `/api/image/download/${encodedImage}`; let url = `/api/image/download/${encodedImage}`;
@@ -737,6 +737,9 @@
params.append('platform', platform.trim()); params.append('platform', platform.trim());
} }
params.append('compressed', useCompressed.toString()); params.append('compressed', useCompressed.toString());
if (mode) {
params.append('mode', mode);
}
if (params.toString()) { if (params.toString()) {
url += '?' + params.toString(); url += '?' + params.toString();
@@ -745,7 +748,65 @@
return url; 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(); e.preventDefault();
const imageName = document.getElementById('imageInput').value.trim(); const imageName = document.getElementById('imageInput').value.trim();
@@ -760,20 +821,50 @@
hideStatus('singleStatus'); hideStatus('singleStatus');
setButtonLoading('downloadBtn', 'downloadText', 'downloadLoading', true); setButtonLoading('downloadBtn', 'downloadText', 'downloadLoading', true);
const downloadUrl = buildDownloadUrl(imageName, platform, useCompressed); showStatus('singleStatus', '正在准备下载...', 'success');
const preflightResult = await preflightImages([imageName]);
const link = document.createElement('a'); if (!preflightResult.ok) {
link.href = downloadUrl; showStatus('singleStatus', preflightResult.error, 'error');
link.download = ''; setButtonLoading('downloadBtn', 'downloadText', 'downloadLoading', false);
link.style.display = 'none'; return;
document.body.appendChild(link); }
link.click(); const prepareUrl = buildDownloadUrl(imageName, platform, useCompressed, 'prepare');
document.body.removeChild(link); try {
const response = await fetch(prepareUrl, {
const platformText = platform ? ` (${platform})` : ''; method: 'GET',
showStatus('singleStatus', `开始下载 ${imageName}${platformText}`, 'success'); headers: {
setButtonLoading('downloadBtn', 'downloadText', 'downloadLoading', false); '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) { document.getElementById('batchForm').addEventListener('submit', async function(e) {
@@ -808,9 +899,16 @@
hideStatus('batchStatus'); hideStatus('batchStatus');
setButtonLoading('batchDownloadBtn', 'batchDownloadText', 'batchDownloadLoading', true); 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 { try {
const response = await fetch('/api/image/batch', { const response = await fetch('/api/image/batch?mode=prepare', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -819,24 +917,19 @@
}); });
if (response.ok) { if (response.ok) {
const contentDisposition = response.headers.get('Content-Disposition'); const data = await response.json();
let filename = `batch_${images.length}_images.tar`; if (!data || !data.download_url) {
showStatus('batchStatus', '下载地址生成失败', 'error');
if (contentDisposition) { return;
const matches = contentDisposition.match(/filename="(.+)"/);
if (matches) filename = matches[1];
} }
const blob = await response.blob(); const url = data.download_url;
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a'); const link = document.createElement('a');
link.href = url; link.href = url;
link.download = filename;
link.style.display = 'none'; link.style.display = 'none';
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
window.URL.revokeObjectURL(url);
const platformText = platform ? ` (${platform})` : ''; const platformText = platform ? ` (${platform})` : '';
showStatus('batchStatus', `开始下载 ${images.length} 个镜像${platformText}`, 'success'); showStatus('batchStatus', `开始下载 ${images.length} 个镜像${platformText}`, 'success');
@@ -872,4 +965,4 @@
initMobileMenu(); initMobileMenu();
</script> </script>
</body> </body>
</html> </html>

View File

@@ -778,16 +778,10 @@
</div> </div>
</div> </div>
<div class="tag-list" id="tagList"> <div class="tag-list" id="tagList"></div>
<div class="pagination" id="tagPagination" style="display: none;">
<button id="tagPrevPage" disabled>上一页</button>
<button id="tagNextPage" disabled>下一页</button>
</div>
</div>
</div> </div>
<div id="toast"></div> <div id="toast"></div>
<script> <script>
const formatUtils = { const formatUtils = {
formatNumber(num) { formatNumber(num) {
@@ -1047,7 +1041,6 @@
} }
// 分页更新函数 // 分页更新函数
const updateSearchPagination = () => updatePagination();
const updateTagPagination = () => updatePagination({ const updateTagPagination = () => updatePagination({
currentPage: currentTagPage, currentPage: currentTagPage,
totalPages: totalTagPages, totalPages: totalTagPages,
@@ -1217,9 +1210,6 @@
const currentNamespace = namespace || repoInfo.namespace; const currentNamespace = namespace || repoInfo.namespace;
const currentName = name || repoInfo.name; const currentName = name || repoInfo.name;
// 调试日志
console.log(`loadTagPage: namespace=${currentNamespace}, name=${currentName}, page=${currentTagPage}`);
if (!currentNamespace || !currentName) { if (!currentNamespace || !currentName) {
showToast('命名空间和镜像名称不能为空'); showToast('命名空间和镜像名称不能为空');
return; return;
@@ -1488,4 +1478,4 @@
</script> </script>
</main> </main>
</body> </body>
</html> </html>

View File

@@ -2,7 +2,6 @@ package utils
import ( import (
"strings" "strings"
"sync"
"hubproxy/config" "hubproxy/config"
) )
@@ -17,7 +16,6 @@ const (
// AccessController 统一访问控制器 // AccessController 统一访问控制器
type AccessController struct { type AccessController struct {
mu sync.RWMutex
} }
// DockerImageInfo Docker镜像信息 // DockerImageInfo Docker镜像信息
@@ -200,6 +198,13 @@ func (ac *AccessController) checkList(matches, list []string) bool {
if strings.HasPrefix(fullRepo, item+"/") { if strings.HasPrefix(fullRepo, item+"/") {
return true return true
} }
if strings.HasPrefix(item, "*/") {
p := item[2:]
if p == repoName || (strings.HasSuffix(p, "*") && strings.HasPrefix(repoName, p[:len(p)-1])) {
return true
}
}
} }
return false return false
} }

View File

@@ -0,0 +1,86 @@
package utils
import (
"os"
"path/filepath"
"testing"
"hubproxy/config"
)
func TestParseDockerImage(t *testing.T) {
tests := []struct {
name string
image string
namespace string
repository string
tag string
fullName string
}{
{"official", "nginx", "library", "nginx", "latest", "library/nginx"},
{"tagged", "redis:7", "library", "redis", "7", "library/redis"},
{"namespaced", "user/app:v1", "user", "app", "v1", "user/app"},
{"registry", "ghcr.io/user/app:v2", "user", "app", "v2", "user/app"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := GlobalAccessController.ParseDockerImage(tt.image)
if got.Namespace != tt.namespace || got.Repository != tt.repository || got.Tag != tt.tag || got.FullName != tt.fullName {
t.Fatalf("ParseDockerImage(%q) = %#v", tt.image, got)
}
})
}
}
func TestDockerAccessLists(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.toml")
data := []byte(`
[access]
whiteList = ["library/*", "good/*"]
blackList = ["good/bad"]
`)
if err := os.WriteFile(path, data, 0644); err != nil {
t.Fatal(err)
}
t.Setenv("CONFIG_PATH", path)
if err := config.LoadConfig(); err != nil {
t.Fatal(err)
}
if allowed, reason := GlobalAccessController.CheckDockerAccess("nginx"); !allowed {
t.Fatalf("nginx denied: %s", reason)
}
if allowed, _ := GlobalAccessController.CheckDockerAccess("good/bad:latest"); allowed {
t.Fatal("blacklisted image allowed")
}
if allowed, _ := GlobalAccessController.CheckDockerAccess("other/app"); allowed {
t.Fatal("image outside whitelist allowed")
}
}
func TestGitHubAccessLists(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.toml")
data := []byte(`
[access]
whiteList = ["allowed/*"]
blackList = ["allowed/blocked"]
`)
if err := os.WriteFile(path, data, 0644); err != nil {
t.Fatal(err)
}
t.Setenv("CONFIG_PATH", path)
if err := config.LoadConfig(); err != nil {
t.Fatal(err)
}
if allowed, reason := GlobalAccessController.CheckGitHubAccess([]string{"allowed", "repo"}); !allowed {
t.Fatalf("allowed/repo denied: %s", reason)
}
if allowed, _ := GlobalAccessController.CheckGitHubAccess([]string{"allowed", "blocked"}); allowed {
t.Fatal("blacklisted repo allowed")
}
if allowed, _ := GlobalAccessController.CheckGitHubAccess([]string{"other", "repo"}); allowed {
t.Fatal("repo outside whitelist allowed")
}
}

49
src/utils/cache_test.go Normal file
View File

@@ -0,0 +1,49 @@
package utils
import (
"testing"
"time"
)
func TestUniversalCacheSetGetAndExpire(t *testing.T) {
cache := &UniversalCache{}
cache.Set("k", []byte("v"), "text/plain", map[string]string{"X-Test": "1"}, time.Minute)
if got := cache.Get("k"); got == nil || string(got.Data) != "v" || got.Headers["X-Test"] != "1" {
t.Fatalf("cache hit mismatch: %#v", got)
}
cache.Set("expired", []byte("v"), "", nil, -time.Second)
if got := cache.Get("expired"); got != nil {
t.Fatalf("expired item returned: %#v", got)
}
}
func TestTokenCacheHelpers(t *testing.T) {
cache := &UniversalCache{}
cache.SetToken("token", `{"token":"abc"}`, time.Minute)
if got := cache.GetToken("token"); got != `{"token":"abc"}` {
t.Fatalf("GetToken = %q", got)
}
}
func TestExtractTTLFromResponse(t *testing.T) {
ttl := ExtractTTLFromResponse([]byte(`{"expires_in":3600}`))
if ttl != 55*time.Minute {
t.Fatalf("TTL = %s, want 55m", ttl)
}
if ttl := ExtractTTLFromResponse([]byte(`{}`)); ttl != 30*time.Minute {
t.Fatalf("default TTL = %s", ttl)
}
}
func TestBuildCacheKeyStable(t *testing.T) {
a := BuildCacheKey("p", "query")
b := BuildCacheKey("p", "query")
c := BuildCacheKey("p", "other")
if a != b || a == c {
t.Fatalf("unexpected keys: %q %q %q", a, b, c)
}
}

View File

@@ -12,47 +12,44 @@ import (
// GitHub URL正则表达式 // 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'")]*`) 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脚本智能处理函数 // MaxShellSize 限制最大处理大小为 10MB
func ProcessSmart(input io.ReadCloser, isCompressed bool, host string) (io.Reader, int64, error) { const MaxShellSize = 10 * 1024 * 1024
defer input.Close()
// ProcessSmart Shell脚本智能处理函数
func ProcessSmart(input io.Reader, isCompressed bool, host string) (io.Reader, int64, error) {
content, err := readShellContent(input, isCompressed) content, err := readShellContent(input, isCompressed)
if err != nil { if err != nil {
return nil, 0, fmt.Errorf("内容读取失败: %v", err) return nil, 0, err
} }
if len(content) == 0 { if len(content) == 0 {
return strings.NewReader(""), 0, nil return strings.NewReader(""), 0, nil
} }
if len(content) > 10*1024*1024 { if !bytes.Contains(content, []byte("github.com")) && !bytes.Contains(content, []byte("githubusercontent.com")) {
return strings.NewReader(content), int64(len(content)), nil return bytes.NewReader(content), int64(len(content)), nil
} }
if !strings.Contains(content, "github.com") && !strings.Contains(content, "githubusercontent.com") { processed := processGitHubURLs(string(content), host)
return strings.NewReader(content), int64(len(content)), nil
}
processed := processGitHubURLs(content, host)
return strings.NewReader(processed), int64(len(processed)), nil 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 var reader io.Reader = input
if isCompressed { if isCompressed {
peek := make([]byte, 2) peek := make([]byte, 2)
n, err := input.Read(peek) n, err := input.Read(peek)
if err != nil && err != io.EOF { 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 { if n >= 2 && peek[0] == 0x1f && peek[1] == 0x8b {
combinedReader := io.MultiReader(bytes.NewReader(peek[:n]), input) combinedReader := io.MultiReader(bytes.NewReader(peek[:n]), input)
gzReader, err := gzip.NewReader(combinedReader) gzReader, err := gzip.NewReader(combinedReader)
if err != nil { if err != nil {
return "", fmt.Errorf("gzip解压失败: %v", err) return nil, fmt.Errorf("gzip解压失败: %v", err)
} }
defer gzReader.Close() defer gzReader.Close()
reader = gzReader 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 { 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 { func processGitHubURLs(content, host string) string {
@@ -100,4 +104,4 @@ func transformURL(url, host string) string {
host = strings.TrimSuffix(host, "/") host = strings.TrimSuffix(host, "/")
return host + "/" + url return host + "/" + url
} }

View 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())
}
}

View File

@@ -176,8 +176,9 @@ func (i *IPRateLimiter) GetLimiter(ip string) (*rate.Limiter, bool) {
now := time.Now() now := time.Now()
var entry *rateLimiterEntry
i.mu.RLock() i.mu.RLock()
entry, exists := i.ips[normalizedIP] _, exists := i.ips[normalizedIP]
i.mu.RUnlock() i.mu.RUnlock()
if exists { if exists {

View 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)
}
}