├── .gitattributes ├── .gitignore ├── .github ├── demo │ └── demo1.jpg └── workflows │ ├── docker-ghcr.yml │ └── release.yml ├── src ├── public │ ├── favicon.ico │ ├── index.html │ └── images.html ├── utils │ ├── http_client.go │ ├── proxy_shell.go │ ├── cache.go │ ├── access_control.go │ └── ratelimiter.go ├── config.toml ├── go.mod ├── main.go ├── handlers │ ├── github.go │ ├── search.go │ ├── docker.go │ └── imagetar.go ├── config │ └── config.go └── go.sum ├── docker-compose.yml ├── Dockerfile ├── hubproxy.service ├── LICENSE ├── install.sh └── README.md /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | *.html linguist-vendored 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .vscode 3 | .DS_Store 4 | hubproxy* 5 | !hubproxy.service -------------------------------------------------------------------------------- /.github/demo/demo1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sky22333/hubproxy/HEAD/.github/demo/demo1.jpg -------------------------------------------------------------------------------- /src/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sky22333/hubproxy/HEAD/src/public/favicon.ico -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | hubproxy: 3 | image: ghcr.io/sky22333/hubproxy 4 | container_name: hubproxy 5 | restart: always 6 | ports: 7 | - "5000:5000" 8 | volumes: 9 | - ./src/config.toml:/root/config.toml 10 | logging: 11 | driver: json-file 12 | options: 13 | max-size: "1g" 14 | max-file: "2" 15 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.25-alpine AS builder 2 | 3 | ARG TARGETARCH 4 | 5 | WORKDIR /app 6 | COPY src/go.mod src/go.sum ./ 7 | RUN go mod download && apk add upx 8 | 9 | COPY src/ . 10 | 11 | RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -ldflags="-s -w" -trimpath -o hubproxy . && upx -9 hubproxy 12 | 13 | FROM alpine 14 | 15 | WORKDIR /root/ 16 | 17 | COPY --from=builder /app/hubproxy . 18 | COPY --from=builder /app/config.toml . 19 | 20 | CMD ["./hubproxy"] -------------------------------------------------------------------------------- /hubproxy.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=hubproxy 3 | After=network-online.target 4 | Wants=network-online.target 5 | 6 | [Service] 7 | Type=simple 8 | User=root 9 | Group=root 10 | WorkingDirectory=/opt/hubproxy 11 | ExecStart=/opt/hubproxy/hubproxy 12 | Restart=always 13 | RestartSec=5 14 | Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin 15 | StandardOutput=journal 16 | StandardError=journal 17 | SyslogIdentifier=hubproxy 18 | 19 | [Install] 20 | WantedBy=multi-user.target 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 sky22333 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/utils/http_client.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "net" 5 | "net/http" 6 | "os" 7 | "time" 8 | 9 | "hubproxy/config" 10 | ) 11 | 12 | var ( 13 | globalHTTPClient *http.Client 14 | searchHTTPClient *http.Client 15 | ) 16 | 17 | // InitHTTPClients 初始化HTTP客户端 18 | func InitHTTPClients() { 19 | cfg := config.GetConfig() 20 | 21 | if p := cfg.Access.Proxy; p != "" { 22 | os.Setenv("HTTP_PROXY", p) 23 | os.Setenv("HTTPS_PROXY", p) 24 | } 25 | 26 | globalHTTPClient = &http.Client{ 27 | Transport: &http.Transport{ 28 | Proxy: http.ProxyFromEnvironment, 29 | DialContext: (&net.Dialer{ 30 | Timeout: 30 * time.Second, 31 | KeepAlive: 30 * time.Second, 32 | }).DialContext, 33 | MaxIdleConns: 1000, 34 | MaxIdleConnsPerHost: 1000, 35 | IdleConnTimeout: 90 * time.Second, 36 | TLSHandshakeTimeout: 10 * time.Second, 37 | ExpectContinueTimeout: 1 * time.Second, 38 | ResponseHeaderTimeout: 300 * time.Second, 39 | }, 40 | } 41 | 42 | searchHTTPClient = &http.Client{ 43 | Timeout: 10 * time.Second, 44 | Transport: &http.Transport{ 45 | Proxy: http.ProxyFromEnvironment, 46 | DialContext: (&net.Dialer{ 47 | Timeout: 5 * time.Second, 48 | KeepAlive: 30 * time.Second, 49 | }).DialContext, 50 | MaxIdleConns: 100, 51 | MaxIdleConnsPerHost: 10, 52 | IdleConnTimeout: 90 * time.Second, 53 | TLSHandshakeTimeout: 5 * time.Second, 54 | DisableCompression: false, 55 | }, 56 | } 57 | } 58 | 59 | // GetGlobalHTTPClient 获取全局HTTP客户端 60 | func GetGlobalHTTPClient() *http.Client { 61 | return globalHTTPClient 62 | } 63 | 64 | // GetSearchHTTPClient 获取搜索HTTP客户端 65 | func GetSearchHTTPClient() *http.Client { 66 | return searchHTTPClient 67 | } 68 | -------------------------------------------------------------------------------- /.github/workflows/docker-ghcr.yml: -------------------------------------------------------------------------------- 1 | name: ghcr镜像构建 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | version: 6 | description: 'Version number' 7 | required: true 8 | default: 'latest' 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | permissions: 14 | contents: write 15 | packages: write 16 | steps: 17 | - name: Checkout repository 18 | uses: actions/checkout@v4 19 | 20 | - name: Set up Docker Buildx 21 | uses: docker/setup-buildx-action@v3 22 | 23 | - name: Cache Docker layers 24 | uses: actions/cache@v4 25 | with: 26 | path: /tmp/.buildx-cache 27 | key: ${{ runner.os }}-buildx-${{ github.sha }} 28 | restore-keys: | 29 | ${{ runner.os }}-buildx- 30 | 31 | - name: Log in to GitHub Docker Registry 32 | uses: docker/login-action@v3 33 | with: 34 | registry: ghcr.io 35 | username: ${{ github.actor }} 36 | password: ${{ secrets.GITHUB_TOKEN }} 37 | 38 | - name: Set version from input 39 | run: echo "VERSION=${{ github.event.inputs.version }}" >> $GITHUB_ENV 40 | 41 | - name: Convert repository name to lowercase 42 | run: | 43 | # 将 github.repository 整体转换为小写 44 | REPO_LOWER=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]') 45 | echo "REPO_LOWER=$REPO_LOWER" >> $GITHUB_ENV 46 | 47 | - name: Build and push Docker image 48 | run: | 49 | docker buildx build --push \ 50 | --platform linux/amd64,linux/arm64 \ 51 | --tag ghcr.io/${{ env.REPO_LOWER }}:${{ env.VERSION }} \ 52 | --tag ghcr.io/${{ env.REPO_LOWER }}:latest \ 53 | --build-arg VERSION=${{ env.VERSION }} \ 54 | -f Dockerfile . 55 | env: 56 | GHCR_PUBLIC: true # 将镜像设置为公开 -------------------------------------------------------------------------------- /src/config.toml: -------------------------------------------------------------------------------- 1 | [server] 2 | host = "0.0.0.0" 3 | # 监听端口 4 | port = 5000 5 | # Github文件大小限制(字节),默认2GB 6 | fileSize = 2147483648 7 | # HTTP/2 多路复用 8 | enableH2C = false 9 | 10 | [rateLimit] 11 | # 每个IP每周期允许的请求数 12 | requestLimit = 500 13 | # 限流周期(小时) 14 | periodHours = 3.0 15 | 16 | [security] 17 | # IP白名单,支持单个IP或IP段 18 | # 白名单中的IP不受限流限制 19 | whiteList = [ 20 | "127.0.0.1", 21 | "172.17.0.0/16", 22 | "192.168.1.0/24" 23 | ] 24 | 25 | # IP黑名单,支持单个IP或IP段 26 | # 黑名单中的IP将被直接拒绝访问 27 | blackList = [ 28 | "192.168.100.1", 29 | "192.168.100.0/24" 30 | ] 31 | 32 | [access] 33 | # 代理服务白名单(支持GitHub仓库和Docker镜像,支持通配符) 34 | # 只允许访问白名单中的仓库/镜像,为空时不限制 35 | whiteList = [] 36 | 37 | # 代理服务黑名单(支持GitHub仓库和Docker镜像,支持通配符) 38 | # 禁止访问黑名单中的仓库/镜像 39 | blackList = [ 40 | "baduser/malicious-repo", 41 | "*/malicious-repo", 42 | "baduser/*" 43 | ] 44 | 45 | # 代理配置,支持有用户名/密码认证和无认证模式 46 | # 无认证: socks5://127.0.0.1:1080 47 | # 有认证: socks5://username:password@127.0.0.1:1080 48 | # 留空不使用代理 49 | proxy = "" 50 | 51 | [download] 52 | # 批量下载离线镜像数量限制 53 | maxImages = 10 54 | 55 | # Registry映射配置,支持多种镜像仓库上游 56 | [registries] 57 | 58 | # GitHub Container Registry 59 | [registries."ghcr.io"] 60 | upstream = "ghcr.io" 61 | authHost = "ghcr.io/token" 62 | authType = "github" 63 | enabled = true 64 | 65 | # Google Container Registry 66 | [registries."gcr.io"] 67 | upstream = "gcr.io" 68 | authHost = "gcr.io/v2/token" 69 | authType = "google" 70 | enabled = true 71 | 72 | # Quay.io Container Registry 73 | [registries."quay.io"] 74 | upstream = "quay.io" 75 | authHost = "quay.io/v2/auth" 76 | authType = "quay" 77 | enabled = true 78 | 79 | # Kubernetes Container Registry 80 | [registries."registry.k8s.io"] 81 | upstream = "registry.k8s.io" 82 | authHost = "registry.k8s.io" 83 | authType = "anonymous" 84 | enabled = true 85 | 86 | [tokenCache] 87 | # 是否启用缓存(同时控制Token和Manifest缓存)显著提升性能 88 | enabled = true 89 | # 默认缓存时间(分钟) 90 | defaultTTL = "20m" 91 | -------------------------------------------------------------------------------- /src/go.mod: -------------------------------------------------------------------------------- 1 | module hubproxy 2 | 3 | go 1.25 4 | 5 | require ( 6 | github.com/gin-gonic/gin v1.10.1 7 | github.com/google/go-containerregistry v0.20.6 8 | github.com/pelletier/go-toml/v2 v2.2.4 9 | golang.org/x/net v0.43.0 10 | golang.org/x/time v0.12.0 11 | ) 12 | 13 | require ( 14 | github.com/bytedance/sonic v1.11.6 // indirect 15 | github.com/bytedance/sonic/loader v0.1.1 // indirect 16 | github.com/cloudwego/base64x v0.1.4 // indirect 17 | github.com/cloudwego/iasm v0.2.0 // indirect 18 | github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect 19 | github.com/docker/cli v28.2.2+incompatible // indirect 20 | github.com/docker/distribution v2.8.3+incompatible // indirect 21 | github.com/docker/docker-credential-helpers v0.9.3 // indirect 22 | github.com/gabriel-vasile/mimetype v1.4.3 // indirect 23 | github.com/gin-contrib/sse v0.1.0 // indirect 24 | github.com/go-playground/locales v0.14.1 // indirect 25 | github.com/go-playground/universal-translator v0.18.1 // indirect 26 | github.com/go-playground/validator/v10 v10.20.0 // indirect 27 | github.com/goccy/go-json v0.10.2 // indirect 28 | github.com/json-iterator/go v1.1.12 // indirect 29 | github.com/klauspost/compress v1.18.0 // indirect 30 | github.com/klauspost/cpuid/v2 v2.2.7 // indirect 31 | github.com/leodido/go-urn v1.4.0 // indirect 32 | github.com/mattn/go-isatty v0.0.20 // indirect 33 | github.com/mitchellh/go-homedir v1.1.0 // indirect 34 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 35 | github.com/modern-go/reflect2 v1.0.2 // indirect 36 | github.com/opencontainers/go-digest v1.0.0 // indirect 37 | github.com/opencontainers/image-spec v1.1.1 // indirect 38 | github.com/pkg/errors v0.9.1 // indirect 39 | github.com/sirupsen/logrus v1.9.3 // indirect 40 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 41 | github.com/ugorji/go/codec v1.2.12 // indirect 42 | github.com/vbatts/tar-split v0.12.1 // indirect 43 | golang.org/x/arch v0.8.0 // indirect 44 | golang.org/x/crypto v0.41.0 // indirect 45 | golang.org/x/sync v0.16.0 // indirect 46 | golang.org/x/sys v0.35.0 // indirect 47 | golang.org/x/text v0.28.0 // indirect 48 | google.golang.org/protobuf v1.36.3 // indirect 49 | gopkg.in/yaml.v3 v3.0.1 // indirect 50 | ) 51 | -------------------------------------------------------------------------------- /src/utils/proxy_shell.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bytes" 5 | "compress/gzip" 6 | "fmt" 7 | "io" 8 | "regexp" 9 | "strings" 10 | ) 11 | 12 | // GitHub URL正则表达式 13 | var githubRegex = regexp.MustCompile(`(?:^|[\s'"(=,\[{;|&<>])https?://(?:github\.com|raw\.githubusercontent\.com|raw\.github\.com|gist\.githubusercontent\.com|gist\.github\.com|api\.github\.com)[^\s'")]*`) 14 | 15 | // ProcessSmart Shell脚本智能处理函数 16 | func ProcessSmart(input io.ReadCloser, isCompressed bool, host string) (io.Reader, int64, error) { 17 | defer input.Close() 18 | 19 | content, err := readShellContent(input, isCompressed) 20 | if err != nil { 21 | return nil, 0, fmt.Errorf("内容读取失败: %v", err) 22 | } 23 | 24 | if len(content) == 0 { 25 | return strings.NewReader(""), 0, nil 26 | } 27 | 28 | if len(content) > 10*1024*1024 { 29 | return strings.NewReader(content), int64(len(content)), nil 30 | } 31 | 32 | if !strings.Contains(content, "github.com") && !strings.Contains(content, "githubusercontent.com") { 33 | return strings.NewReader(content), int64(len(content)), nil 34 | } 35 | 36 | processed := processGitHubURLs(content, host) 37 | 38 | return strings.NewReader(processed), int64(len(processed)), nil 39 | } 40 | 41 | func readShellContent(input io.ReadCloser, isCompressed bool) (string, error) { 42 | var reader io.Reader = input 43 | 44 | if isCompressed { 45 | peek := make([]byte, 2) 46 | n, err := input.Read(peek) 47 | if err != nil && err != io.EOF { 48 | return "", fmt.Errorf("读取数据失败: %v", err) 49 | } 50 | 51 | if n >= 2 && peek[0] == 0x1f && peek[1] == 0x8b { 52 | combinedReader := io.MultiReader(bytes.NewReader(peek[:n]), input) 53 | gzReader, err := gzip.NewReader(combinedReader) 54 | if err != nil { 55 | return "", fmt.Errorf("gzip解压失败: %v", err) 56 | } 57 | defer gzReader.Close() 58 | reader = gzReader 59 | } else { 60 | reader = io.MultiReader(bytes.NewReader(peek[:n]), input) 61 | } 62 | } 63 | 64 | data, err := io.ReadAll(reader) 65 | if err != nil { 66 | return "", fmt.Errorf("读取内容失败: %v", err) 67 | } 68 | 69 | return string(data), nil 70 | } 71 | 72 | func processGitHubURLs(content, host string) string { 73 | return githubRegex.ReplaceAllStringFunc(content, func(match string) string { 74 | // 如果匹配包含前缀分隔符,保留它,防止出现重复转换 75 | if len(match) > 0 && match[0] != 'h' { 76 | prefix := match[0:1] 77 | url := match[1:] 78 | return prefix + transformURL(url, host) 79 | } 80 | return transformURL(match, host) 81 | }) 82 | } 83 | 84 | // transformURL URL转换函数 85 | func transformURL(url, host string) string { 86 | if strings.Contains(url, host) { 87 | return url 88 | } 89 | 90 | if strings.HasPrefix(url, "http://") { 91 | url = "https" + url[4:] 92 | } else if !strings.HasPrefix(url, "https://") && !strings.HasPrefix(url, "//") { 93 | url = "https://" + url 94 | } 95 | 96 | // 确保 host 有协议头 97 | if !strings.HasPrefix(host, "http://") && !strings.HasPrefix(host, "https://") { 98 | host = "https://" + host 99 | } 100 | host = strings.TrimSuffix(host, "/") 101 | 102 | return host + "/" + url 103 | } -------------------------------------------------------------------------------- /src/utils/cache.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "crypto/md5" 5 | "encoding/json" 6 | "fmt" 7 | "strings" 8 | "sync" 9 | "time" 10 | 11 | "github.com/gin-gonic/gin" 12 | "hubproxy/config" 13 | ) 14 | 15 | // CachedItem 通用缓存项 16 | type CachedItem struct { 17 | Data []byte 18 | ContentType string 19 | Headers map[string]string 20 | ExpiresAt time.Time 21 | } 22 | 23 | // UniversalCache 通用缓存 24 | type UniversalCache struct { 25 | cache sync.Map 26 | } 27 | 28 | var GlobalCache = &UniversalCache{} 29 | 30 | // Get 获取缓存项 31 | func (c *UniversalCache) Get(key string) *CachedItem { 32 | if v, ok := c.cache.Load(key); ok { 33 | if cached := v.(*CachedItem); time.Now().Before(cached.ExpiresAt) { 34 | return cached 35 | } 36 | c.cache.Delete(key) 37 | } 38 | return nil 39 | } 40 | 41 | func (c *UniversalCache) Set(key string, data []byte, contentType string, headers map[string]string, ttl time.Duration) { 42 | c.cache.Store(key, &CachedItem{ 43 | Data: data, 44 | ContentType: contentType, 45 | Headers: headers, 46 | ExpiresAt: time.Now().Add(ttl), 47 | }) 48 | } 49 | 50 | func (c *UniversalCache) GetToken(key string) string { 51 | if item := c.Get(key); item != nil { 52 | return string(item.Data) 53 | } 54 | return "" 55 | } 56 | 57 | func (c *UniversalCache) SetToken(key, token string, ttl time.Duration) { 58 | c.Set(key, []byte(token), "application/json", nil, ttl) 59 | } 60 | 61 | // BuildCacheKey 构建稳定的缓存key 62 | func BuildCacheKey(prefix, query string) string { 63 | return fmt.Sprintf("%s:%x", prefix, md5.Sum([]byte(query))) 64 | } 65 | 66 | func BuildTokenCacheKey(query string) string { 67 | return BuildCacheKey("token", query) 68 | } 69 | 70 | func BuildManifestCacheKey(imageRef, reference string) string { 71 | key := fmt.Sprintf("%s:%s", imageRef, reference) 72 | return BuildCacheKey("manifest", key) 73 | } 74 | 75 | func GetManifestTTL(reference string) time.Duration { 76 | cfg := config.GetConfig() 77 | defaultTTL := 30 * time.Minute 78 | if cfg.TokenCache.DefaultTTL != "" { 79 | if parsed, err := time.ParseDuration(cfg.TokenCache.DefaultTTL); err == nil { 80 | defaultTTL = parsed 81 | } 82 | } 83 | 84 | if strings.HasPrefix(reference, "sha256:") { 85 | return 24 * time.Hour 86 | } 87 | 88 | if reference == "latest" || reference == "main" || reference == "master" || 89 | reference == "dev" || reference == "develop" { 90 | return 10 * time.Minute 91 | } 92 | 93 | return defaultTTL 94 | } 95 | 96 | // ExtractTTLFromResponse 从响应中智能提取TTL 97 | func ExtractTTLFromResponse(responseBody []byte) time.Duration { 98 | var tokenResp struct { 99 | ExpiresIn int `json:"expires_in"` 100 | } 101 | 102 | defaultTTL := 30 * time.Minute 103 | 104 | if json.Unmarshal(responseBody, &tokenResp) == nil && tokenResp.ExpiresIn > 0 { 105 | safeTTL := time.Duration(tokenResp.ExpiresIn-300) * time.Second 106 | if safeTTL > 5*time.Minute { 107 | return safeTTL 108 | } 109 | } 110 | 111 | return defaultTTL 112 | } 113 | 114 | func WriteTokenResponse(c *gin.Context, cachedBody string) { 115 | c.Header("Content-Type", "application/json") 116 | c.String(200, cachedBody) 117 | } 118 | 119 | func WriteCachedResponse(c *gin.Context, item *CachedItem) { 120 | if item.ContentType != "" { 121 | c.Header("Content-Type", item.ContentType) 122 | } 123 | 124 | for key, value := range item.Headers { 125 | c.Header(key, value) 126 | } 127 | 128 | c.Data(200, item.ContentType, item.Data) 129 | } 130 | 131 | // IsCacheEnabled 检查缓存是否启用 132 | func IsCacheEnabled() bool { 133 | cfg := config.GetConfig() 134 | return cfg.TokenCache.Enabled 135 | } 136 | 137 | // IsTokenCacheEnabled 检查token缓存是否启用 138 | func IsTokenCacheEnabled() bool { 139 | return IsCacheEnabled() 140 | } 141 | 142 | // 定期清理过期缓存 143 | func init() { 144 | go func() { 145 | ticker := time.NewTicker(20 * time.Minute) 146 | defer ticker.Stop() 147 | 148 | for range ticker.C { 149 | now := time.Now() 150 | expiredKeys := make([]string, 0) 151 | 152 | GlobalCache.cache.Range(func(key, value interface{}) bool { 153 | if cached := value.(*CachedItem); now.After(cached.ExpiresAt) { 154 | expiredKeys = append(expiredKeys, key.(string)) 155 | } 156 | return true 157 | }) 158 | 159 | for _, key := range expiredKeys { 160 | GlobalCache.cache.Delete(key) 161 | } 162 | } 163 | }() 164 | } 165 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: 发布二进制文件 2 | 3 | on: 4 | workflow_dispatch: # 手动触发 5 | inputs: 6 | version: 7 | description: '版本号 (例如: v1.0.0)' 8 | required: true 9 | default: 'v1.0.0' 10 | 11 | jobs: 12 | build-and-release: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: write 16 | 17 | steps: 18 | - name: 检出代码 19 | uses: actions/checkout@v4 20 | with: 21 | fetch-depth: 0 # 获取完整历史,用于生成变更日志 22 | 23 | - name: 设置Go环境 24 | uses: actions/setup-go@v5 25 | with: 26 | go-version-file: "src/go.mod" 27 | cache-dependency-path: "src/go.sum" 28 | 29 | - name: 获取版本号 30 | id: version 31 | run: | 32 | VERSION=${{ github.event.inputs.version }} 33 | echo "version=$VERSION" >> $GITHUB_OUTPUT 34 | echo "版本号: $VERSION" 35 | 36 | - name: 生成变更日志 37 | id: changelog 38 | run: | 39 | # 获取上一个标签 40 | PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") 41 | 42 | if [ -n "$PREV_TAG" ]; then 43 | echo "changelog<> $GITHUB_OUTPUT 44 | echo "## 更新内容" >> $GITHUB_OUTPUT 45 | echo "" >> $GITHUB_OUTPUT 46 | git log --pretty=format:"- %s" $PREV_TAG..HEAD >> $GITHUB_OUTPUT 47 | echo "" >> $GITHUB_OUTPUT 48 | echo "EOF" >> $GITHUB_OUTPUT 49 | else 50 | echo "changelog=## 首次发布" >> $GITHUB_OUTPUT 51 | fi 52 | 53 | - name: 创建构建目录 54 | run: | 55 | mkdir -p build/hubproxy 56 | 57 | - name: 安装 UPX 58 | uses: crazy-max/ghaction-upx@v3 59 | with: 60 | install-only: true 61 | 62 | - name: 编译二进制文件 63 | run: | 64 | cd src 65 | 66 | # Linux AMD64 67 | CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o ../build/hubproxy/hubproxy-linux-amd64 . 68 | 69 | # Linux ARM64 70 | CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o ../build/hubproxy/hubproxy-linux-arm64 . 71 | 72 | # 压缩二进制文件 73 | upx -9 ../build/hubproxy/hubproxy-linux-amd64 74 | upx -9 ../build/hubproxy/hubproxy-linux-arm64 75 | 76 | - name: 复制配置文件 77 | run: | 78 | # 复制配置文件 79 | cp src/config.toml build/hubproxy/ 80 | 81 | # 复制systemd服务文件 82 | cp hubproxy.service build/hubproxy/ 83 | 84 | # 复制安装脚本 85 | cp install.sh build/hubproxy/ 86 | 87 | # 创建README文件 88 | cat > build/hubproxy/README.md << 'EOF' 89 | # HubProxy 90 | 91 | 项目地址:https://github.com/sky22333/hubproxy 92 | EOF 93 | 94 | - name: 创建压缩包 95 | run: | 96 | cd build 97 | 98 | # Linux AMD64 包 99 | mkdir -p linux-amd64/hubproxy 100 | cp hubproxy/hubproxy-linux-amd64 linux-amd64/hubproxy/hubproxy 101 | cp hubproxy/config.toml hubproxy/hubproxy.service hubproxy/install.sh hubproxy/README.md linux-amd64/hubproxy/ 102 | tar -czf hubproxy-${{ steps.version.outputs.version }}-linux-amd64.tar.gz -C linux-amd64 hubproxy 103 | 104 | # Linux ARM64 包 105 | mkdir -p linux-arm64/hubproxy 106 | cp hubproxy/hubproxy-linux-arm64 linux-arm64/hubproxy/hubproxy 107 | cp hubproxy/config.toml hubproxy/hubproxy.service hubproxy/install.sh hubproxy/README.md linux-arm64/hubproxy/ 108 | tar -czf hubproxy-${{ steps.version.outputs.version }}-linux-arm64.tar.gz -C linux-arm64 hubproxy 109 | 110 | # 列出生成的文件 111 | ls -la *.tar.gz 112 | 113 | - name: 计算文件校验和 114 | run: | 115 | cd build 116 | sha256sum *.tar.gz > checksums.txt 117 | cat checksums.txt 118 | 119 | - name: 创建或更新Release 120 | uses: softprops/action-gh-release@v2 121 | with: 122 | tag_name: ${{ steps.version.outputs.version }} 123 | name: "HubProxy ${{ steps.version.outputs.version }}" 124 | body: | 125 | ${{ steps.changelog.outputs.changelog }} 126 | 127 | 128 | ## 下载文件 129 | 130 | - **Linux AMD64**: `hubproxy-${{ steps.version.outputs.version }}-linux-amd64.tar.gz` 131 | - **Linux ARM64**: `hubproxy-${{ steps.version.outputs.version }}-linux-arm64.tar.gz` 132 | 133 | files: | 134 | build/*.tar.gz 135 | build/checksums.txt 136 | draft: false 137 | prerelease: false 138 | token: ${{ secrets.GITHUB_TOKEN }} 139 | -------------------------------------------------------------------------------- /src/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "embed" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "strings" 9 | "time" 10 | 11 | "github.com/gin-gonic/gin" 12 | "golang.org/x/net/http2" 13 | "golang.org/x/net/http2/h2c" 14 | "hubproxy/config" 15 | "hubproxy/handlers" 16 | "hubproxy/utils" 17 | ) 18 | 19 | //go:embed public/* 20 | var staticFiles embed.FS 21 | 22 | // 服务嵌入的静态文件 23 | func serveEmbedFile(c *gin.Context, filename string) { 24 | data, err := staticFiles.ReadFile(filename) 25 | if err != nil { 26 | c.Status(404) 27 | return 28 | } 29 | contentType := "text/html; charset=utf-8" 30 | if strings.HasSuffix(filename, ".ico") { 31 | contentType = "image/x-icon" 32 | } 33 | c.Data(200, contentType, data) 34 | } 35 | 36 | var ( 37 | globalLimiter *utils.IPRateLimiter 38 | 39 | // 服务启动时间 40 | serviceStartTime = time.Now() 41 | ) 42 | 43 | func main() { 44 | // 加载配置 45 | if err := config.LoadConfig(); err != nil { 46 | fmt.Printf("配置加载失败: %v\n", err) 47 | return 48 | } 49 | 50 | // 初始化HTTP客户端 51 | utils.InitHTTPClients() 52 | 53 | // 初始化限流器 54 | globalLimiter = utils.InitGlobalLimiter() 55 | 56 | // 初始化Docker流式代理 57 | handlers.InitDockerProxy() 58 | 59 | // 初始化镜像流式下载器 60 | handlers.InitImageStreamer() 61 | 62 | // 初始化防抖器 63 | handlers.InitDebouncer() 64 | 65 | gin.SetMode(gin.ReleaseMode) 66 | router := gin.Default() 67 | 68 | // 全局Panic恢复保护 69 | router.Use(gin.CustomRecovery(func(c *gin.Context, recovered interface{}) { 70 | log.Printf("🚨 Panic recovered: %v", recovered) 71 | c.JSON(http.StatusInternalServerError, gin.H{ 72 | "error": "Internal server error", 73 | "code": "INTERNAL_ERROR", 74 | }) 75 | })) 76 | 77 | // 全局限流中间件 78 | router.Use(utils.RateLimitMiddleware(globalLimiter)) 79 | 80 | // 初始化监控端点 81 | initHealthRoutes(router) 82 | 83 | // 初始化镜像tar下载路由 84 | handlers.InitImageTarRoutes(router) 85 | 86 | // 静态文件路由 87 | router.GET("/", func(c *gin.Context) { 88 | serveEmbedFile(c, "public/index.html") 89 | }) 90 | router.GET("/public/*filepath", func(c *gin.Context) { 91 | filepath := strings.TrimPrefix(c.Param("filepath"), "/") 92 | serveEmbedFile(c, "public/"+filepath) 93 | }) 94 | 95 | router.GET("/images.html", func(c *gin.Context) { 96 | serveEmbedFile(c, "public/images.html") 97 | }) 98 | router.GET("/search.html", func(c *gin.Context) { 99 | serveEmbedFile(c, "public/search.html") 100 | }) 101 | router.GET("/favicon.ico", func(c *gin.Context) { 102 | serveEmbedFile(c, "public/favicon.ico") 103 | }) 104 | 105 | // 注册dockerhub搜索路由 106 | handlers.RegisterSearchRoute(router) 107 | 108 | // 注册Docker认证路由 109 | router.Any("/token", handlers.ProxyDockerAuthGin) 110 | router.Any("/token/*path", handlers.ProxyDockerAuthGin) 111 | 112 | // 注册Docker Registry代理路由 113 | router.Any("/v2/*path", handlers.ProxyDockerRegistryGin) 114 | 115 | // 注册GitHub代理路由(NoRoute处理器) 116 | router.NoRoute(handlers.GitHubProxyHandler) 117 | 118 | cfg := config.GetConfig() 119 | fmt.Printf("HubProxy 启动成功\n") 120 | fmt.Printf("监听地址: %s:%d\n", cfg.Server.Host, cfg.Server.Port) 121 | fmt.Printf("限流配置: %d请求/%g小时\n", cfg.RateLimit.RequestLimit, cfg.RateLimit.PeriodHours) 122 | 123 | // 显示HTTP/2支持状态 124 | if cfg.Server.EnableH2C { 125 | fmt.Printf("H2c: 已启用\n") 126 | } 127 | 128 | fmt.Printf("版本号: v1.2.0\n") 129 | fmt.Printf("项目地址: https://github.com/sky22333/hubproxy\n") 130 | 131 | // 创建HTTP2服务器 132 | server := &http.Server{ 133 | Addr: fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port), 134 | ReadTimeout: 60 * time.Second, 135 | WriteTimeout: 30 * time.Minute, 136 | IdleTimeout: 120 * time.Second, 137 | } 138 | 139 | // 根据配置决定是否启用H2C 140 | if cfg.Server.EnableH2C { 141 | h2cHandler := h2c.NewHandler(router, &http2.Server{ 142 | MaxConcurrentStreams: 250, 143 | IdleTimeout: 300 * time.Second, 144 | MaxReadFrameSize: 4 << 20, 145 | MaxUploadBufferPerConnection: 8 << 20, 146 | MaxUploadBufferPerStream: 2 << 20, 147 | }) 148 | server.Handler = h2cHandler 149 | } else { 150 | server.Handler = router 151 | } 152 | 153 | err := server.ListenAndServe() 154 | if err != nil { 155 | fmt.Printf("启动服务失败: %v\n", err) 156 | } 157 | } 158 | 159 | // 简单的健康检查 160 | func formatDuration(d time.Duration) string { 161 | if d < time.Minute { 162 | return fmt.Sprintf("%d秒", int(d.Seconds())) 163 | } else if d < time.Hour { 164 | return fmt.Sprintf("%d分钟%d秒", int(d.Minutes()), int(d.Seconds())%60) 165 | } else if d < 24*time.Hour { 166 | return fmt.Sprintf("%d小时%d分钟", int(d.Hours()), int(d.Minutes())%60) 167 | } else { 168 | days := int(d.Hours()) / 24 169 | hours := int(d.Hours()) % 24 170 | return fmt.Sprintf("%d天%d小时", days, hours) 171 | } 172 | } 173 | 174 | func getUptimeInfo() (time.Duration, float64, string) { 175 | uptime := time.Since(serviceStartTime) 176 | return uptime, uptime.Seconds(), formatDuration(uptime) 177 | } 178 | 179 | func initHealthRoutes(router *gin.Engine) { 180 | router.GET("/ready", func(c *gin.Context) { 181 | _, uptimeSec, uptimeHuman := getUptimeInfo() 182 | c.JSON(http.StatusOK, gin.H{ 183 | "ready": true, 184 | "service": "hubproxy", 185 | "start_time_unix": serviceStartTime.Unix(), 186 | "uptime_sec": uptimeSec, 187 | "uptime_human": uptimeHuman, 188 | }) 189 | }) 190 | } 191 | -------------------------------------------------------------------------------- /src/utils/access_control.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "strings" 5 | "sync" 6 | 7 | "hubproxy/config" 8 | ) 9 | 10 | // ResourceType 资源类型 11 | type ResourceType string 12 | 13 | const ( 14 | ResourceTypeGitHub ResourceType = "github" 15 | ResourceTypeDocker ResourceType = "docker" 16 | ) 17 | 18 | // AccessController 统一访问控制器 19 | type AccessController struct { 20 | mu sync.RWMutex 21 | } 22 | 23 | // DockerImageInfo Docker镜像信息 24 | type DockerImageInfo struct { 25 | Namespace string 26 | Repository string 27 | Tag string 28 | FullName string 29 | } 30 | 31 | // GlobalAccessController 全局访问控制器实例 32 | var GlobalAccessController = &AccessController{} 33 | 34 | // ParseDockerImage 解析Docker镜像名称 35 | func (ac *AccessController) ParseDockerImage(image string) DockerImageInfo { 36 | image = strings.TrimPrefix(image, "docker://") 37 | 38 | var tag string 39 | if idx := strings.LastIndex(image, ":"); idx != -1 { 40 | part := image[idx+1:] 41 | if !strings.Contains(part, "/") { 42 | tag = part 43 | image = image[:idx] 44 | } 45 | } 46 | if tag == "" { 47 | tag = "latest" 48 | } 49 | 50 | var namespace, repository string 51 | if strings.Contains(image, "/") { 52 | parts := strings.Split(image, "/") 53 | if len(parts) >= 2 { 54 | if strings.Contains(parts[0], ".") { 55 | if len(parts) >= 3 { 56 | namespace = parts[1] 57 | repository = parts[2] 58 | } else { 59 | namespace = "library" 60 | repository = parts[1] 61 | } 62 | } else { 63 | namespace = parts[0] 64 | repository = parts[1] 65 | } 66 | } 67 | } else { 68 | namespace = "library" 69 | repository = image 70 | } 71 | 72 | fullName := namespace + "/" + repository 73 | 74 | return DockerImageInfo{ 75 | Namespace: namespace, 76 | Repository: repository, 77 | Tag: tag, 78 | FullName: fullName, 79 | } 80 | } 81 | 82 | // CheckDockerAccess 检查Docker镜像访问权限 83 | func (ac *AccessController) CheckDockerAccess(image string) (allowed bool, reason string) { 84 | cfg := config.GetConfig() 85 | 86 | imageInfo := ac.ParseDockerImage(image) 87 | 88 | if len(cfg.Access.WhiteList) > 0 { 89 | if !ac.matchImageInList(imageInfo, cfg.Access.WhiteList) { 90 | return false, "不在Docker镜像白名单内" 91 | } 92 | } 93 | 94 | if len(cfg.Access.BlackList) > 0 { 95 | if ac.matchImageInList(imageInfo, cfg.Access.BlackList) { 96 | return false, "Docker镜像在黑名单内" 97 | } 98 | } 99 | 100 | return true, "" 101 | } 102 | 103 | // CheckGitHubAccess 检查GitHub仓库访问权限 104 | func (ac *AccessController) CheckGitHubAccess(matches []string) (allowed bool, reason string) { 105 | if len(matches) < 2 { 106 | return false, "无效的GitHub仓库格式" 107 | } 108 | 109 | cfg := config.GetConfig() 110 | 111 | if len(cfg.Access.WhiteList) > 0 && !ac.checkList(matches, cfg.Access.WhiteList) { 112 | return false, "不在GitHub仓库白名单内" 113 | } 114 | 115 | if len(cfg.Access.BlackList) > 0 && ac.checkList(matches, cfg.Access.BlackList) { 116 | return false, "GitHub仓库在黑名单内" 117 | } 118 | 119 | return true, "" 120 | } 121 | 122 | // matchImageInList 检查Docker镜像是否在指定列表中 123 | func (ac *AccessController) matchImageInList(imageInfo DockerImageInfo, list []string) bool { 124 | fullName := strings.ToLower(imageInfo.FullName) 125 | namespace := strings.ToLower(imageInfo.Namespace) 126 | 127 | for _, item := range list { 128 | item = strings.ToLower(strings.TrimSpace(item)) 129 | if item == "" { 130 | continue 131 | } 132 | 133 | if fullName == item { 134 | return true 135 | } 136 | 137 | if item == namespace || item == namespace+"/*" { 138 | return true 139 | } 140 | 141 | if strings.HasSuffix(item, "*") { 142 | prefix := strings.TrimSuffix(item, "*") 143 | if strings.HasPrefix(fullName, prefix) { 144 | return true 145 | } 146 | } 147 | 148 | if strings.HasPrefix(item, "*/") { 149 | repoPattern := strings.TrimPrefix(item, "*/") 150 | if strings.HasSuffix(repoPattern, "*") { 151 | repoPrefix := strings.TrimSuffix(repoPattern, "*") 152 | if strings.HasPrefix(imageInfo.Repository, repoPrefix) { 153 | return true 154 | } 155 | } else { 156 | if strings.ToLower(imageInfo.Repository) == repoPattern { 157 | return true 158 | } 159 | } 160 | } 161 | 162 | if strings.HasPrefix(fullName, item+"/") { 163 | return true 164 | } 165 | } 166 | return false 167 | } 168 | 169 | // checkList GitHub仓库检查逻辑 170 | func (ac *AccessController) checkList(matches, list []string) bool { 171 | if len(matches) < 2 { 172 | return false 173 | } 174 | 175 | username := strings.ToLower(strings.TrimSpace(matches[0])) 176 | repoName := strings.ToLower(strings.TrimSpace(strings.TrimSuffix(matches[1], ".git"))) 177 | fullRepo := username + "/" + repoName 178 | 179 | for _, item := range list { 180 | item = strings.ToLower(strings.TrimSpace(item)) 181 | if item == "" { 182 | continue 183 | } 184 | 185 | if fullRepo == item { 186 | return true 187 | } 188 | 189 | if item == username || item == username+"/*" { 190 | return true 191 | } 192 | 193 | if strings.HasSuffix(item, "*") { 194 | prefix := strings.TrimSuffix(item, "*") 195 | if strings.HasPrefix(fullRepo, prefix) { 196 | return true 197 | } 198 | } 199 | 200 | if strings.HasPrefix(fullRepo, item+"/") { 201 | return true 202 | } 203 | } 204 | return false 205 | } 206 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # HubProxy 一键安装脚本 4 | # 支持自动下载最新版本或使用本地文件安装 5 | set -e 6 | 7 | # 颜色定义 8 | RED='\033[0;31m' 9 | GREEN='\033[0;32m' 10 | YELLOW='\033[1;33m' 11 | BLUE='\033[0;34m' 12 | NC='\033[0m' # No Color 13 | 14 | # 配置 15 | REPO="sky22333/hubproxy" 16 | GITHUB_API="https://api.github.com/repos/${REPO}" 17 | GITHUB_RELEASES="${GITHUB_API}/releases" 18 | SERVICE_NAME="hubproxy" 19 | INSTALL_DIR="/opt/hubproxy" 20 | CONFIG_FILE="config.toml" 21 | BINARY_NAME="hubproxy" 22 | LOG_DIR="/var/log/hubproxy" 23 | TEMP_DIR="/tmp/hubproxy-install" 24 | 25 | echo -e "${BLUE}HubProxy 一键安装脚本${NC}" 26 | echo "=================================================" 27 | 28 | # 检查是否以root权限运行 29 | if [[ $EUID -ne 0 ]]; then 30 | echo -e "${RED}此脚本需要root权限运行${NC}" 31 | echo "请使用: sudo $0" 32 | exit 1 33 | fi 34 | 35 | # 检测系统架构 36 | detect_arch() { 37 | local arch=$(uname -m) 38 | case $arch in 39 | x86_64) 40 | echo "amd64" 41 | ;; 42 | aarch64|arm64) 43 | echo "arm64" 44 | ;; 45 | *) 46 | echo -e "${RED}不支持的架构: $arch${NC}" 47 | exit 1 48 | ;; 49 | esac 50 | } 51 | 52 | ARCH=$(detect_arch) 53 | echo -e "${BLUE}检测到架构: linux-${ARCH}${NC}" 54 | 55 | # 检查是否为本地安装模式 56 | if [ -f "${BINARY_NAME}" ]; then 57 | echo -e "${BLUE}发现本地文件,使用本地安装模式${NC}" 58 | LOCAL_INSTALL=true 59 | else 60 | echo -e "${BLUE}本地无文件,使用自动下载模式${NC}" 61 | LOCAL_INSTALL=false 62 | 63 | # 检查依赖 64 | missing_deps=() 65 | for cmd in curl jq tar; do 66 | if ! command -v $cmd &> /dev/null; then 67 | missing_deps+=($cmd) 68 | fi 69 | done 70 | 71 | if [ ${#missing_deps[@]} -gt 0 ]; then 72 | echo -e "${YELLOW}检测到缺少依赖: ${missing_deps[*]}${NC}" 73 | echo -e "${BLUE}正在自动安装依赖...${NC}" 74 | 75 | apt update && apt install -y curl jq 76 | if [ $? -ne 0 ]; then 77 | echo -e "${RED}依赖安装失败${NC}" 78 | exit 1 79 | fi 80 | 81 | # 重新检查依赖 82 | for cmd in curl jq tar; do 83 | if ! command -v $cmd &> /dev/null; then 84 | echo -e "${RED}依赖安装后仍缺少: $cmd${NC}" 85 | exit 1 86 | fi 87 | done 88 | 89 | echo -e "${GREEN}依赖安装成功${NC}" 90 | fi 91 | fi 92 | 93 | # 自动下载功能 94 | if [ "$LOCAL_INSTALL" = false ]; then 95 | echo -e "${BLUE}获取最新版本信息...${NC}" 96 | LATEST_RELEASE=$(curl -s "${GITHUB_RELEASES}/latest") 97 | if [ $? -ne 0 ]; then 98 | echo -e "${RED}无法获取版本信息${NC}" 99 | exit 1 100 | fi 101 | 102 | VERSION=$(echo "$LATEST_RELEASE" | jq -r '.tag_name') 103 | if [ "$VERSION" = "null" ]; then 104 | echo -e "${RED}无法解析版本信息${NC}" 105 | exit 1 106 | fi 107 | 108 | echo -e "${GREEN}最新版本: ${VERSION}${NC}" 109 | 110 | # 构造下载URL 111 | ASSET_NAME="hubproxy-${VERSION}-linux-${ARCH}.tar.gz" 112 | DOWNLOAD_URL="https://github.com/${REPO}/releases/download/${VERSION}/${ASSET_NAME}" 113 | 114 | echo -e "${BLUE}下载: ${ASSET_NAME}${NC}" 115 | 116 | # 创建临时目录并下载 117 | rm -rf "${TEMP_DIR}" 118 | mkdir -p "${TEMP_DIR}" 119 | cd "${TEMP_DIR}" 120 | 121 | curl -L -o "${ASSET_NAME}" "${DOWNLOAD_URL}" 122 | if [ $? -ne 0 ]; then 123 | echo -e "${RED}下载失败${NC}" 124 | exit 1 125 | fi 126 | 127 | # 解压 128 | tar -xzf "${ASSET_NAME}" 129 | if [ $? -ne 0 ] || [ ! -d "hubproxy" ]; then 130 | echo -e "${RED}解压失败${NC}" 131 | exit 1 132 | fi 133 | 134 | cd hubproxy 135 | echo -e "${GREEN}下载完成${NC}" 136 | fi 137 | 138 | echo -e "${YELLOW}开始安装 HubProxy...${NC}" 139 | 140 | # 停止现有服务(如果存在) 141 | if systemctl is-active --quiet ${SERVICE_NAME} 2>/dev/null; then 142 | echo -e "${YELLOW}停止现有服务...${NC}" 143 | systemctl stop ${SERVICE_NAME} 144 | fi 145 | 146 | # 备份现有配置(如果存在) 147 | CONFIG_BACKUP_EXISTS=false 148 | if [ -f "${INSTALL_DIR}/${CONFIG_FILE}" ]; then 149 | echo -e "${BLUE}备份现有配置...${NC}" 150 | cp "${INSTALL_DIR}/${CONFIG_FILE}" "${TEMP_DIR}/config.toml.backup" 151 | CONFIG_BACKUP_EXISTS=true 152 | fi 153 | 154 | # 1. 创建目录结构 155 | echo -e "${BLUE}创建目录结构${NC}" 156 | mkdir -p ${INSTALL_DIR} 157 | mkdir -p ${LOG_DIR} 158 | chmod 755 ${INSTALL_DIR} 159 | chmod 755 ${LOG_DIR} 160 | 161 | # 2. 复制二进制文件 162 | echo -e "${BLUE}复制二进制文件${NC}" 163 | cp "${BINARY_NAME}" "${INSTALL_DIR}/" 164 | chmod +x "${INSTALL_DIR}/${BINARY_NAME}" 165 | 166 | # 3. 复制配置文件 167 | echo -e "${BLUE}复制配置文件${NC}" 168 | if [ -f "${CONFIG_FILE}" ]; then 169 | if [ "$CONFIG_BACKUP_EXISTS" = false ]; then 170 | cp "${CONFIG_FILE}" "${INSTALL_DIR}/" 171 | echo -e "${GREEN}配置文件复制成功${NC}" 172 | else 173 | echo -e "${YELLOW}保留现有配置文件${NC}" 174 | fi 175 | else 176 | echo -e "${YELLOW}配置文件不存在,将使用默认配置${NC}" 177 | fi 178 | 179 | # 5. 安装systemd服务文件 180 | echo -e "${BLUE}安装systemd服务文件${NC}" 181 | cp "${SERVICE_NAME}.service" "/etc/systemd/system/" 182 | systemctl daemon-reload 183 | 184 | # 6. 恢复配置文件(如果有备份) 185 | if [ "$CONFIG_BACKUP_EXISTS" = true ]; then 186 | echo -e "${BLUE}恢复配置文件...${NC}" 187 | cp "${TEMP_DIR}/config.toml.backup" "${INSTALL_DIR}/${CONFIG_FILE}" 188 | fi 189 | 190 | # 7. 启用并启动服务 191 | echo -e "${BLUE}启用并启动服务${NC}" 192 | systemctl enable ${SERVICE_NAME} 193 | systemctl start ${SERVICE_NAME} 194 | 195 | # 8. 清理临时文件 196 | if [ "$LOCAL_INSTALL" = false ]; then 197 | echo -e "${BLUE}清理临时文件...${NC}" 198 | cd / 199 | rm -rf "${TEMP_DIR}" 200 | fi 201 | 202 | # 9. 检查服务状态 203 | sleep 2 204 | if systemctl is-active --quiet ${SERVICE_NAME}; then 205 | echo "" 206 | echo -e "${GREEN}HubProxy 安装成功!${NC}" 207 | echo -e "${GREEN}默认运行端口: 5000${NC}" 208 | echo -e "${GREEN}配置文件路径: ${INSTALL_DIR}/${CONFIG_FILE}${NC}" 209 | else 210 | echo -e "${RED}服务启动失败${NC}" 211 | echo "查看错误日志: sudo journalctl -u ${SERVICE_NAME} -f" 212 | exit 1 213 | fi 214 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HubProxy 2 | 3 | **Docker 和 GitHub 加速代理服务器** 4 | 5 | 一个轻量级、高性能的多功能代理服务,提供 Docker 镜像加速、GitHub 文件加速、下载离线镜像、在线搜索 Docker 镜像等功能。 6 | 7 | 8 |

