13 Commits
v1.2.2 ... dev

Author SHA1 Message Date
sky22333
11bf9221c2 fix 修复返回了过期的匿名token 2026-05-16 06:26:21 +08:00
sky22333
b80f4844a4 标记匿名token区分匿名manifest 2026-05-16 06:07:41 +08:00
sky22333
fc77ddb1ef 降低日志IO + 匿名manifest缓存 2026-05-16 05:39:54 +08:00
sky22333
85e47b7ce5 fix 2026-05-16 04:29:36 +08:00
sky22333
3e8ceb2b32 fix 2026-05-16 03:56:29 +08:00
starry
53cc1761ce 重构Docker代理,支持认证透传和可配置Docker Hub上游,并补充边界测试 2026-05-16 02:58:31 +08:00
sky22333
ba83a44492 fix 2026-05-11 23:14:21 +08:00
sky22333
c7a7f3d146 fix 2026-05-11 21:52:02 +08:00
sky22333
e4d4f33ea1 fix 2026-05-11 21:28:44 +08:00
sky22333
6e91fe9925 优化构建和打包 2026-05-11 21:16:24 +08:00
sky22333
d0b3c657cc 更新构建配置并补充测试 2026-05-06 19:16:27 +08:00
user123
f5bc86ef79 补齐访问控制 2026-02-02 09:53:45 +08:00
user123
23dd077f5d 优化离线下载镜像的实现 2026-02-02 06:12:31 +08:00
37 changed files with 2964 additions and 977 deletions

View File

@@ -15,13 +15,13 @@ jobs:
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Cache Docker layers
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
@@ -29,7 +29,7 @@ jobs:
${{ runner.os }}-buildx-
- name: Log in to GitHub Docker Registry
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -58,4 +58,4 @@ jobs:
--build-arg VERSION=${{ env.VERSION }} \
-f Dockerfile .
env:
GHCR_PUBLIC: true
GHCR_PUBLIC: true

View File

