10 Commits

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
18 changed files with 1836 additions and 723 deletions
+58 -26
View File
@@ -59,6 +59,9 @@ jobs:
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,23 +89,61 @@ 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@v3
@@ -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 }}
+2 -2
View File
@@ -1,5 +1,5 @@
.idea
.vscode
.DS_Store
hubproxy*
!hubproxy.service
/hubproxy*
*.exe
+59 -12
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>
#### systemd(Debian / Ubuntu / RHEL / CentOS / Fedora)
```bash
# 查看状态
sudo systemctl status hubproxy
# 重启服务
sudo systemctl restart hubproxy
# 查看实时日志
sudo journalctl -u hubproxy -f
# 编辑配置文件
sudo nano /etc/hubproxy/config.toml
# 卸载服务
sudo apt remove hubproxy
# 连配置一起清理
sudo apt purge hubproxy
```
#### OpenRC(Alpine Linux)
```bash
# 查看状态
sudo rc-service hubproxy status
# 重启服务
sudo rc-service hubproxy restart
# 查看实时日志
sudo tail -f /var/log/hubproxy.log
# 编辑配置文件
sudo vi /etc/hubproxy/config.toml
# 卸载
sudo apk del hubproxy
```
</details>
### 文件路径
- Linux 安装包配置文件:`/etc/hubproxy/config.toml`
- Linux 安装包二进制文件:`/usr/bin/hubproxy`
- systemd 服务文件:`/lib/systemd/system/hubproxy.service`
- Alpine OpenRC 服务文件:`/etc/init.d/hubproxy`
- Alpine 日志文件:`/var/log/hubproxy.log`
- Alpine 日志轮转配置:`/etc/logrotate.d/hubproxy`
## 使用方法
@@ -202,10 +253,6 @@ defaultTTL = "20m"
</details>
容器内的配置文件位于 `/app/config.toml`
脚本部署配置文件位于 `/opt/hubproxy/config.toml`
### 环境变量(可选)
支持通过环境变量覆盖部分配置,优先级高于`config.toml`,以下是默认值:
+97 -189
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 "安装 HubProxy:linux/${ARCH}(${PACKAGER})"
# 9. 检查服务状态
sleep 2
if systemctl is-active --quiet ${SERVICE_NAME}; then
echo ""
echo -e "${GREEN}HubProxy 安装成功!${NC}"
echo -e "${GREEN}默认运行端口: 5000${NC}"
echo -e "${GREEN}配置文件路径: ${INSTALL_DIR}/${CONFIG_FILE}${NC}"
else
echo -e "${RED}服务启动失败${NC}"
echo "查看错误日志: sudo journalctl -u ${SERVICE_NAME} -f"
exit 1
fi
ASSET="$(asset_name "$PACKAGER" "$ARCH")"
ASSET_URL="$(asset_url "$ASSET")"
PACKAGE_FILE="${TMP_DIR}/$(basename "$ASSET_URL")"
log "下载安装包..."
curl -fL -o "$PACKAGE_FILE" "$ASSET_URL" || fail "下载安装包失败"
log "安装软件包..."
install_package "$PACKAGE_FILE" "$PACKAGER"
log "安装完成"
log "默认端口:5000"
log "配置文件:/etc/hubproxy/config.toml"
+9
View File
@@ -0,0 +1,9 @@
/var/log/hubproxy.log {
weekly
maxsize 50M
rotate 4
compress
missingok
notifempty
copytruncate
}
+18
View File
@@ -0,0 +1,18 @@
#!/sbin/openrc-run
name="hubproxy"
description="Docker and GitHub acceleration proxy server"
command="/usr/bin/hubproxy"
pidfile="/run/${RC_SVCNAME}.pid"
output_log="/var/log/hubproxy.log"
error_log="/var/log/hubproxy.log"
supervisor="supervise-daemon"
respawn_delay=5
respawn_max=0
export CONFIG_PATH="/etc/hubproxy/config.toml"
depend() {
need net
after firewall
}
@@ -7,11 +7,10 @@ Wants=network-online.target
Type=simple
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
View File
@@ -0,0 +1,45 @@
name: hubproxy
arch: ${NFPM_ARCH}
platform: linux
version: ${NFPM_VERSION}
release: "1"
section: net
priority: optional
maintainer: sky22333
description: Docker and GitHub acceleration proxy server
vendor: sky22333
homepage: https://github.com/sky22333/hubproxy
license: MIT
depends:
- logrotate
contents:
- src: ./build/package-root/hubproxy
dst: /usr/bin/hubproxy
file_info:
mode: 0755
- src: ./src/config.toml
dst: /etc/hubproxy/config.toml
type: config|noreplace
file_info:
mode: 0644
- src: ./packaging/hubproxy.openrc
dst: /etc/init.d/hubproxy
file_info:
mode: 0755
- src: ./packaging/hubproxy.logrotate
dst: /etc/logrotate.d/hubproxy
file_info:
mode: 0644
scripts:
postinstall: ./packaging/postinstall.sh
preremove: ./packaging/preremove.sh
postremove: ./packaging/postremove.sh
apk:
scripts:
postupgrade: ./packaging/postinstall.sh
+34
View File
@@ -0,0 +1,34 @@
name: hubproxy
arch: ${NFPM_ARCH}
platform: linux
version: ${NFPM_VERSION}
release: "1"
section: net
priority: optional
maintainer: sky22333
description: Docker and GitHub acceleration proxy server
vendor: sky22333
homepage: https://github.com/sky22333/hubproxy
license: MIT
contents:
- src: ./build/package-root/hubproxy
dst: /usr/bin/hubproxy
file_info:
mode: 0755
- src: ./src/config.toml
dst: /etc/hubproxy/config.toml
type: config|noreplace
file_info:
mode: 0644
- src: ./packaging/hubproxy.service
dst: /lib/systemd/system/hubproxy.service
file_info:
mode: 0644
scripts:
postinstall: ./packaging/postinstall.sh
preremove: ./packaging/preremove.sh
postremove: ./packaging/postremove.sh
+27
View File
@@ -0,0 +1,27 @@
#!/bin/sh
set -e
warn() {
echo "hubproxy: $1"
}
if command -v systemctl >/dev/null 2>&1; then
systemctl daemon-reload || warn "systemd reload failed"
systemctl enable hubproxy >/dev/null 2>&1 || warn "systemd enable failed"
if [ -d /run/systemd/system ]; then
systemctl restart hubproxy || systemctl start hubproxy || {
warn "service start failed, check: journalctl -u hubproxy"
}
fi
fi
if command -v rc-update >/dev/null 2>&1; then
rc-update add hubproxy default >/dev/null 2>&1 || warn "OpenRC enable failed"
fi
if command -v rc-service >/dev/null 2>&1; then
rc-service hubproxy restart || rc-service hubproxy start || {
warn "service start failed, check: rc-service hubproxy status"
}
fi
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
set -e
if command -v systemctl >/dev/null 2>&1; then
systemctl daemon-reload >/dev/null 2>&1 || true
fi
+21
View File
@@ -0,0 +1,21 @@
#!/bin/sh
set -e
case "${1:-}" in
1|upgrade)
exit 0
;;
esac
if command -v systemctl >/dev/null 2>&1; then
systemctl stop hubproxy >/dev/null 2>&1 || true
systemctl disable hubproxy >/dev/null 2>&1 || true
fi
if command -v rc-service >/dev/null 2>&1; then
rc-service hubproxy stop >/dev/null 2>&1 || true
fi
if command -v rc-update >/dev/null 2>&1; then
rc-update del hubproxy default >/dev/null 2>&1 || true
fi
+516 -469
View File
File diff suppressed because it is too large Load Diff
+908 -1
View File
@@ -1,6 +1,57 @@
package handlers
import "testing"
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 {
@@ -28,3 +79,859 @@ func TestParseRegistryPathInvalid(t *testing.T) {
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)
}
}
}
+2 -7
View File
@@ -140,15 +140,10 @@ func TestGitHubNoRouteRejectsUnsupportedHost(t *testing.T) {
}
}
func TestDockerV2PingAndInvalidPath(t *testing.T) {
func TestDockerV2InvalidPath(t *testing.T) {
router := newTestRouter(t, "")
w := performRequest(router, http.MethodGet, "/v2/", "")
if w.Code != http.StatusOK {
t.Fatalf("/v2/ status = %d, want 200; body=%s", w.Code, w.Body.String())
}
w = performRequest(router, http.MethodGet, "/v2/library/nginx/unknown/latest", "")
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())
}
+11 -3
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
+4
View File
@@ -34,6 +34,10 @@ func TestExtractTTLFromResponse(t *testing.T) {
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)
}
+17 -11
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)