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 @@ - - DeepWiki - - DeepWiki - 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 加速代理服务器** - -

- - DeepWiki - -

+ **Docker 和 GitHub 加速代理服务器** 一个轻量级、高性能的多功能代理服务,提供 Docker 镜像加速、GitHub 文件加速、下载离线镜像、在线搜索 Docker 镜像等功能。 @@ -15,7 +9,7 @@ Visitors

-## ✨ 特性 +## 特性 - 🐳 **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 { - ## 界面预览 ![1](./.github/demo/demo1.jpg) -![2](./.github/demo/demo2.jpg) - -![3](./.github/demo/demo3.jpg) - - - ## Star 趋势 [![Star 趋势](https://starchart.cc/sky22333/hubproxy.svg?variant=adaptive)](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(); - \ No newline at end of file + diff --git a/src/public/search.html b/src/public/search.html index eb0e7e2..87fbc8c 100644 --- a/src/public/search.html +++ b/src/public/search.html @@ -778,16 +778,10 @@ -
- -
+
- - \ No newline at end of file + diff --git a/src/utils/access_control.go b/src/utils/access_control.go index 48a685b..d676177 100644 --- a/src/utils/access_control.go +++ b/src/utils/access_control.go @@ -2,7 +2,6 @@ package utils import ( "strings" - "sync" "hubproxy/config" ) @@ -17,7 +16,6 @@ const ( // AccessController 统一访问控制器 type AccessController struct { - mu sync.RWMutex } // DockerImageInfo Docker镜像信息 @@ -200,6 +198,13 @@ func (ac *AccessController) checkList(matches, list []string) bool { if strings.HasPrefix(fullRepo, item+"/") { return true } + + if strings.HasPrefix(item, "*/") { + p := item[2:] + if p == repoName || (strings.HasSuffix(p, "*") && strings.HasPrefix(repoName, p[:len(p)-1])) { + return true + } + } } return false } diff --git a/src/utils/proxy_shell.go b/src/utils/proxy_shell.go index 12af000..9d82d93 100644 --- a/src/utils/proxy_shell.go +++ b/src/utils/proxy_shell.go @@ -10,49 +10,46 @@ import ( ) // GitHub URL正则表达式 -var githubRegex = regexp.MustCompile(`https?://(?:github\.com|raw\.githubusercontent\.com|raw\.github\.com|gist\.githubusercontent\.com|gist\.github\.com|api\.github\.com)[^\s'"]+`) +var githubRegex = regexp.MustCompile(`(?:^|[\s'"(=,\[{;|&<>])https?://(?:github\.com|raw\.githubusercontent\.com|raw\.github\.com|gist\.githubusercontent\.com|gist\.github\.com|api\.github\.com)[^\s'")]*`) + +// MaxShellSize 限制最大处理大小为 10MB +const MaxShellSize = 10 * 1024 * 1024 // ProcessSmart Shell脚本智能处理函数 -func ProcessSmart(input io.ReadCloser, isCompressed bool, host string) (io.Reader, int64, error) { - defer input.Close() - +func ProcessSmart(input io.Reader, isCompressed bool, host string) (io.Reader, int64, error) { content, err := readShellContent(input, isCompressed) if err != nil { - return nil, 0, fmt.Errorf("内容读取失败: %v", err) + return nil, 0, err } if len(content) == 0 { return strings.NewReader(""), 0, nil } - if len(content) > 10*1024*1024 { - return strings.NewReader(content), int64(len(content)), nil + if !bytes.Contains(content, []byte("github.com")) && !bytes.Contains(content, []byte("githubusercontent.com")) { + return bytes.NewReader(content), int64(len(content)), nil } - if !strings.Contains(content, "github.com") && !strings.Contains(content, "githubusercontent.com") { - return strings.NewReader(content), int64(len(content)), nil - } - - processed := processGitHubURLs(content, host) + processed := processGitHubURLs(string(content), host) return strings.NewReader(processed), int64(len(processed)), nil } -func readShellContent(input io.ReadCloser, isCompressed bool) (string, error) { +func readShellContent(input io.Reader, isCompressed bool) ([]byte, error) { var reader io.Reader = input if isCompressed { peek := make([]byte, 2) n, err := input.Read(peek) if err != nil && err != io.EOF { - return "", fmt.Errorf("读取数据失败: %v", err) + return nil, fmt.Errorf("读取数据失败: %v", err) } if n >= 2 && peek[0] == 0x1f && peek[1] == 0x8b { combinedReader := io.MultiReader(bytes.NewReader(peek[:n]), input) gzReader, err := gzip.NewReader(combinedReader) if err != nil { - return "", fmt.Errorf("gzip解压失败: %v", err) + return nil, fmt.Errorf("gzip解压失败: %v", err) } defer gzReader.Close() reader = gzReader @@ -61,37 +58,50 @@ func readShellContent(input io.ReadCloser, isCompressed bool) (string, error) { } } - data, err := io.ReadAll(reader) + limit := int64(MaxShellSize + 1) + limitedReader := io.LimitReader(reader, limit) + + data, err := io.ReadAll(limitedReader) if err != nil { - return "", fmt.Errorf("读取内容失败: %v", err) + return nil, fmt.Errorf("读取内容失败: %v", err) } - return string(data), nil + if int64(len(data)) > MaxShellSize { + return nil, fmt.Errorf("脚本文件过大,超过 %d MB 限制", MaxShellSize/1024/1024) + } + + return data, nil } func processGitHubURLs(content, host string) string { - return githubRegex.ReplaceAllStringFunc(content, func(url string) string { - return transformURL(url, host) + return githubRegex.ReplaceAllStringFunc(content, func(match string) string { + // 如果匹配包含前缀分隔符,保留它,防止出现重复转换 + if len(match) > 0 && match[0] != 'h' { + prefix := match[0:1] + url := match[1:] + return prefix + transformURL(url, host) + } + return transformURL(match, host) }) } // transformURL URL转换函数 func transformURL(url, host string) string { - if strings.Contains(url, host) { - return url - } + if strings.Contains(url, host) { + return url + } - if strings.HasPrefix(url, "http://") { - url = "https" + url[4:] - } else if !strings.HasPrefix(url, "https://") && !strings.HasPrefix(url, "//") { - url = "https://" + url - } + if strings.HasPrefix(url, "http://") { + url = "https" + url[4:] + } else if !strings.HasPrefix(url, "https://") && !strings.HasPrefix(url, "//") { + url = "https://" + url + } - // 确保 host 有协议头 - if !strings.HasPrefix(host, "http://") && !strings.HasPrefix(host, "https://") { - host = "https://" + host - } - host = strings.TrimSuffix(host, "/") + // 确保 host 有协议头 + if !strings.HasPrefix(host, "http://") && !strings.HasPrefix(host, "https://") { + host = "https://" + host + } + host = strings.TrimSuffix(host, "/") - return host + "/" + url + return host + "/" + url } diff --git a/src/utils/ratelimiter.go b/src/utils/ratelimiter.go index 1678a9a..416d2f9 100644 --- a/src/utils/ratelimiter.go +++ b/src/utils/ratelimiter.go @@ -177,8 +177,9 @@ func (i *IPRateLimiter) GetLimiter(ip string) (*rate.Limiter, bool) { now := time.Now() + var entry *rateLimiterEntry i.mu.RLock() - entry, exists := i.ips[normalizedIP] + _, exists := i.ips[normalizedIP] i.mu.RUnlock() if exists {