@@ -16,12 +16,12 @@ jobs:
steps:
- name: 检出代码
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: 设置Go环境
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:
go-version-file: "src/go.mod"
cache-dependency-path: "src/go.sum"
@@ -55,10 +55,13 @@ jobs:
mkdir -p build/hubproxy
- name: 安装 UPX
uses: crazy-max/ghaction-upx@v3
uses: crazy-max/ghaction-upx@v4
with:
install-only: true
- name: 安装 nFPM
run: go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.46.3
- name: 编译二进制文件
run: |
cd src
@@ -74,24 +77,11 @@ jobs:
upx -9 ../build/hubproxy/hubproxy-linux-amd64
upx -9 ../build/hubproxy/hubproxy-linux-arm64
- name: 复制配置文件
- name: 准备压缩包文件
run: |
# 复制配置文件
cp src/config.toml build/hubproxy/
# 复制systemd服务文件
cp hubproxy.service build/hubproxy/
# 复制安装脚本
cp install.sh build/hubproxy/
# 创建README文件
cat > build/hubproxy/README.md << 'EOF'
# HubProxy
项目地址https://github.com/sky22333/hubproxy
EOF
- name: 创建压缩包
run: |
cd build
@@ -99,26 +89,64 @@ jobs:
# Linux AMD64 包
mkdir -p linux-amd64/hubproxy
cp hubproxy/hubproxy-linux-amd64 linux-amd64/hubproxy/hubproxy
cp hubproxy/config.toml hubproxy/hubproxy.service hubproxy/install.sh hubproxy/README.md linux-amd64/hubproxy/
tar -czf hubproxy-${{ steps.version.outputs.version }}-linux-amd64.tar.gz -C linux-amd64 hubproxy
cp hubproxy/config.toml linux-amd64/hubproxy/
tar -czf hubproxy-linux-amd64.tar.gz -C linux-amd64 hubproxy
# Linux ARM64 包
mkdir -p linux-arm64/hubproxy
cp hubproxy/hubproxy-linux-arm64 linux-arm64/hubproxy/hubproxy
cp hubproxy/config.toml hubproxy/hubproxy.service hubproxy/install.sh hubproxy/README.md linux-arm64/hubproxy/
tar -czf hubproxy-${{ steps.version.outputs.version }}-linux-arm64.tar.gz -C linux-arm64 hubproxy
cp hubproxy/config.toml linux-arm64/hubproxy/
tar -czf hubproxy-linux-arm64.tar.gz -C linux-arm64 hubproxy
# 列出生成的文件
ls -la *.tar.gz
- name: 计算文件校验和
- name: 创建Linux发行版安装包
run: |
cd build
sha256sum *.tar.gz > checksums.txt
cat checksums.txt
mkdir -p build/packages
VERSION="${{ steps.version.outputs.version }}"
NFPM_VERSION="${VERSION#v}"
package() {
hubproxy_arch="$1"
nfpm_arch="$2"
packager="$3"
config="$4"
target="build/packages/hubproxy-linux-${hubproxy_arch}.${packager}"
temp_dir="build/packages/${hubproxy_arch}-${packager}"
binary="./build/hubproxy/hubproxy-linux-${hubproxy_arch}"
rm -rf "${temp_dir}"
mkdir -p "${temp_dir}"
rm -rf build/package-root
mkdir -p build/package-root
cp "${binary}" build/package-root/hubproxy
NFPM_ARCH="${nfpm_arch}" NFPM_VERSION="${NFPM_VERSION}" nfpm package --config "${config}" --packager "${packager}" --target "${temp_dir}/"
mv "${temp_dir}"/*.${packager} "${target}"
rm -rf "${temp_dir}"
rm -rf build/package-root
}
# AMD64 包
package amd64 amd64 deb packaging/nfpm.deb-rpm.yaml
package amd64 amd64 rpm packaging/nfpm.deb-rpm.yaml
package amd64 amd64 apk packaging/nfpm.apk.yaml
# ARM64 包
package arm64 arm64 deb packaging/nfpm.deb-rpm.yaml
package arm64 arm64 rpm packaging/nfpm.deb-rpm.yaml
package arm64 arm64 apk packaging/nfpm.apk.yaml
ls -la build/packages
- name: 检查安装包内容
run: |
dpkg-deb -c build/packages/hubproxy-linux-amd64.deb
rpm -qpl build/packages/hubproxy-linux-amd64.rpm
tar -tf build/packages/hubproxy-linux-amd64.apk
- name: 创建或更新Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ steps.version.outputs.version }}
name: "HubProxy ${{ steps.version.outputs.version }}"
@@ -128,12 +156,16 @@ jobs:
## 下载文件
- **Linux AMD64**: `hubproxy-${{ steps.version.outputs.version }}-linux-amd64.tar.gz`
- **Linux ARM64**: `hubproxy-${{ steps.version.outputs.version }}-linux-arm64.tar.gz`
- **Linux AMD64**: `hubproxy-linux-amd64.tar.gz`
- **Linux ARM64**: `hubproxy-linux-arm64.tar.gz`
- **Debian/Ubuntu**: `.deb`
- **RHEL/CentOS/Fedora**: `.rpm`
- **Alpine Linux**: `.apk`
files: |
build/*.tar.gz
build/checksums.txt
build/packages/*
overwrite_files: true
draft: false
prerelease: false
token: ${{ secrets.GITHUB_TOKEN }}

4
.gitignore vendored
View File

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

View File

@@ -1,11 +1,11 @@
FROM golang:1.25-alpine AS builder
FROM golang:1.26-alpine AS builder
ARG TARGETARCH
ARG VERSION=dev
WORKDIR /app
COPY src/go.mod src/go.sum ./
RUN go mod download && apk add upx
RUN apk add --no-cache upx && go mod download
COPY src/ .
@@ -13,7 +13,7 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -ldflags="-s -w -X ma
FROM alpine
WORKDIR /root/
WORKDIR /app
COPY --from=builder /app/hubproxy .
COPY --from=builder /app/config.toml .

View File

@@ -40,19 +40,70 @@ docker run -d \
ghcr.io/sky22333/hubproxy
```
### 一键脚本安装
### 脚本安装
自动识别系统与架构,从 GitHub Releases 下载对应的 `.deb``.rpm``.apk` 安装包:
```bash
curl -fsSL https://raw.githubusercontent.com/sky22333/hubproxy/main/install.sh | sudo bash
curl -fsSL https://raw.githubusercontent.com/sky22333/hubproxy/main/install.sh | sh
```
支持单个二进制文件直接启动,无需其他配置,内置默认配置,支持所有功能
安装包会自动安装并启动 `hubproxy` 服务
这个脚本会:
- 自动检测系统架构AMD64/ARM64
- 从 GitHub Releases 下载最新版本
- 自动配置系统服务
- 保留现有配置(升级时)
<details>
<summary>服务管理命令</summary>
#### 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`
## 使用方法
@@ -202,15 +253,12 @@ defaultTTL = "20m"
</details>
容器内的配置文件位于 `/root/config.toml`
脚本部署配置文件位于 `/opt/hubproxy/config.toml`
### 环境变量(可选)
支持通过环境变量覆盖部分配置,优先级高于`config.toml`,以下是默认值:
```
CONFIG_PATH=config.toml # 配置文件路径
SERVER_HOST=0.0.0.0 # 监听地址
SERVER_PORT=5000 # 监听端口
ENABLE_H2C=false # 是否启用 H2C
@@ -221,6 +269,7 @@ 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为例

View File

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

View File

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

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
User=root
Group=root
WorkingDirectory=/opt/hubproxy
ExecStart=/opt/hubproxy/hubproxy
Environment=CONFIG_PATH=/etc/hubproxy/config.toml
ExecStart=/usr/bin/hubproxy
Restart=always
RestartSec=5
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
StandardOutput=journal
StandardError=journal
SyslogIdentifier=hubproxy

45
packaging/nfpm.apk.yaml Normal file
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]
# 可通过 CONFIG_PATH 环境变量指定配置文件路径,默认读取当前工作目录下的 config.toml
host = "0.0.0.0"
# 监听端口
port = 5000

View File

@@ -197,16 +197,23 @@ func setConfig(cfg *AppConfig) {
configCacheMutex.Unlock()
}
// LoadConfig 加载配置文件
func configFilePath() string {
if path := strings.TrimSpace(os.Getenv("CONFIG_PATH")); path != "" {
return path
}
return "config.toml"
}
func LoadConfig() error {
cfg := DefaultConfig()
path := configFilePath()
if data, err := os.ReadFile("config.toml"); err == nil {
if data, err := os.ReadFile(path); err == nil {
if err := toml.Unmarshal(data, cfg); err != nil {
return fmt.Errorf("解析配置文件失败: %v", err)
return fmt.Errorf("解析配置文件 %s 失败: %v", path, err)
}
} else {
fmt.Println("未找到config.toml使用默认配置")
fmt.Printf("未找到配置文件 %s使用默认配置\n", path)
}
overrideFromEnv(cfg)
@@ -259,6 +266,10 @@ func overrideFromEnv(cfg *AppConfig) {
cfg.Security.BlackList = append(cfg.Security.BlackList, strings.Split(val, ",")...)
}
if val, ok := os.LookupEnv("ACCESS_PROXY"); ok {
cfg.Access.Proxy = strings.TrimSpace(val)
}
if val := os.Getenv("MAX_IMAGES"); val != "" {
if maxImages, err := strconv.Atoi(val); err == nil && maxImages > 0 {
cfg.Download.MaxImages = maxImages

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

View File

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

File diff suppressed because it is too large Load Diff

937
src/handlers/docker_test.go Normal file
View File

@@ -0,0 +1,937 @@
package handlers
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/gin-gonic/gin"
"hubproxy/config"
"hubproxy/utils"
)
type zeroReader struct{}
func (zeroReader) Read(p []byte) (int, error) {
for i := range p {
p[i] = 0
}
return len(p), nil
}
type discardResponseWriter struct {
header http.Header
status int
bytes int64
}
func newDiscardResponseWriter() *discardResponseWriter {
return &discardResponseWriter{header: make(http.Header)}
}
func (w *discardResponseWriter) Header() http.Header {
return w.header
}
func (w *discardResponseWriter) WriteHeader(status int) {
w.status = status
}
func (w *discardResponseWriter) Write(p []byte) (int, error) {
if w.status == 0 {
w.status = http.StatusOK
}
w.bytes += int64(len(p))
return len(p), nil
}
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)
}
}
type testEnv interface {
Helper()
TempDir() string
Setenv(string, string)
Fatal(...interface{})
}
func initDockerProxyTest(t testEnv, configBody string) {
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()
}
func TestRewriteAuthChallengePreservesScopeAndUsesProxyRealm(t *testing.T) {
target := registryTarget{
Name: "ghcr.io",
AuthService: "ghcr.io",
}
got := rewriteAuthChallenge(
`Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:owner/image:pull"`,
target,
"https://proxy.example.com",
)
want := `Bearer realm="https://proxy.example.com/token/ghcr.io",service="ghcr.io",scope="repository:owner/image:pull"`
if got != want {
t.Fatalf("challenge = %q, want %q", got, want)
}
}
func TestBuildAuthURLForDockerHubAddsLibraryScopeAndService(t *testing.T) {
got, err := buildAuthURL(
defaultRegistryTarget(),
"service=ignored&scope=repository%3Aalpine%3Apull&client_id=docker",
)
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(got, dockerHubAuthRealm+"?") {
t.Fatalf("auth URL = %q", got)
}
if !strings.Contains(got, "service=registry.docker.io") {
t.Fatalf("auth URL missing service: %q", got)
}
if !strings.Contains(got, "scope=repository%3Alibrary%2Falpine%3Apull") {
t.Fatalf("auth URL missing normalized scope: %q", got)
}
}
func TestTokenTargetIsInferredFromPathBasedRegistryScope(t *testing.T) {
initDockerProxyTest(t, `
[registries."ghcr.io"]
upstream = "ghcr.io"
authHost = "ghcr.io/token"
authType = "github"
enabled = true
`)
gin.SetMode(gin.TestMode)
req := httptest.NewRequest(http.MethodGet, "/token/docker.io?scope=repository:ghcr.io/jeessy2/ddns-go:pull&service=registry.docker.io", nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = req
c.Params = gin.Params{{Key: "path", Value: "/docker.io"}}
target, ok := resolveTokenTarget(c)
if !ok {
t.Fatal("resolveTokenTarget returned false")
}
if target.Name != "ghcr.io" {
t.Fatalf("target.Name = %q, want ghcr.io", target.Name)
}
if target.AuthService != "ghcr.io" {
t.Fatalf("AuthService = %q, want ghcr.io", target.AuthService)
}
}
func TestBuildAuthURLStripsPathBasedRegistryPrefixForGHCR(t *testing.T) {
target := registryTarget{
Name: "ghcr.io",
AuthRealm: "https://ghcr.io/token",
AuthService: "ghcr.io",
}
got, err := buildAuthURL(
target,
"scope=repository%3Aghcr.io%2Fjeessy2%2Fddns-go%3Apull&service=registry.docker.io",
)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(got, "service=ghcr.io") {
t.Fatalf("auth URL missing ghcr service: %q", got)
}
if !strings.Contains(got, "scope=repository%3Ajeessy2%2Fddns-go%3Apull") {
t.Fatalf("auth URL missing stripped scope: %q", got)
}
if strings.Contains(got, "registry.docker.io") {
t.Fatalf("auth URL leaked Docker Hub service: %q", got)
}
}
func TestDockerIODefaultTargetUsesBuiltInWhenUnconfigured(t *testing.T) {
initDockerProxyTest(t, "")
target := defaultRegistryTarget()
if target.Upstream != dockerHubUpstream {
t.Fatalf("Upstream = %q, want %q", target.Upstream, dockerHubUpstream)
}
if target.AuthRealm != dockerHubAuthRealm {
t.Fatalf("AuthRealm = %q, want %q", target.AuthRealm, dockerHubAuthRealm)
}
if !target.AutoLibraryPrefix {
t.Fatal("AutoLibraryPrefix = false, want true")
}
}
func TestDockerIODefaultTargetCanBeOverriddenByConfig(t *testing.T) {
initDockerProxyTest(t, `
[registries."docker.io"]
upstream = "mirror.local"
authHost = "auth.mirror.local/token"
authType = "docker"
enabled = true
`)
target := defaultRegistryTarget()
if target.Upstream != "https://mirror.local" {
t.Fatalf("Upstream = %q, want custom mirror", target.Upstream)
}
if target.AuthRealm != "https://auth.mirror.local/token" {
t.Fatalf("AuthRealm = %q, want custom auth realm", target.AuthRealm)
}
if target.AuthService != dockerHubAuthService {
t.Fatalf("AuthService = %q, want %q", target.AuthService, dockerHubAuthService)
}
if !target.AutoLibraryPrefix {
t.Fatal("AutoLibraryPrefix = false, want true")
}
}
func TestDockerIODefaultTargetIgnoresDisabledOverride(t *testing.T) {
initDockerProxyTest(t, `
[registries."docker.io"]
upstream = "mirror.local"
authHost = "auth.mirror.local/token"
authType = "docker"
enabled = false
`)
target := defaultRegistryTarget()
if target.Upstream != dockerHubUpstream {
t.Fatalf("Upstream = %q, want built-in %q", target.Upstream, dockerHubUpstream)
}
}
func TestProxyDockerRegistryTransparentlyForwardsAuthAndRewritesChallenge(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v2/team/app/manifests/latest" {
t.Fatalf("upstream path = %q", r.URL.Path)
}
if got := r.Header.Get("Authorization"); got != "Bearer client-token" {
t.Fatalf("Authorization = %q", got)
}
if got := r.Header.Get("Accept"); got != "application/vnd.docker.distribution.manifest.v2+json" {
t.Fatalf("Accept = %q", got)
}
if got := r.Header.Get("Range"); got != "bytes=0-99" {
t.Fatalf("Range = %q", got)
}
w.Header().Set("WWW-Authenticate", `Bearer realm="https://upstream.example/token",service="upstream.example",scope="repository:team/app:pull"`)
w.WriteHeader(http.StatusUnauthorized)
}))
defer upstream.Close()
initDockerProxyTest(t, `
[registries."test.local"]
upstream = "`+upstream.URL+`"
authHost = "https://auth.test.local/token"
authType = "anonymous"
enabled = true
`)
gin.SetMode(gin.TestMode)
router := gin.New()
router.Any("/v2/*path", ProxyDockerRegistryGin)
req := httptest.NewRequest(http.MethodGet, "/v2/test.local/team/app/manifests/latest", nil)
req.Host = "proxy.example.com"
req.Header.Set("X-Forwarded-Proto", "https")
req.Header.Set("Authorization", "Bearer client-token")
req.Header.Set("Accept", "application/vnd.docker.distribution.manifest.v2+json")
req.Header.Set("Range", "bytes=0-99")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401; body=%s", w.Code, w.Body.String())
}
wantChallenge := `Bearer realm="https://proxy.example.com/token/test.local",service="` + strings.TrimPrefix(upstream.URL, "http://") + `",scope="repository:team/app:pull"`
if got := w.Header().Get("WWW-Authenticate"); got != wantChallenge {
t.Fatalf("WWW-Authenticate = %q, want %q", got, wantChallenge)
}
}
func TestDockerV2BaseProxiesUpstreamChallenge(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v2/" {
t.Fatalf("upstream path = %q", r.URL.Path)
}
w.Header().Set("WWW-Authenticate", `Bearer realm="https://registry.example/token",service="registry.example"`)
w.WriteHeader(http.StatusUnauthorized)
}))
defer upstream.Close()
initDockerProxyTest(t, `
[registries."docker.io"]
upstream = "`+upstream.URL+`"
authHost = "https://auth.example/token"
authType = "docker"
enabled = true
`)
gin.SetMode(gin.TestMode)
router := gin.New()
router.Any("/v2/", ProxyDockerRegistryGin)
req := httptest.NewRequest(http.MethodGet, "/v2/", nil)
req.Host = "hub.example.com"
req.Header.Set("X-Forwarded-Proto", "https")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401; body=%s", w.Code, w.Body.String())
}
wantChallenge := `Bearer realm="https://hub.example.com/token/docker.io",service="registry.docker.io"`
if got := w.Header().Get("WWW-Authenticate"); got != wantChallenge {
t.Fatalf("WWW-Authenticate = %q, want %q", got, wantChallenge)
}
}
func TestProxyDockerAuthForwardsBasicCredentials(t *testing.T) {
authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Basic dXNlcjpwYXNz" {
t.Fatalf("Authorization = %q", got)
}
if got := r.URL.Query().Get("service"); got != "127.0.0.1" {
t.Fatalf("service = %q", got)
}
if got := r.URL.Query().Get("scope"); got != "repository:team/app:pull" {
t.Fatalf("scope = %q", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"token":"secret","expires_in":3600}`))
}))
defer authServer.Close()
initDockerProxyTest(t, `
[registries."test.local"]
upstream = "https://127.0.0.1"
authHost = "`+authServer.URL+`"
authType = "anonymous"
enabled = true
`)
gin.SetMode(gin.TestMode)
router := gin.New()
router.Any("/token/*path", ProxyDockerAuthGin)
req := httptest.NewRequest(http.MethodGet, "/token/test.local?scope=repository:team/app:pull", nil)
req.Header.Set("Authorization", "Basic dXNlcjpwYXNz")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
}
if got := w.Body.String(); !strings.Contains(got, `"token":"secret"`) {
t.Fatalf("body = %q", got)
}
}
func TestProxyDockerAuthRoutesPathBasedGHCRScopeToGHCRAuth(t *testing.T) {
authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("service"); got != "ghcr.io" {
t.Fatalf("service = %q, want ghcr.io", got)
}
if got := r.URL.Query().Get("scope"); got != "repository:jeessy2/ddns-go:pull" {
t.Fatalf("scope = %q, want repository:jeessy2/ddns-go:pull", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"token":"ghcr-token","expires_in":3600}`))
}))
defer authServer.Close()
initDockerProxyTest(t, `
[registries."ghcr.io"]
upstream = "ghcr.io"
authHost = "`+authServer.URL+`"
authType = "github"
enabled = true
`)
utils.GlobalCache = &utils.UniversalCache{}
gin.SetMode(gin.TestMode)
router := gin.New()
router.Any("/token/*path", ProxyDockerAuthGin)
req := httptest.NewRequest(http.MethodGet, "/token/docker.io?scope=repository%3Aghcr.io%2Fjeessy2%2Fddns-go%3Apull&service=registry.docker.io", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
}
if got := w.Body.String(); !strings.Contains(got, `"token":"ghcr-token"`) {
t.Fatalf("body = %q", got)
}
}
func TestDockerHubShortNameIsProxiedWithLibraryPrefix(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v2/library/nginx/manifests/latest" {
t.Fatalf("upstream path = %q", r.URL.Path)
}
w.Header().Set("Content-Type", "application/vnd.docker.distribution.manifest.v2+json")
w.Header().Set("Docker-Content-Digest", "sha256:abc")
_, _ = w.Write([]byte(`{"schemaVersion":2}`))
}))
defer upstream.Close()
gin.SetMode(gin.TestMode)
router := gin.New()
router.Any("/v2/*path", ProxyDockerRegistryGin)
target := defaultRegistryTarget()
target.Upstream = upstream.URL
req := httptest.NewRequest(http.MethodGet, "/v2/nginx/manifests/latest", nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = req
proxyRegistryHTTP(c, target, "/v2/library/nginx/manifests/latest")
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
}
if got := w.Header().Get("Docker-Content-Digest"); got != "sha256:abc" {
t.Fatalf("Docker-Content-Digest = %q", got)
}
}
func TestProxyDockerRegistryHeadReturnsHeadersWithoutBody(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodHead {
t.Fatalf("method = %s, want HEAD", r.Method)
}
w.Header().Set("Content-Length", "123")
w.Header().Set("Docker-Content-Digest", "sha256:head")
w.WriteHeader(http.StatusOK)
}))
defer upstream.Close()
initDockerProxyTest(t, `
[registries."test.local"]
upstream = "`+upstream.URL+`"
authHost = "https://auth.test.local/token"
authType = "anonymous"
enabled = true
`)
gin.SetMode(gin.TestMode)
router := gin.New()
router.Any("/v2/*path", ProxyDockerRegistryGin)
req := httptest.NewRequest(http.MethodHead, "/v2/test.local/team/app/blobs/sha256:abc", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
if got := w.Header().Get("Docker-Content-Digest"); got != "sha256:head" {
t.Fatalf("Docker-Content-Digest = %q", got)
}
if body := w.Body.String(); body != "" {
t.Fatalf("HEAD body = %q, want empty", body)
}
}
func TestProxyDockerRegistryStreamsBlobAndSkipsHopByHopHeaders(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Connection", "close")
w.Header().Set("Transfer-Encoding", "chunked")
w.Header().Set("Content-Type", "application/octet-stream")
_, _ = io.WriteString(w, "layer-data")
}))
defer upstream.Close()
initDockerProxyTest(t, `
[registries."test.local"]
upstream = "`+upstream.URL+`"
authHost = "https://auth.test.local/token"
authType = "anonymous"
enabled = true
`)
gin.SetMode(gin.TestMode)
router := gin.New()
router.Any("/v2/*path", ProxyDockerRegistryGin)
req := httptest.NewRequest(http.MethodGet, "/v2/test.local/team/app/blobs/sha256:abc", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
}
if got := w.Body.String(); got != "layer-data" {
t.Fatalf("body = %q", got)
}
if got := w.Header().Get("Connection"); got != "" {
t.Fatalf("Connection header leaked: %q", got)
}
}
func TestProxyDockerRegistryCachesAnonymousManifestByAccept(t *testing.T) {
var hits int32
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
count := atomic.AddInt32(&hits, 1)
body := fmt.Sprintf(`{"schemaVersion":2,"hit":%d}`, count)
w.Header().Set("Content-Type", r.Header.Get("Accept"))
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
_, _ = w.Write([]byte(body))
}))
defer upstream.Close()
initDockerProxyTest(t, `
[registries."test.local"]
upstream = "`+upstream.URL+`"
authHost = "https://auth.test.local/token"
authType = "anonymous"
enabled = true
`)
utils.GlobalCache = &utils.UniversalCache{}
gin.SetMode(gin.TestMode)
router := gin.New()
router.Any("/v2/*path", ProxyDockerRegistryGin)
for i := 0; i < 2; i++ {
req := httptest.NewRequest(http.MethodGet, "/v2/test.local/team/app/manifests/latest", nil)
req.Header.Set("Accept", "application/vnd.docker.distribution.manifest.v2+json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("request %d status = %d; body=%s", i, w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), `"hit":1`) {
t.Fatalf("request %d body = %q", i, w.Body.String())
}
}
if got := atomic.LoadInt32(&hits); got != 1 {
t.Fatalf("hits = %d, want 1", got)
}
req := httptest.NewRequest(http.MethodGet, "/v2/test.local/team/app/manifests/latest", nil)
req.Header.Set("Accept", "application/vnd.oci.image.index.v1+json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("second accept status = %d; body=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), `"hit":2`) {
t.Fatalf("second accept body = %q", w.Body.String())
}
}
func TestProxyDockerRegistryDoesNotCacheAuthenticatedManifest(t *testing.T) {
var hits int32
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
count := atomic.AddInt32(&hits, 1)
body := fmt.Sprintf(`{"schemaVersion":2,"hit":%d}`, count)
w.Header().Set("Content-Type", "application/vnd.docker.distribution.manifest.v2+json")
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
_, _ = w.Write([]byte(body))
}))
defer upstream.Close()
initDockerProxyTest(t, `
[registries."test.local"]
upstream = "`+upstream.URL+`"
authHost = "https://auth.test.local/token"
authType = "anonymous"
enabled = true
`)
utils.GlobalCache = &utils.UniversalCache{}
gin.SetMode(gin.TestMode)
router := gin.New()
router.Any("/v2/*path", ProxyDockerRegistryGin)
for i := 1; i <= 2; i++ {
req := httptest.NewRequest(http.MethodGet, "/v2/test.local/team/app/manifests/latest", nil)
req.Header.Set("Authorization", "Bearer token")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("request %d status = %d; body=%s", i, w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), fmt.Sprintf(`"hit":%d`, i)) {
t.Fatalf("request %d body = %q", i, w.Body.String())
}
}
if got := atomic.LoadInt32(&hits); got != 2 {
t.Fatalf("hits = %d, want 2", got)
}
}
func TestProxyDockerRegistryCachesKnownAnonymousBearerManifest(t *testing.T) {
anonymousTokens = &anonymousTokenStore{entries: make(map[string]time.Time)}
anonymousTokens.RememberFromResponse([]byte(`{"token":"anonymous-token","expires_in":3600}`), time.Hour)
var hits int32
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
count := atomic.AddInt32(&hits, 1)
body := fmt.Sprintf(`{"schemaVersion":2,"hit":%d}`, count)
w.Header().Set("Content-Type", "application/vnd.docker.distribution.manifest.v2+json")
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
_, _ = w.Write([]byte(body))
}))
defer upstream.Close()
initDockerProxyTest(t, `
[registries."test.local"]
upstream = "`+upstream.URL+`"
authHost = "https://auth.test.local/token"
authType = "anonymous"
enabled = true
`)
utils.GlobalCache = &utils.UniversalCache{}
gin.SetMode(gin.TestMode)
router := gin.New()
router.Any("/v2/*path", ProxyDockerRegistryGin)
for i := 0; i < 2; i++ {
req := httptest.NewRequest(http.MethodGet, "/v2/test.local/team/app/manifests/latest", nil)
req.Header.Set("Authorization", "Bearer anonymous-token")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("request %d status = %d; body=%s", i, w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), `"hit":1`) {
t.Fatalf("request %d body = %q", i, w.Body.String())
}
}
if got := atomic.LoadInt32(&hits); got != 1 {
t.Fatalf("hits = %d, want 1", got)
}
}
func TestProxyDockerRegistryDoesNotCacheUnknownBearerManifest(t *testing.T) {
anonymousTokens = &anonymousTokenStore{entries: make(map[string]time.Time)}
var hits int32
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
count := atomic.AddInt32(&hits, 1)
body := fmt.Sprintf(`{"schemaVersion":2,"hit":%d}`, count)
w.Header().Set("Content-Type", "application/vnd.docker.distribution.manifest.v2+json")
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
_, _ = w.Write([]byte(body))
}))
defer upstream.Close()
initDockerProxyTest(t, `
[registries."test.local"]
upstream = "`+upstream.URL+`"
authHost = "https://auth.test.local/token"
authType = "anonymous"
enabled = true
`)
utils.GlobalCache = &utils.UniversalCache{}
gin.SetMode(gin.TestMode)
router := gin.New()
router.Any("/v2/*path", ProxyDockerRegistryGin)
for i := 1; i <= 2; i++ {
req := httptest.NewRequest(http.MethodGet, "/v2/test.local/team/app/manifests/latest", nil)
req.Header.Set("Authorization", "Bearer user-token")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("request %d status = %d; body=%s", i, w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), fmt.Sprintf(`"hit":%d`, i)) {
t.Fatalf("request %d body = %q", i, w.Body.String())
}
}
if got := atomic.LoadInt32(&hits); got != 2 {
t.Fatalf("hits = %d, want 2", got)
}
}
func TestProxyDockerRegistryUsesNsQueryForContainerd(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v2/team/app/manifests/latest" {
t.Fatalf("upstream path = %q", r.URL.Path)
}
if got := r.URL.Query().Get("ns"); got != "test.local" {
t.Fatalf("ns query = %q", got)
}
w.WriteHeader(http.StatusOK)
}))
defer upstream.Close()
initDockerProxyTest(t, `
[registries."test.local"]
upstream = "`+upstream.URL+`"
authHost = "https://auth.test.local/token"
authType = "anonymous"
enabled = true
`)
gin.SetMode(gin.TestMode)
router := gin.New()
router.Any("/v2/*path", ProxyDockerRegistryGin)
req := httptest.NewRequest(http.MethodGet, "/v2/team/app/manifests/latest?ns=test.local", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
}
}
func TestProxyDockerAuthCachesOnlyAnonymousTokenRequests(t *testing.T) {
var hits int32
authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
count := atomic.AddInt32(&hits, 1)
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintf(w, `{"token":"token-%d","expires_in":3600}`, count)
}))
defer authServer.Close()
initDockerProxyTest(t, `
[registries."test.local"]
upstream = "https://test.local"
authHost = "`+authServer.URL+`"
authType = "anonymous"
enabled = true
`)
utils.GlobalCache = &utils.UniversalCache{}
gin.SetMode(gin.TestMode)
router := gin.New()
router.Any("/token/*path", ProxyDockerAuthGin)
for i := 0; i < 2; i++ {
req := httptest.NewRequest(http.MethodGet, "/token/test.local?scope=repository:team/app:pull", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("anonymous request %d status = %d; body=%s", i, w.Code, w.Body.String())
}
if got := w.Body.String(); !strings.Contains(got, `"token":"token-1"`) {
t.Fatalf("anonymous request %d body = %q", i, got)
}
}
if got := atomic.LoadInt32(&hits); got != 1 {
t.Fatalf("anonymous token hits = %d, want 1", got)
}
for i := 0; i < 2; i++ {
req := httptest.NewRequest(http.MethodGet, "/token/test.local?scope=repository:team/app:pull", nil)
req.Header.Set("Authorization", "Basic dXNlcjpwYXNz")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("authenticated request %d status = %d; body=%s", i, w.Code, w.Body.String())
}
}
if got := atomic.LoadInt32(&hits); got != 3 {
t.Fatalf("authenticated token hits total = %d, want 3", got)
}
}
func TestProxyDockerAuthRejectsUnknownRegistry(t *testing.T) {
initDockerProxyTest(t, "")
gin.SetMode(gin.TestMode)
router := gin.New()
router.Any("/token/*path", ProxyDockerAuthGin)
req := httptest.NewRequest(http.MethodGet, "/token/missing.local?scope=repository:team/app:pull", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", w.Code, w.Body.String())
}
}
func TestProxyDockerRegistryConcurrentRequests(t *testing.T) {
const requests = 64
var hits int32
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&hits, 1)
if got := r.Header.Get("Authorization"); got == "" {
t.Fatal("missing Authorization")
}
w.Header().Set("Content-Type", "application/octet-stream")
_, _ = w.Write([]byte("ok"))
}))
defer upstream.Close()
initDockerProxyTest(t, `
[registries."test.local"]
upstream = "`+upstream.URL+`"
authHost = "https://auth.test.local/token"
authType = "anonymous"
enabled = true
`)
gin.SetMode(gin.TestMode)
router := gin.New()
router.Any("/v2/*path", ProxyDockerRegistryGin)
var wg sync.WaitGroup
errs := make(chan string, requests)
for i := 0; i < requests; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
req := httptest.NewRequest(http.MethodGet, "/v2/test.local/team/app/blobs/sha256:abc", nil)
req.Header.Set("Authorization", fmt.Sprintf("Bearer token-%d", i))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK || w.Body.String() != "ok" {
errs <- fmt.Sprintf("request %d status=%d body=%q", i, w.Code, w.Body.String())
}
}(i)
}
wg.Wait()
close(errs)
for err := range errs {
t.Fatal(err)
}
if got := atomic.LoadInt32(&hits); got != requests {
t.Fatalf("hits = %d, want %d", got, requests)
}
}
func TestProxyDockerRegistryLargeBlobStreamsWithoutRecorderBuffer(t *testing.T) {
const blobSize = 8 << 20
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Length", fmt.Sprintf("%d", blobSize))
_, _ = io.CopyN(w, zeroReader{}, blobSize)
}))
defer upstream.Close()
initDockerProxyTest(t, `
[registries."test.local"]
upstream = "`+upstream.URL+`"
authHost = "https://auth.test.local/token"
authType = "anonymous"
enabled = true
`)
gin.SetMode(gin.TestMode)
router := gin.New()
router.Any("/v2/*path", ProxyDockerRegistryGin)
req := httptest.NewRequest(http.MethodGet, "/v2/test.local/team/app/blobs/sha256:large", nil)
w := newDiscardResponseWriter()
router.ServeHTTP(w, req)
if w.status != http.StatusOK {
t.Fatalf("status = %d, want 200", w.status)
}
if w.bytes != blobSize {
t.Fatalf("streamed bytes = %d, want %d", w.bytes, blobSize)
}
if got := w.Header().Get("Content-Length"); got != fmt.Sprintf("%d", blobSize) {
t.Fatalf("Content-Length = %q", got)
}
}
func BenchmarkProxyDockerRegistryBlobStreaming(b *testing.B) {
const blobSize = 1 << 20
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Length", fmt.Sprintf("%d", blobSize))
_, _ = io.CopyN(w, zeroReader{}, blobSize)
}))
defer upstream.Close()
initDockerProxyTest(b, `
[registries."bench.local"]
upstream = "`+upstream.URL+`"
authHost = "https://auth.bench.local/token"
authType = "anonymous"
enabled = true
`)
gin.SetMode(gin.TestMode)
router := gin.New()
router.Any("/v2/*path", ProxyDockerRegistryGin)
b.ReportAllocs()
b.SetBytes(blobSize)
b.ResetTimer()
for i := 0; i < b.N; i++ {
req := httptest.NewRequest(http.MethodGet, "/v2/bench.local/team/app/blobs/sha256:bench", nil)
w := newDiscardResponseWriter()
router.ServeHTTP(w, req)
if w.status != http.StatusOK || w.bytes != blobSize {
b.Fatalf("status=%d bytes=%d", w.status, w.bytes)
}
}
}

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

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

@@ -251,7 +251,7 @@ func searchDockerHubWithDepth(ctx context.Context, query string, page, pageSize
}
return nil, fmt.Errorf("未找到相关镜像")
case http.StatusBadGateway, http.StatusServiceUnavailable:
return nil, fmt.Errorf("Docker Hub服务暂时不可用请稍后重试")
return nil, fmt.Errorf("docker hub 服务暂时不可用,请稍后重试")
default:
return nil, fmt.Errorf("请求失败: 状态码=%d, 响应=%s", resp.StatusCode, string(body))
}

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

167
src/main_test.go Normal file
View File

@@ -0,0 +1,167 @@
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 TestDockerV2InvalidPath(t *testing.T) {
router := newTestRouter(t, "")
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, '_');
let url = `/api/image/download/${encodedImage}`;
@@ -737,6 +737,9 @@
params.append('platform', platform.trim());
}
params.append('compressed', useCompressed.toString());
if (mode) {
params.append('mode', mode);
}
if (params.toString()) {
url += '?' + params.toString();
@@ -745,7 +748,65 @@
return url;
}
document.getElementById('singleForm').addEventListener('submit', function(e) {
function buildInfoUrl(imageName) {
const encodedImage = imageName.replace(/\//g, '_');
return `/api/image/info/${encodedImage}`;
}
async function preflightImageDownload(imageName) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 8000);
try {
const response = await fetch(buildInfoUrl(imageName), {
method: 'GET',
headers: {
'Accept': 'application/json'
},
cache: 'no-store',
signal: controller.signal
});
const contentType = response.headers.get('Content-Type') || '';
let payload = null;
if (contentType.includes('application/json')) {
payload = await response.json();
}
if (!response.ok) {
return { ok: false, error: (payload && payload.error) ? payload.error : '镜像预检失败' };
}
if (payload && payload.success === false) {
return { ok: false, error: payload.error || '镜像预检失败' };
}
return { ok: true };
} catch (error) {
if (error.name === 'AbortError') {
return { ok: false, error: '预检超时,请稍后重试' };
}
return { ok: false, error: '网络错误: ' + error.message };
} finally {
clearTimeout(timeoutId);
}
}
async function preflightImages(images) {
const uniqueImages = Array.from(new Set(images));
const results = await Promise.allSettled(uniqueImages.map((imageName) => preflightImageDownload(imageName)));
for (let i = 0; i < results.length; i++) {
const result = results[i];
if (result.status === 'rejected') {
return { ok: false, error: '预检失败,请稍后重试' };
}
if (!result.value.ok) {
return { ok: false, error: result.value.error || '预检失败' };
}
}
return { ok: true };
}
document.getElementById('singleForm').addEventListener('submit', async function(e) {
e.preventDefault();
const imageName = document.getElementById('imageInput').value.trim();
@@ -760,20 +821,50 @@
hideStatus('singleStatus');
setButtonLoading('downloadBtn', 'downloadText', 'downloadLoading', true);
const downloadUrl = buildDownloadUrl(imageName, platform, useCompressed);
const link = document.createElement('a');
link.href = downloadUrl;
link.download = '';
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
const platformText = platform ? ` (${platform})` : '';
showStatus('singleStatus', `开始下载 ${imageName}${platformText}`, 'success');
setButtonLoading('downloadBtn', 'downloadText', 'downloadLoading', false);
showStatus('singleStatus', '正在准备下载...', 'success');
const preflightResult = await preflightImages([imageName]);
if (!preflightResult.ok) {
showStatus('singleStatus', preflightResult.error, 'error');
setButtonLoading('downloadBtn', 'downloadText', 'downloadLoading', false);
return;
}
const prepareUrl = buildDownloadUrl(imageName, platform, useCompressed, 'prepare');
try {
const response = await fetch(prepareUrl, {
method: 'GET',
headers: {
'Accept': 'application/json'
},
cache: 'no-store'
});
if (response.ok) {
const data = await response.json();
if (!data || !data.download_url) {
showStatus('singleStatus', '下载地址生成失败', 'error');
return;
}
const link = document.createElement('a');
link.href = data.download_url;
link.download = '';
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
const platformText = platform ? ` (${platform})` : '';
showStatus('singleStatus', `开始下载 ${imageName}${platformText}`, 'success');
} else {
const error = await response.json();
showStatus('singleStatus', error.error || '下载失败', 'error');
}
} catch (error) {
showStatus('singleStatus', '网络错误: ' + error.message, 'error');
} finally {
setButtonLoading('downloadBtn', 'downloadText', 'downloadLoading', false);
}
});
document.getElementById('batchForm').addEventListener('submit', async function(e) {
@@ -808,9 +899,16 @@
hideStatus('batchStatus');
setButtonLoading('batchDownloadBtn', 'batchDownloadText', 'batchDownloadLoading', true);
showStatus('batchStatus', '正在准备下载...', 'success');
const preflightResult = await preflightImages(images);
if (!preflightResult.ok) {
showStatus('batchStatus', preflightResult.error, 'error');
setButtonLoading('batchDownloadBtn', 'batchDownloadText', 'batchDownloadLoading', false);
return;
}
try {
const response = await fetch('/api/image/batch', {
const response = await fetch('/api/image/batch?mode=prepare', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -819,24 +917,19 @@
});
if (response.ok) {
const contentDisposition = response.headers.get('Content-Disposition');
let filename = `batch_${images.length}_images.tar`;
if (contentDisposition) {
const matches = contentDisposition.match(/filename="(.+)"/);
if (matches) filename = matches[1];
const data = await response.json();
if (!data || !data.download_url) {
showStatus('batchStatus', '下载地址生成失败', 'error');
return;
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const url = data.download_url;
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
const platformText = platform ? ` (${platform})` : '';
showStatus('batchStatus', `开始下载 ${images.length} 个镜像${platformText}`, 'success');
@@ -872,4 +965,4 @@
initMobileMenu();
</script>
</body>
</html>
</html>

View File

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

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

View File

@@ -102,10 +102,18 @@ func ExtractTTLFromResponse(responseBody []byte) time.Duration {
defaultTTL := 30 * time.Minute
if json.Unmarshal(responseBody, &tokenResp) == nil && tokenResp.ExpiresIn > 0 {
safeTTL := time.Duration(tokenResp.ExpiresIn-300) * time.Second
if safeTTL > 5*time.Minute {
return safeTTL
expires := time.Duration(tokenResp.ExpiresIn) * time.Second
skew := expires / 10
if skew > 5*time.Minute {
skew = 5 * time.Minute
}
if skew < 10*time.Second {
skew = 10 * time.Second
}
if expires > skew {
return expires - skew
}
return expires / 2
}
return defaultTTL

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

@@ -0,0 +1,53 @@
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(`{"expires_in":300}`)); ttl != 270*time.Second {
t.Fatalf("short TTL = %s, want 270s", 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

@@ -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

@@ -3,6 +3,7 @@ package utils
import (
"fmt"
"net"
"os"
"strings"
"sync"
"time"
@@ -17,6 +18,8 @@ const (
MaxIPCacheSize = 10000
)
var debugRateLimitLog = strings.EqualFold(os.Getenv("DEBUG_RATE_LIMIT_LOG"), "true")
// IPRateLimiter IP限流器结构体
type IPRateLimiter struct {
ips map[string]*rateLimiterEntry
@@ -234,17 +237,20 @@ func RateLimitMiddleware(limiter *IPRateLimiter) gin.HandlerFunc {
cleanIP := extractIPFromAddress(ip)
normalizedIP := normalizeIPForRateLimit(cleanIP)
if cleanIP != normalizedIP {
fmt.Printf("请求IP: %s (提纯后: %s, 限流段: %s), X-Forwarded-For: %s, X-Real-IP: %s\n",
ip, cleanIP, normalizedIP,
c.GetHeader("X-Forwarded-For"),
c.GetHeader("X-Real-IP"))
} else {
fmt.Printf("请求IP: %s (提纯后: %s), X-Forwarded-For: %s, X-Real-IP: %s\n",
ip, cleanIP,
c.GetHeader("X-Forwarded-For"),
c.GetHeader("X-Real-IP"))
if debugRateLimitLog {
normalizedIP := normalizeIPForRateLimit(cleanIP)
if cleanIP != normalizedIP {
fmt.Printf("请求IP: %s (提纯后: %s, 限流段: %s), X-Forwarded-For: %s, X-Real-IP: %s\n",
ip, cleanIP, normalizedIP,
c.GetHeader("X-Forwarded-For"),
c.GetHeader("X-Real-IP"))
} else {
fmt.Printf("请求IP: %s (提纯后: %s), X-Forwarded-For: %s, X-Real-IP: %s\n",
ip, cleanIP,
c.GetHeader("X-Forwarded-For"),
c.GetHeader("X-Real-IP"))
}
}
ipLimiter, allowed := limiter.GetLimiter(cleanIP)

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