diff --git a/.github/demo/deepwiki.svg b/.github/demo/deepwiki.svg
deleted file mode 100644
index 34aa7b6..0000000
--- a/.github/demo/deepwiki.svg
+++ /dev/null
@@ -1,5 +0,0 @@
-
diff --git a/.github/demo/demo1.jpg b/.github/demo/demo1.jpg
index 563b0fe..099a4e9 100644
Binary files a/.github/demo/demo1.jpg and b/.github/demo/demo1.jpg differ
diff --git a/.github/demo/demo2.jpg b/.github/demo/demo2.jpg
deleted file mode 100644
index 8c5a85c..0000000
Binary files a/.github/demo/demo2.jpg and /dev/null differ
diff --git a/.github/demo/demo3.jpg b/.github/demo/demo3.jpg
deleted file mode 100644
index 9f4c811..0000000
Binary files a/.github/demo/demo3.jpg and /dev/null differ
diff --git a/.github/workflows/docker-ghcr.yml b/.github/workflows/docker-ghcr.yml
index fc1808e..d711391 100644
--- a/.github/workflows/docker-ghcr.yml
+++ b/.github/workflows/docker-ghcr.yml
@@ -3,9 +3,9 @@ on:
workflow_dispatch:
inputs:
version:
- description: 'Version number'
+ description: '版本号 (例如: v1.0.0)'
required: true
- default: 'latest'
+ default: 'v1.0.0'
jobs:
build:
@@ -36,7 +36,12 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set version from input
- run: echo "VERSION=${{ github.event.inputs.version }}" >> $GITHUB_ENV
+ run: |
+ VERSION=${{ github.event.inputs.version }}
+ if [[ $VERSION == v* ]]; then
+ VERSION=${VERSION:1}
+ fi
+ echo "VERSION=$VERSION" >> $GITHUB_ENV
- name: Convert repository name to lowercase
run: |
@@ -53,4 +58,4 @@ jobs:
--build-arg VERSION=${{ env.VERSION }} \
-f Dockerfile .
env:
- GHCR_PUBLIC: true # 将镜像设置为公开
\ No newline at end of file
+ GHCR_PUBLIC: true
\ No newline at end of file
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 73931ec..2336b24 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,7 +1,7 @@
name: 发布二进制文件
on:
- workflow_dispatch: # 手动触发
+ workflow_dispatch:
inputs:
version:
description: '版本号 (例如: v1.0.0)'
@@ -18,7 +18,7 @@ jobs:
- name: 检出代码
uses: actions/checkout@v4
with:
- fetch-depth: 0 # 获取完整历史,用于生成变更日志
+ fetch-depth: 0
- name: 设置Go环境
uses: actions/setup-go@v5
@@ -62,12 +62,13 @@ jobs:
- name: 编译二进制文件
run: |
cd src
+ VERSION=${{ steps.version.outputs.version }}
# Linux AMD64
- CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o ../build/hubproxy/hubproxy-linux-amd64 .
+ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.Version=${VERSION}" -o ../build/hubproxy/hubproxy-linux-amd64 .
# Linux ARM64
- CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o ../build/hubproxy/hubproxy-linux-arm64 .
+ CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w -X main.Version=${VERSION}" -o ../build/hubproxy/hubproxy-linux-arm64 .
# 压缩二进制文件
upx -9 ../build/hubproxy/hubproxy-linux-amd64
diff --git a/Dockerfile b/Dockerfile
index e4f248e..704ddb7 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,6 +1,7 @@
FROM golang:1.25-alpine AS builder
ARG TARGETARCH
+ARG VERSION=dev
WORKDIR /app
COPY src/go.mod src/go.sum ./
@@ -8,7 +9,7 @@ RUN go mod download && apk add upx
COPY src/ .
-RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -ldflags="-s -w" -trimpath -o hubproxy . && upx -9 hubproxy
+RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -ldflags="-s -w -X main.Version=${VERSION}" -trimpath -o hubproxy . && upx -9 hubproxy
FROM alpine
@@ -17,4 +18,4 @@ WORKDIR /root/
COPY --from=builder /app/hubproxy .
COPY --from=builder /app/config.toml .
-CMD ["./hubproxy"]
\ No newline at end of file
+CMD ["./hubproxy"]
diff --git a/README.md b/README.md
index e85ccb6..ba99ee5 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,6 @@
# HubProxy
-🚀 **Docker 和 GitHub 加速代理服务器**
-
-
-
-
-
-
+ **Docker 和 GitHub 加速代理服务器**
一个轻量级、高性能的多功能代理服务,提供 Docker 镜像加速、GitHub 文件加速、下载离线镜像、在线搜索 Docker 镜像等功能。
@@ -15,7 +9,7 @@
-## ✨ 特性
+## 特性
- 🐳 **Docker 镜像加速** - 支持 Docker Hub、GHCR、Quay 等多个镜像仓库加速,流式传输优化拉取速度。
- 🐳 **离线镜像包** - 支持下载离线镜像包,流式传输加防抖设计。
@@ -29,8 +23,13 @@
- 🛡️ **完全自托管** - 避免依赖免费第三方服务的不稳定性,例如`cloudflare`等等。
- 🚀 **多服务统一加速** - 单个程序即可统一加速 Docker、GitHub、Hugging Face 等多种服务,简化部署与管理。
+## 详细文档
-## 🚀 快速开始
+[中文文档](https://zread.ai/sky22333/hubproxy)
+
+[English](https://deepwiki.com/sky22333/hubproxy)
+
+## 快速开始
### Docker部署(推荐)
```
@@ -41,8 +40,6 @@ docker run -d \
ghcr.io/sky22333/hubproxy
```
-
-
### 一键脚本安装
```bash
@@ -52,14 +49,12 @@ curl -fsSL https://raw.githubusercontent.com/sky22333/hubproxy/main/install.sh |
支持单个二进制文件直接启动,无需其他配置,内置默认配置,支持所有功能。
这个脚本会:
-- 🔍 自动检测系统架构(AMD64/ARM64)
-- 📥 从 GitHub Releases 下载最新版本
-- ⚙️ 自动配置系统服务
-- 🔄 保留现有配置(升级时)
+- 自动检测系统架构(AMD64/ARM64)
+- 从 GitHub Releases 下载最新版本
+- 自动配置系统服务
+- 保留现有配置(升级时)
-
-
-## 📖 使用方法
+## 使用方法
### Docker 镜像加速
@@ -103,7 +98,7 @@ https://yourdomain.com/https://github.com/user/repo/releases/download/v1.0.0/fil
git clone https://yourdomain.com/https://github.com/sky22333/hubproxy.git
```
-## ⚙️ 配置
+## 配置
config.toml 配置说明
@@ -119,6 +114,8 @@ port = 5000
fileSize = 2147483648
# HTTP/2 多路复用,提升下载速度
enableH2C = false
+# 是否启用前端静态页面
+enableFrontend = true
[rateLimit]
# 每个IP每周期允许的请求数(注意Docker镜像会有多个层,会消耗多个次数)
@@ -209,6 +206,23 @@ defaultTTL = "20m"
脚本部署配置文件位于 `/opt/hubproxy/config.toml`
+### 环境变量(可选)
+
+支持通过环境变量覆盖部分配置,优先级高于`config.toml`,以下是默认值:
+
+```
+SERVER_HOST=0.0.0.0 # 监听地址
+SERVER_PORT=5000 # 监听端口
+ENABLE_H2C=false # 是否启用 H2C
+ENABLE_FRONTEND=true # 是否启用前端静态页面
+MAX_FILE_SIZE=2147483648 # GitHub 文件大小限制(字节)
+RATE_LIMIT=500 # 每周期请求数
+RATE_PERIOD_HOURS=3 # 限流周期(小时)
+IP_WHITELIST=127.0.0.1,192.168.1.0/24 # IP 白名单(逗号分隔)
+IP_BLACKLIST=192.168.100.1,192.168.100.0/24 # IP 黑名单(逗号分隔)
+MAX_IMAGES=10 # 批量下载镜像数量限制
+```
+
为了IP限流能够正常运行,反向代理需要传递IP头用来获取访客真实IP,以caddy为例:
```
example.com {
@@ -249,16 +263,9 @@ example.com {
-
## 界面预览

-
-
-
-
-
-
## Star 趋势
[](https://starchart.cc/sky22333/hubproxy)
diff --git a/src/config.toml b/src/config.toml
index 18e8c2d..eb3813f 100644
--- a/src/config.toml
+++ b/src/config.toml
@@ -6,6 +6,7 @@ port = 5000
fileSize = 2147483648
# HTTP/2 多路复用
enableH2C = false
+enableFrontend = true
[rateLimit]
# 每个IP每周期允许的请求数
diff --git a/src/config/config.go b/src/config/config.go
index ceaf9de..f0f6e41 100644
--- a/src/config/config.go
+++ b/src/config/config.go
@@ -22,10 +22,11 @@ type RegistryMapping struct {
// AppConfig 应用配置结构体
type AppConfig struct {
Server struct {
- Host string `toml:"host"`
- Port int `toml:"port"`
- FileSize int64 `toml:"fileSize"`
- EnableH2C bool `toml:"enableH2C"`
+ Host string `toml:"host"`
+ Port int `toml:"port"`
+ FileSize int64 `toml:"fileSize"`
+ EnableH2C bool `toml:"enableH2C"`
+ EnableFrontend bool `toml:"enableFrontend"`
} `toml:"server"`
RateLimit struct {
@@ -70,15 +71,17 @@ var (
func DefaultConfig() *AppConfig {
return &AppConfig{
Server: struct {
- Host string `toml:"host"`
- Port int `toml:"port"`
- FileSize int64 `toml:"fileSize"`
- EnableH2C bool `toml:"enableH2C"`
+ Host string `toml:"host"`
+ Port int `toml:"port"`
+ FileSize int64 `toml:"fileSize"`
+ EnableH2C bool `toml:"enableH2C"`
+ EnableFrontend bool `toml:"enableFrontend"`
}{
- Host: "0.0.0.0",
- Port: 5000,
- FileSize: 2 * 1024 * 1024 * 1024, // 2GB
- EnableH2C: false, // 默认关闭H2C
+ Host: "0.0.0.0",
+ Port: 5000,
+ FileSize: 2 * 1024 * 1024 * 1024,
+ EnableH2C: false,
+ EnableFrontend: true,
},
RateLimit: struct {
RequestLimit int `toml:"requestLimit"`
@@ -227,6 +230,11 @@ func overrideFromEnv(cfg *AppConfig) {
cfg.Server.EnableH2C = enable
}
}
+ if val := os.Getenv("ENABLE_FRONTEND"); val != "" {
+ if enable, err := strconv.ParseBool(val); err == nil {
+ cfg.Server.EnableFrontend = enable
+ }
+ }
if val := os.Getenv("MAX_FILE_SIZE"); val != "" {
if size, err := strconv.ParseInt(val, 10, 64); err == nil && size > 0 {
cfg.Server.FileSize = size
diff --git a/src/handlers/docker.go b/src/handlers/docker.go
index cdcb25b..174b3ca 100644
--- a/src/handlers/docker.go
+++ b/src/handlers/docker.go
@@ -29,9 +29,16 @@ var dockerProxy *DockerProxy
type RegistryDetector struct{}
// detectRegistryDomain 检测Registry域名并返回域名和剩余路径
-func (rd *RegistryDetector) detectRegistryDomain(path string) (string, string) {
+func (rd *RegistryDetector) detectRegistryDomain(c *gin.Context, path string) (string, string) {
cfg := config.GetConfig()
+ // 兼容Containerd的ns参数
+ if ns := c.Query("ns"); ns != "" {
+ if mapping, exists := cfg.Registries[ns]; exists && mapping.Enabled {
+ return ns, path
+ }
+ }
+
for domain := range cfg.Registries {
if strings.HasPrefix(path, domain+"/") {
remainingPath := strings.TrimPrefix(path, domain+"/")
@@ -100,7 +107,7 @@ func ProxyDockerRegistryGin(c *gin.Context) {
func handleRegistryRequest(c *gin.Context, path string) {
pathWithoutV2 := strings.TrimPrefix(path, "/v2/")
- if registryDomain, remainingPath := registryDetector.detectRegistryDomain(pathWithoutV2); registryDomain != "" {
+ if registryDomain, remainingPath := registryDetector.detectRegistryDomain(c, pathWithoutV2); registryDomain != "" {
if registryDetector.isRegistryEnabled(registryDomain) {
c.Set("target_registry_domain", registryDomain)
c.Set("target_path", remainingPath)
@@ -268,7 +275,9 @@ func handleBlobRequest(c *gin.Context, imageRef, digest string) {
c.Header("Docker-Content-Digest", digest)
c.Status(http.StatusOK)
- io.Copy(c.Writer, reader)
+ if _, err := io.Copy(c.Writer, reader); err != nil {
+ fmt.Printf("复制layer内容失败: %v\n", err)
+ }
}
// handleTagsRequest 处理tags列表请求
@@ -410,7 +419,9 @@ func proxyDockerAuthOriginal(c *gin.Context) {
}
c.Status(resp.StatusCode)
- io.Copy(c.Writer, resp.Body)
+ if _, err := io.Copy(c.Writer, resp.Body); err != nil {
+ fmt.Printf("复制认证响应失败: %v\n", err)
+ }
}
// rewriteAuthHeader 重写认证头
@@ -563,7 +574,9 @@ func handleUpstreamBlobRequest(c *gin.Context, imageRef, digest string, mapping
c.Header("Docker-Content-Digest", digest)
c.Status(http.StatusOK)
- io.Copy(c.Writer, reader)
+ if _, err := io.Copy(c.Writer, reader); err != nil {
+ fmt.Printf("复制layer内容失败: %v\n", err)
+ }
}
// handleUpstreamTagsRequest 处理上游Registry的tags请求
diff --git a/src/handlers/github.go b/src/handlers/github.go
index e6c6bbb..77414f2 100644
--- a/src/handlers/github.go
+++ b/src/handlers/github.go
@@ -172,9 +172,9 @@ func proxyGitHubWithRedirect(c *gin.Context, u string, redirectCount int) {
processedBody, processedSize, err := utils.ProcessSmart(resp.Body, isGzipCompressed, realHost)
if err != nil {
- fmt.Printf("智能处理失败,回退到直接代理: %v\n", err)
- processedBody = resp.Body
- processedSize = 0
+ fmt.Printf("脚本处理失败: %v\n", err)
+ c.String(http.StatusBadGateway, "Script processing failed: %v", err)
+ return
}
// 智能设置响应头
@@ -228,6 +228,8 @@ func proxyGitHubWithRedirect(c *gin.Context, u string, redirectCount int) {
c.Status(resp.StatusCode)
// 直接流式转发
- io.Copy(c.Writer, resp.Body)
+ if _, err := io.Copy(c.Writer, resp.Body); err != nil {
+ fmt.Printf("转发响应体失败: %v\n", err)
+ }
}
}
diff --git a/src/handlers/imagetar.go b/src/handlers/imagetar.go
index 418db73..e4f9610 100644
--- a/src/handlers/imagetar.go
+++ b/src/handlers/imagetar.go
@@ -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"
@@ -116,6 +119,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
@@ -127,6 +139,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
@@ -210,6 +314,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 {
@@ -217,12 +332,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)
}
@@ -580,6 +690,7 @@ func InitImageTarRoutes(router *gin.Engine) {
{
imageAPI.GET("/download/:image", handleDirectImageDownload)
imageAPI.GET("/info/:image", handleImageInfo)
+ imageAPI.GET("/batch", handleSimpleBatchDownload)
imageAPI.POST("/batch", handleSimpleBatchDownload)
}
}
@@ -607,28 +718,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
@@ -637,6 +793,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"`
@@ -652,12 +853,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 {
@@ -683,25 +896,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 处理镜像信息查询
@@ -724,6 +931,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))
diff --git a/src/handlers/search.go b/src/handlers/search.go
index 6f69612..81fae20 100644
--- a/src/handlers/search.go
+++ b/src/handlers/search.go
@@ -7,7 +7,6 @@ import (
"io"
"net/http"
"net/url"
- "sort"
"strings"
"sync"
"time"
@@ -161,51 +160,6 @@ func init() {
}()
}
-func filterSearchResults(results []Repository, query string) []Repository {
- searchTerm := strings.ToLower(strings.TrimPrefix(query, "library/"))
- filtered := make([]Repository, 0)
-
- for _, repo := range results {
- repoName := strings.ToLower(repo.Name)
- repoDesc := strings.ToLower(repo.Description)
-
- score := 0
-
- if repoName == searchTerm {
- score += 100
- }
-
- if strings.HasPrefix(repoName, searchTerm) {
- score += 50
- }
-
- if strings.Contains(repoName, searchTerm) {
- score += 30
- }
-
- if strings.Contains(repoDesc, searchTerm) {
- score += 10
- }
-
- if repo.IsOfficial {
- score += 20
- }
-
- if score > 0 {
- filtered = append(filtered, repo)
- }
- }
-
- sort.Slice(filtered, func(i, j int) bool {
- if filtered[i].IsOfficial != filtered[j].IsOfficial {
- return filtered[i].IsOfficial
- }
- return filtered[i].PullCount > filtered[j].PullCount
- })
-
- return filtered
-}
-
// normalizeRepository 统一规范化仓库信息
func normalizeRepository(repo *Repository) {
if repo.IsOfficial {
@@ -298,7 +252,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))
}
@@ -488,10 +442,14 @@ func parsePaginationParams(c *gin.Context, defaultPageSize int) (page, pageSize
pageSize = defaultPageSize
if p := c.Query("page"); p != "" {
- fmt.Sscanf(p, "%d", &page)
+ if _, err := fmt.Sscanf(p, "%d", &page); err != nil {
+ fmt.Printf("解析page参数失败: %v\n", err)
+ }
}
if ps := c.Query("page_size"); ps != "" {
- fmt.Sscanf(ps, "%d", &pageSize)
+ if _, err := fmt.Sscanf(ps, "%d", &pageSize); err != nil {
+ fmt.Printf("解析page_size参数失败: %v\n", err)
+ }
}
return page, pageSize
diff --git a/src/main.go b/src/main.go
index 2619fd8..04f016d 100644
--- a/src/main.go
+++ b/src/main.go
@@ -8,13 +8,12 @@ import (
"strings"
"time"
- "hubproxy/config"
- "hubproxy/handlers"
- "hubproxy/utils"
-
"github.com/gin-gonic/gin"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
+ "hubproxy/config"
+ "hubproxy/handlers"
+ "hubproxy/utils"
)
//go:embed public/*
@@ -41,6 +40,82 @@ var (
serviceStartTime = time.Now()
)
+var Version = "dev"
+
+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)
+ 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 {
+ router.GET("/", func(c *gin.Context) {
+ serveEmbedFile(c, "public/index.html")
+ })
+ router.GET("/public/*filepath", func(c *gin.Context) {
+ filepath := strings.TrimPrefix(c.Param("filepath"), "/")
+ serveEmbedFile(c, "public/"+filepath)
+ })
+
+ router.GET("/images.html", func(c *gin.Context) {
+ serveEmbedFile(c, "public/images.html")
+ })
+ router.GET("/search.html", func(c *gin.Context) {
+ serveEmbedFile(c, "public/search.html")
+ })
+ router.GET("/favicon.ico", func(c *gin.Context) {
+ serveEmbedFile(c, "public/favicon.ico")
+ })
+ } else {
+ router.GET("/", func(c *gin.Context) {
+ c.Status(http.StatusNotFound)
+ })
+ router.GET("/public/*filepath", func(c *gin.Context) {
+ c.Status(http.StatusNotFound)
+ })
+ router.GET("/images.html", func(c *gin.Context) {
+ c.Status(http.StatusNotFound)
+ })
+ router.GET("/search.html", func(c *gin.Context) {
+ c.Status(http.StatusNotFound)
+ })
+ router.GET("/favicon.ico", func(c *gin.Context) {
+ c.Status(http.StatusNotFound)
+ })
+ }
+
+ // 注册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 {
@@ -63,60 +138,9 @@ func main() {
// 初始化防抖器
handlers.InitDebouncer()
- gin.SetMode(gin.ReleaseMode)
- router := gin.Default()
-
- // 全局Panic恢复保护
- router.Use(gin.CustomRecovery(func(c *gin.Context, recovered interface{}) {
- log.Printf("🚨 Panic recovered: %v", recovered)
- c.JSON(http.StatusInternalServerError, gin.H{
- "error": "Internal server error",
- "code": "INTERNAL_ERROR",
- })
- }))
-
- // 全局限流中间件
- router.Use(utils.RateLimitMiddleware(globalLimiter))
-
- // 初始化监控端点
- initHealthRoutes(router)
-
- // 初始化镜像tar下载路由
- handlers.InitImageTarRoutes(router)
-
- // 静态文件路由
- router.GET("/", func(c *gin.Context) {
- serveEmbedFile(c, "public/index.html")
- })
- router.GET("/public/*filepath", func(c *gin.Context) {
- filepath := strings.TrimPrefix(c.Param("filepath"), "/")
- serveEmbedFile(c, "public/"+filepath)
- })
-
- router.GET("/images.html", func(c *gin.Context) {
- serveEmbedFile(c, "public/images.html")
- })
- router.GET("/search.html", func(c *gin.Context) {
- serveEmbedFile(c, "public/search.html")
- })
- router.GET("/favicon.ico", func(c *gin.Context) {
- serveEmbedFile(c, "public/favicon.ico")
- })
-
- // 注册dockerhub搜索路由
- handlers.RegisterSearchRoute(router)
-
- // 注册Docker认证路由
- router.Any("/token", handlers.ProxyDockerAuthGin)
- router.Any("/token/*path", handlers.ProxyDockerAuthGin)
-
- // 注册Docker Registry代理路由
- router.Any("/v2/*path", handlers.ProxyDockerRegistryGin)
-
- // 注册GitHub代理路由(NoRoute处理器)
- router.NoRoute(handlers.GitHubProxyHandler)
-
cfg := config.GetConfig()
+ router := buildRouter(cfg)
+
fmt.Printf("HubProxy 启动成功\n")
fmt.Printf("监听地址: %s:%d\n", cfg.Server.Host, cfg.Server.Port)
fmt.Printf("限流配置: %d请求/%g小时\n", cfg.RateLimit.RequestLimit, cfg.RateLimit.PeriodHours)
@@ -126,7 +150,8 @@ func main() {
fmt.Printf("H2c: 已启用\n")
}
- fmt.Printf("版本号: v1.1.9\n")
+ fmt.Printf("版本号: %s\n", Version)
+ fmt.Printf("项目地址: https://github.com/sky22333/hubproxy\n")
// 创建HTTP2服务器
server := &http.Server{
@@ -182,6 +207,7 @@ func initHealthRoutes(router *gin.Engine) {
c.JSON(http.StatusOK, gin.H{
"ready": true,
"service": "hubproxy",
+ "version": Version,
"start_time_unix": serviceStartTime.Unix(),
"uptime_sec": uptimeSec,
"uptime_human": uptimeHuman,
diff --git a/src/public/images.html b/src/public/images.html
index 3810646..a01e992 100644
--- a/src/public/images.html
+++ b/src/public/images.html
@@ -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();