9 | Visitors 10 |

11 | 12 | ## 特性 13 | 14 | - 🐳 **Docker 镜像加速** - 支持 Docker Hub、GHCR、Quay 等多个镜像仓库加速,流式传输优化拉取速度。 15 | - 🐳 **离线镜像包** - 支持下载离线镜像包,流式传输加防抖设计。 16 | - 📁 **GitHub 文件加速** - 加速 GitHub Release、Raw 文件下载,支持`api.github.com`,脚本嵌套加速等等 17 | - 🤖 **AI 模型库支持** - 支持 Hugging Face 模型下载加速 18 | - 🛡️ **智能限流** - IP 限流保护,防止滥用 19 | - 🚫 **仓库审计** - 强大的自定义黑名单,白名单,同时审计镜像仓库,和GitHub仓库 20 | - 🔍 **镜像搜索** - 在线搜索 Docker 镜像 21 | - ⚡ **轻量高效** - 基于 Go 语言,单二进制文件运行,资源占用低。 22 | - 🔧 **统一配置** - 统一配置管理,便于维护。 23 | - 🛡️ **完全自托管** - 避免依赖免费第三方服务的不稳定性,例如`cloudflare`等等。 24 | - 🚀 **多服务统一加速** - 单个程序即可统一加速 Docker、GitHub、Hugging Face 等多种服务,简化部署与管理。 25 | 26 | ## 详细文档 27 | 28 | [中文文档](https://zread.ai/sky22333/hubproxy) 29 | 30 | [English](https://deepwiki.com/sky22333/hubproxy) 31 | 32 | ## 快速开始 33 | 34 | ### Docker部署(推荐) 35 | ``` 36 | docker run -d \ 37 | --name hubproxy \ 38 | -p 5000:5000 \ 39 | --restart always \ 40 | ghcr.io/sky22333/hubproxy 41 | ``` 42 | 43 | ### 一键脚本安装 44 | 45 | ```bash 46 | curl -fsSL https://raw.githubusercontent.com/sky22333/hubproxy/main/install.sh | sudo bash 47 | ``` 48 | 49 | 支持单个二进制文件直接启动,无需其他配置,内置默认配置,支持所有功能。 50 | 51 | 这个脚本会: 52 | - 自动检测系统架构(AMD64/ARM64) 53 | - 从 GitHub Releases 下载最新版本 54 | - 自动配置系统服务 55 | - 保留现有配置(升级时) 56 | 57 | ## 使用方法 58 | 59 | ### Docker 镜像加速 60 | 61 | ```bash 62 | # 原命令 63 | docker pull nginx 64 | 65 | # 使用加速 66 | docker pull yourdomain.com/nginx 67 | 68 | # ghcr加速 69 | docker pull yourdomain.com/ghcr.io/sky22333/hubproxy 70 | 71 | # 符合Docker Registry API v2标准的仓库都支持 72 | ``` 73 | 74 | 当然也支持配置为全局镜像加速,在主机上新建(或编辑)`/etc/docker/daemon.json` 75 | 76 | 在 `"registry-mirrors"` 中加入域名: 77 | 78 | ```json 79 | { 80 | "registry-mirrors": [ 81 | "https://yourdomain.com" 82 | ] 83 | } 84 | ``` 85 | 86 | 若已设置其他加速地址,直接并列添加后保存,再执行 `sudo systemctl restart docker` 重启docker服务让配置生效。 87 | 88 | ### GitHub 文件加速 89 | 90 | ```bash 91 | # 原链接 92 | https://github.com/user/repo/releases/download/v1.0.0/file.tar.gz 93 | 94 | # 加速链接 95 | https://yourdomain.com/https://github.com/user/repo/releases/download/v1.0.0/file.tar.gz 96 | 97 | # 加速下载仓库 98 | git clone https://yourdomain.com/https://github.com/sky22333/hubproxy.git 99 | ``` 100 | 101 | ## 配置 102 | 103 |
104 | config.toml 配置说明 105 | 106 | *此配置是默认配置,已经内置在程序中了* 107 | 108 | ``` 109 | [server] 110 | host = "0.0.0.0" 111 | # 监听端口 112 | port = 5000 113 | # Github文件大小限制(字节),默认2GB 114 | fileSize = 2147483648 115 | # HTTP/2 多路复用,提升下载速度 116 | enableH2C = false 117 | 118 | [rateLimit] 119 | # 每个IP每周期允许的请求数(注意Docker镜像会有多个层,会消耗多个次数) 120 | requestLimit = 500 121 | # 限流周期(小时) 122 | periodHours = 3.0 123 | 124 | [security] 125 | # IP白名单,支持单个IP或IP段 126 | # 白名单中的IP不受限流限制 127 | whiteList = [ 128 | "127.0.0.1", 129 | "172.17.0.0/16", 130 | "192.168.1.0/24" 131 | ] 132 | 133 | # IP黑名单,支持单个IP或IP段 134 | # 黑名单中的IP将被直接拒绝访问 135 | blackList = [ 136 | "192.168.100.1", 137 | "192.168.100.0/24" 138 | ] 139 | 140 | [access] 141 | # 代理服务白名单(支持GitHub仓库和Docker镜像,支持通配符) 142 | # 只允许访问白名单中的仓库/镜像,为空时不限制 143 | whiteList = [] 144 | 145 | # 代理服务黑名单(支持GitHub仓库和Docker镜像,支持通配符) 146 | # 禁止访问黑名单中的仓库/镜像 147 | blackList = [ 148 | "baduser/malicious-repo", 149 | "*/malicious-repo", 150 | "baduser/*" 151 | ] 152 | 153 | # 代理配置,支持有用户名/密码认证和无认证模式 154 | # 无认证: socks5://127.0.0.1:1080 155 | # 有认证: socks5://username:password@127.0.0.1:1080 156 | # 留空不使用代理 157 | proxy = "" 158 | 159 | [download] 160 | # 批量下载离线镜像数量限制 161 | maxImages = 10 162 | 163 | # Registry映射配置,支持多种镜像仓库上游 164 | [registries] 165 | 166 | # GitHub Container Registry 167 | [registries."ghcr.io"] 168 | upstream = "ghcr.io" 169 | authHost = "ghcr.io/token" 170 | authType = "github" 171 | enabled = true 172 | 173 | # Google Container Registry 174 | [registries."gcr.io"] 175 | upstream = "gcr.io" 176 | authHost = "gcr.io/v2/token" 177 | authType = "google" 178 | enabled = true 179 | 180 | # Quay.io Container Registry 181 | [registries."quay.io"] 182 | upstream = "quay.io" 183 | authHost = "quay.io/v2/auth" 184 | authType = "quay" 185 | enabled = true 186 | 187 | # Kubernetes Container Registry 188 | [registries."registry.k8s.io"] 189 | upstream = "registry.k8s.io" 190 | authHost = "registry.k8s.io" 191 | authType = "anonymous" 192 | enabled = true 193 | 194 | [tokenCache] 195 | # 是否启用缓存(同时控制Token和Manifest缓存)显著提升性能 196 | enabled = true 197 | # 默认缓存时间(分钟) 198 | defaultTTL = "20m" 199 | ``` 200 | 201 |
202 | 203 | 容器内的配置文件位于 `/root/config.toml` 204 | 205 | 脚本部署配置文件位于 `/opt/hubproxy/config.toml` 206 | 207 | 为了IP限流能够正常运行,反向代理需要传递IP头用来获取访客真实IP,以caddy为例: 208 | ``` 209 | example.com { 210 | reverse_proxy { 211 | to 127.0.0.1:5000 212 | header_up X-Real-IP {remote} 213 | header_up X-Forwarded-For {remote} 214 | header_up X-Forwarded-Proto {scheme} 215 | } 216 | } 217 | ``` 218 | cloudflare CDN: 219 | ``` 220 | example.com { 221 | reverse_proxy 127.0.0.1:5000 { 222 | header_up X-Forwarded-For {http.request.header.CF-Connecting-IP} 223 | header_up X-Real-IP {http.request.header.CF-Connecting-IP} 224 | header_up X-Forwarded-Proto https 225 | header_up X-Forwarded-Host {host} 226 | } 227 | } 228 | ``` 229 | 230 | > 对于使用nginx反代的用户,Github加速提示`无效输入`的问题可以参见[issues/62](https://github.com/sky22333/hubproxy/issues/62#issuecomment-3219572440) 231 | 232 | 233 | ## ⚠️ 免责声明 234 | 235 | - 本程序仅供学习交流使用,请勿用于非法用途 236 | - 使用本程序需遵守当地法律法规 237 | - 作者不对使用者的任何行为承担责任 238 | 239 | --- 240 | 241 |
242 | 243 | **⭐ 如果这个项目对你有帮助,请给个 Star!⭐** 244 | 245 |
246 | 247 | ## 界面预览 248 | 249 | ![1](./.github/demo/demo1.jpg) 250 | 251 | ## Star 趋势 252 | [![Star 趋势](https://starchart.cc/sky22333/hubproxy.svg?variant=adaptive)](https://starchart.cc/sky22333/hubproxy) 253 | -------------------------------------------------------------------------------- /src/utils/ratelimiter.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "strings" 7 | "sync" 8 | "time" 9 | 10 | "github.com/gin-gonic/gin" 11 | "golang.org/x/time/rate" 12 | "hubproxy/config" 13 | ) 14 | 15 | const ( 16 | CleanupInterval = 20 * time.Minute 17 | MaxIPCacheSize = 10000 18 | ) 19 | 20 | // IPRateLimiter IP限流器结构体 21 | type IPRateLimiter struct { 22 | ips map[string]*rateLimiterEntry 23 | mu *sync.RWMutex 24 | r rate.Limit 25 | b int 26 | whitelist []*net.IPNet 27 | blacklist []*net.IPNet 28 | whitelistLimiter *rate.Limiter // 全局共享的白名单限流器 29 | } 30 | 31 | // rateLimiterEntry 限流器条目 32 | type rateLimiterEntry struct { 33 | limiter *rate.Limiter 34 | lastAccess time.Time 35 | } 36 | 37 | // InitGlobalLimiter 初始化全局限流器 38 | func InitGlobalLimiter() *IPRateLimiter { 39 | cfg := config.GetConfig() 40 | 41 | whitelist := make([]*net.IPNet, 0, len(cfg.Security.WhiteList)) 42 | for _, item := range cfg.Security.WhiteList { 43 | if item = strings.TrimSpace(item); item != "" { 44 | if !strings.Contains(item, "/") { 45 | item = item + "/32" 46 | } 47 | _, ipnet, err := net.ParseCIDR(item) 48 | if err == nil { 49 | whitelist = append(whitelist, ipnet) 50 | } else { 51 | fmt.Printf("警告: 无效的白名单IP格式: %s\n", item) 52 | } 53 | } 54 | } 55 | 56 | blacklist := make([]*net.IPNet, 0, len(cfg.Security.BlackList)) 57 | for _, item := range cfg.Security.BlackList { 58 | if item = strings.TrimSpace(item); item != "" { 59 | if !strings.Contains(item, "/") { 60 | item = item + "/32" 61 | } 62 | _, ipnet, err := net.ParseCIDR(item) 63 | if err == nil { 64 | blacklist = append(blacklist, ipnet) 65 | } else { 66 | fmt.Printf("警告: 无效的黑名单IP格式: %s\n", item) 67 | } 68 | } 69 | } 70 | 71 | ratePerSecond := rate.Limit(float64(cfg.RateLimit.RequestLimit) / (cfg.RateLimit.PeriodHours * 3600)) 72 | 73 | burstSize := cfg.RateLimit.RequestLimit 74 | 75 | limiter := &IPRateLimiter{ 76 | ips: make(map[string]*rateLimiterEntry), 77 | mu: &sync.RWMutex{}, 78 | r: ratePerSecond, 79 | b: burstSize, 80 | whitelist: whitelist, 81 | blacklist: blacklist, 82 | whitelistLimiter: rate.NewLimiter(rate.Inf, burstSize), 83 | } 84 | 85 | go limiter.cleanupRoutine() 86 | 87 | return limiter 88 | } 89 | 90 | // cleanupRoutine 定期清理过期的限流器 91 | func (i *IPRateLimiter) cleanupRoutine() { 92 | ticker := time.NewTicker(CleanupInterval) 93 | defer ticker.Stop() 94 | 95 | for range ticker.C { 96 | now := time.Now() 97 | expired := make([]string, 0) 98 | 99 | i.mu.RLock() 100 | for ip, entry := range i.ips { 101 | if now.Sub(entry.lastAccess) > 2*time.Hour { 102 | expired = append(expired, ip) 103 | } 104 | } 105 | i.mu.RUnlock() 106 | 107 | if len(expired) > 0 || len(i.ips) > MaxIPCacheSize { 108 | i.mu.Lock() 109 | for _, ip := range expired { 110 | delete(i.ips, ip) 111 | } 112 | 113 | if len(i.ips) > MaxIPCacheSize { 114 | i.ips = make(map[string]*rateLimiterEntry) 115 | } 116 | i.mu.Unlock() 117 | } 118 | } 119 | } 120 | 121 | // extractIPFromAddress 从地址中提取纯IP 122 | func extractIPFromAddress(address string) string { 123 | if host, _, err := net.SplitHostPort(address); err == nil { 124 | return host 125 | } 126 | return address 127 | } 128 | 129 | // normalizeIPForRateLimit 标准化IP地址用于限流 130 | func normalizeIPForRateLimit(ipStr string) string { 131 | ip := net.ParseIP(ipStr) 132 | if ip == nil { 133 | return ipStr 134 | } 135 | 136 | if ip.To4() != nil { 137 | return ipStr 138 | } 139 | 140 | ipv6 := ip.To16() 141 | for i := 8; i < 16; i++ { 142 | ipv6[i] = 0 143 | } 144 | return ipv6.String() + "/64" 145 | } 146 | 147 | // isIPInCIDRList 检查IP是否在CIDR列表中 148 | func isIPInCIDRList(ip string, cidrList []*net.IPNet) bool { 149 | cleanIP := extractIPFromAddress(ip) 150 | parsedIP := net.ParseIP(cleanIP) 151 | if parsedIP == nil { 152 | return false 153 | } 154 | 155 | for _, cidr := range cidrList { 156 | if cidr.Contains(parsedIP) { 157 | return true 158 | } 159 | } 160 | return false 161 | } 162 | 163 | // GetLimiter 获取指定IP的限流器 164 | func (i *IPRateLimiter) GetLimiter(ip string) (*rate.Limiter, bool) { 165 | cleanIP := extractIPFromAddress(ip) 166 | 167 | if isIPInCIDRList(cleanIP, i.blacklist) { 168 | return nil, false 169 | } 170 | 171 | if isIPInCIDRList(cleanIP, i.whitelist) { 172 | return i.whitelistLimiter, true 173 | } 174 | 175 | normalizedIP := normalizeIPForRateLimit(cleanIP) 176 | 177 | now := time.Now() 178 | 179 | i.mu.RLock() 180 | entry, exists := i.ips[normalizedIP] 181 | i.mu.RUnlock() 182 | 183 | if exists { 184 | i.mu.Lock() 185 | if entry, stillExists := i.ips[normalizedIP]; stillExists { 186 | entry.lastAccess = now 187 | i.mu.Unlock() 188 | return entry.limiter, true 189 | } 190 | i.mu.Unlock() 191 | } 192 | 193 | i.mu.Lock() 194 | if entry, exists := i.ips[normalizedIP]; exists { 195 | entry.lastAccess = now 196 | i.mu.Unlock() 197 | return entry.limiter, true 198 | } 199 | 200 | entry = &rateLimiterEntry{ 201 | limiter: rate.NewLimiter(i.r, i.b), 202 | lastAccess: now, 203 | } 204 | i.ips[normalizedIP] = entry 205 | i.mu.Unlock() 206 | 207 | return entry.limiter, true 208 | } 209 | 210 | // RateLimitMiddleware 速率限制中间件 211 | func RateLimitMiddleware(limiter *IPRateLimiter) gin.HandlerFunc { 212 | return func(c *gin.Context) { 213 | path := c.Request.URL.Path 214 | if path == "/" || path == "/favicon.ico" || path == "/images.html" || path == "/search.html" || 215 | strings.HasPrefix(path, "/public/") { 216 | c.Next() 217 | return 218 | } 219 | 220 | var ip string 221 | 222 | if forwarded := c.GetHeader("X-Forwarded-For"); forwarded != "" { 223 | ips := strings.Split(forwarded, ",") 224 | ip = strings.TrimSpace(ips[0]) 225 | } else if realIP := c.GetHeader("X-Real-IP"); realIP != "" { 226 | ip = realIP 227 | } else if remoteIP := c.GetHeader("X-Original-Forwarded-For"); remoteIP != "" { 228 | ips := strings.Split(remoteIP, ",") 229 | ip = strings.TrimSpace(ips[0]) 230 | } else { 231 | ip = c.ClientIP() 232 | } 233 | 234 | cleanIP := extractIPFromAddress(ip) 235 | 236 | normalizedIP := normalizeIPForRateLimit(cleanIP) 237 | if cleanIP != normalizedIP { 238 | fmt.Printf("请求IP: %s (提纯后: %s, 限流段: %s), X-Forwarded-For: %s, X-Real-IP: %s\n", 239 | ip, cleanIP, normalizedIP, 240 | c.GetHeader("X-Forwarded-For"), 241 | c.GetHeader("X-Real-IP")) 242 | } else { 243 | fmt.Printf("请求IP: %s (提纯后: %s), X-Forwarded-For: %s, X-Real-IP: %s\n", 244 | ip, cleanIP, 245 | c.GetHeader("X-Forwarded-For"), 246 | c.GetHeader("X-Real-IP")) 247 | } 248 | 249 | ipLimiter, allowed := limiter.GetLimiter(cleanIP) 250 | 251 | if !allowed { 252 | c.JSON(403, gin.H{ 253 | "error": "您已被限制访问", 254 | }) 255 | c.Abort() 256 | return 257 | } 258 | 259 | if !ipLimiter.Allow() { 260 | c.JSON(429, gin.H{ 261 | "error": "请求频率过快,暂时限制访问", 262 | }) 263 | c.Abort() 264 | return 265 | } 266 | 267 | c.Next() 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /src/handlers/github.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "regexp" 8 | "strconv" 9 | "strings" 10 | 11 | "github.com/gin-gonic/gin" 12 | "hubproxy/config" 13 | "hubproxy/utils" 14 | ) 15 | 16 | var ( 17 | // GitHub URL匹配正则表达式 18 | githubExps = []*regexp.Regexp{ 19 | regexp.MustCompile(`^(?:https?://)?github\.com/([^/]+)/([^/]+)/(?:releases|archive)/.*`), 20 | regexp.MustCompile(`^(?:https?://)?github\.com/([^/]+)/([^/]+)/(?:blob|raw)/.*`), 21 | regexp.MustCompile(`^(?:https?://)?github\.com/([^/]+)/([^/]+)/(?:info|git-).*`), 22 | regexp.MustCompile(`^(?:https?://)?raw\.github(?:usercontent|)\.com/([^/]+)/([^/]+)/.+?/.+`), 23 | regexp.MustCompile(`^(?:https?://)?gist\.(?:githubusercontent|github)\.com/([^/]+)/([^/]+).*`), 24 | regexp.MustCompile(`^(?:https?://)?api\.github\.com/repos/([^/]+)/([^/]+)/.*`), 25 | regexp.MustCompile(`^(?:https?://)?huggingface\.co(?:/spaces)?/([^/]+)/(.+)`), 26 | regexp.MustCompile(`^(?:https?://)?cdn-lfs\.hf\.co(?:/spaces)?/([^/]+)/([^/]+)(?:/(.*))?`), 27 | regexp.MustCompile(`^(?:https?://)?download\.docker\.com/([^/]+)/.*\.(tgz|zip)`), 28 | regexp.MustCompile(`^(?:https?://)?(github|opengraph)\.githubassets\.com/([^/]+)/.+?`), 29 | } 30 | ) 31 | 32 | // 全局变量:被阻止的内容类型 33 | var blockedContentTypes = map[string]bool{ 34 | "text/html": true, 35 | "application/xhtml+xml": true, 36 | "text/xml": true, 37 | "application/xml": true, 38 | } 39 | 40 | // GitHubProxyHandler GitHub代理处理器 41 | func GitHubProxyHandler(c *gin.Context) { 42 | rawPath := strings.TrimPrefix(c.Request.URL.RequestURI(), "/") 43 | 44 | for strings.HasPrefix(rawPath, "/") { 45 | rawPath = strings.TrimPrefix(rawPath, "/") 46 | } 47 | 48 | // 自动补全协议头 49 | if !strings.HasPrefix(rawPath, "https://") { 50 | if strings.HasPrefix(rawPath, "http:/") || strings.HasPrefix(rawPath, "https:/") { 51 | rawPath = strings.Replace(rawPath, "http:/", "", 1) 52 | rawPath = strings.Replace(rawPath, "https:/", "", 1) 53 | } else if strings.HasPrefix(rawPath, "http://") { 54 | rawPath = strings.TrimPrefix(rawPath, "http://") 55 | } 56 | rawPath = "https://" + rawPath 57 | } 58 | 59 | matches := CheckGitHubURL(rawPath) 60 | if matches != nil { 61 | if allowed, reason := utils.GlobalAccessController.CheckGitHubAccess(matches); !allowed { 62 | var repoPath string 63 | if len(matches) >= 2 { 64 | username := matches[0] 65 | repoName := strings.TrimSuffix(matches[1], ".git") 66 | repoPath = username + "/" + repoName 67 | } 68 | fmt.Printf("GitHub仓库 %s 访问被拒绝: %s\n", repoPath, reason) 69 | c.String(http.StatusForbidden, reason) 70 | return 71 | } 72 | } else { 73 | c.String(http.StatusForbidden, "无效输入") 74 | return 75 | } 76 | 77 | // 将blob链接转换为raw链接 78 | if githubExps[1].MatchString(rawPath) { 79 | rawPath = strings.Replace(rawPath, "/blob/", "/raw/", 1) 80 | } 81 | 82 | ProxyGitHubRequest(c, rawPath) 83 | } 84 | 85 | // CheckGitHubURL 检查URL是否匹配GitHub模式 86 | func CheckGitHubURL(u string) []string { 87 | for _, exp := range githubExps { 88 | if matches := exp.FindStringSubmatch(u); matches != nil { 89 | return matches[1:] 90 | } 91 | } 92 | return nil 93 | } 94 | 95 | // ProxyGitHubRequest 代理GitHub请求 96 | func ProxyGitHubRequest(c *gin.Context, u string) { 97 | proxyGitHubWithRedirect(c, u, 0) 98 | } 99 | 100 | // proxyGitHubWithRedirect 带重定向的GitHub代理请求 101 | func proxyGitHubWithRedirect(c *gin.Context, u string, redirectCount int) { 102 | const maxRedirects = 20 103 | if redirectCount > maxRedirects { 104 | c.String(http.StatusLoopDetected, "重定向次数过多,可能存在循环重定向") 105 | return 106 | } 107 | 108 | req, err := http.NewRequest(c.Request.Method, u, c.Request.Body) 109 | if err != nil { 110 | c.String(http.StatusInternalServerError, fmt.Sprintf("server error %v", err)) 111 | return 112 | } 113 | 114 | // 复制请求头 115 | for key, values := range c.Request.Header { 116 | for _, value := range values { 117 | req.Header.Add(key, value) 118 | } 119 | } 120 | req.Header.Del("Host") 121 | 122 | resp, err := utils.GetGlobalHTTPClient().Do(req) 123 | if err != nil { 124 | c.String(http.StatusInternalServerError, fmt.Sprintf("server error %v", err)) 125 | return 126 | } 127 | defer func() { 128 | if err := resp.Body.Close(); err != nil { 129 | fmt.Printf("关闭响应体失败: %v\n", err) 130 | } 131 | }() 132 | 133 | // 检查并处理被阻止的内容类型 134 | if c.Request.Method == "GET" { 135 | if contentType := resp.Header.Get("Content-Type"); blockedContentTypes[strings.ToLower(strings.Split(contentType, ";")[0])] { 136 | c.JSON(http.StatusForbidden, map[string]string{ 137 | "error": "Content type not allowed", 138 | "message": "检测到网页类型,本服务不支持加速网页,请检查您的链接是否正确。", 139 | }) 140 | return 141 | } 142 | } 143 | 144 | // 检查文件大小限制 145 | cfg := config.GetConfig() 146 | if contentLength := resp.Header.Get("Content-Length"); contentLength != "" { 147 | if size, err := strconv.ParseInt(contentLength, 10, 64); err == nil && size > cfg.Server.FileSize { 148 | c.String(http.StatusRequestEntityTooLarge, 149 | fmt.Sprintf("文件过大,限制大小: %d MB", cfg.Server.FileSize/(1024*1024))) 150 | return 151 | } 152 | } 153 | 154 | // 清理安全相关的头 155 | resp.Header.Del("Content-Security-Policy") 156 | resp.Header.Del("Referrer-Policy") 157 | resp.Header.Del("Strict-Transport-Security") 158 | 159 | // 获取真实域名 160 | realHost := c.Request.Header.Get("X-Forwarded-Host") 161 | if realHost == "" { 162 | realHost = c.Request.Host 163 | } 164 | if !strings.HasPrefix(realHost, "http://") && !strings.HasPrefix(realHost, "https://") { 165 | realHost = "https://" + realHost 166 | } 167 | 168 | // 处理.sh和.ps1文件的智能处理 169 | if strings.HasSuffix(strings.ToLower(u), ".sh") || strings.HasSuffix(strings.ToLower(u), ".ps1") { 170 | isGzipCompressed := resp.Header.Get("Content-Encoding") == "gzip" 171 | 172 | processedBody, processedSize, err := utils.ProcessSmart(resp.Body, isGzipCompressed, realHost) 173 | if err != nil { 174 | fmt.Printf("智能处理失败,回退到直接代理: %v\n", err) 175 | processedBody = resp.Body 176 | processedSize = 0 177 | } 178 | 179 | // 智能设置响应头 180 | if processedSize > 0 { 181 | resp.Header.Del("Content-Length") 182 | resp.Header.Del("Content-Encoding") 183 | resp.Header.Set("Transfer-Encoding", "chunked") 184 | } 185 | 186 | // 复制其他响应头 187 | for key, values := range resp.Header { 188 | for _, value := range values { 189 | c.Header(key, value) 190 | } 191 | } 192 | 193 | // 处理重定向 194 | if location := resp.Header.Get("Location"); location != "" { 195 | if CheckGitHubURL(location) != nil { 196 | c.Header("Location", "/"+location) 197 | } else { 198 | proxyGitHubWithRedirect(c, location, redirectCount+1) 199 | return 200 | } 201 | } 202 | 203 | c.Status(resp.StatusCode) 204 | 205 | // 输出处理后的内容 206 | if _, err := io.Copy(c.Writer, processedBody); err != nil { 207 | return 208 | } 209 | } else { 210 | // 复制响应头 211 | for key, values := range resp.Header { 212 | for _, value := range values { 213 | c.Header(key, value) 214 | } 215 | } 216 | 217 | // 处理重定向 218 | if location := resp.Header.Get("Location"); location != "" { 219 | if CheckGitHubURL(location) != nil { 220 | c.Header("Location", "/"+location) 221 | } else { 222 | proxyGitHubWithRedirect(c, location, redirectCount+1) 223 | return 224 | } 225 | } 226 | 227 | c.Status(resp.StatusCode) 228 | 229 | // 直接流式转发 230 | io.Copy(c.Writer, resp.Body) 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /src/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strconv" 7 | "strings" 8 | "sync" 9 | "time" 10 | 11 | "github.com/pelletier/go-toml/v2" 12 | ) 13 | 14 | // RegistryMapping Registry映射配置 15 | type RegistryMapping struct { 16 | Upstream string `toml:"upstream"` 17 | AuthHost string `toml:"authHost"` 18 | AuthType string `toml:"authType"` 19 | Enabled bool `toml:"enabled"` 20 | } 21 | 22 | // AppConfig 应用配置结构体 23 | type AppConfig struct { 24 | Server struct { 25 | Host string `toml:"host"` 26 | Port int `toml:"port"` 27 | FileSize int64 `toml:"fileSize"` 28 | EnableH2C bool `toml:"enableH2C"` 29 | } `toml:"server"` 30 | 31 | RateLimit struct { 32 | RequestLimit int `toml:"requestLimit"` 33 | PeriodHours float64 `toml:"periodHours"` 34 | } `toml:"rateLimit"` 35 | 36 | Security struct { 37 | WhiteList []string `toml:"whiteList"` 38 | BlackList []string `toml:"blackList"` 39 | } `toml:"security"` 40 | 41 | Access struct { 42 | WhiteList []string `toml:"whiteList"` 43 | BlackList []string `toml:"blackList"` 44 | Proxy string `toml:"proxy"` 45 | } `toml:"access"` 46 | 47 | Download struct { 48 | MaxImages int `toml:"maxImages"` 49 | } `toml:"download"` 50 | 51 | Registries map[string]RegistryMapping `toml:"registries"` 52 | 53 | TokenCache struct { 54 | Enabled bool `toml:"enabled"` 55 | DefaultTTL string `toml:"defaultTTL"` 56 | } `toml:"tokenCache"` 57 | } 58 | 59 | var ( 60 | appConfig *AppConfig 61 | appConfigLock sync.RWMutex 62 | 63 | cachedConfig *AppConfig 64 | configCacheTime time.Time 65 | configCacheTTL = 5 * time.Second 66 | configCacheMutex sync.RWMutex 67 | ) 68 | 69 | // DefaultConfig 返回默认配置 70 | func DefaultConfig() *AppConfig { 71 | return &AppConfig{ 72 | Server: struct { 73 | Host string `toml:"host"` 74 | Port int `toml:"port"` 75 | FileSize int64 `toml:"fileSize"` 76 | EnableH2C bool `toml:"enableH2C"` 77 | }{ 78 | Host: "0.0.0.0", 79 | Port: 5000, 80 | FileSize: 2 * 1024 * 1024 * 1024, // 2GB 81 | EnableH2C: false, // 默认关闭H2C 82 | }, 83 | RateLimit: struct { 84 | RequestLimit int `toml:"requestLimit"` 85 | PeriodHours float64 `toml:"periodHours"` 86 | }{ 87 | RequestLimit: 500, 88 | PeriodHours: 3.0, 89 | }, 90 | Security: struct { 91 | WhiteList []string `toml:"whiteList"` 92 | BlackList []string `toml:"blackList"` 93 | }{ 94 | WhiteList: []string{}, 95 | BlackList: []string{}, 96 | }, 97 | Access: struct { 98 | WhiteList []string `toml:"whiteList"` 99 | BlackList []string `toml:"blackList"` 100 | Proxy string `toml:"proxy"` 101 | }{ 102 | WhiteList: []string{}, 103 | BlackList: []string{}, 104 | Proxy: "", 105 | }, 106 | Download: struct { 107 | MaxImages int `toml:"maxImages"` 108 | }{ 109 | MaxImages: 10, 110 | }, 111 | Registries: map[string]RegistryMapping{ 112 | "ghcr.io": { 113 | Upstream: "ghcr.io", 114 | AuthHost: "ghcr.io/token", 115 | AuthType: "github", 116 | Enabled: true, 117 | }, 118 | "gcr.io": { 119 | Upstream: "gcr.io", 120 | AuthHost: "gcr.io/v2/token", 121 | AuthType: "google", 122 | Enabled: true, 123 | }, 124 | "quay.io": { 125 | Upstream: "quay.io", 126 | AuthHost: "quay.io/v2/auth", 127 | AuthType: "quay", 128 | Enabled: true, 129 | }, 130 | "registry.k8s.io": { 131 | Upstream: "registry.k8s.io", 132 | AuthHost: "registry.k8s.io", 133 | AuthType: "anonymous", 134 | Enabled: true, 135 | }, 136 | }, 137 | TokenCache: struct { 138 | Enabled bool `toml:"enabled"` 139 | DefaultTTL string `toml:"defaultTTL"` 140 | }{ 141 | Enabled: true, 142 | DefaultTTL: "20m", 143 | }, 144 | } 145 | } 146 | 147 | // GetConfig 安全地获取配置副本 148 | func GetConfig() *AppConfig { 149 | configCacheMutex.RLock() 150 | if cachedConfig != nil && time.Since(configCacheTime) < configCacheTTL { 151 | config := cachedConfig 152 | configCacheMutex.RUnlock() 153 | return config 154 | } 155 | configCacheMutex.RUnlock() 156 | 157 | configCacheMutex.Lock() 158 | defer configCacheMutex.Unlock() 159 | 160 | if cachedConfig != nil && time.Since(configCacheTime) < configCacheTTL { 161 | return cachedConfig 162 | } 163 | 164 | appConfigLock.RLock() 165 | if appConfig == nil { 166 | appConfigLock.RUnlock() 167 | defaultCfg := DefaultConfig() 168 | cachedConfig = defaultCfg 169 | configCacheTime = time.Now() 170 | return defaultCfg 171 | } 172 | 173 | configCopy := *appConfig 174 | configCopy.Security.WhiteList = append([]string(nil), appConfig.Security.WhiteList...) 175 | configCopy.Security.BlackList = append([]string(nil), appConfig.Security.BlackList...) 176 | configCopy.Access.WhiteList = append([]string(nil), appConfig.Access.WhiteList...) 177 | configCopy.Access.BlackList = append([]string(nil), appConfig.Access.BlackList...) 178 | appConfigLock.RUnlock() 179 | 180 | cachedConfig = &configCopy 181 | configCacheTime = time.Now() 182 | 183 | return cachedConfig 184 | } 185 | 186 | // setConfig 安全地设置配置 187 | func setConfig(cfg *AppConfig) { 188 | appConfigLock.Lock() 189 | defer appConfigLock.Unlock() 190 | appConfig = cfg 191 | 192 | configCacheMutex.Lock() 193 | cachedConfig = nil 194 | configCacheMutex.Unlock() 195 | } 196 | 197 | // LoadConfig 加载配置文件 198 | func LoadConfig() error { 199 | cfg := DefaultConfig() 200 | 201 | if data, err := os.ReadFile("config.toml"); err == nil { 202 | if err := toml.Unmarshal(data, cfg); err != nil { 203 | return fmt.Errorf("解析配置文件失败: %v", err) 204 | } 205 | } else { 206 | fmt.Println("未找到config.toml,使用默认配置") 207 | } 208 | 209 | overrideFromEnv(cfg) 210 | setConfig(cfg) 211 | 212 | return nil 213 | } 214 | 215 | // overrideFromEnv 从环境变量覆盖配置 216 | func overrideFromEnv(cfg *AppConfig) { 217 | if val := os.Getenv("SERVER_HOST"); val != "" { 218 | cfg.Server.Host = val 219 | } 220 | if val := os.Getenv("SERVER_PORT"); val != "" { 221 | if port, err := strconv.Atoi(val); err == nil && port > 0 { 222 | cfg.Server.Port = port 223 | } 224 | } 225 | if val := os.Getenv("ENABLE_H2C"); val != "" { 226 | if enable, err := strconv.ParseBool(val); err == nil { 227 | cfg.Server.EnableH2C = enable 228 | } 229 | } 230 | if val := os.Getenv("MAX_FILE_SIZE"); val != "" { 231 | if size, err := strconv.ParseInt(val, 10, 64); err == nil && size > 0 { 232 | cfg.Server.FileSize = size 233 | } 234 | } 235 | 236 | if val := os.Getenv("RATE_LIMIT"); val != "" { 237 | if limit, err := strconv.Atoi(val); err == nil && limit > 0 { 238 | cfg.RateLimit.RequestLimit = limit 239 | } 240 | } 241 | if val := os.Getenv("RATE_PERIOD_HOURS"); val != "" { 242 | if period, err := strconv.ParseFloat(val, 64); err == nil && period > 0 { 243 | cfg.RateLimit.PeriodHours = period 244 | } 245 | } 246 | 247 | if val := os.Getenv("IP_WHITELIST"); val != "" { 248 | cfg.Security.WhiteList = append(cfg.Security.WhiteList, strings.Split(val, ",")...) 249 | } 250 | if val := os.Getenv("IP_BLACKLIST"); val != "" { 251 | cfg.Security.BlackList = append(cfg.Security.BlackList, strings.Split(val, ",")...) 252 | } 253 | 254 | if val := os.Getenv("MAX_IMAGES"); val != "" { 255 | if maxImages, err := strconv.Atoi(val); err == nil && maxImages > 0 { 256 | cfg.Download.MaxImages = maxImages 257 | } 258 | } 259 | } 260 | 261 | // CreateDefaultConfigFile 创建默认配置文件 262 | func CreateDefaultConfigFile() error { 263 | cfg := DefaultConfig() 264 | 265 | data, err := toml.Marshal(cfg) 266 | if err != nil { 267 | return fmt.Errorf("序列化默认配置失败: %v", err) 268 | } 269 | 270 | return os.WriteFile("config.toml", data, 0644) 271 | } 272 | -------------------------------------------------------------------------------- /src/go.sum: -------------------------------------------------------------------------------- 1 | github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= 2 | github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= 3 | github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= 4 | github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= 5 | github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= 6 | github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= 7 | github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= 8 | github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= 9 | github.com/containerd/stargz-snapshotter/estargz v0.16.3 h1:7evrXtoh1mSbGj/pfRccTampEyKpjpOnS3CyiV1Ebr8= 10 | github.com/containerd/stargz-snapshotter/estargz v0.16.3/go.mod h1:uyr4BfYfOj3G9WBVE8cOlQmXAbPN9VEQpBBeJIuOipU= 11 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 13 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 14 | github.com/docker/cli v28.2.2+incompatible h1:qzx5BNUDFqlvyq4AHzdNB7gSyVTmU4cgsyN9SdInc1A= 15 | github.com/docker/cli v28.2.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= 16 | github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= 17 | github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= 18 | github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= 19 | github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= 20 | github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= 21 | github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= 22 | github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= 23 | github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= 24 | github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= 25 | github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= 26 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 27 | github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 28 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 29 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 30 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 31 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 32 | github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= 33 | github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= 34 | github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= 35 | github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= 36 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 37 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 38 | github.com/google/go-containerregistry v0.20.6 h1:cvWX87UxxLgaH76b4hIvya6Dzz9qHB31qAwjAohdSTU= 39 | github.com/google/go-containerregistry v0.20.6/go.mod h1:T0x8MuoAoKX/873bkeSfLD2FAkwCDf9/HZgsFJ02E2Y= 40 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 41 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 42 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 43 | github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 44 | github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 45 | github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= 46 | github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= 47 | github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= 48 | github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= 49 | github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= 50 | github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 51 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 52 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 53 | github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= 54 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 55 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 56 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 57 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 58 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 59 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 60 | github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= 61 | github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= 62 | github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= 63 | github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= 64 | github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= 65 | github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 66 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 67 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 68 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 69 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 70 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 71 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 72 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 73 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 74 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 75 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 76 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 77 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 78 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 79 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 80 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 81 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 82 | github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= 83 | github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 84 | github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= 85 | github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= 86 | github.com/vbatts/tar-split v0.12.1 h1:CqKoORW7BUWBe7UL/iqTVvkTBOF8UvOMKOIZykxnnbo= 87 | github.com/vbatts/tar-split v0.12.1/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= 88 | golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= 89 | golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= 90 | golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= 91 | golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= 92 | golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= 93 | golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= 94 | golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= 95 | golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= 96 | golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 97 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 98 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 99 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 100 | golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= 101 | golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 102 | golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= 103 | golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= 104 | golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= 105 | golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 106 | google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU= 107 | google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 108 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 109 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 110 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 111 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 112 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 113 | gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0= 114 | gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8= 115 | nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= 116 | rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= 117 | -------------------------------------------------------------------------------- /src/handlers/search.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "net/url" 10 | "sort" 11 | "strings" 12 | "sync" 13 | "time" 14 | 15 | "github.com/gin-gonic/gin" 16 | "hubproxy/utils" 17 | ) 18 | 19 | // SearchResult Docker Hub搜索结果 20 | type SearchResult struct { 21 | Count int `json:"count"` 22 | Next string `json:"next"` 23 | Previous string `json:"previous"` 24 | Results []Repository `json:"results"` 25 | } 26 | 27 | // Repository 仓库信息 28 | type Repository struct { 29 | Name string `json:"repo_name"` 30 | Description string `json:"short_description"` 31 | IsOfficial bool `json:"is_official"` 32 | IsAutomated bool `json:"is_automated"` 33 | StarCount int `json:"star_count"` 34 | PullCount int `json:"pull_count"` 35 | RepoOwner string `json:"repo_owner"` 36 | LastUpdated string `json:"last_updated"` 37 | Status int `json:"status"` 38 | Organization string `json:"affiliation"` 39 | PullsLastWeek int `json:"pulls_last_week"` 40 | Namespace string `json:"namespace"` 41 | } 42 | 43 | // TagInfo 标签信息 44 | type TagInfo struct { 45 | Name string `json:"name"` 46 | FullSize int64 `json:"full_size"` 47 | LastUpdated time.Time `json:"last_updated"` 48 | LastPusher string `json:"last_pusher"` 49 | Images []Image `json:"images"` 50 | Vulnerabilities struct { 51 | Critical int `json:"critical"` 52 | High int `json:"high"` 53 | Medium int `json:"medium"` 54 | Low int `json:"low"` 55 | Unknown int `json:"unknown"` 56 | } `json:"vulnerabilities"` 57 | } 58 | 59 | // Image 镜像信息 60 | type Image struct { 61 | Architecture string `json:"architecture"` 62 | Features string `json:"features"` 63 | Variant string `json:"variant,omitempty"` 64 | Digest string `json:"digest"` 65 | OS string `json:"os"` 66 | OSFeatures string `json:"os_features"` 67 | Size int64 `json:"size"` 68 | } 69 | 70 | // TagPageResult 分页标签结果 71 | type TagPageResult struct { 72 | Tags []TagInfo `json:"tags"` 73 | HasMore bool `json:"has_more"` 74 | } 75 | 76 | type cacheEntry struct { 77 | data interface{} 78 | expiresAt time.Time 79 | } 80 | 81 | const ( 82 | maxCacheSize = 1000 83 | maxPaginationCache = 200 84 | cacheTTL = 30 * time.Minute 85 | ) 86 | 87 | type Cache struct { 88 | data map[string]cacheEntry 89 | mu sync.RWMutex 90 | maxSize int 91 | } 92 | 93 | var ( 94 | searchCache = &Cache{ 95 | data: make(map[string]cacheEntry), 96 | maxSize: maxCacheSize, 97 | } 98 | ) 99 | 100 | func (c *Cache) Get(key string) (interface{}, bool) { 101 | c.mu.RLock() 102 | entry, exists := c.data[key] 103 | c.mu.RUnlock() 104 | 105 | if !exists { 106 | return nil, false 107 | } 108 | 109 | if time.Now().After(entry.expiresAt) { 110 | c.mu.Lock() 111 | delete(c.data, key) 112 | c.mu.Unlock() 113 | return nil, false 114 | } 115 | 116 | return entry.data, true 117 | } 118 | 119 | func (c *Cache) Set(key string, data interface{}) { 120 | c.SetWithTTL(key, data, cacheTTL) 121 | } 122 | 123 | func (c *Cache) SetWithTTL(key string, data interface{}, ttl time.Duration) { 124 | c.mu.Lock() 125 | defer c.mu.Unlock() 126 | 127 | if len(c.data) >= c.maxSize { 128 | c.cleanupExpiredLocked() 129 | } 130 | 131 | c.data[key] = cacheEntry{ 132 | data: data, 133 | expiresAt: time.Now().Add(ttl), 134 | } 135 | } 136 | 137 | func (c *Cache) Cleanup() { 138 | c.mu.Lock() 139 | defer c.mu.Unlock() 140 | c.cleanupExpiredLocked() 141 | } 142 | 143 | func (c *Cache) cleanupExpiredLocked() { 144 | now := time.Now() 145 | for key, entry := range c.data { 146 | if now.After(entry.expiresAt) { 147 | delete(c.data, key) 148 | } 149 | } 150 | } 151 | 152 | func init() { 153 | go func() { 154 | ticker := time.NewTicker(5 * time.Minute) 155 | defer ticker.Stop() 156 | 157 | for range ticker.C { 158 | searchCache.Cleanup() 159 | } 160 | }() 161 | } 162 | 163 | func filterSearchResults(results []Repository, query string) []Repository { 164 | searchTerm := strings.ToLower(strings.TrimPrefix(query, "library/")) 165 | filtered := make([]Repository, 0) 166 | 167 | for _, repo := range results { 168 | repoName := strings.ToLower(repo.Name) 169 | repoDesc := strings.ToLower(repo.Description) 170 | 171 | score := 0 172 | 173 | if repoName == searchTerm { 174 | score += 100 175 | } 176 | 177 | if strings.HasPrefix(repoName, searchTerm) { 178 | score += 50 179 | } 180 | 181 | if strings.Contains(repoName, searchTerm) { 182 | score += 30 183 | } 184 | 185 | if strings.Contains(repoDesc, searchTerm) { 186 | score += 10 187 | } 188 | 189 | if repo.IsOfficial { 190 | score += 20 191 | } 192 | 193 | if score > 0 { 194 | filtered = append(filtered, repo) 195 | } 196 | } 197 | 198 | sort.Slice(filtered, func(i, j int) bool { 199 | if filtered[i].IsOfficial != filtered[j].IsOfficial { 200 | return filtered[i].IsOfficial 201 | } 202 | return filtered[i].PullCount > filtered[j].PullCount 203 | }) 204 | 205 | return filtered 206 | } 207 | 208 | // normalizeRepository 统一规范化仓库信息 209 | func normalizeRepository(repo *Repository) { 210 | if repo.IsOfficial { 211 | repo.Namespace = "library" 212 | if !strings.Contains(repo.Name, "/") { 213 | repo.Name = "library/" + repo.Name 214 | } 215 | } else { 216 | if repo.Namespace == "" && repo.RepoOwner != "" { 217 | repo.Namespace = repo.RepoOwner 218 | } 219 | 220 | if strings.Contains(repo.Name, "/") { 221 | parts := strings.Split(repo.Name, "/") 222 | if len(parts) > 1 { 223 | if repo.Namespace == "" { 224 | repo.Namespace = parts[0] 225 | } 226 | repo.Name = parts[len(parts)-1] 227 | } 228 | } 229 | } 230 | } 231 | 232 | // searchDockerHub 搜索镜像 233 | func searchDockerHub(ctx context.Context, query string, page, pageSize int) (*SearchResult, error) { 234 | return searchDockerHubWithDepth(ctx, query, page, pageSize, 0) 235 | } 236 | 237 | func searchDockerHubWithDepth(ctx context.Context, query string, page, pageSize int, depth int) (*SearchResult, error) { 238 | if depth > 1 { 239 | return nil, fmt.Errorf("搜索请求过于复杂,请尝试更具体的关键词") 240 | } 241 | cacheKey := fmt.Sprintf("search:%s:%d:%d", query, page, pageSize) 242 | 243 | if cached, ok := searchCache.Get(cacheKey); ok { 244 | return cached.(*SearchResult), nil 245 | } 246 | 247 | isUserRepo := strings.Contains(query, "/") 248 | var namespace, repoName string 249 | 250 | if isUserRepo { 251 | parts := strings.Split(query, "/") 252 | if len(parts) == 2 { 253 | namespace = parts[0] 254 | repoName = parts[1] 255 | } 256 | } 257 | 258 | baseURL := "https://registry.hub.docker.com/v2" 259 | var fullURL string 260 | var params url.Values 261 | 262 | if isUserRepo && namespace != "" { 263 | fullURL = fmt.Sprintf("%s/repositories/%s/", baseURL, namespace) 264 | params = url.Values{ 265 | "page": {fmt.Sprintf("%d", page)}, 266 | "page_size": {fmt.Sprintf("%d", pageSize)}, 267 | } 268 | } else { 269 | fullURL = baseURL + "/search/repositories/" 270 | params = url.Values{ 271 | "query": {query}, 272 | "page": {fmt.Sprintf("%d", page)}, 273 | "page_size": {fmt.Sprintf("%d", pageSize)}, 274 | } 275 | } 276 | 277 | fullURL = fullURL + "?" + params.Encode() 278 | 279 | resp, err := utils.GetSearchHTTPClient().Get(fullURL) 280 | if err != nil { 281 | return nil, fmt.Errorf("请求Docker Hub API失败: %v", err) 282 | } 283 | defer safeCloseResponseBody(resp.Body, "搜索响应体") 284 | 285 | body, err := io.ReadAll(resp.Body) 286 | if err != nil { 287 | return nil, fmt.Errorf("读取响应失败: %v", err) 288 | } 289 | 290 | if resp.StatusCode != http.StatusOK { 291 | switch resp.StatusCode { 292 | case http.StatusTooManyRequests: 293 | return nil, fmt.Errorf("请求过于频繁,请稍后重试") 294 | case http.StatusNotFound: 295 | if isUserRepo && namespace != "" { 296 | return searchDockerHubWithDepth(ctx, repoName, page, pageSize, depth+1) 297 | } 298 | return nil, fmt.Errorf("未找到相关镜像") 299 | case http.StatusBadGateway, http.StatusServiceUnavailable: 300 | return nil, fmt.Errorf("Docker Hub服务暂时不可用,请稍后重试") 301 | default: 302 | return nil, fmt.Errorf("请求失败: 状态码=%d, 响应=%s", resp.StatusCode, string(body)) 303 | } 304 | } 305 | 306 | var result *SearchResult 307 | if isUserRepo && namespace != "" { 308 | var userRepos struct { 309 | Count int `json:"count"` 310 | Next string `json:"next"` 311 | Previous string `json:"previous"` 312 | Results []Repository `json:"results"` 313 | } 314 | if err := json.Unmarshal(body, &userRepos); err != nil { 315 | return nil, fmt.Errorf("解析响应失败: %v", err) 316 | } 317 | 318 | result = &SearchResult{ 319 | Count: userRepos.Count, 320 | Next: userRepos.Next, 321 | Previous: userRepos.Previous, 322 | Results: make([]Repository, 0), 323 | } 324 | 325 | for _, repo := range userRepos.Results { 326 | if repoName == "" || strings.Contains(strings.ToLower(repo.Name), strings.ToLower(repoName)) { 327 | repo.Namespace = namespace 328 | normalizeRepository(&repo) 329 | result.Results = append(result.Results, repo) 330 | } 331 | } 332 | 333 | if len(result.Results) == 0 { 334 | return searchDockerHubWithDepth(ctx, repoName, page, pageSize, depth+1) 335 | } 336 | 337 | result.Count = len(result.Results) 338 | } else { 339 | result = &SearchResult{} 340 | if err := json.Unmarshal(body, &result); err != nil { 341 | return nil, fmt.Errorf("解析响应失败: %v", err) 342 | } 343 | 344 | for i := range result.Results { 345 | normalizeRepository(&result.Results[i]) 346 | } 347 | 348 | if isUserRepo && namespace != "" { 349 | filteredResults := make([]Repository, 0) 350 | for _, repo := range result.Results { 351 | if strings.EqualFold(repo.Namespace, namespace) { 352 | filteredResults = append(filteredResults, repo) 353 | } 354 | } 355 | result.Results = filteredResults 356 | result.Count = len(filteredResults) 357 | } 358 | } 359 | 360 | searchCache.Set(cacheKey, result) 361 | return result, nil 362 | } 363 | 364 | func isRetryableError(err error) bool { 365 | if err == nil { 366 | return false 367 | } 368 | 369 | if strings.Contains(err.Error(), "timeout") || 370 | strings.Contains(err.Error(), "connection refused") || 371 | strings.Contains(err.Error(), "no such host") || 372 | strings.Contains(err.Error(), "too many requests") { 373 | return true 374 | } 375 | 376 | return false 377 | } 378 | 379 | // getRepositoryTags 获取仓库标签信息 380 | func getRepositoryTags(ctx context.Context, namespace, name string, page, pageSize int) ([]TagInfo, bool, error) { 381 | if namespace == "" || name == "" { 382 | return nil, false, fmt.Errorf("无效输入:命名空间和名称不能为空") 383 | } 384 | 385 | if page <= 0 { 386 | page = 1 387 | } 388 | if pageSize <= 0 || pageSize > 100 { 389 | pageSize = 100 390 | } 391 | 392 | cacheKey := fmt.Sprintf("tags:%s:%s:page_%d", namespace, name, page) 393 | if cached, ok := searchCache.Get(cacheKey); ok { 394 | result := cached.(TagPageResult) 395 | return result.Tags, result.HasMore, nil 396 | } 397 | 398 | baseURL := fmt.Sprintf("https://registry.hub.docker.com/v2/repositories/%s/%s/tags", namespace, name) 399 | params := url.Values{} 400 | params.Set("page", fmt.Sprintf("%d", page)) 401 | params.Set("page_size", fmt.Sprintf("%d", pageSize)) 402 | params.Set("ordering", "last_updated") 403 | 404 | fullURL := baseURL + "?" + params.Encode() 405 | 406 | pageResult, err := fetchTagPage(ctx, fullURL, 3) 407 | if err != nil { 408 | return nil, false, fmt.Errorf("获取标签失败: %v", err) 409 | } 410 | 411 | hasMore := pageResult.Next != "" 412 | 413 | result := TagPageResult{Tags: pageResult.Results, HasMore: hasMore} 414 | searchCache.SetWithTTL(cacheKey, result, 30*time.Minute) 415 | 416 | return pageResult.Results, hasMore, nil 417 | } 418 | 419 | func fetchTagPage(ctx context.Context, url string, maxRetries int) (*struct { 420 | Count int `json:"count"` 421 | Next string `json:"next"` 422 | Previous string `json:"previous"` 423 | Results []TagInfo `json:"results"` 424 | }, error) { 425 | var lastErr error 426 | 427 | for retry := 0; retry < maxRetries; retry++ { 428 | if retry > 0 { 429 | time.Sleep(time.Duration(retry) * 500 * time.Millisecond) 430 | } 431 | 432 | resp, err := utils.GetSearchHTTPClient().Get(url) 433 | if err != nil { 434 | lastErr = err 435 | if isRetryableError(err) && retry < maxRetries-1 { 436 | continue 437 | } 438 | return nil, fmt.Errorf("发送请求失败: %v", err) 439 | } 440 | 441 | body, err := func() ([]byte, error) { 442 | defer safeCloseResponseBody(resp.Body, "标签响应体") 443 | return io.ReadAll(resp.Body) 444 | }() 445 | 446 | if err != nil { 447 | lastErr = err 448 | if retry < maxRetries-1 { 449 | continue 450 | } 451 | return nil, fmt.Errorf("读取响应失败: %v", err) 452 | } 453 | 454 | if resp.StatusCode != http.StatusOK { 455 | lastErr = fmt.Errorf("状态码=%d, 响应=%s", resp.StatusCode, string(body)) 456 | if resp.StatusCode >= 400 && resp.StatusCode < 500 && resp.StatusCode != 429 { 457 | return nil, fmt.Errorf("请求失败: %v", lastErr) 458 | } 459 | if retry < maxRetries-1 { 460 | continue 461 | } 462 | return nil, fmt.Errorf("请求失败: %v", lastErr) 463 | } 464 | 465 | var result struct { 466 | Count int `json:"count"` 467 | Next string `json:"next"` 468 | Previous string `json:"previous"` 469 | Results []TagInfo `json:"results"` 470 | } 471 | if err := json.Unmarshal(body, &result); err != nil { 472 | lastErr = err 473 | if retry < maxRetries-1 { 474 | continue 475 | } 476 | return nil, fmt.Errorf("解析响应失败: %v", err) 477 | } 478 | 479 | return &result, nil 480 | } 481 | 482 | return nil, lastErr 483 | } 484 | 485 | func parsePaginationParams(c *gin.Context, defaultPageSize int) (page, pageSize int) { 486 | page = 1 487 | pageSize = defaultPageSize 488 | 489 | if p := c.Query("page"); p != "" { 490 | fmt.Sscanf(p, "%d", &page) 491 | } 492 | if ps := c.Query("page_size"); ps != "" { 493 | fmt.Sscanf(ps, "%d", &pageSize) 494 | } 495 | 496 | return page, pageSize 497 | } 498 | 499 | func safeCloseResponseBody(body io.ReadCloser, context string) { 500 | if body != nil { 501 | if err := body.Close(); err != nil { 502 | fmt.Printf("关闭%s失败: %v\n", context, err) 503 | } 504 | } 505 | } 506 | 507 | func sendErrorResponse(c *gin.Context, message string) { 508 | c.JSON(http.StatusBadRequest, gin.H{"error": message}) 509 | } 510 | 511 | // RegisterSearchRoute 注册搜索相关路由 512 | func RegisterSearchRoute(r *gin.Engine) { 513 | r.GET("/search", func(c *gin.Context) { 514 | query := c.Query("q") 515 | if query == "" { 516 | sendErrorResponse(c, "搜索关键词不能为空") 517 | return 518 | } 519 | 520 | page, pageSize := parsePaginationParams(c, 25) 521 | 522 | result, err := searchDockerHub(c.Request.Context(), query, page, pageSize) 523 | if err != nil { 524 | sendErrorResponse(c, err.Error()) 525 | return 526 | } 527 | 528 | c.JSON(http.StatusOK, result) 529 | }) 530 | 531 | r.GET("/tags/:namespace/:name", func(c *gin.Context) { 532 | namespace := c.Param("namespace") 533 | name := c.Param("name") 534 | 535 | if namespace == "" || name == "" { 536 | sendErrorResponse(c, "命名空间和名称不能为空") 537 | return 538 | } 539 | 540 | page, pageSize := parsePaginationParams(c, 100) 541 | 542 | tags, hasMore, err := getRepositoryTags(c.Request.Context(), namespace, name, page, pageSize) 543 | if err != nil { 544 | sendErrorResponse(c, err.Error()) 545 | return 546 | } 547 | 548 | if c.Query("page") != "" || c.Query("page_size") != "" { 549 | c.JSON(http.StatusOK, gin.H{ 550 | "tags": tags, 551 | "has_more": hasMore, 552 | "page": page, 553 | "page_size": pageSize, 554 | }) 555 | } else { 556 | c.JSON(http.StatusOK, tags) 557 | } 558 | }) 559 | } 560 | -------------------------------------------------------------------------------- /src/handlers/docker.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "strings" 9 | "time" 10 | 11 | "github.com/gin-gonic/gin" 12 | "github.com/google/go-containerregistry/pkg/authn" 13 | "github.com/google/go-containerregistry/pkg/name" 14 | "github.com/google/go-containerregistry/pkg/v1/remote" 15 | "hubproxy/config" 16 | "hubproxy/utils" 17 | ) 18 | 19 | // DockerProxy Docker代理配置 20 | type DockerProxy struct { 21 | registry name.Registry 22 | options []remote.Option 23 | } 24 | 25 | var dockerProxy *DockerProxy 26 | 27 | // RegistryDetector Registry检测器 28 | type RegistryDetector struct{} 29 | 30 | // detectRegistryDomain 检测Registry域名并返回域名和剩余路径 31 | func (rd *RegistryDetector) detectRegistryDomain(path string) (string, string) { 32 | cfg := config.GetConfig() 33 | 34 | for domain := range cfg.Registries { 35 | if strings.HasPrefix(path, domain+"/") { 36 | remainingPath := strings.TrimPrefix(path, domain+"/") 37 | return domain, remainingPath 38 | } 39 | } 40 | 41 | return "", path 42 | } 43 | 44 | // isRegistryEnabled 检查Registry是否启用 45 | func (rd *RegistryDetector) isRegistryEnabled(domain string) bool { 46 | cfg := config.GetConfig() 47 | if mapping, exists := cfg.Registries[domain]; exists { 48 | return mapping.Enabled 49 | } 50 | return false 51 | } 52 | 53 | // getRegistryMapping 获取Registry映射配置 54 | func (rd *RegistryDetector) getRegistryMapping(domain string) (config.RegistryMapping, bool) { 55 | cfg := config.GetConfig() 56 | mapping, exists := cfg.Registries[domain] 57 | return mapping, exists && mapping.Enabled 58 | } 59 | 60 | var registryDetector = &RegistryDetector{} 61 | 62 | // InitDockerProxy 初始化Docker代理 63 | func InitDockerProxy() { 64 | registry, err := name.NewRegistry("registry-1.docker.io") 65 | if err != nil { 66 | fmt.Printf("创建Docker registry失败: %v\n", err) 67 | return 68 | } 69 | 70 | options := []remote.Option{ 71 | remote.WithAuth(authn.Anonymous), 72 | remote.WithUserAgent("hubproxy/go-containerregistry"), 73 | remote.WithTransport(utils.GetGlobalHTTPClient().Transport), 74 | } 75 | 76 | dockerProxy = &DockerProxy{ 77 | registry: registry, 78 | options: options, 79 | } 80 | } 81 | 82 | // ProxyDockerRegistryGin 标准Docker Registry API v2代理 83 | func ProxyDockerRegistryGin(c *gin.Context) { 84 | path := c.Request.URL.Path 85 | 86 | if path == "/v2/" { 87 | c.JSON(http.StatusOK, gin.H{}) 88 | return 89 | } 90 | 91 | if strings.HasPrefix(path, "/v2/") { 92 | handleRegistryRequest(c, path) 93 | } else { 94 | c.String(http.StatusNotFound, "Docker Registry API v2 only") 95 | } 96 | } 97 | 98 | // handleRegistryRequest 处理Registry请求 99 | func handleRegistryRequest(c *gin.Context, path string) { 100 | pathWithoutV2 := strings.TrimPrefix(path, "/v2/") 101 | 102 | if registryDomain, remainingPath := registryDetector.detectRegistryDomain(pathWithoutV2); registryDomain != "" { 103 | if registryDetector.isRegistryEnabled(registryDomain) { 104 | c.Set("target_registry_domain", registryDomain) 105 | c.Set("target_path", remainingPath) 106 | 107 | handleMultiRegistryRequest(c, registryDomain, remainingPath) 108 | return 109 | } 110 | } 111 | 112 | imageName, apiType, reference := parseRegistryPath(pathWithoutV2) 113 | if imageName == "" || apiType == "" { 114 | c.String(http.StatusBadRequest, "Invalid path format") 115 | return 116 | } 117 | 118 | if !strings.Contains(imageName, "/") { 119 | imageName = "library/" + imageName 120 | } 121 | 122 | if allowed, reason := utils.GlobalAccessController.CheckDockerAccess(imageName); !allowed { 123 | fmt.Printf("Docker镜像 %s 访问被拒绝: %s\n", imageName, reason) 124 | c.String(http.StatusForbidden, "镜像访问被限制") 125 | return 126 | } 127 | 128 | imageRef := fmt.Sprintf("%s/%s", dockerProxy.registry.Name(), imageName) 129 | 130 | switch apiType { 131 | case "manifests": 132 | handleManifestRequest(c, imageRef, reference) 133 | case "blobs": 134 | handleBlobRequest(c, imageRef, reference) 135 | case "tags": 136 | handleTagsRequest(c, imageRef) 137 | default: 138 | c.String(http.StatusNotFound, "API endpoint not found") 139 | } 140 | } 141 | 142 | // parseRegistryPath 解析Registry路径 143 | func parseRegistryPath(path string) (imageName, apiType, reference string) { 144 | if idx := strings.Index(path, "/manifests/"); idx != -1 { 145 | imageName = path[:idx] 146 | apiType = "manifests" 147 | reference = path[idx+len("/manifests/"):] 148 | return 149 | } 150 | 151 | if idx := strings.Index(path, "/blobs/"); idx != -1 { 152 | imageName = path[:idx] 153 | apiType = "blobs" 154 | reference = path[idx+len("/blobs/"):] 155 | return 156 | } 157 | 158 | if idx := strings.Index(path, "/tags/list"); idx != -1 { 159 | imageName = path[:idx] 160 | apiType = "tags" 161 | reference = "list" 162 | return 163 | } 164 | 165 | return "", "", "" 166 | } 167 | 168 | // handleManifestRequest 处理manifest请求 169 | func handleManifestRequest(c *gin.Context, imageRef, reference string) { 170 | if utils.IsCacheEnabled() && c.Request.Method == http.MethodGet { 171 | cacheKey := utils.BuildManifestCacheKey(imageRef, reference) 172 | 173 | if cachedItem := utils.GlobalCache.Get(cacheKey); cachedItem != nil { 174 | utils.WriteCachedResponse(c, cachedItem) 175 | return 176 | } 177 | } 178 | 179 | var ref name.Reference 180 | var err error 181 | 182 | if strings.HasPrefix(reference, "sha256:") { 183 | ref, err = name.NewDigest(fmt.Sprintf("%s@%s", imageRef, reference)) 184 | } else { 185 | ref, err = name.NewTag(fmt.Sprintf("%s:%s", imageRef, reference)) 186 | } 187 | 188 | if err != nil { 189 | fmt.Printf("解析镜像引用失败: %v\n", err) 190 | c.String(http.StatusBadRequest, "Invalid reference") 191 | return 192 | } 193 | 194 | if c.Request.Method == http.MethodHead { 195 | desc, err := remote.Head(ref, dockerProxy.options...) 196 | if err != nil { 197 | fmt.Printf("HEAD请求失败: %v\n", err) 198 | c.String(http.StatusNotFound, "Manifest not found") 199 | return 200 | } 201 | 202 | c.Header("Content-Type", string(desc.MediaType)) 203 | c.Header("Docker-Content-Digest", desc.Digest.String()) 204 | c.Header("Content-Length", fmt.Sprintf("%d", desc.Size)) 205 | c.Status(http.StatusOK) 206 | } else { 207 | desc, err := remote.Get(ref, dockerProxy.options...) 208 | if err != nil { 209 | fmt.Printf("GET请求失败: %v\n", err) 210 | c.String(http.StatusNotFound, "Manifest not found") 211 | return 212 | } 213 | 214 | headers := map[string]string{ 215 | "Docker-Content-Digest": desc.Digest.String(), 216 | "Content-Length": fmt.Sprintf("%d", len(desc.Manifest)), 217 | } 218 | 219 | if utils.IsCacheEnabled() { 220 | cacheKey := utils.BuildManifestCacheKey(imageRef, reference) 221 | ttl := utils.GetManifestTTL(reference) 222 | utils.GlobalCache.Set(cacheKey, desc.Manifest, string(desc.MediaType), headers, ttl) 223 | } 224 | 225 | c.Header("Content-Type", string(desc.MediaType)) 226 | for key, value := range headers { 227 | c.Header(key, value) 228 | } 229 | 230 | c.Data(http.StatusOK, string(desc.MediaType), desc.Manifest) 231 | } 232 | } 233 | 234 | // handleBlobRequest 处理blob请求 235 | func handleBlobRequest(c *gin.Context, imageRef, digest string) { 236 | digestRef, err := name.NewDigest(fmt.Sprintf("%s@%s", imageRef, digest)) 237 | if err != nil { 238 | fmt.Printf("解析digest引用失败: %v\n", err) 239 | c.String(http.StatusBadRequest, "Invalid digest reference") 240 | return 241 | } 242 | 243 | layer, err := remote.Layer(digestRef, dockerProxy.options...) 244 | if err != nil { 245 | fmt.Printf("获取layer失败: %v\n", err) 246 | c.String(http.StatusNotFound, "Layer not found") 247 | return 248 | } 249 | 250 | size, err := layer.Size() 251 | if err != nil { 252 | fmt.Printf("获取layer大小失败: %v\n", err) 253 | c.String(http.StatusInternalServerError, "Failed to get layer size") 254 | return 255 | } 256 | 257 | reader, err := layer.Compressed() 258 | if err != nil { 259 | fmt.Printf("获取layer内容失败: %v\n", err) 260 | c.String(http.StatusInternalServerError, "Failed to get layer content") 261 | return 262 | } 263 | defer reader.Close() 264 | 265 | c.Header("Content-Type", "application/octet-stream") 266 | c.Header("Content-Length", fmt.Sprintf("%d", size)) 267 | c.Header("Docker-Content-Digest", digest) 268 | 269 | c.Status(http.StatusOK) 270 | io.Copy(c.Writer, reader) 271 | } 272 | 273 | // handleTagsRequest 处理tags列表请求 274 | func handleTagsRequest(c *gin.Context, imageRef string) { 275 | repo, err := name.NewRepository(imageRef) 276 | if err != nil { 277 | fmt.Printf("解析repository失败: %v\n", err) 278 | c.String(http.StatusBadRequest, "Invalid repository") 279 | return 280 | } 281 | 282 | tags, err := remote.List(repo, dockerProxy.options...) 283 | if err != nil { 284 | fmt.Printf("获取tags失败: %v\n", err) 285 | c.String(http.StatusNotFound, "Tags not found") 286 | return 287 | } 288 | 289 | response := map[string]interface{}{ 290 | "name": strings.TrimPrefix(imageRef, dockerProxy.registry.Name()+"/"), 291 | "tags": tags, 292 | } 293 | 294 | c.JSON(http.StatusOK, response) 295 | } 296 | 297 | // ProxyDockerAuthGin Docker认证代理 298 | func ProxyDockerAuthGin(c *gin.Context) { 299 | if utils.IsTokenCacheEnabled() { 300 | proxyDockerAuthWithCache(c) 301 | } else { 302 | proxyDockerAuthOriginal(c) 303 | } 304 | } 305 | 306 | // proxyDockerAuthWithCache 带缓存的认证代理 307 | func proxyDockerAuthWithCache(c *gin.Context) { 308 | cacheKey := utils.BuildTokenCacheKey(c.Request.URL.RawQuery) 309 | 310 | if cachedToken := utils.GlobalCache.GetToken(cacheKey); cachedToken != "" { 311 | utils.WriteTokenResponse(c, cachedToken) 312 | return 313 | } 314 | 315 | recorder := &ResponseRecorder{ 316 | ResponseWriter: c.Writer, 317 | statusCode: 200, 318 | } 319 | c.Writer = recorder 320 | 321 | proxyDockerAuthOriginal(c) 322 | 323 | if recorder.statusCode == 200 && len(recorder.body) > 0 { 324 | ttl := utils.ExtractTTLFromResponse(recorder.body) 325 | utils.GlobalCache.SetToken(cacheKey, string(recorder.body), ttl) 326 | } 327 | 328 | c.Writer = recorder.ResponseWriter 329 | c.Data(recorder.statusCode, "application/json", recorder.body) 330 | } 331 | 332 | // ResponseRecorder HTTP响应记录器 333 | type ResponseRecorder struct { 334 | gin.ResponseWriter 335 | statusCode int 336 | body []byte 337 | } 338 | 339 | func (r *ResponseRecorder) WriteHeader(code int) { 340 | r.statusCode = code 341 | } 342 | 343 | func (r *ResponseRecorder) Write(data []byte) (int, error) { 344 | r.body = append(r.body, data...) 345 | return len(data), nil 346 | } 347 | 348 | func proxyDockerAuthOriginal(c *gin.Context) { 349 | var authURL string 350 | if targetDomain, exists := c.Get("target_registry_domain"); exists { 351 | if mapping, found := registryDetector.getRegistryMapping(targetDomain.(string)); found { 352 | authURL = "https://" + mapping.AuthHost + c.Request.URL.Path 353 | } else { 354 | authURL = "https://auth.docker.io" + c.Request.URL.Path 355 | } 356 | } else { 357 | authURL = "https://auth.docker.io" + c.Request.URL.Path 358 | } 359 | 360 | if c.Request.URL.RawQuery != "" { 361 | authURL += "?" + c.Request.URL.RawQuery 362 | } 363 | 364 | client := &http.Client{ 365 | Timeout: 30 * time.Second, 366 | Transport: utils.GetGlobalHTTPClient().Transport, 367 | } 368 | 369 | req, err := http.NewRequestWithContext( 370 | context.Background(), 371 | c.Request.Method, 372 | authURL, 373 | c.Request.Body, 374 | ) 375 | if err != nil { 376 | c.String(http.StatusInternalServerError, "Failed to create request") 377 | return 378 | } 379 | 380 | for key, values := range c.Request.Header { 381 | for _, value := range values { 382 | req.Header.Add(key, value) 383 | } 384 | } 385 | 386 | resp, err := client.Do(req) 387 | if err != nil { 388 | c.String(http.StatusBadGateway, "Auth request failed") 389 | return 390 | } 391 | defer resp.Body.Close() 392 | 393 | proxyHost := c.Request.Host 394 | if proxyHost == "" { 395 | cfg := config.GetConfig() 396 | proxyHost = fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port) 397 | if cfg.Server.Host == "0.0.0.0" { 398 | proxyHost = fmt.Sprintf("localhost:%d", cfg.Server.Port) 399 | } 400 | } 401 | 402 | for key, values := range resp.Header { 403 | for _, value := range values { 404 | if key == "Www-Authenticate" { 405 | value = rewriteAuthHeader(value, proxyHost) 406 | } 407 | c.Header(key, value) 408 | } 409 | } 410 | 411 | c.Status(resp.StatusCode) 412 | io.Copy(c.Writer, resp.Body) 413 | } 414 | 415 | // rewriteAuthHeader 重写认证头 416 | func rewriteAuthHeader(authHeader, proxyHost string) string { 417 | authHeader = strings.ReplaceAll(authHeader, "https://auth.docker.io", "http://"+proxyHost) 418 | authHeader = strings.ReplaceAll(authHeader, "https://ghcr.io", "http://"+proxyHost) 419 | authHeader = strings.ReplaceAll(authHeader, "https://gcr.io", "http://"+proxyHost) 420 | authHeader = strings.ReplaceAll(authHeader, "https://quay.io", "http://"+proxyHost) 421 | 422 | return authHeader 423 | } 424 | 425 | // handleMultiRegistryRequest 处理多Registry请求 426 | func handleMultiRegistryRequest(c *gin.Context, registryDomain, remainingPath string) { 427 | mapping, exists := registryDetector.getRegistryMapping(registryDomain) 428 | if !exists { 429 | c.String(http.StatusBadRequest, "Registry not configured") 430 | return 431 | } 432 | 433 | imageName, apiType, reference := parseRegistryPath(remainingPath) 434 | if imageName == "" || apiType == "" { 435 | c.String(http.StatusBadRequest, "Invalid path format") 436 | return 437 | } 438 | 439 | fullImageName := registryDomain + "/" + imageName 440 | if allowed, reason := utils.GlobalAccessController.CheckDockerAccess(fullImageName); !allowed { 441 | fmt.Printf("镜像 %s 访问被拒绝: %s\n", fullImageName, reason) 442 | c.String(http.StatusForbidden, "镜像访问被限制") 443 | return 444 | } 445 | 446 | upstreamImageRef := fmt.Sprintf("%s/%s", mapping.Upstream, imageName) 447 | 448 | switch apiType { 449 | case "manifests": 450 | handleUpstreamManifestRequest(c, upstreamImageRef, reference, mapping) 451 | case "blobs": 452 | handleUpstreamBlobRequest(c, upstreamImageRef, reference, mapping) 453 | case "tags": 454 | handleUpstreamTagsRequest(c, upstreamImageRef, mapping) 455 | default: 456 | c.String(http.StatusNotFound, "API endpoint not found") 457 | } 458 | } 459 | 460 | // handleUpstreamManifestRequest 处理上游Registry的manifest请求 461 | func handleUpstreamManifestRequest(c *gin.Context, imageRef, reference string, mapping config.RegistryMapping) { 462 | if utils.IsCacheEnabled() && c.Request.Method == http.MethodGet { 463 | cacheKey := utils.BuildManifestCacheKey(imageRef, reference) 464 | 465 | if cachedItem := utils.GlobalCache.Get(cacheKey); cachedItem != nil { 466 | utils.WriteCachedResponse(c, cachedItem) 467 | return 468 | } 469 | } 470 | 471 | var ref name.Reference 472 | var err error 473 | 474 | if strings.HasPrefix(reference, "sha256:") { 475 | ref, err = name.NewDigest(fmt.Sprintf("%s@%s", imageRef, reference)) 476 | } else { 477 | ref, err = name.NewTag(fmt.Sprintf("%s:%s", imageRef, reference)) 478 | } 479 | 480 | if err != nil { 481 | fmt.Printf("解析镜像引用失败: %v\n", err) 482 | c.String(http.StatusBadRequest, "Invalid reference") 483 | return 484 | } 485 | 486 | options := createUpstreamOptions(mapping) 487 | 488 | if c.Request.Method == http.MethodHead { 489 | desc, err := remote.Head(ref, options...) 490 | if err != nil { 491 | fmt.Printf("HEAD请求失败: %v\n", err) 492 | c.String(http.StatusNotFound, "Manifest not found") 493 | return 494 | } 495 | 496 | c.Header("Content-Type", string(desc.MediaType)) 497 | c.Header("Docker-Content-Digest", desc.Digest.String()) 498 | c.Header("Content-Length", fmt.Sprintf("%d", desc.Size)) 499 | c.Status(http.StatusOK) 500 | } else { 501 | desc, err := remote.Get(ref, options...) 502 | if err != nil { 503 | fmt.Printf("GET请求失败: %v\n", err) 504 | c.String(http.StatusNotFound, "Manifest not found") 505 | return 506 | } 507 | 508 | headers := map[string]string{ 509 | "Docker-Content-Digest": desc.Digest.String(), 510 | "Content-Length": fmt.Sprintf("%d", len(desc.Manifest)), 511 | } 512 | 513 | if utils.IsCacheEnabled() { 514 | cacheKey := utils.BuildManifestCacheKey(imageRef, reference) 515 | ttl := utils.GetManifestTTL(reference) 516 | utils.GlobalCache.Set(cacheKey, desc.Manifest, string(desc.MediaType), headers, ttl) 517 | } 518 | 519 | c.Header("Content-Type", string(desc.MediaType)) 520 | for key, value := range headers { 521 | c.Header(key, value) 522 | } 523 | 524 | c.Data(http.StatusOK, string(desc.MediaType), desc.Manifest) 525 | } 526 | } 527 | 528 | // handleUpstreamBlobRequest 处理上游Registry的blob请求 529 | func handleUpstreamBlobRequest(c *gin.Context, imageRef, digest string, mapping config.RegistryMapping) { 530 | digestRef, err := name.NewDigest(fmt.Sprintf("%s@%s", imageRef, digest)) 531 | if err != nil { 532 | fmt.Printf("解析digest引用失败: %v\n", err) 533 | c.String(http.StatusBadRequest, "Invalid digest reference") 534 | return 535 | } 536 | 537 | options := createUpstreamOptions(mapping) 538 | layer, err := remote.Layer(digestRef, options...) 539 | if err != nil { 540 | fmt.Printf("获取layer失败: %v\n", err) 541 | c.String(http.StatusNotFound, "Layer not found") 542 | return 543 | } 544 | 545 | size, err := layer.Size() 546 | if err != nil { 547 | fmt.Printf("获取layer大小失败: %v\n", err) 548 | c.String(http.StatusInternalServerError, "Failed to get layer size") 549 | return 550 | } 551 | 552 | reader, err := layer.Compressed() 553 | if err != nil { 554 | fmt.Printf("获取layer内容失败: %v\n", err) 555 | c.String(http.StatusInternalServerError, "Failed to get layer content") 556 | return 557 | } 558 | defer reader.Close() 559 | 560 | c.Header("Content-Type", "application/octet-stream") 561 | c.Header("Content-Length", fmt.Sprintf("%d", size)) 562 | c.Header("Docker-Content-Digest", digest) 563 | 564 | c.Status(http.StatusOK) 565 | io.Copy(c.Writer, reader) 566 | } 567 | 568 | // handleUpstreamTagsRequest 处理上游Registry的tags请求 569 | func handleUpstreamTagsRequest(c *gin.Context, imageRef string, mapping config.RegistryMapping) { 570 | repo, err := name.NewRepository(imageRef) 571 | if err != nil { 572 | fmt.Printf("解析repository失败: %v\n", err) 573 | c.String(http.StatusBadRequest, "Invalid repository") 574 | return 575 | } 576 | 577 | options := createUpstreamOptions(mapping) 578 | tags, err := remote.List(repo, options...) 579 | if err != nil { 580 | fmt.Printf("获取tags失败: %v\n", err) 581 | c.String(http.StatusNotFound, "Tags not found") 582 | return 583 | } 584 | 585 | response := map[string]interface{}{ 586 | "name": strings.TrimPrefix(imageRef, mapping.Upstream+"/"), 587 | "tags": tags, 588 | } 589 | 590 | c.JSON(http.StatusOK, response) 591 | } 592 | 593 | // createUpstreamOptions 创建上游Registry选项 594 | func createUpstreamOptions(mapping config.RegistryMapping) []remote.Option { 595 | options := []remote.Option{ 596 | remote.WithAuth(authn.Anonymous), 597 | remote.WithUserAgent("hubproxy/go-containerregistry"), 598 | remote.WithTransport(utils.GetGlobalHTTPClient().Transport), 599 | } 600 | 601 | // 预留将来不同Registry的差异化认证逻辑扩展点 602 | switch mapping.AuthType { 603 | case "github": 604 | case "google": 605 | case "quay": 606 | } 607 | 608 | return options 609 | } 610 | -------------------------------------------------------------------------------- /src/handlers/imagetar.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "archive/tar" 5 | "compress/gzip" 6 | "context" 7 | "crypto/md5" 8 | "encoding/hex" 9 | "encoding/json" 10 | "fmt" 11 | "io" 12 | "log" 13 | "net/http" 14 | "sort" 15 | "strings" 16 | "sync" 17 | "time" 18 | 19 | "github.com/gin-gonic/gin" 20 | "github.com/google/go-containerregistry/pkg/authn" 21 | "github.com/google/go-containerregistry/pkg/name" 22 | "github.com/google/go-containerregistry/pkg/v1" 23 | "github.com/google/go-containerregistry/pkg/v1/partial" 24 | "github.com/google/go-containerregistry/pkg/v1/remote" 25 | "github.com/google/go-containerregistry/pkg/v1/types" 26 | "hubproxy/config" 27 | "hubproxy/utils" 28 | ) 29 | 30 | // DebounceEntry 防抖条目 31 | type DebounceEntry struct { 32 | LastRequest time.Time 33 | UserID string 34 | } 35 | 36 | // DownloadDebouncer 下载防抖器 37 | type DownloadDebouncer struct { 38 | mu sync.RWMutex 39 | entries map[string]*DebounceEntry 40 | window time.Duration 41 | lastCleanup time.Time 42 | } 43 | 44 | // NewDownloadDebouncer 创建下载防抖器 45 | func NewDownloadDebouncer(window time.Duration) *DownloadDebouncer { 46 | return &DownloadDebouncer{ 47 | entries: make(map[string]*DebounceEntry), 48 | window: window, 49 | lastCleanup: time.Now(), 50 | } 51 | } 52 | 53 | // ShouldAllow 检查是否应该允许请求 54 | func (d *DownloadDebouncer) ShouldAllow(userID, contentKey string) bool { 55 | d.mu.Lock() 56 | defer d.mu.Unlock() 57 | 58 | key := userID + ":" + contentKey 59 | now := time.Now() 60 | 61 | if entry, exists := d.entries[key]; exists { 62 | if now.Sub(entry.LastRequest) < d.window { 63 | return false 64 | } 65 | } 66 | 67 | d.entries[key] = &DebounceEntry{ 68 | LastRequest: now, 69 | UserID: userID, 70 | } 71 | 72 | if time.Since(d.lastCleanup) > 5*time.Minute { 73 | d.cleanup(now) 74 | d.lastCleanup = now 75 | } 76 | 77 | return true 78 | } 79 | 80 | // cleanup 清理过期条目 81 | func (d *DownloadDebouncer) cleanup(now time.Time) { 82 | for key, entry := range d.entries { 83 | if now.Sub(entry.LastRequest) > d.window*2 { 84 | delete(d.entries, key) 85 | } 86 | } 87 | } 88 | 89 | // generateContentFingerprint 生成内容指纹 90 | func generateContentFingerprint(images []string, platform string) string { 91 | sortedImages := make([]string, len(images)) 92 | copy(sortedImages, images) 93 | sort.Strings(sortedImages) 94 | 95 | content := strings.Join(sortedImages, "|") + ":" + platform 96 | 97 | hash := md5.Sum([]byte(content)) 98 | return hex.EncodeToString(hash[:]) 99 | } 100 | 101 | // getUserID 获取用户标识 102 | func getUserID(c *gin.Context) string { 103 | if sessionID, err := c.Cookie("session_id"); err == nil && sessionID != "" { 104 | return "session:" + sessionID 105 | } 106 | 107 | ip := c.ClientIP() 108 | userAgent := c.GetHeader("User-Agent") 109 | if userAgent == "" { 110 | userAgent = "unknown" 111 | } 112 | 113 | combined := ip + ":" + userAgent 114 | hash := md5.Sum([]byte(combined)) 115 | return "ip:" + hex.EncodeToString(hash[:8]) 116 | } 117 | 118 | var ( 119 | singleImageDebouncer *DownloadDebouncer 120 | batchImageDebouncer *DownloadDebouncer 121 | ) 122 | 123 | // InitDebouncer 初始化防抖器 124 | func InitDebouncer() { 125 | singleImageDebouncer = NewDownloadDebouncer(5 * time.Second) 126 | batchImageDebouncer = NewDownloadDebouncer(60 * time.Second) 127 | } 128 | 129 | // ImageStreamer 镜像流式下载器 130 | type ImageStreamer struct { 131 | concurrency int 132 | remoteOptions []remote.Option 133 | } 134 | 135 | // ImageStreamerConfig 下载器配置 136 | type ImageStreamerConfig struct { 137 | Concurrency int 138 | } 139 | 140 | // NewImageStreamer 创建镜像下载器 141 | func NewImageStreamer(cfg *ImageStreamerConfig) *ImageStreamer { 142 | if cfg == nil { 143 | cfg = &ImageStreamerConfig{} 144 | } 145 | 146 | concurrency := cfg.Concurrency 147 | if concurrency <= 0 { 148 | appCfg := config.GetConfig() 149 | concurrency = appCfg.Download.MaxImages 150 | if concurrency <= 0 { 151 | concurrency = 10 152 | } 153 | } 154 | 155 | remoteOptions := []remote.Option{ 156 | remote.WithAuth(authn.Anonymous), 157 | remote.WithTransport(utils.GetGlobalHTTPClient().Transport), 158 | } 159 | 160 | return &ImageStreamer{ 161 | concurrency: concurrency, 162 | remoteOptions: remoteOptions, 163 | } 164 | } 165 | 166 | // StreamOptions 下载选项 167 | type StreamOptions struct { 168 | Platform string 169 | Compression bool 170 | UseCompressedLayers bool 171 | } 172 | 173 | // StreamImageToWriter 流式下载镜像到Writer 174 | func (is *ImageStreamer) StreamImageToWriter(ctx context.Context, imageRef string, writer io.Writer, options *StreamOptions) error { 175 | if options == nil { 176 | options = &StreamOptions{UseCompressedLayers: true} 177 | } 178 | 179 | ref, err := name.ParseReference(imageRef) 180 | if err != nil { 181 | return fmt.Errorf("解析镜像引用失败: %w", err) 182 | } 183 | 184 | log.Printf("开始下载镜像: %s", ref.String()) 185 | 186 | contextOptions := append(is.remoteOptions, remote.WithContext(ctx)) 187 | 188 | desc, err := is.getImageDescriptorWithPlatform(ref, contextOptions, options.Platform) 189 | if err != nil { 190 | return fmt.Errorf("获取镜像描述失败: %w", err) 191 | } 192 | switch desc.MediaType { 193 | case types.OCIImageIndex, types.DockerManifestList: 194 | return is.streamMultiArchImage(ctx, desc, writer, options, contextOptions, imageRef) 195 | case types.OCIManifestSchema1, types.DockerManifestSchema2: 196 | return is.streamSingleImage(ctx, desc, writer, options, contextOptions, imageRef) 197 | default: 198 | return is.streamSingleImage(ctx, desc, writer, options, contextOptions, imageRef) 199 | } 200 | } 201 | 202 | // getImageDescriptor 获取镜像描述符 203 | func (is *ImageStreamer) getImageDescriptor(ref name.Reference, options []remote.Option) (*remote.Descriptor, error) { 204 | return is.getImageDescriptorWithPlatform(ref, options, "") 205 | } 206 | 207 | // getImageDescriptorWithPlatform 获取指定平台的镜像描述符 208 | func (is *ImageStreamer) getImageDescriptorWithPlatform(ref name.Reference, options []remote.Option, platform string) (*remote.Descriptor, error) { 209 | return remote.Get(ref, options...) 210 | } 211 | 212 | // StreamImageToGin 流式响应到Gin 213 | func (is *ImageStreamer) StreamImageToGin(ctx context.Context, imageRef string, c *gin.Context, options *StreamOptions) error { 214 | if options == nil { 215 | options = &StreamOptions{UseCompressedLayers: true} 216 | } 217 | 218 | filename := strings.ReplaceAll(imageRef, "/", "_") + ".tar" 219 | c.Header("Content-Type", "application/octet-stream") 220 | c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) 221 | 222 | if options.Compression { 223 | c.Header("Content-Encoding", "gzip") 224 | } 225 | 226 | return is.StreamImageToWriter(ctx, imageRef, c.Writer, options) 227 | } 228 | 229 | // streamMultiArchImage 处理多架构镜像 230 | func (is *ImageStreamer) streamMultiArchImage(ctx context.Context, desc *remote.Descriptor, writer io.Writer, options *StreamOptions, remoteOptions []remote.Option, imageRef string) error { 231 | img, err := is.selectPlatformImage(desc, options) 232 | if err != nil { 233 | return err 234 | } 235 | 236 | return is.streamImageLayers(ctx, img, writer, options, imageRef) 237 | } 238 | 239 | // streamSingleImage 处理单架构镜像 240 | func (is *ImageStreamer) streamSingleImage(ctx context.Context, desc *remote.Descriptor, writer io.Writer, options *StreamOptions, remoteOptions []remote.Option, imageRef string) error { 241 | img, err := desc.Image() 242 | if err != nil { 243 | return fmt.Errorf("获取镜像失败: %w", err) 244 | } 245 | 246 | return is.streamImageLayers(ctx, img, writer, options, imageRef) 247 | } 248 | 249 | // streamImageLayers 处理镜像层 250 | func (is *ImageStreamer) streamImageLayers(ctx context.Context, img v1.Image, writer io.Writer, options *StreamOptions, imageRef string) error { 251 | var finalWriter io.Writer = writer 252 | 253 | if options.Compression { 254 | gzWriter := gzip.NewWriter(writer) 255 | defer gzWriter.Close() 256 | finalWriter = gzWriter 257 | } 258 | 259 | tarWriter := tar.NewWriter(finalWriter) 260 | defer tarWriter.Close() 261 | 262 | configFile, err := img.ConfigFile() 263 | if err != nil { 264 | return fmt.Errorf("获取镜像配置失败: %w", err) 265 | } 266 | 267 | layers, err := img.Layers() 268 | if err != nil { 269 | return fmt.Errorf("获取镜像层失败: %w", err) 270 | } 271 | 272 | log.Printf("镜像包含 %d 层", len(layers)) 273 | 274 | return is.streamDockerFormat(ctx, tarWriter, img, layers, configFile, imageRef, options) 275 | } 276 | 277 | // streamDockerFormat 生成Docker格式 278 | func (is *ImageStreamer) streamDockerFormat(ctx context.Context, tarWriter *tar.Writer, img v1.Image, layers []v1.Layer, configFile *v1.ConfigFile, imageRef string, options *StreamOptions) error { 279 | return is.streamDockerFormatWithReturn(ctx, tarWriter, img, layers, configFile, imageRef, nil, nil, options) 280 | } 281 | 282 | // streamDockerFormatWithReturn 生成Docker格式并返回manifest和repositories信息 283 | func (is *ImageStreamer) streamDockerFormatWithReturn(ctx context.Context, tarWriter *tar.Writer, img v1.Image, layers []v1.Layer, configFile *v1.ConfigFile, imageRef string, manifestOut *map[string]interface{}, repositoriesOut *map[string]map[string]string, options *StreamOptions) error { 284 | configDigest, err := img.ConfigName() 285 | if err != nil { 286 | return err 287 | } 288 | 289 | configData, err := json.Marshal(configFile) 290 | if err != nil { 291 | return err 292 | } 293 | 294 | configHeader := &tar.Header{ 295 | Name: configDigest.String() + ".json", 296 | Size: int64(len(configData)), 297 | Mode: 0644, 298 | } 299 | 300 | if err := tarWriter.WriteHeader(configHeader); err != nil { 301 | return err 302 | } 303 | if _, err := tarWriter.Write(configData); err != nil { 304 | return err 305 | } 306 | 307 | layerDigests := make([]string, len(layers)) 308 | for i, layer := range layers { 309 | select { 310 | case <-ctx.Done(): 311 | return ctx.Err() 312 | default: 313 | } 314 | 315 | if err := func() error { 316 | digest, err := layer.Digest() 317 | if err != nil { 318 | return err 319 | } 320 | layerDigests[i] = digest.String() 321 | 322 | layerDir := digest.String() 323 | layerHeader := &tar.Header{ 324 | Name: layerDir + "/", 325 | Typeflag: tar.TypeDir, 326 | Mode: 0755, 327 | } 328 | 329 | if err := tarWriter.WriteHeader(layerHeader); err != nil { 330 | return err 331 | } 332 | 333 | var layerSize int64 334 | var layerReader io.ReadCloser 335 | 336 | if options != nil && options.UseCompressedLayers { 337 | layerSize, err = layer.Size() 338 | if err != nil { 339 | return err 340 | } 341 | layerReader, err = layer.Compressed() 342 | } else { 343 | layerSize, err = partial.UncompressedSize(layer) 344 | if err != nil { 345 | return err 346 | } 347 | layerReader, err = layer.Uncompressed() 348 | } 349 | 350 | if err != nil { 351 | return err 352 | } 353 | defer layerReader.Close() 354 | 355 | layerTarHeader := &tar.Header{ 356 | Name: layerDir + "/layer.tar", 357 | Size: layerSize, 358 | Mode: 0644, 359 | } 360 | 361 | if err := tarWriter.WriteHeader(layerTarHeader); err != nil { 362 | return err 363 | } 364 | 365 | if _, err := io.Copy(tarWriter, layerReader); err != nil { 366 | return err 367 | } 368 | 369 | return nil 370 | }(); err != nil { 371 | return err 372 | } 373 | 374 | log.Printf("已处理层 %d/%d", i+1, len(layers)) 375 | } 376 | 377 | singleManifest := map[string]interface{}{ 378 | "Config": configDigest.String() + ".json", 379 | "RepoTags": []string{imageRef}, 380 | "Layers": func() []string { 381 | var layers []string 382 | for _, digest := range layerDigests { 383 | layers = append(layers, digest+"/layer.tar") 384 | } 385 | return layers 386 | }(), 387 | } 388 | 389 | repositories := make(map[string]map[string]string) 390 | parts := strings.Split(imageRef, ":") 391 | if len(parts) == 2 { 392 | repoName := parts[0] 393 | tag := parts[1] 394 | repositories[repoName] = map[string]string{tag: configDigest.String()} 395 | } 396 | 397 | if manifestOut != nil && repositoriesOut != nil { 398 | *manifestOut = singleManifest 399 | *repositoriesOut = repositories 400 | return nil 401 | } 402 | 403 | manifest := []map[string]interface{}{singleManifest} 404 | 405 | manifestData, err := json.Marshal(manifest) 406 | if err != nil { 407 | return err 408 | } 409 | 410 | manifestHeader := &tar.Header{ 411 | Name: "manifest.json", 412 | Size: int64(len(manifestData)), 413 | Mode: 0644, 414 | } 415 | 416 | if err := tarWriter.WriteHeader(manifestHeader); err != nil { 417 | return err 418 | } 419 | 420 | if _, err := tarWriter.Write(manifestData); err != nil { 421 | return err 422 | } 423 | 424 | repositoriesData, err := json.Marshal(repositories) 425 | if err != nil { 426 | return err 427 | } 428 | 429 | repositoriesHeader := &tar.Header{ 430 | Name: "repositories", 431 | Size: int64(len(repositoriesData)), 432 | Mode: 0644, 433 | } 434 | 435 | if err := tarWriter.WriteHeader(repositoriesHeader); err != nil { 436 | return err 437 | } 438 | 439 | _, err = tarWriter.Write(repositoriesData) 440 | return err 441 | } 442 | 443 | // processImageForBatch 处理镜像的公共逻辑 444 | func (is *ImageStreamer) processImageForBatch(ctx context.Context, img v1.Image, tarWriter *tar.Writer, imageRef string, options *StreamOptions) (map[string]interface{}, map[string]map[string]string, error) { 445 | layers, err := img.Layers() 446 | if err != nil { 447 | return nil, nil, fmt.Errorf("获取镜像层失败: %w", err) 448 | } 449 | 450 | configFile, err := img.ConfigFile() 451 | if err != nil { 452 | return nil, nil, fmt.Errorf("获取镜像配置失败: %w", err) 453 | } 454 | 455 | log.Printf("镜像包含 %d 层", len(layers)) 456 | 457 | var manifest map[string]interface{} 458 | var repositories map[string]map[string]string 459 | 460 | err = is.streamDockerFormatWithReturn(ctx, tarWriter, img, layers, configFile, imageRef, &manifest, &repositories, options) 461 | if err != nil { 462 | return nil, nil, err 463 | } 464 | 465 | return manifest, repositories, nil 466 | } 467 | 468 | func (is *ImageStreamer) streamSingleImageForBatch(ctx context.Context, tarWriter *tar.Writer, imageRef string, options *StreamOptions) (map[string]interface{}, map[string]map[string]string, error) { 469 | ref, err := name.ParseReference(imageRef) 470 | if err != nil { 471 | return nil, nil, fmt.Errorf("解析镜像引用失败: %w", err) 472 | } 473 | 474 | contextOptions := append(is.remoteOptions, remote.WithContext(ctx)) 475 | 476 | desc, err := is.getImageDescriptorWithPlatform(ref, contextOptions, options.Platform) 477 | if err != nil { 478 | return nil, nil, fmt.Errorf("获取镜像描述失败: %w", err) 479 | } 480 | 481 | var img v1.Image 482 | 483 | switch desc.MediaType { 484 | case types.OCIImageIndex, types.DockerManifestList: 485 | img, err = is.selectPlatformImage(desc, options) 486 | if err != nil { 487 | return nil, nil, fmt.Errorf("选择平台镜像失败: %w", err) 488 | } 489 | case types.OCIManifestSchema1, types.DockerManifestSchema2: 490 | img, err = desc.Image() 491 | if err != nil { 492 | return nil, nil, fmt.Errorf("获取镜像失败: %w", err) 493 | } 494 | default: 495 | img, err = desc.Image() 496 | if err != nil { 497 | return nil, nil, fmt.Errorf("获取镜像失败: %w", err) 498 | } 499 | } 500 | 501 | return is.processImageForBatch(ctx, img, tarWriter, imageRef, options) 502 | } 503 | 504 | // selectPlatformImage 从多架构镜像中选择合适的平台镜像 505 | func (is *ImageStreamer) selectPlatformImage(desc *remote.Descriptor, options *StreamOptions) (v1.Image, error) { 506 | index, err := desc.ImageIndex() 507 | if err != nil { 508 | return nil, fmt.Errorf("获取镜像索引失败: %w", err) 509 | } 510 | 511 | manifest, err := index.IndexManifest() 512 | if err != nil { 513 | return nil, fmt.Errorf("获取索引清单失败: %w", err) 514 | } 515 | 516 | var selectedDesc *v1.Descriptor 517 | for _, m := range manifest.Manifests { 518 | if m.Platform == nil { 519 | continue 520 | } 521 | 522 | if options.Platform != "" { 523 | platformParts := strings.Split(options.Platform, "/") 524 | if len(platformParts) >= 2 { 525 | targetOS := platformParts[0] 526 | targetArch := platformParts[1] 527 | targetVariant := "" 528 | if len(platformParts) >= 3 { 529 | targetVariant = platformParts[2] 530 | } 531 | 532 | if m.Platform.OS == targetOS && 533 | m.Platform.Architecture == targetArch && 534 | m.Platform.Variant == targetVariant { 535 | selectedDesc = &m 536 | break 537 | } 538 | } 539 | } else if m.Platform.OS == "linux" && m.Platform.Architecture == "amd64" { 540 | selectedDesc = &m 541 | break 542 | } 543 | } 544 | 545 | if selectedDesc == nil && len(manifest.Manifests) > 0 { 546 | selectedDesc = &manifest.Manifests[0] 547 | } 548 | 549 | if selectedDesc == nil { 550 | return nil, fmt.Errorf("未找到合适的平台镜像") 551 | } 552 | 553 | img, err := index.Image(selectedDesc.Digest) 554 | if err != nil { 555 | return nil, fmt.Errorf("获取选中镜像失败: %w", err) 556 | } 557 | 558 | return img, nil 559 | } 560 | 561 | var globalImageStreamer *ImageStreamer 562 | 563 | // InitImageStreamer 初始化镜像下载器 564 | func InitImageStreamer() { 565 | globalImageStreamer = NewImageStreamer(nil) 566 | } 567 | 568 | // formatPlatformText 格式化平台文本 569 | func formatPlatformText(platform string) string { 570 | if platform == "" { 571 | return "自动选择" 572 | } 573 | return platform 574 | } 575 | 576 | // InitImageTarRoutes 初始化镜像下载路由 577 | func InitImageTarRoutes(router *gin.Engine) { 578 | imageAPI := router.Group("/api/image") 579 | { 580 | imageAPI.GET("/download/:image", handleDirectImageDownload) 581 | imageAPI.GET("/info/:image", handleImageInfo) 582 | imageAPI.POST("/batch", handleSimpleBatchDownload) 583 | } 584 | } 585 | 586 | // handleDirectImageDownload 处理单镜像下载 587 | func handleDirectImageDownload(c *gin.Context) { 588 | imageParam := c.Param("image") 589 | if imageParam == "" { 590 | c.JSON(http.StatusBadRequest, gin.H{"error": "缺少镜像参数"}) 591 | return 592 | } 593 | 594 | imageRef := strings.ReplaceAll(imageParam, "_", "/") 595 | platform := c.Query("platform") 596 | tag := c.DefaultQuery("tag", "") 597 | useCompressed := c.DefaultQuery("compressed", "true") == "true" 598 | 599 | if tag != "" && !strings.Contains(imageRef, ":") && !strings.Contains(imageRef, "@") { 600 | imageRef = imageRef + ":" + tag 601 | } else if !strings.Contains(imageRef, ":") && !strings.Contains(imageRef, "@") { 602 | imageRef = imageRef + ":latest" 603 | } 604 | 605 | if _, err := name.ParseReference(imageRef); err != nil { 606 | c.JSON(http.StatusBadRequest, gin.H{"error": "镜像引用格式错误: " + err.Error()}) 607 | return 608 | } 609 | 610 | userID := getUserID(c) 611 | contentKey := generateContentFingerprint([]string{imageRef}, platform) 612 | 613 | if !singleImageDebouncer.ShouldAllow(userID, contentKey) { 614 | c.JSON(http.StatusTooManyRequests, gin.H{ 615 | "error": "请求过于频繁,请稍后再试", 616 | "retry_after": 5, 617 | }) 618 | return 619 | } 620 | 621 | options := &StreamOptions{ 622 | Platform: platform, 623 | Compression: false, 624 | UseCompressedLayers: useCompressed, 625 | } 626 | 627 | ctx := c.Request.Context() 628 | log.Printf("下载镜像: %s (平台: %s)", imageRef, formatPlatformText(platform)) 629 | 630 | if err := globalImageStreamer.StreamImageToGin(ctx, imageRef, c, options); err != nil { 631 | log.Printf("镜像下载失败: %v", err) 632 | c.JSON(http.StatusInternalServerError, gin.H{"error": "镜像下载失败: " + err.Error()}) 633 | return 634 | } 635 | } 636 | 637 | // handleSimpleBatchDownload 处理批量下载 638 | func handleSimpleBatchDownload(c *gin.Context) { 639 | var req struct { 640 | Images []string `json:"images" binding:"required"` 641 | Platform string `json:"platform"` 642 | UseCompressedLayers *bool `json:"useCompressedLayers"` 643 | } 644 | 645 | if err := c.ShouldBindJSON(&req); err != nil { 646 | c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误: " + err.Error()}) 647 | return 648 | } 649 | 650 | if len(req.Images) == 0 { 651 | c.JSON(http.StatusBadRequest, gin.H{"error": "镜像列表不能为空"}) 652 | return 653 | } 654 | 655 | for i, imageRef := range req.Images { 656 | if !strings.Contains(imageRef, ":") && !strings.Contains(imageRef, "@") { 657 | req.Images[i] = imageRef + ":latest" 658 | } 659 | } 660 | 661 | cfg := config.GetConfig() 662 | if len(req.Images) > cfg.Download.MaxImages { 663 | c.JSON(http.StatusBadRequest, gin.H{ 664 | "error": fmt.Sprintf("镜像数量超过限制,最大允许: %d", cfg.Download.MaxImages), 665 | }) 666 | return 667 | } 668 | 669 | userID := getUserID(c) 670 | contentKey := generateContentFingerprint(req.Images, req.Platform) 671 | 672 | if !batchImageDebouncer.ShouldAllow(userID, contentKey) { 673 | c.JSON(http.StatusTooManyRequests, gin.H{ 674 | "error": "批量下载请求过于频繁,请稍后再试", 675 | "retry_after": 60, 676 | }) 677 | return 678 | } 679 | 680 | useCompressed := true 681 | if req.UseCompressedLayers != nil { 682 | useCompressed = *req.UseCompressedLayers 683 | } 684 | 685 | options := &StreamOptions{ 686 | Platform: req.Platform, 687 | Compression: false, 688 | UseCompressedLayers: useCompressed, 689 | } 690 | 691 | ctx := c.Request.Context() 692 | log.Printf("批量下载 %d 个镜像 (平台: %s)", len(req.Images), formatPlatformText(req.Platform)) 693 | 694 | filename := fmt.Sprintf("batch_%d_images.tar", len(req.Images)) 695 | 696 | c.Header("Content-Type", "application/octet-stream") 697 | c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) 698 | 699 | if err := globalImageStreamer.StreamMultipleImages(ctx, req.Images, c.Writer, options); err != nil { 700 | log.Printf("批量镜像下载失败: %v", err) 701 | c.JSON(http.StatusInternalServerError, gin.H{"error": "批量镜像下载失败: " + err.Error()}) 702 | return 703 | } 704 | } 705 | 706 | // handleImageInfo 处理镜像信息查询 707 | func handleImageInfo(c *gin.Context) { 708 | imageParam := c.Param("image") 709 | if imageParam == "" { 710 | c.JSON(http.StatusBadRequest, gin.H{"error": "缺少镜像参数"}) 711 | return 712 | } 713 | 714 | imageRef := strings.ReplaceAll(imageParam, "_", "/") 715 | tag := c.DefaultQuery("tag", "latest") 716 | 717 | if !strings.Contains(imageRef, ":") && !strings.Contains(imageRef, "@") { 718 | imageRef = imageRef + ":" + tag 719 | } 720 | 721 | ref, err := name.ParseReference(imageRef) 722 | if err != nil { 723 | c.JSON(http.StatusBadRequest, gin.H{"error": "镜像引用格式错误: " + err.Error()}) 724 | return 725 | } 726 | 727 | ctx := c.Request.Context() 728 | contextOptions := append(globalImageStreamer.remoteOptions, remote.WithContext(ctx)) 729 | 730 | desc, err := globalImageStreamer.getImageDescriptor(ref, contextOptions) 731 | if err != nil { 732 | c.JSON(http.StatusInternalServerError, gin.H{"error": "获取镜像信息失败: " + err.Error()}) 733 | return 734 | } 735 | 736 | info := gin.H{ 737 | "name": ref.String(), 738 | "mediaType": desc.MediaType, 739 | "digest": desc.Digest.String(), 740 | "size": desc.Size, 741 | } 742 | 743 | if desc.MediaType == types.OCIImageIndex || desc.MediaType == types.DockerManifestList { 744 | index, err := desc.ImageIndex() 745 | if err == nil { 746 | manifest, err := index.IndexManifest() 747 | if err == nil { 748 | var platforms []string 749 | for _, m := range manifest.Manifests { 750 | if m.Platform != nil { 751 | platforms = append(platforms, m.Platform.OS+"/"+m.Platform.Architecture) 752 | } 753 | } 754 | info["platforms"] = platforms 755 | info["multiArch"] = true 756 | } 757 | } 758 | } else { 759 | info["multiArch"] = false 760 | } 761 | 762 | c.JSON(http.StatusOK, gin.H{"success": true, "data": info}) 763 | } 764 | 765 | // StreamMultipleImages 批量下载多个镜像 766 | func (is *ImageStreamer) StreamMultipleImages(ctx context.Context, imageRefs []string, writer io.Writer, options *StreamOptions) error { 767 | if options == nil { 768 | options = &StreamOptions{UseCompressedLayers: true} 769 | } 770 | 771 | var finalWriter io.Writer = writer 772 | if options.Compression { 773 | gzWriter := gzip.NewWriter(writer) 774 | defer gzWriter.Close() 775 | finalWriter = gzWriter 776 | } 777 | 778 | tarWriter := tar.NewWriter(finalWriter) 779 | defer tarWriter.Close() 780 | 781 | var allManifests []map[string]interface{} 782 | var allRepositories = make(map[string]map[string]string) 783 | 784 | for i, imageRef := range imageRefs { 785 | select { 786 | case <-ctx.Done(): 787 | return ctx.Err() 788 | default: 789 | } 790 | 791 | log.Printf("处理镜像 %d/%d: %s", i+1, len(imageRefs), imageRef) 792 | 793 | timeoutCtx, cancel := context.WithTimeout(ctx, 15*time.Minute) 794 | manifest, repositories, err := is.streamSingleImageForBatch(timeoutCtx, tarWriter, imageRef, options) 795 | cancel() 796 | 797 | if err != nil { 798 | log.Printf("下载镜像 %s 失败: %v", imageRef, err) 799 | return fmt.Errorf("下载镜像 %s 失败: %w", imageRef, err) 800 | } 801 | 802 | if manifest == nil { 803 | return fmt.Errorf("镜像 %s manifest数据为空", imageRef) 804 | } 805 | 806 | allManifests = append(allManifests, manifest) 807 | 808 | for repo, tags := range repositories { 809 | if allRepositories[repo] == nil { 810 | allRepositories[repo] = make(map[string]string) 811 | } 812 | for tag, digest := range tags { 813 | allRepositories[repo][tag] = digest 814 | } 815 | } 816 | } 817 | 818 | manifestData, err := json.Marshal(allManifests) 819 | if err != nil { 820 | return fmt.Errorf("序列化manifest失败: %w", err) 821 | } 822 | 823 | manifestHeader := &tar.Header{ 824 | Name: "manifest.json", 825 | Size: int64(len(manifestData)), 826 | Mode: 0644, 827 | } 828 | 829 | if err := tarWriter.WriteHeader(manifestHeader); err != nil { 830 | return fmt.Errorf("写入manifest header失败: %w", err) 831 | } 832 | 833 | if _, err := tarWriter.Write(manifestData); err != nil { 834 | return fmt.Errorf("写入manifest数据失败: %w", err) 835 | } 836 | 837 | repositoriesData, err := json.Marshal(allRepositories) 838 | if err != nil { 839 | return fmt.Errorf("序列化repositories失败: %w", err) 840 | } 841 | 842 | repositoriesHeader := &tar.Header{ 843 | Name: "repositories", 844 | Size: int64(len(repositoriesData)), 845 | Mode: 0644, 846 | } 847 | 848 | if err := tarWriter.WriteHeader(repositoriesHeader); err != nil { 849 | return fmt.Errorf("写入repositories header失败: %w", err) 850 | } 851 | 852 | if _, err := tarWriter.Write(repositoriesData); err != nil { 853 | return fmt.Errorf("写入repositories数据失败: %w", err) 854 | } 855 | 856 | log.Printf("批量下载完成,共处理 %d 个镜像", len(imageRefs)) 857 | return nil 858 | } 859 | -------------------------------------------------------------------------------- /src/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Github、Docker加速 10 | 11 | 570 | 571 | 572 | 573 | 598 | 599 |
600 |
601 |
602 |

GitHub 文件加速

603 |

604 | 快速下载GitHub上的文件和仓库,解决国内访问GitHub速度慢的问题,支持Docker镜像加速和Hugging Face仓库。 605 |

606 |
607 | 608 |
609 |
610 |

611 | ⚡ 快速转换加速链接 612 |

613 |

614 | 输入GitHub文件链接,自动转换加速链接,可以直接在Github文件链接前加上本站域名使用。 615 |

616 |
617 | 618 |
619 |
620 | 626 | 629 |
630 |
631 | 632 |
633 |
634 | 635 | 加速链接已生成 636 |
637 |
638 |
639 | 642 | 645 |
646 |
647 |
648 | 649 |
650 |
651 |

652 | 🐳 Docker 镜像加速 653 |

654 |

655 | 支持多种镜像仓库,在镜像名称前添加本站域名即可加速下载。 656 |

657 |
658 | 659 | 662 |
663 |
664 |
665 | 666 | 692 | 693 |
694 | 链接已复制到剪贴板 695 |
696 | 697 | 705 | 706 | 830 | 831 | 832 | 833 | -------------------------------------------------------------------------------- /src/public/images.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Docker离线镜像下载 10 | 11 | 539 | 540 | 541 | 566 | 567 |
568 |
569 |
570 |

Docker离线镜像下载

571 |

即点即下,无需等待打包,完全符合docker load加载标准

572 | 573 |
574 |
575 | 576 | 即时下载 577 |
578 |
579 | 🔄 580 | 流式传输 581 |
582 |
583 | 💾 584 | 无需等待 585 |
586 |
587 | 🏗️ 588 | 多架构支持 589 |
590 |
591 |
592 | 593 |
594 |

单镜像下载

595 | 596 |
597 | 598 |
599 |
600 | 601 | 607 |
608 | 609 |
610 | 611 | 618 |
619 | 常用平台: linux/amd64, linux/arm64, linux/arm/v7 620 |
621 |
622 | 623 |
624 | 628 | 629 |
630 | 631 | 635 |
636 |
637 | 638 |
639 |

多个镜像批量下载

640 | 641 |
642 | 643 |
644 |
645 | 646 | 651 |
652 | 653 |
654 | 655 | 662 |
663 | 所有镜像将使用相同的目标架构 664 |
665 |
666 | 667 |
668 | 672 | 673 |
674 | 675 | 679 |
680 |
681 |
682 |
683 | 684 | 874 | 875 | --------------------------------------------------------------------------------