From 8c127a795bc4eab2ac30a35815162c0f083ccac5 Mon Sep 17 00:00:00 2001 From: beck-8 <1504068285@qq.com> Date: Thu, 19 Jun 2025 22:52:51 +0800 Subject: [PATCH 1/4] op http client proxy --- .gitignore | 4 + README.md | 10 +- src/access_control.go | 62 +++-- src/config.go | 551 +++++++++++++++++++++--------------------- src/config.toml | 12 +- src/go.mod | 2 +- src/http_client.go | 181 ++++++-------- 7 files changed, 396 insertions(+), 426 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b9602e3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.idea +.vscode +.DS_Store +hubproxy* \ No newline at end of file diff --git a/README.md b/README.md index 6a242e9..200e943 100644 --- a/README.md +++ b/README.md @@ -138,11 +138,17 @@ blackList = [ "baduser/*" ] -# SOCKS5代理配置,支持有用户名/密码认证和无认证模式 +# 代理配置,支持有用户名/密码认证和无认证模式 # 无认证: socks5://127.0.0.1:1080 # 有认证: socks5://username:password@127.0.0.1:1080 +# HTTP 代理示例 +# http://username:password@127.0.0.1:7890 +# SOCKS5 代理示例 +# socks5://username:password@127.0.0.1:1080 +# SOCKS5H 代理示例 +# socks5h://username:password@127.0.0.1:1080 # 留空不使用代理 -socks5 = "" +proxy = "" [download] # 批量下载离线镜像数量限制 diff --git a/src/access_control.go b/src/access_control.go index e23ee52..b8c6ab1 100644 --- a/src/access_control.go +++ b/src/access_control.go @@ -32,7 +32,7 @@ var GlobalAccessController = &AccessController{} // ParseDockerImage 解析Docker镜像名称 func (ac *AccessController) ParseDockerImage(image string) DockerImageInfo { image = strings.TrimPrefix(image, "docker://") - + var tag string if idx := strings.LastIndex(image, ":"); idx != -1 { part := image[idx+1:] @@ -44,7 +44,7 @@ func (ac *AccessController) ParseDockerImage(image string) DockerImageInfo { if tag == "" { tag = "latest" } - + var namespace, repository string if strings.Contains(image, "/") { parts := strings.Split(image, "/") @@ -66,9 +66,9 @@ func (ac *AccessController) ParseDockerImage(image string) DockerImageInfo { namespace = "library" repository = image } - + fullName := namespace + "/" + repository - + return DockerImageInfo{ Namespace: namespace, Repository: repository, @@ -80,24 +80,24 @@ func (ac *AccessController) ParseDockerImage(image string) DockerImageInfo { // CheckDockerAccess 检查Docker镜像访问权限 func (ac *AccessController) CheckDockerAccess(image string) (allowed bool, reason string) { cfg := GetConfig() - + // 解析镜像名称 imageInfo := ac.ParseDockerImage(image) - + // 检查白名单(如果配置了白名单,则只允许白名单中的镜像) - if len(cfg.Proxy.WhiteList) > 0 { - if !ac.matchImageInList(imageInfo, cfg.Proxy.WhiteList) { + if len(cfg.Access.WhiteList) > 0 { + if !ac.matchImageInList(imageInfo, cfg.Access.WhiteList) { return false, "不在Docker镜像白名单内" } } - + // 检查黑名单 - if len(cfg.Proxy.BlackList) > 0 { - if ac.matchImageInList(imageInfo, cfg.Proxy.BlackList) { + if len(cfg.Access.BlackList) > 0 { + if ac.matchImageInList(imageInfo, cfg.Access.BlackList) { return false, "Docker镜像在黑名单内" } } - + return true, "" } @@ -106,19 +106,19 @@ func (ac *AccessController) CheckGitHubAccess(matches []string) (allowed bool, r if len(matches) < 2 { return false, "无效的GitHub仓库格式" } - + cfg := GetConfig() - + // 检查白名单 - if len(cfg.Proxy.WhiteList) > 0 && !ac.checkList(matches, cfg.Proxy.WhiteList) { + if len(cfg.Access.WhiteList) > 0 && !ac.checkList(matches, cfg.Access.WhiteList) { return false, "不在GitHub仓库白名单内" } - + // 检查黑名单 - if len(cfg.Proxy.BlackList) > 0 && ac.checkList(matches, cfg.Proxy.BlackList) { + if len(cfg.Access.BlackList) > 0 && ac.checkList(matches, cfg.Access.BlackList) { return false, "GitHub仓库在黑名单内" } - + return true, "" } @@ -126,28 +126,28 @@ func (ac *AccessController) CheckGitHubAccess(matches []string) (allowed bool, r func (ac *AccessController) matchImageInList(imageInfo DockerImageInfo, list []string) bool { fullName := strings.ToLower(imageInfo.FullName) namespace := strings.ToLower(imageInfo.Namespace) - + for _, item := range list { item = strings.ToLower(strings.TrimSpace(item)) if item == "" { continue } - + if fullName == item { return true } - + if item == namespace || item == namespace+"/*" { return true } - + if strings.HasSuffix(item, "*") { prefix := strings.TrimSuffix(item, "*") if strings.HasPrefix(fullName, prefix) { return true } } - + if strings.HasPrefix(item, "*/") { repoPattern := strings.TrimPrefix(item, "*/") if strings.HasSuffix(repoPattern, "*") { @@ -161,7 +161,7 @@ func (ac *AccessController) matchImageInList(imageInfo DockerImageInfo, list []s } } } - + if strings.HasPrefix(fullName, item+"/") { return true } @@ -174,27 +174,27 @@ func (ac *AccessController) checkList(matches, list []string) bool { if len(matches) < 2 { return false } - + username := strings.ToLower(strings.TrimSpace(matches[0])) repoName := strings.ToLower(strings.TrimSpace(strings.TrimSuffix(matches[1], ".git"))) fullRepo := username + "/" + repoName - + for _, item := range list { item = strings.ToLower(strings.TrimSpace(item)) if item == "" { continue } - + // 支持多种匹配模式 if fullRepo == item { return true } - + // 用户级匹配 if item == username || item == username+"/*" { return true } - + // 前缀匹配(支持通配符) if strings.HasSuffix(item, "*") { prefix := strings.TrimSuffix(item, "*") @@ -202,7 +202,7 @@ func (ac *AccessController) checkList(matches, list []string) bool { return true } } - + // 子仓库匹配(防止 user/repo 匹配到 user/repo-fork) if strings.HasPrefix(fullRepo, item+"/") { return true @@ -210,5 +210,3 @@ func (ac *AccessController) checkList(matches, list []string) bool { } return false } - - \ No newline at end of file diff --git a/src/config.go b/src/config.go index d85c217..fd22dc5 100644 --- a/src/config.go +++ b/src/config.go @@ -1,275 +1,276 @@ -package main - -import ( - "fmt" - "os" - "strconv" - "strings" - "sync" - "time" - - "github.com/pelletier/go-toml/v2" -) - -// RegistryMapping Registry映射配置 -type RegistryMapping struct { - Upstream string `toml:"upstream"` // 上游Registry地址 - AuthHost string `toml:"authHost"` // 认证服务器地址 - AuthType string `toml:"authType"` // 认证类型: docker/github/google/basic - Enabled bool `toml:"enabled"` // 是否启用 -} - -// AppConfig 应用配置结构体 -type AppConfig struct { - Server struct { - Host string `toml:"host"` // 监听地址 - Port int `toml:"port"` // 监听端口 - FileSize int64 `toml:"fileSize"` // 文件大小限制(字节) - } `toml:"server"` - - RateLimit struct { - RequestLimit int `toml:"requestLimit"` // 每小时请求限制 - PeriodHours float64 `toml:"periodHours"` // 限制周期(小时) - } `toml:"rateLimit"` - - Security struct { - WhiteList []string `toml:"whiteList"` // 白名单IP/CIDR列表 - BlackList []string `toml:"blackList"` // 黑名单IP/CIDR列表 - } `toml:"security"` - - Proxy struct { - WhiteList []string `toml:"whiteList"` // 代理白名单(仓库级别) - BlackList []string `toml:"blackList"` // 代理黑名单(仓库级别) - Socks5 string `toml:"socks5"` // SOCKS5代理地址: socks5://[user:pass@]host:port - } `toml:"proxy"` - - Download struct { - MaxImages int `toml:"maxImages"` // 单次下载最大镜像数量限制 - } `toml:"download"` - - Registries map[string]RegistryMapping `toml:"registries"` - - TokenCache struct { - Enabled bool `toml:"enabled"` // 是否启用token缓存 - DefaultTTL string `toml:"defaultTTL"` // 默认缓存时间 - } `toml:"tokenCache"` -} - -var ( - appConfig *AppConfig - appConfigLock sync.RWMutex - - cachedConfig *AppConfig - configCacheTime time.Time - configCacheTTL = 5 * time.Second - configCacheMutex sync.RWMutex -) - -// DefaultConfig 返回默认配置 -func DefaultConfig() *AppConfig { - return &AppConfig{ - Server: struct { - Host string `toml:"host"` - Port int `toml:"port"` - FileSize int64 `toml:"fileSize"` - }{ - Host: "0.0.0.0", - Port: 5000, - FileSize: 2 * 1024 * 1024 * 1024, // 2GB - }, - RateLimit: struct { - RequestLimit int `toml:"requestLimit"` - PeriodHours float64 `toml:"periodHours"` - }{ - RequestLimit: 20, - PeriodHours: 1.0, - }, - Security: struct { - WhiteList []string `toml:"whiteList"` - BlackList []string `toml:"blackList"` - }{ - WhiteList: []string{}, - BlackList: []string{}, - }, - Proxy: struct { - WhiteList []string `toml:"whiteList"` - BlackList []string `toml:"blackList"` - Socks5 string `toml:"socks5"` - }{ - WhiteList: []string{}, - BlackList: []string{}, - Socks5: "", // 默认不使用代理 - }, - Download: struct { - MaxImages int `toml:"maxImages"` - }{ - MaxImages: 10, // 默认值:最多同时下载10个镜像 - }, - Registries: map[string]RegistryMapping{ - "ghcr.io": { - Upstream: "ghcr.io", - AuthHost: "ghcr.io/token", - AuthType: "github", - Enabled: true, - }, - "gcr.io": { - Upstream: "gcr.io", - AuthHost: "gcr.io/v2/token", - AuthType: "google", - Enabled: true, - }, - "quay.io": { - Upstream: "quay.io", - AuthHost: "quay.io/v2/auth", - AuthType: "quay", - Enabled: true, - }, - "registry.k8s.io": { - Upstream: "registry.k8s.io", - AuthHost: "registry.k8s.io", - AuthType: "anonymous", - Enabled: true, - }, - }, - TokenCache: struct { - Enabled bool `toml:"enabled"` - DefaultTTL string `toml:"defaultTTL"` - }{ - Enabled: true, // docker认证的匿名Token缓存配置,用于提升性能 - DefaultTTL: "20m", - }, - } -} - -// GetConfig 安全地获取配置副本 -func GetConfig() *AppConfig { - configCacheMutex.RLock() - if cachedConfig != nil && time.Since(configCacheTime) < configCacheTTL { - config := cachedConfig - configCacheMutex.RUnlock() - return config - } - configCacheMutex.RUnlock() - - // 缓存过期,重新生成配置 - configCacheMutex.Lock() - defer configCacheMutex.Unlock() - - // 双重检查,防止重复生成 - if cachedConfig != nil && time.Since(configCacheTime) < configCacheTTL { - return cachedConfig - } - - appConfigLock.RLock() - if appConfig == nil { - appConfigLock.RUnlock() - defaultCfg := DefaultConfig() - cachedConfig = defaultCfg - configCacheTime = time.Now() - return defaultCfg - } - - // 生成新的配置深拷贝 - configCopy := *appConfig - configCopy.Security.WhiteList = append([]string(nil), appConfig.Security.WhiteList...) - configCopy.Security.BlackList = append([]string(nil), appConfig.Security.BlackList...) - configCopy.Proxy.WhiteList = append([]string(nil), appConfig.Proxy.WhiteList...) - configCopy.Proxy.BlackList = append([]string(nil), appConfig.Proxy.BlackList...) - appConfigLock.RUnlock() - - cachedConfig = &configCopy - configCacheTime = time.Now() - - return cachedConfig -} - -// setConfig 安全地设置配置 -func setConfig(cfg *AppConfig) { - appConfigLock.Lock() - defer appConfigLock.Unlock() - appConfig = cfg - - configCacheMutex.Lock() - cachedConfig = nil - configCacheMutex.Unlock() -} - -// LoadConfig 加载配置文件 -func LoadConfig() error { - // 首先使用默认配置 - cfg := DefaultConfig() - - // 尝试加载TOML配置文件 - if data, err := os.ReadFile("config.toml"); err == nil { - if err := toml.Unmarshal(data, cfg); err != nil { - return fmt.Errorf("解析配置文件失败: %v", err) - } - } else { - fmt.Println("未找到config.toml,使用默认配置") - } - - // 从环境变量覆盖配置 - overrideFromEnv(cfg) - - // 设置配置 - setConfig(cfg) - - return nil -} - -// overrideFromEnv 从环境变量覆盖配置 -func overrideFromEnv(cfg *AppConfig) { - // 服务器配置 - if val := os.Getenv("SERVER_HOST"); val != "" { - cfg.Server.Host = val - } - if val := os.Getenv("SERVER_PORT"); val != "" { - if port, err := strconv.Atoi(val); err == nil && port > 0 { - cfg.Server.Port = port - } - } - if val := os.Getenv("MAX_FILE_SIZE"); val != "" { - if size, err := strconv.ParseInt(val, 10, 64); err == nil && size > 0 { - cfg.Server.FileSize = size - } - } - - // 限流配置 - if val := os.Getenv("RATE_LIMIT"); val != "" { - if limit, err := strconv.Atoi(val); err == nil && limit > 0 { - cfg.RateLimit.RequestLimit = limit - } - } - if val := os.Getenv("RATE_PERIOD_HOURS"); val != "" { - if period, err := strconv.ParseFloat(val, 64); err == nil && period > 0 { - cfg.RateLimit.PeriodHours = period - } - } - - // IP限制配置 - if val := os.Getenv("IP_WHITELIST"); val != "" { - cfg.Security.WhiteList = append(cfg.Security.WhiteList, strings.Split(val, ",")...) - } - if val := os.Getenv("IP_BLACKLIST"); val != "" { - cfg.Security.BlackList = append(cfg.Security.BlackList, strings.Split(val, ",")...) - } - - // 下载限制配置 - if val := os.Getenv("MAX_IMAGES"); val != "" { - if maxImages, err := strconv.Atoi(val); err == nil && maxImages > 0 { - cfg.Download.MaxImages = maxImages - } - } -} - -// CreateDefaultConfigFile 创建默认配置文件 -func CreateDefaultConfigFile() error { - cfg := DefaultConfig() - - data, err := toml.Marshal(cfg) - if err != nil { - return fmt.Errorf("序列化默认配置失败: %v", err) - } - - return os.WriteFile("config.toml", data, 0644) -} \ No newline at end of file +package main + +import ( + "fmt" + "os" + "strconv" + "strings" + "sync" + "time" + + "github.com/pelletier/go-toml/v2" +) + +// RegistryMapping Registry映射配置 +type RegistryMapping struct { + Upstream string `toml:"upstream"` // 上游Registry地址 + AuthHost string `toml:"authHost"` // 认证服务器地址 + AuthType string `toml:"authType"` // 认证类型: docker/github/google/basic + Enabled bool `toml:"enabled"` // 是否启用 +} + +// AppConfig 应用配置结构体 +type AppConfig struct { + Server struct { + Host string `toml:"host"` // 监听地址 + Port int `toml:"port"` // 监听端口 + FileSize int64 `toml:"fileSize"` // 文件大小限制(字节) + } `toml:"server"` + + RateLimit struct { + RequestLimit int `toml:"requestLimit"` // 每小时请求限制 + PeriodHours float64 `toml:"periodHours"` // 限制周期(小时) + } `toml:"rateLimit"` + + Security struct { + WhiteList []string `toml:"whiteList"` // 白名单IP/CIDR列表 + BlackList []string `toml:"blackList"` // 黑名单IP/CIDR列表 + } `toml:"security"` + + Access struct { + WhiteList []string `toml:"whiteList"` // 代理白名单(仓库级别) + BlackList []string `toml:"blackList"` // 代理黑名单(仓库级别) + Proxy string `toml:"proxy"` // 代理地址: 支持 http/https/socks5/socks5h + } `toml:"proxy"` + + Download struct { + MaxImages int `toml:"maxImages"` // 单次下载最大镜像数量限制 + } `toml:"download"` + + Registries map[string]RegistryMapping `toml:"registries"` + + TokenCache struct { + Enabled bool `toml:"enabled"` // 是否启用token缓存 + DefaultTTL string `toml:"defaultTTL"` // 默认缓存时间 + } `toml:"tokenCache"` +} + +var ( + appConfig *AppConfig + appConfigLock sync.RWMutex + + cachedConfig *AppConfig + configCacheTime time.Time + configCacheTTL = 5 * time.Second + configCacheMutex sync.RWMutex +) + +// todo:Refactoring is needed +// DefaultConfig 返回默认配置 +func DefaultConfig() *AppConfig { + return &AppConfig{ + Server: struct { + Host string `toml:"host"` + Port int `toml:"port"` + FileSize int64 `toml:"fileSize"` + }{ + Host: "0.0.0.0", + Port: 5000, + FileSize: 2 * 1024 * 1024 * 1024, // 2GB + }, + RateLimit: struct { + RequestLimit int `toml:"requestLimit"` + PeriodHours float64 `toml:"periodHours"` + }{ + RequestLimit: 20, + PeriodHours: 1.0, + }, + Security: struct { + WhiteList []string `toml:"whiteList"` + BlackList []string `toml:"blackList"` + }{ + WhiteList: []string{}, + BlackList: []string{}, + }, + Access: struct { + WhiteList []string `toml:"whiteList"` + BlackList []string `toml:"blackList"` + Proxy string `toml:"proxy"` + }{ + WhiteList: []string{}, + BlackList: []string{}, + Proxy: "", // 默认不使用代理 + }, + Download: struct { + MaxImages int `toml:"maxImages"` + }{ + MaxImages: 10, // 默认值:最多同时下载10个镜像 + }, + Registries: map[string]RegistryMapping{ + "ghcr.io": { + Upstream: "ghcr.io", + AuthHost: "ghcr.io/token", + AuthType: "github", + Enabled: true, + }, + "gcr.io": { + Upstream: "gcr.io", + AuthHost: "gcr.io/v2/token", + AuthType: "google", + Enabled: true, + }, + "quay.io": { + Upstream: "quay.io", + AuthHost: "quay.io/v2/auth", + AuthType: "quay", + Enabled: true, + }, + "registry.k8s.io": { + Upstream: "registry.k8s.io", + AuthHost: "registry.k8s.io", + AuthType: "anonymous", + Enabled: true, + }, + }, + TokenCache: struct { + Enabled bool `toml:"enabled"` + DefaultTTL string `toml:"defaultTTL"` + }{ + Enabled: true, // docker认证的匿名Token缓存配置,用于提升性能 + DefaultTTL: "20m", + }, + } +} + +// GetConfig 安全地获取配置副本 +func GetConfig() *AppConfig { + configCacheMutex.RLock() + if cachedConfig != nil && time.Since(configCacheTime) < configCacheTTL { + config := cachedConfig + configCacheMutex.RUnlock() + return config + } + configCacheMutex.RUnlock() + + // 缓存过期,重新生成配置 + configCacheMutex.Lock() + defer configCacheMutex.Unlock() + + // 双重检查,防止重复生成 + if cachedConfig != nil && time.Since(configCacheTime) < configCacheTTL { + return cachedConfig + } + + appConfigLock.RLock() + if appConfig == nil { + appConfigLock.RUnlock() + defaultCfg := DefaultConfig() + cachedConfig = defaultCfg + configCacheTime = time.Now() + return defaultCfg + } + + // 生成新的配置深拷贝 + configCopy := *appConfig + configCopy.Security.WhiteList = append([]string(nil), appConfig.Security.WhiteList...) + configCopy.Security.BlackList = append([]string(nil), appConfig.Security.BlackList...) + configCopy.Access.WhiteList = append([]string(nil), appConfig.Access.WhiteList...) + configCopy.Access.BlackList = append([]string(nil), appConfig.Access.BlackList...) + appConfigLock.RUnlock() + + cachedConfig = &configCopy + configCacheTime = time.Now() + + return cachedConfig +} + +// setConfig 安全地设置配置 +func setConfig(cfg *AppConfig) { + appConfigLock.Lock() + defer appConfigLock.Unlock() + appConfig = cfg + + configCacheMutex.Lock() + cachedConfig = nil + configCacheMutex.Unlock() +} + +// LoadConfig 加载配置文件 +func LoadConfig() error { + // 首先使用默认配置 + cfg := DefaultConfig() + + // 尝试加载TOML配置文件 + if data, err := os.ReadFile("config.toml"); err == nil { + if err := toml.Unmarshal(data, cfg); err != nil { + return fmt.Errorf("解析配置文件失败: %v", err) + } + } else { + fmt.Println("未找到config.toml,使用默认配置") + } + + // 从环境变量覆盖配置 + overrideFromEnv(cfg) + + // 设置配置 + setConfig(cfg) + + return nil +} + +// overrideFromEnv 从环境变量覆盖配置 +func overrideFromEnv(cfg *AppConfig) { + // 服务器配置 + if val := os.Getenv("SERVER_HOST"); val != "" { + cfg.Server.Host = val + } + if val := os.Getenv("SERVER_PORT"); val != "" { + if port, err := strconv.Atoi(val); err == nil && port > 0 { + cfg.Server.Port = port + } + } + if val := os.Getenv("MAX_FILE_SIZE"); val != "" { + if size, err := strconv.ParseInt(val, 10, 64); err == nil && size > 0 { + cfg.Server.FileSize = size + } + } + + // 限流配置 + if val := os.Getenv("RATE_LIMIT"); val != "" { + if limit, err := strconv.Atoi(val); err == nil && limit > 0 { + cfg.RateLimit.RequestLimit = limit + } + } + if val := os.Getenv("RATE_PERIOD_HOURS"); val != "" { + if period, err := strconv.ParseFloat(val, 64); err == nil && period > 0 { + cfg.RateLimit.PeriodHours = period + } + } + + // IP限制配置 + if val := os.Getenv("IP_WHITELIST"); val != "" { + cfg.Security.WhiteList = append(cfg.Security.WhiteList, strings.Split(val, ",")...) + } + if val := os.Getenv("IP_BLACKLIST"); val != "" { + cfg.Security.BlackList = append(cfg.Security.BlackList, strings.Split(val, ",")...) + } + + // 下载限制配置 + if val := os.Getenv("MAX_IMAGES"); val != "" { + if maxImages, err := strconv.Atoi(val); err == nil && maxImages > 0 { + cfg.Download.MaxImages = maxImages + } + } +} + +// CreateDefaultConfigFile 创建默认配置文件 +func CreateDefaultConfigFile() error { + cfg := DefaultConfig() + + data, err := toml.Marshal(cfg) + if err != nil { + return fmt.Errorf("序列化默认配置失败: %v", err) + } + + return os.WriteFile("config.toml", data, 0644) +} diff --git a/src/config.toml b/src/config.toml index 1411c8f..6a96c11 100644 --- a/src/config.toml +++ b/src/config.toml @@ -26,7 +26,7 @@ blackList = [ "192.168.100.0/24" ] -[proxy] +[access] # 代理服务白名单(支持GitHub仓库和Docker镜像,支持通配符) # 只允许访问白名单中的仓库/镜像,为空时不限制 whiteList = [] @@ -39,11 +39,17 @@ blackList = [ "baduser/*" ] -# SOCKS5代理配置,支持有用户名/密码认证和无认证模式 +# 代理配置,支持有用户名/密码认证和无认证模式 # 无认证: socks5://127.0.0.1:1080 # 有认证: socks5://username:password@127.0.0.1:1080 +# HTTP 代理示例 +# http://username:password@127.0.0.1:7890 +# SOCKS5 代理示例 +# socks5://username:password@127.0.0.1:1080 +# SOCKS5H 代理示例 +# socks5h://username:password@127.0.0.1:1080 # 留空不使用代理 -socks5 = "" +proxy = "" [download] # 批量下载离线镜像数量限制 diff --git a/src/go.mod b/src/go.mod index 6e8a2f8..7c2806d 100644 --- a/src/go.mod +++ b/src/go.mod @@ -6,7 +6,6 @@ require ( github.com/gin-gonic/gin v1.10.0 github.com/google/go-containerregistry v0.20.5 github.com/pelletier/go-toml/v2 v2.2.3 - golang.org/x/net v0.33.0 golang.org/x/time v0.11.0 ) @@ -44,6 +43,7 @@ require ( github.com/vbatts/tar-split v0.12.1 // indirect golang.org/x/arch v0.8.0 // indirect golang.org/x/crypto v0.32.0 // indirect + golang.org/x/net v0.33.0 // indirect golang.org/x/sync v0.14.0 // indirect golang.org/x/sys v0.33.0 // indirect golang.org/x/text v0.21.0 // indirect diff --git a/src/http_client.go b/src/http_client.go index 24d509b..93988eb 100644 --- a/src/http_client.go +++ b/src/http_client.go @@ -1,113 +1,68 @@ -package main - -import ( - "context" - "log" - "net" - "net/http" - "net/url" - "time" - - "golang.org/x/net/proxy" -) - -var ( - // 全局HTTP客户端 - 用于代理请求(长超时) - globalHTTPClient *http.Client - // 搜索HTTP客户端 - 用于API请求(短超时) - searchHTTPClient *http.Client -) - -// initHTTPClients 初始化HTTP客户端 -func initHTTPClients() { - cfg := GetConfig() - - // 创建DialContext函数,支持SOCKS5代理 - createDialContext := func(timeout time.Duration) func(ctx context.Context, network, addr string) (net.Conn, error) { - if cfg.Proxy.Socks5 == "" { - // 没有配置代理,使用直连 - dialer := &net.Dialer{ - Timeout: timeout, - KeepAlive: 30 * time.Second, - } - return dialer.DialContext - } - - // 解析SOCKS5代理URL - proxyURL, err := url.Parse(cfg.Proxy.Socks5) - if err != nil { - log.Printf("SOCKS5代理配置错误,使用直连: %v", err) - dialer := &net.Dialer{ - Timeout: timeout, - KeepAlive: 30 * time.Second, - } - return dialer.DialContext - } - - // 创建基础dialer - baseDialer := &net.Dialer{ - Timeout: timeout, - KeepAlive: 30 * time.Second, - } - - // 创建SOCKS5代理dialer - var auth *proxy.Auth - if proxyURL.User != nil { - if password, ok := proxyURL.User.Password(); ok { - auth = &proxy.Auth{ - User: proxyURL.User.Username(), - Password: password, - } - } - } - - socks5Dialer, err := proxy.SOCKS5("tcp", proxyURL.Host, auth, baseDialer) - if err != nil { - log.Printf("创建SOCKS5代理失败,使用直连: %v", err) - return baseDialer.DialContext - } - - log.Printf("使用SOCKS5代理: %s", proxyURL.Host) - - // 返回带上下文的dial函数 - return func(ctx context.Context, network, addr string) (net.Conn, error) { - return socks5Dialer.Dial(network, addr) - } - } - - // 代理客户端配置 - 适用于大文件传输 - globalHTTPClient = &http.Client{ - Transport: &http.Transport{ - DialContext: createDialContext(30 * time.Second), - MaxIdleConns: 1000, - MaxIdleConnsPerHost: 1000, - IdleConnTimeout: 90 * time.Second, - TLSHandshakeTimeout: 10 * time.Second, - ExpectContinueTimeout: 1 * time.Second, - ResponseHeaderTimeout: 300 * time.Second, - }, - } - - // 搜索客户端配置 - 适用于API调用 - searchHTTPClient = &http.Client{ - Timeout: 10 * time.Second, - Transport: &http.Transport{ - DialContext: createDialContext(5 * time.Second), - MaxIdleConns: 100, - MaxIdleConnsPerHost: 10, - IdleConnTimeout: 90 * time.Second, - TLSHandshakeTimeout: 5 * time.Second, - DisableCompression: false, - }, - } -} - -// GetGlobalHTTPClient 获取全局HTTP客户端(用于代理) -func GetGlobalHTTPClient() *http.Client { - return globalHTTPClient -} - -// GetSearchHTTPClient 获取搜索HTTP客户端(用于API调用) -func GetSearchHTTPClient() *http.Client { - return searchHTTPClient -} \ No newline at end of file +package main + +import ( + "net" + "net/http" + "os" + "time" +) + +var ( + // 全局HTTP客户端 - 用于代理请求(长超时) + globalHTTPClient *http.Client + // 搜索HTTP客户端 - 用于API请求(短超时) + searchHTTPClient *http.Client +) + +// initHTTPClients 初始化HTTP客户端 +func initHTTPClients() { + cfg := GetConfig() + + if p := cfg.Access.Proxy; p != "" { + os.Setenv("HTTP_PROXY", p) + os.Setenv("HTTPS_PROXY", p) + } + // 代理客户端配置 - 适用于大文件传输 + globalHTTPClient = &http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + MaxIdleConns: 1000, + MaxIdleConnsPerHost: 1000, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + ResponseHeaderTimeout: 300 * time.Second, + }, + } + + // 搜索客户端配置 - 适用于API调用 + searchHTTPClient = &http.Client{ + Timeout: 10 * time.Second, + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{ + Timeout: 5 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 5 * time.Second, + DisableCompression: false, + }, + } +} + +// GetGlobalHTTPClient 获取全局HTTP客户端(用于代理) +func GetGlobalHTTPClient() *http.Client { + return globalHTTPClient +} + +// GetSearchHTTPClient 获取搜索HTTP客户端(用于API调用) +func GetSearchHTTPClient() *http.Client { + return searchHTTPClient +} -- 2.49.1 From 5bd32cd6c1bd27ed22549cb351ef23a8f634b9c7 Mon Sep 17 00:00:00 2001 From: beck-8 <1504068285@qq.com> Date: Thu, 19 Jun 2025 22:53:20 +0800 Subject: [PATCH 2/4] go fmt . --- src/access_control.go | 424 ++++++------- src/docker.go | 1352 ++++++++++++++++++++--------------------- src/imagetar.go | 93 ++- src/main.go | 761 ++++++++++++----------- src/proxysh.go | 190 +++--- src/ratelimiter.go | 604 +++++++++--------- src/search.go | 1000 +++++++++++++++--------------- src/token_cache.go | 34 +- 8 files changed, 2226 insertions(+), 2232 deletions(-) diff --git a/src/access_control.go b/src/access_control.go index b8c6ab1..7d71a14 100644 --- a/src/access_control.go +++ b/src/access_control.go @@ -1,212 +1,212 @@ -package main - -import ( - "strings" - "sync" -) - -// ResourceType 资源类型 -type ResourceType string - -const ( - ResourceTypeGitHub ResourceType = "github" - ResourceTypeDocker ResourceType = "docker" -) - -// AccessController 统一访问控制器 -type AccessController struct { - mu sync.RWMutex -} - -// DockerImageInfo Docker镜像信息 -type DockerImageInfo struct { - Namespace string - Repository string - Tag string - FullName string -} - -// 全局访问控制器实例 -var GlobalAccessController = &AccessController{} - -// ParseDockerImage 解析Docker镜像名称 -func (ac *AccessController) ParseDockerImage(image string) DockerImageInfo { - image = strings.TrimPrefix(image, "docker://") - - var tag string - if idx := strings.LastIndex(image, ":"); idx != -1 { - part := image[idx+1:] - if !strings.Contains(part, "/") { - tag = part - image = image[:idx] - } - } - if tag == "" { - tag = "latest" - } - - var namespace, repository string - if strings.Contains(image, "/") { - parts := strings.Split(image, "/") - if len(parts) >= 2 { - if strings.Contains(parts[0], ".") { - if len(parts) >= 3 { - namespace = parts[1] - repository = parts[2] - } else { - namespace = "library" - repository = parts[1] - } - } else { - namespace = parts[0] - repository = parts[1] - } - } - } else { - namespace = "library" - repository = image - } - - fullName := namespace + "/" + repository - - return DockerImageInfo{ - Namespace: namespace, - Repository: repository, - Tag: tag, - FullName: fullName, - } -} - -// CheckDockerAccess 检查Docker镜像访问权限 -func (ac *AccessController) CheckDockerAccess(image string) (allowed bool, reason string) { - cfg := GetConfig() - - // 解析镜像名称 - imageInfo := ac.ParseDockerImage(image) - - // 检查白名单(如果配置了白名单,则只允许白名单中的镜像) - if len(cfg.Access.WhiteList) > 0 { - if !ac.matchImageInList(imageInfo, cfg.Access.WhiteList) { - return false, "不在Docker镜像白名单内" - } - } - - // 检查黑名单 - if len(cfg.Access.BlackList) > 0 { - if ac.matchImageInList(imageInfo, cfg.Access.BlackList) { - return false, "Docker镜像在黑名单内" - } - } - - return true, "" -} - -// CheckGitHubAccess 检查GitHub仓库访问权限 -func (ac *AccessController) CheckGitHubAccess(matches []string) (allowed bool, reason string) { - if len(matches) < 2 { - return false, "无效的GitHub仓库格式" - } - - cfg := GetConfig() - - // 检查白名单 - if len(cfg.Access.WhiteList) > 0 && !ac.checkList(matches, cfg.Access.WhiteList) { - return false, "不在GitHub仓库白名单内" - } - - // 检查黑名单 - if len(cfg.Access.BlackList) > 0 && ac.checkList(matches, cfg.Access.BlackList) { - return false, "GitHub仓库在黑名单内" - } - - return true, "" -} - -// matchImageInList 检查Docker镜像是否在指定列表中 -func (ac *AccessController) matchImageInList(imageInfo DockerImageInfo, list []string) bool { - fullName := strings.ToLower(imageInfo.FullName) - namespace := strings.ToLower(imageInfo.Namespace) - - for _, item := range list { - item = strings.ToLower(strings.TrimSpace(item)) - if item == "" { - continue - } - - if fullName == item { - return true - } - - if item == namespace || item == namespace+"/*" { - return true - } - - if strings.HasSuffix(item, "*") { - prefix := strings.TrimSuffix(item, "*") - if strings.HasPrefix(fullName, prefix) { - return true - } - } - - if strings.HasPrefix(item, "*/") { - repoPattern := strings.TrimPrefix(item, "*/") - if strings.HasSuffix(repoPattern, "*") { - repoPrefix := strings.TrimSuffix(repoPattern, "*") - if strings.HasPrefix(imageInfo.Repository, repoPrefix) { - return true - } - } else { - if strings.ToLower(imageInfo.Repository) == repoPattern { - return true - } - } - } - - if strings.HasPrefix(fullName, item+"/") { - return true - } - } - return false -} - -// checkList GitHub仓库检查逻辑 -func (ac *AccessController) checkList(matches, list []string) bool { - if len(matches) < 2 { - return false - } - - username := strings.ToLower(strings.TrimSpace(matches[0])) - repoName := strings.ToLower(strings.TrimSpace(strings.TrimSuffix(matches[1], ".git"))) - fullRepo := username + "/" + repoName - - for _, item := range list { - item = strings.ToLower(strings.TrimSpace(item)) - if item == "" { - continue - } - - // 支持多种匹配模式 - if fullRepo == item { - return true - } - - // 用户级匹配 - if item == username || item == username+"/*" { - return true - } - - // 前缀匹配(支持通配符) - if strings.HasSuffix(item, "*") { - prefix := strings.TrimSuffix(item, "*") - if strings.HasPrefix(fullRepo, prefix) { - return true - } - } - - // 子仓库匹配(防止 user/repo 匹配到 user/repo-fork) - if strings.HasPrefix(fullRepo, item+"/") { - return true - } - } - return false -} +package main + +import ( + "strings" + "sync" +) + +// ResourceType 资源类型 +type ResourceType string + +const ( + ResourceTypeGitHub ResourceType = "github" + ResourceTypeDocker ResourceType = "docker" +) + +// AccessController 统一访问控制器 +type AccessController struct { + mu sync.RWMutex +} + +// DockerImageInfo Docker镜像信息 +type DockerImageInfo struct { + Namespace string + Repository string + Tag string + FullName string +} + +// 全局访问控制器实例 +var GlobalAccessController = &AccessController{} + +// ParseDockerImage 解析Docker镜像名称 +func (ac *AccessController) ParseDockerImage(image string) DockerImageInfo { + image = strings.TrimPrefix(image, "docker://") + + var tag string + if idx := strings.LastIndex(image, ":"); idx != -1 { + part := image[idx+1:] + if !strings.Contains(part, "/") { + tag = part + image = image[:idx] + } + } + if tag == "" { + tag = "latest" + } + + var namespace, repository string + if strings.Contains(image, "/") { + parts := strings.Split(image, "/") + if len(parts) >= 2 { + if strings.Contains(parts[0], ".") { + if len(parts) >= 3 { + namespace = parts[1] + repository = parts[2] + } else { + namespace = "library" + repository = parts[1] + } + } else { + namespace = parts[0] + repository = parts[1] + } + } + } else { + namespace = "library" + repository = image + } + + fullName := namespace + "/" + repository + + return DockerImageInfo{ + Namespace: namespace, + Repository: repository, + Tag: tag, + FullName: fullName, + } +} + +// CheckDockerAccess 检查Docker镜像访问权限 +func (ac *AccessController) CheckDockerAccess(image string) (allowed bool, reason string) { + cfg := GetConfig() + + // 解析镜像名称 + imageInfo := ac.ParseDockerImage(image) + + // 检查白名单(如果配置了白名单,则只允许白名单中的镜像) + if len(cfg.Access.WhiteList) > 0 { + if !ac.matchImageInList(imageInfo, cfg.Access.WhiteList) { + return false, "不在Docker镜像白名单内" + } + } + + // 检查黑名单 + if len(cfg.Access.BlackList) > 0 { + if ac.matchImageInList(imageInfo, cfg.Access.BlackList) { + return false, "Docker镜像在黑名单内" + } + } + + return true, "" +} + +// CheckGitHubAccess 检查GitHub仓库访问权限 +func (ac *AccessController) CheckGitHubAccess(matches []string) (allowed bool, reason string) { + if len(matches) < 2 { + return false, "无效的GitHub仓库格式" + } + + cfg := GetConfig() + + // 检查白名单 + if len(cfg.Access.WhiteList) > 0 && !ac.checkList(matches, cfg.Access.WhiteList) { + return false, "不在GitHub仓库白名单内" + } + + // 检查黑名单 + if len(cfg.Access.BlackList) > 0 && ac.checkList(matches, cfg.Access.BlackList) { + return false, "GitHub仓库在黑名单内" + } + + return true, "" +} + +// matchImageInList 检查Docker镜像是否在指定列表中 +func (ac *AccessController) matchImageInList(imageInfo DockerImageInfo, list []string) bool { + fullName := strings.ToLower(imageInfo.FullName) + namespace := strings.ToLower(imageInfo.Namespace) + + for _, item := range list { + item = strings.ToLower(strings.TrimSpace(item)) + if item == "" { + continue + } + + if fullName == item { + return true + } + + if item == namespace || item == namespace+"/*" { + return true + } + + if strings.HasSuffix(item, "*") { + prefix := strings.TrimSuffix(item, "*") + if strings.HasPrefix(fullName, prefix) { + return true + } + } + + if strings.HasPrefix(item, "*/") { + repoPattern := strings.TrimPrefix(item, "*/") + if strings.HasSuffix(repoPattern, "*") { + repoPrefix := strings.TrimSuffix(repoPattern, "*") + if strings.HasPrefix(imageInfo.Repository, repoPrefix) { + return true + } + } else { + if strings.ToLower(imageInfo.Repository) == repoPattern { + return true + } + } + } + + if strings.HasPrefix(fullName, item+"/") { + return true + } + } + return false +} + +// checkList GitHub仓库检查逻辑 +func (ac *AccessController) checkList(matches, list []string) bool { + if len(matches) < 2 { + return false + } + + username := strings.ToLower(strings.TrimSpace(matches[0])) + repoName := strings.ToLower(strings.TrimSpace(strings.TrimSuffix(matches[1], ".git"))) + fullRepo := username + "/" + repoName + + for _, item := range list { + item = strings.ToLower(strings.TrimSpace(item)) + if item == "" { + continue + } + + // 支持多种匹配模式 + if fullRepo == item { + return true + } + + // 用户级匹配 + if item == username || item == username+"/*" { + return true + } + + // 前缀匹配(支持通配符) + if strings.HasSuffix(item, "*") { + prefix := strings.TrimSuffix(item, "*") + if strings.HasPrefix(fullRepo, prefix) { + return true + } + } + + // 子仓库匹配(防止 user/repo 匹配到 user/repo-fork) + if strings.HasPrefix(fullRepo, item+"/") { + return true + } + } + return false +} diff --git a/src/docker.go b/src/docker.go index 80ffd1f..2760fec 100644 --- a/src/docker.go +++ b/src/docker.go @@ -1,676 +1,676 @@ -package main - -import ( - "context" - "fmt" - "io" - "net/http" - "strings" - "time" - - "github.com/gin-gonic/gin" - "github.com/google/go-containerregistry/pkg/authn" - "github.com/google/go-containerregistry/pkg/name" - "github.com/google/go-containerregistry/pkg/v1/remote" -) - -// DockerProxy Docker代理配置 -type DockerProxy struct { - registry name.Registry - options []remote.Option -} - -var dockerProxy *DockerProxy - -// RegistryDetector Registry检测器 -type RegistryDetector struct{} - -// detectRegistryDomain 检测Registry域名并返回域名和剩余路径 -func (rd *RegistryDetector) detectRegistryDomain(path string) (string, string) { - cfg := GetConfig() - - // 检查路径是否以已知Registry域名开头 - for domain := range cfg.Registries { - if strings.HasPrefix(path, domain+"/") { - // 找到匹配的域名,返回域名和剩余路径 - remainingPath := strings.TrimPrefix(path, domain+"/") - return domain, remainingPath - } - } - - return "", path -} - -// isRegistryEnabled 检查Registry是否启用 -func (rd *RegistryDetector) isRegistryEnabled(domain string) bool { - cfg := GetConfig() - if mapping, exists := cfg.Registries[domain]; exists { - return mapping.Enabled - } - return false -} - -// getRegistryMapping 获取Registry映射配置 -func (rd *RegistryDetector) getRegistryMapping(domain string) (RegistryMapping, bool) { - cfg := GetConfig() - mapping, exists := cfg.Registries[domain] - return mapping, exists && mapping.Enabled -} - -var registryDetector = &RegistryDetector{} - -// 初始化Docker代理 -func initDockerProxy() { - // 创建目标registry - registry, err := name.NewRegistry("registry-1.docker.io") - if err != nil { - fmt.Printf("创建Docker registry失败: %v\n", err) - return - } - - // 配置代理选项 - options := []remote.Option{ - remote.WithAuth(authn.Anonymous), - remote.WithUserAgent("hubproxy/go-containerregistry"), - remote.WithTransport(GetGlobalHTTPClient().Transport), - } - - dockerProxy = &DockerProxy{ - registry: registry, - options: options, - } -} - -// ProxyDockerRegistryGin 标准Docker Registry API v2代理 -func ProxyDockerRegistryGin(c *gin.Context) { - path := c.Request.URL.Path - - // 处理 /v2/ API版本检查 - if path == "/v2/" { - c.JSON(http.StatusOK, gin.H{}) - return - } - - // 处理不同的API端点 - if strings.HasPrefix(path, "/v2/") { - handleRegistryRequest(c, path) - } else { - c.String(http.StatusNotFound, "Docker Registry API v2 only") - } -} - -// handleRegistryRequest 处理Registry请求 -func handleRegistryRequest(c *gin.Context, path string) { - // 移除 /v2/ 前缀 - pathWithoutV2 := strings.TrimPrefix(path, "/v2/") - - if registryDomain, remainingPath := registryDetector.detectRegistryDomain(pathWithoutV2); registryDomain != "" { - if registryDetector.isRegistryEnabled(registryDomain) { - // 设置目标Registry信息到Context - c.Set("target_registry_domain", registryDomain) - c.Set("target_path", remainingPath) - - // 处理多Registry请求 - handleMultiRegistryRequest(c, registryDomain, remainingPath) - return - } - } - - imageName, apiType, reference := parseRegistryPath(pathWithoutV2) - if imageName == "" || apiType == "" { - c.String(http.StatusBadRequest, "Invalid path format") - return - } - - // 自动处理官方镜像的library命名空间 - if !strings.Contains(imageName, "/") { - imageName = "library/" + imageName - } - - // Docker镜像访问控制检查 - if allowed, reason := GlobalAccessController.CheckDockerAccess(imageName); !allowed { - fmt.Printf("Docker镜像 %s 访问被拒绝: %s\n", imageName, reason) - c.String(http.StatusForbidden, "镜像访问被限制") - return - } - - // 构建完整的镜像引用 - imageRef := fmt.Sprintf("%s/%s", dockerProxy.registry.Name(), imageName) - - switch apiType { - case "manifests": - handleManifestRequest(c, imageRef, reference) - case "blobs": - handleBlobRequest(c, imageRef, reference) - case "tags": - handleTagsRequest(c, imageRef) - default: - c.String(http.StatusNotFound, "API endpoint not found") - } -} - -// parseRegistryPath 解析Registry路径 -func parseRegistryPath(path string) (imageName, apiType, reference string) { - // 查找API端点关键字 - if idx := strings.Index(path, "/manifests/"); idx != -1 { - imageName = path[:idx] - apiType = "manifests" - reference = path[idx+len("/manifests/"):] - return - } - - if idx := strings.Index(path, "/blobs/"); idx != -1 { - imageName = path[:idx] - apiType = "blobs" - reference = path[idx+len("/blobs/"):] - return - } - - if idx := strings.Index(path, "/tags/list"); idx != -1 { - imageName = path[:idx] - apiType = "tags" - reference = "list" - return - } - - return "", "", "" -} - -// handleManifestRequest 处理manifest请求 -func handleManifestRequest(c *gin.Context, imageRef, reference string) { - // Manifest缓存逻辑(仅对GET请求缓存) - if isCacheEnabled() && c.Request.Method == http.MethodGet { - cacheKey := buildManifestCacheKey(imageRef, reference) - - // 优先从缓存获取 - if cachedItem := globalCache.Get(cacheKey); cachedItem != nil { - writeCachedResponse(c, cachedItem) - return - } - } - - var ref name.Reference - var err error - - // 判断reference是digest还是tag - if strings.HasPrefix(reference, "sha256:") { - // 是digest - ref, err = name.NewDigest(fmt.Sprintf("%s@%s", imageRef, reference)) - } else { - // 是tag - ref, err = name.NewTag(fmt.Sprintf("%s:%s", imageRef, reference)) - } - - if err != nil { - fmt.Printf("解析镜像引用失败: %v\n", err) - c.String(http.StatusBadRequest, "Invalid reference") - return - } - - // 根据请求方法选择操作 - if c.Request.Method == http.MethodHead { - // HEAD请求,使用remote.Head - desc, err := remote.Head(ref, dockerProxy.options...) - if err != nil { - fmt.Printf("HEAD请求失败: %v\n", err) - c.String(http.StatusNotFound, "Manifest not found") - return - } - - // 设置响应头 - c.Header("Content-Type", string(desc.MediaType)) - c.Header("Docker-Content-Digest", desc.Digest.String()) - c.Header("Content-Length", fmt.Sprintf("%d", desc.Size)) - c.Status(http.StatusOK) - } else { - // GET请求,使用remote.Get - desc, err := remote.Get(ref, dockerProxy.options...) - if err != nil { - fmt.Printf("GET请求失败: %v\n", err) - c.String(http.StatusNotFound, "Manifest not found") - return - } - - // 设置响应头 - headers := map[string]string{ - "Docker-Content-Digest": desc.Digest.String(), - "Content-Length": fmt.Sprintf("%d", len(desc.Manifest)), - } - - // 缓存响应 - if isCacheEnabled() { - cacheKey := buildManifestCacheKey(imageRef, reference) - ttl := getManifestTTL(reference) - globalCache.Set(cacheKey, desc.Manifest, string(desc.MediaType), headers, ttl) - } - - // 设置响应头 - c.Header("Content-Type", string(desc.MediaType)) - for key, value := range headers { - c.Header(key, value) - } - - // 返回manifest内容 - c.Data(http.StatusOK, string(desc.MediaType), desc.Manifest) - } -} - -// handleBlobRequest 处理blob请求 -func handleBlobRequest(c *gin.Context, imageRef, digest string) { - // 构建digest引用 - digestRef, err := name.NewDigest(fmt.Sprintf("%s@%s", imageRef, digest)) - if err != nil { - fmt.Printf("解析digest引用失败: %v\n", err) - c.String(http.StatusBadRequest, "Invalid digest reference") - return - } - - // 使用remote.Layer获取layer - layer, err := remote.Layer(digestRef, dockerProxy.options...) - if err != nil { - fmt.Printf("获取layer失败: %v\n", err) - c.String(http.StatusNotFound, "Layer not found") - return - } - - // 获取layer信息 - size, err := layer.Size() - if err != nil { - fmt.Printf("获取layer大小失败: %v\n", err) - c.String(http.StatusInternalServerError, "Failed to get layer size") - return - } - - // 获取layer内容 - reader, err := layer.Compressed() - if err != nil { - fmt.Printf("获取layer内容失败: %v\n", err) - c.String(http.StatusInternalServerError, "Failed to get layer content") - return - } - defer reader.Close() - - // 设置响应头 - c.Header("Content-Type", "application/octet-stream") - c.Header("Content-Length", fmt.Sprintf("%d", size)) - c.Header("Docker-Content-Digest", digest) - - // 流式传输blob内容 - c.Status(http.StatusOK) - io.Copy(c.Writer, reader) -} - -// handleTagsRequest 处理tags列表请求 -func handleTagsRequest(c *gin.Context, imageRef string) { - // 解析repository - repo, err := name.NewRepository(imageRef) - if err != nil { - fmt.Printf("解析repository失败: %v\n", err) - c.String(http.StatusBadRequest, "Invalid repository") - return - } - - // 使用remote.List获取tags - tags, err := remote.List(repo, dockerProxy.options...) - if err != nil { - fmt.Printf("获取tags失败: %v\n", err) - c.String(http.StatusNotFound, "Tags not found") - return - } - - // 构建响应 - response := map[string]interface{}{ - "name": strings.TrimPrefix(imageRef, dockerProxy.registry.Name()+"/"), - "tags": tags, - } - - c.JSON(http.StatusOK, response) -} - -// ProxyDockerAuthGin Docker认证代理(带缓存优化) -func ProxyDockerAuthGin(c *gin.Context) { - // 检查是否启用token缓存 - if isTokenCacheEnabled() { - proxyDockerAuthWithCache(c) - } else { - proxyDockerAuthOriginal(c) - } -} - -// proxyDockerAuthWithCache 带缓存的认证代理 -func proxyDockerAuthWithCache(c *gin.Context) { - // 1. 构建缓存key(基于完整的查询参数) - cacheKey := buildTokenCacheKey(c.Request.URL.RawQuery) - - // 2. 尝试从缓存获取token - if cachedToken := globalCache.GetToken(cacheKey); cachedToken != "" { - writeTokenResponse(c, cachedToken) - return - } - - // 3. 缓存未命中,创建响应记录器 - recorder := &ResponseRecorder{ - ResponseWriter: c.Writer, - statusCode: 200, - } - c.Writer = recorder - - // 4. 调用原有认证逻辑 - proxyDockerAuthOriginal(c) - - // 5. 如果认证成功,缓存响应 - if recorder.statusCode == 200 && len(recorder.body) > 0 { - ttl := extractTTLFromResponse(recorder.body) - globalCache.SetToken(cacheKey, string(recorder.body), ttl) - } - - // 6. 写入实际响应 - c.Writer = recorder.ResponseWriter - c.Data(recorder.statusCode, "application/json", recorder.body) -} - -// ResponseRecorder HTTP响应记录器 -type ResponseRecorder struct { - gin.ResponseWriter - statusCode int - body []byte -} - -func (r *ResponseRecorder) WriteHeader(code int) { - r.statusCode = code -} - -func (r *ResponseRecorder) Write(data []byte) (int, error) { - r.body = append(r.body, data...) - return len(data), nil -} - -func proxyDockerAuthOriginal(c *gin.Context) { - var authURL string - if targetDomain, exists := c.Get("target_registry_domain"); exists { - if mapping, found := registryDetector.getRegistryMapping(targetDomain.(string)); found { - // 使用Registry特定的认证服务器 - authURL = "https://" + mapping.AuthHost + c.Request.URL.Path - } else { - // fallback到默认Docker认证 - authURL = "https://auth.docker.io" + c.Request.URL.Path - } - } else { - // 构建默认Docker认证URL - authURL = "https://auth.docker.io" + c.Request.URL.Path - } - - if c.Request.URL.RawQuery != "" { - authURL += "?" + c.Request.URL.RawQuery - } - - // 创建HTTP客户端,复用全局传输配置(包含代理设置) - client := &http.Client{ - Timeout: 30 * time.Second, - Transport: GetGlobalHTTPClient().Transport, - } - - // 创建请求 - req, err := http.NewRequestWithContext( - context.Background(), - c.Request.Method, - authURL, - c.Request.Body, - ) - if err != nil { - c.String(http.StatusInternalServerError, "Failed to create request") - return - } - - // 复制请求头 - for key, values := range c.Request.Header { - for _, value := range values { - req.Header.Add(key, value) - } - } - - // 执行请求 - resp, err := client.Do(req) - if err != nil { - c.String(http.StatusBadGateway, "Auth request failed") - return - } - defer resp.Body.Close() - - // 获取当前代理的Host地址 - proxyHost := c.Request.Host - if proxyHost == "" { - // 使用配置中的服务器地址和端口 - cfg := GetConfig() - proxyHost = fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port) - if cfg.Server.Host == "0.0.0.0" { - proxyHost = fmt.Sprintf("localhost:%d", cfg.Server.Port) - } - } - - // 复制响应头并重写认证URL - for key, values := range resp.Header { - for _, value := range values { - // 重写WWW-Authenticate头中的realm URL - if key == "Www-Authenticate" { - // 支持多Registry的URL重写 - value = rewriteAuthHeader(value, proxyHost) - } - c.Header(key, value) - } - } - - // 返回响应 - c.Status(resp.StatusCode) - io.Copy(c.Writer, resp.Body) -} - -// rewriteAuthHeader 重写认证头 -func rewriteAuthHeader(authHeader, proxyHost string) string { - // 重写各种Registry的认证URL - authHeader = strings.ReplaceAll(authHeader, "https://auth.docker.io", "http://"+proxyHost) - authHeader = strings.ReplaceAll(authHeader, "https://ghcr.io", "http://"+proxyHost) - authHeader = strings.ReplaceAll(authHeader, "https://gcr.io", "http://"+proxyHost) - authHeader = strings.ReplaceAll(authHeader, "https://quay.io", "http://"+proxyHost) - - return authHeader -} - -// handleMultiRegistryRequest 处理多Registry请求 -func handleMultiRegistryRequest(c *gin.Context, registryDomain, remainingPath string) { - // 获取Registry映射配置 - mapping, exists := registryDetector.getRegistryMapping(registryDomain) - if !exists { - c.String(http.StatusBadRequest, "Registry not configured") - return - } - - // 解析剩余路径 - imageName, apiType, reference := parseRegistryPath(remainingPath) - if imageName == "" || apiType == "" { - c.String(http.StatusBadRequest, "Invalid path format") - return - } - - // 访问控制检查(使用完整的镜像路径) - fullImageName := registryDomain + "/" + imageName - if allowed, reason := GlobalAccessController.CheckDockerAccess(fullImageName); !allowed { - fmt.Printf("镜像 %s 访问被拒绝: %s\n", fullImageName, reason) - c.String(http.StatusForbidden, "镜像访问被限制") - return - } - - // 构建上游Registry引用 - upstreamImageRef := fmt.Sprintf("%s/%s", mapping.Upstream, imageName) - - // 根据API类型处理请求 - switch apiType { - case "manifests": - handleUpstreamManifestRequest(c, upstreamImageRef, reference, mapping) - case "blobs": - handleUpstreamBlobRequest(c, upstreamImageRef, reference, mapping) - case "tags": - handleUpstreamTagsRequest(c, upstreamImageRef, mapping) - default: - c.String(http.StatusNotFound, "API endpoint not found") - } -} - -// handleUpstreamManifestRequest 处理上游Registry的manifest请求 -func handleUpstreamManifestRequest(c *gin.Context, imageRef, reference string, mapping RegistryMapping) { - // Manifest缓存逻辑(仅对GET请求缓存) - if isCacheEnabled() && c.Request.Method == http.MethodGet { - cacheKey := buildManifestCacheKey(imageRef, reference) - - // 优先从缓存获取 - if cachedItem := globalCache.Get(cacheKey); cachedItem != nil { - writeCachedResponse(c, cachedItem) - return - } - } - - var ref name.Reference - var err error - - // 判断reference是digest还是tag - if strings.HasPrefix(reference, "sha256:") { - ref, err = name.NewDigest(fmt.Sprintf("%s@%s", imageRef, reference)) - } else { - ref, err = name.NewTag(fmt.Sprintf("%s:%s", imageRef, reference)) - } - - if err != nil { - fmt.Printf("解析镜像引用失败: %v\n", err) - c.String(http.StatusBadRequest, "Invalid reference") - return - } - - // 创建针对上游Registry的选项 - options := createUpstreamOptions(mapping) - - // 根据请求方法选择操作 - if c.Request.Method == http.MethodHead { - desc, err := remote.Head(ref, options...) - if err != nil { - fmt.Printf("HEAD请求失败: %v\n", err) - c.String(http.StatusNotFound, "Manifest not found") - return - } - - c.Header("Content-Type", string(desc.MediaType)) - c.Header("Docker-Content-Digest", desc.Digest.String()) - c.Header("Content-Length", fmt.Sprintf("%d", desc.Size)) - c.Status(http.StatusOK) - } else { - desc, err := remote.Get(ref, options...) - if err != nil { - fmt.Printf("GET请求失败: %v\n", err) - c.String(http.StatusNotFound, "Manifest not found") - return - } - - // 设置响应头 - headers := map[string]string{ - "Docker-Content-Digest": desc.Digest.String(), - "Content-Length": fmt.Sprintf("%d", len(desc.Manifest)), - } - - // 缓存响应 - if isCacheEnabled() { - cacheKey := buildManifestCacheKey(imageRef, reference) - ttl := getManifestTTL(reference) - globalCache.Set(cacheKey, desc.Manifest, string(desc.MediaType), headers, ttl) - } - - // 设置响应头 - c.Header("Content-Type", string(desc.MediaType)) - for key, value := range headers { - c.Header(key, value) - } - - c.Data(http.StatusOK, string(desc.MediaType), desc.Manifest) - } -} - -// handleUpstreamBlobRequest 处理上游Registry的blob请求 -func handleUpstreamBlobRequest(c *gin.Context, imageRef, digest string, mapping RegistryMapping) { - digestRef, err := name.NewDigest(fmt.Sprintf("%s@%s", imageRef, digest)) - if err != nil { - fmt.Printf("解析digest引用失败: %v\n", err) - c.String(http.StatusBadRequest, "Invalid digest reference") - return - } - - options := createUpstreamOptions(mapping) - layer, err := remote.Layer(digestRef, options...) - if err != nil { - fmt.Printf("获取layer失败: %v\n", err) - c.String(http.StatusNotFound, "Layer not found") - return - } - - size, err := layer.Size() - if err != nil { - fmt.Printf("获取layer大小失败: %v\n", err) - c.String(http.StatusInternalServerError, "Failed to get layer size") - return - } - - reader, err := layer.Compressed() - if err != nil { - fmt.Printf("获取layer内容失败: %v\n", err) - c.String(http.StatusInternalServerError, "Failed to get layer content") - return - } - defer reader.Close() - - c.Header("Content-Type", "application/octet-stream") - c.Header("Content-Length", fmt.Sprintf("%d", size)) - c.Header("Docker-Content-Digest", digest) - - c.Status(http.StatusOK) - io.Copy(c.Writer, reader) -} - -// handleUpstreamTagsRequest 处理上游Registry的tags请求 -func handleUpstreamTagsRequest(c *gin.Context, imageRef string, mapping RegistryMapping) { - repo, err := name.NewRepository(imageRef) - if err != nil { - fmt.Printf("解析repository失败: %v\n", err) - c.String(http.StatusBadRequest, "Invalid repository") - return - } - - options := createUpstreamOptions(mapping) - tags, err := remote.List(repo, options...) - if err != nil { - fmt.Printf("获取tags失败: %v\n", err) - c.String(http.StatusNotFound, "Tags not found") - return - } - - response := map[string]interface{}{ - "name": strings.TrimPrefix(imageRef, mapping.Upstream+"/"), - "tags": tags, - } - - c.JSON(http.StatusOK, response) -} - -// createUpstreamOptions 创建上游Registry选项 -func createUpstreamOptions(mapping RegistryMapping) []remote.Option { - options := []remote.Option{ - remote.WithAuth(authn.Anonymous), - remote.WithUserAgent("hubproxy/go-containerregistry"), - remote.WithTransport(GetGlobalHTTPClient().Transport), - } - - // 根据Registry类型添加特定的认证选项(方便后续扩展) - switch mapping.AuthType { - case "github": - case "google": - case "quay": - } - - return options -} +package main + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/remote" +) + +// DockerProxy Docker代理配置 +type DockerProxy struct { + registry name.Registry + options []remote.Option +} + +var dockerProxy *DockerProxy + +// RegistryDetector Registry检测器 +type RegistryDetector struct{} + +// detectRegistryDomain 检测Registry域名并返回域名和剩余路径 +func (rd *RegistryDetector) detectRegistryDomain(path string) (string, string) { + cfg := GetConfig() + + // 检查路径是否以已知Registry域名开头 + for domain := range cfg.Registries { + if strings.HasPrefix(path, domain+"/") { + // 找到匹配的域名,返回域名和剩余路径 + remainingPath := strings.TrimPrefix(path, domain+"/") + return domain, remainingPath + } + } + + return "", path +} + +// isRegistryEnabled 检查Registry是否启用 +func (rd *RegistryDetector) isRegistryEnabled(domain string) bool { + cfg := GetConfig() + if mapping, exists := cfg.Registries[domain]; exists { + return mapping.Enabled + } + return false +} + +// getRegistryMapping 获取Registry映射配置 +func (rd *RegistryDetector) getRegistryMapping(domain string) (RegistryMapping, bool) { + cfg := GetConfig() + mapping, exists := cfg.Registries[domain] + return mapping, exists && mapping.Enabled +} + +var registryDetector = &RegistryDetector{} + +// 初始化Docker代理 +func initDockerProxy() { + // 创建目标registry + registry, err := name.NewRegistry("registry-1.docker.io") + if err != nil { + fmt.Printf("创建Docker registry失败: %v\n", err) + return + } + + // 配置代理选项 + options := []remote.Option{ + remote.WithAuth(authn.Anonymous), + remote.WithUserAgent("hubproxy/go-containerregistry"), + remote.WithTransport(GetGlobalHTTPClient().Transport), + } + + dockerProxy = &DockerProxy{ + registry: registry, + options: options, + } +} + +// ProxyDockerRegistryGin 标准Docker Registry API v2代理 +func ProxyDockerRegistryGin(c *gin.Context) { + path := c.Request.URL.Path + + // 处理 /v2/ API版本检查 + if path == "/v2/" { + c.JSON(http.StatusOK, gin.H{}) + return + } + + // 处理不同的API端点 + if strings.HasPrefix(path, "/v2/") { + handleRegistryRequest(c, path) + } else { + c.String(http.StatusNotFound, "Docker Registry API v2 only") + } +} + +// handleRegistryRequest 处理Registry请求 +func handleRegistryRequest(c *gin.Context, path string) { + // 移除 /v2/ 前缀 + pathWithoutV2 := strings.TrimPrefix(path, "/v2/") + + if registryDomain, remainingPath := registryDetector.detectRegistryDomain(pathWithoutV2); registryDomain != "" { + if registryDetector.isRegistryEnabled(registryDomain) { + // 设置目标Registry信息到Context + c.Set("target_registry_domain", registryDomain) + c.Set("target_path", remainingPath) + + // 处理多Registry请求 + handleMultiRegistryRequest(c, registryDomain, remainingPath) + return + } + } + + imageName, apiType, reference := parseRegistryPath(pathWithoutV2) + if imageName == "" || apiType == "" { + c.String(http.StatusBadRequest, "Invalid path format") + return + } + + // 自动处理官方镜像的library命名空间 + if !strings.Contains(imageName, "/") { + imageName = "library/" + imageName + } + + // Docker镜像访问控制检查 + if allowed, reason := GlobalAccessController.CheckDockerAccess(imageName); !allowed { + fmt.Printf("Docker镜像 %s 访问被拒绝: %s\n", imageName, reason) + c.String(http.StatusForbidden, "镜像访问被限制") + return + } + + // 构建完整的镜像引用 + imageRef := fmt.Sprintf("%s/%s", dockerProxy.registry.Name(), imageName) + + switch apiType { + case "manifests": + handleManifestRequest(c, imageRef, reference) + case "blobs": + handleBlobRequest(c, imageRef, reference) + case "tags": + handleTagsRequest(c, imageRef) + default: + c.String(http.StatusNotFound, "API endpoint not found") + } +} + +// parseRegistryPath 解析Registry路径 +func parseRegistryPath(path string) (imageName, apiType, reference string) { + // 查找API端点关键字 + if idx := strings.Index(path, "/manifests/"); idx != -1 { + imageName = path[:idx] + apiType = "manifests" + reference = path[idx+len("/manifests/"):] + return + } + + if idx := strings.Index(path, "/blobs/"); idx != -1 { + imageName = path[:idx] + apiType = "blobs" + reference = path[idx+len("/blobs/"):] + return + } + + if idx := strings.Index(path, "/tags/list"); idx != -1 { + imageName = path[:idx] + apiType = "tags" + reference = "list" + return + } + + return "", "", "" +} + +// handleManifestRequest 处理manifest请求 +func handleManifestRequest(c *gin.Context, imageRef, reference string) { + // Manifest缓存逻辑(仅对GET请求缓存) + if isCacheEnabled() && c.Request.Method == http.MethodGet { + cacheKey := buildManifestCacheKey(imageRef, reference) + + // 优先从缓存获取 + if cachedItem := globalCache.Get(cacheKey); cachedItem != nil { + writeCachedResponse(c, cachedItem) + return + } + } + + var ref name.Reference + var err error + + // 判断reference是digest还是tag + if strings.HasPrefix(reference, "sha256:") { + // 是digest + ref, err = name.NewDigest(fmt.Sprintf("%s@%s", imageRef, reference)) + } else { + // 是tag + ref, err = name.NewTag(fmt.Sprintf("%s:%s", imageRef, reference)) + } + + if err != nil { + fmt.Printf("解析镜像引用失败: %v\n", err) + c.String(http.StatusBadRequest, "Invalid reference") + return + } + + // 根据请求方法选择操作 + if c.Request.Method == http.MethodHead { + // HEAD请求,使用remote.Head + desc, err := remote.Head(ref, dockerProxy.options...) + if err != nil { + fmt.Printf("HEAD请求失败: %v\n", err) + c.String(http.StatusNotFound, "Manifest not found") + return + } + + // 设置响应头 + c.Header("Content-Type", string(desc.MediaType)) + c.Header("Docker-Content-Digest", desc.Digest.String()) + c.Header("Content-Length", fmt.Sprintf("%d", desc.Size)) + c.Status(http.StatusOK) + } else { + // GET请求,使用remote.Get + desc, err := remote.Get(ref, dockerProxy.options...) + if err != nil { + fmt.Printf("GET请求失败: %v\n", err) + c.String(http.StatusNotFound, "Manifest not found") + return + } + + // 设置响应头 + headers := map[string]string{ + "Docker-Content-Digest": desc.Digest.String(), + "Content-Length": fmt.Sprintf("%d", len(desc.Manifest)), + } + + // 缓存响应 + if isCacheEnabled() { + cacheKey := buildManifestCacheKey(imageRef, reference) + ttl := getManifestTTL(reference) + globalCache.Set(cacheKey, desc.Manifest, string(desc.MediaType), headers, ttl) + } + + // 设置响应头 + c.Header("Content-Type", string(desc.MediaType)) + for key, value := range headers { + c.Header(key, value) + } + + // 返回manifest内容 + c.Data(http.StatusOK, string(desc.MediaType), desc.Manifest) + } +} + +// handleBlobRequest 处理blob请求 +func handleBlobRequest(c *gin.Context, imageRef, digest string) { + // 构建digest引用 + digestRef, err := name.NewDigest(fmt.Sprintf("%s@%s", imageRef, digest)) + if err != nil { + fmt.Printf("解析digest引用失败: %v\n", err) + c.String(http.StatusBadRequest, "Invalid digest reference") + return + } + + // 使用remote.Layer获取layer + layer, err := remote.Layer(digestRef, dockerProxy.options...) + if err != nil { + fmt.Printf("获取layer失败: %v\n", err) + c.String(http.StatusNotFound, "Layer not found") + return + } + + // 获取layer信息 + size, err := layer.Size() + if err != nil { + fmt.Printf("获取layer大小失败: %v\n", err) + c.String(http.StatusInternalServerError, "Failed to get layer size") + return + } + + // 获取layer内容 + reader, err := layer.Compressed() + if err != nil { + fmt.Printf("获取layer内容失败: %v\n", err) + c.String(http.StatusInternalServerError, "Failed to get layer content") + return + } + defer reader.Close() + + // 设置响应头 + c.Header("Content-Type", "application/octet-stream") + c.Header("Content-Length", fmt.Sprintf("%d", size)) + c.Header("Docker-Content-Digest", digest) + + // 流式传输blob内容 + c.Status(http.StatusOK) + io.Copy(c.Writer, reader) +} + +// handleTagsRequest 处理tags列表请求 +func handleTagsRequest(c *gin.Context, imageRef string) { + // 解析repository + repo, err := name.NewRepository(imageRef) + if err != nil { + fmt.Printf("解析repository失败: %v\n", err) + c.String(http.StatusBadRequest, "Invalid repository") + return + } + + // 使用remote.List获取tags + tags, err := remote.List(repo, dockerProxy.options...) + if err != nil { + fmt.Printf("获取tags失败: %v\n", err) + c.String(http.StatusNotFound, "Tags not found") + return + } + + // 构建响应 + response := map[string]interface{}{ + "name": strings.TrimPrefix(imageRef, dockerProxy.registry.Name()+"/"), + "tags": tags, + } + + c.JSON(http.StatusOK, response) +} + +// ProxyDockerAuthGin Docker认证代理(带缓存优化) +func ProxyDockerAuthGin(c *gin.Context) { + // 检查是否启用token缓存 + if isTokenCacheEnabled() { + proxyDockerAuthWithCache(c) + } else { + proxyDockerAuthOriginal(c) + } +} + +// proxyDockerAuthWithCache 带缓存的认证代理 +func proxyDockerAuthWithCache(c *gin.Context) { + // 1. 构建缓存key(基于完整的查询参数) + cacheKey := buildTokenCacheKey(c.Request.URL.RawQuery) + + // 2. 尝试从缓存获取token + if cachedToken := globalCache.GetToken(cacheKey); cachedToken != "" { + writeTokenResponse(c, cachedToken) + return + } + + // 3. 缓存未命中,创建响应记录器 + recorder := &ResponseRecorder{ + ResponseWriter: c.Writer, + statusCode: 200, + } + c.Writer = recorder + + // 4. 调用原有认证逻辑 + proxyDockerAuthOriginal(c) + + // 5. 如果认证成功,缓存响应 + if recorder.statusCode == 200 && len(recorder.body) > 0 { + ttl := extractTTLFromResponse(recorder.body) + globalCache.SetToken(cacheKey, string(recorder.body), ttl) + } + + // 6. 写入实际响应 + c.Writer = recorder.ResponseWriter + c.Data(recorder.statusCode, "application/json", recorder.body) +} + +// ResponseRecorder HTTP响应记录器 +type ResponseRecorder struct { + gin.ResponseWriter + statusCode int + body []byte +} + +func (r *ResponseRecorder) WriteHeader(code int) { + r.statusCode = code +} + +func (r *ResponseRecorder) Write(data []byte) (int, error) { + r.body = append(r.body, data...) + return len(data), nil +} + +func proxyDockerAuthOriginal(c *gin.Context) { + var authURL string + if targetDomain, exists := c.Get("target_registry_domain"); exists { + if mapping, found := registryDetector.getRegistryMapping(targetDomain.(string)); found { + // 使用Registry特定的认证服务器 + authURL = "https://" + mapping.AuthHost + c.Request.URL.Path + } else { + // fallback到默认Docker认证 + authURL = "https://auth.docker.io" + c.Request.URL.Path + } + } else { + // 构建默认Docker认证URL + authURL = "https://auth.docker.io" + c.Request.URL.Path + } + + if c.Request.URL.RawQuery != "" { + authURL += "?" + c.Request.URL.RawQuery + } + + // 创建HTTP客户端,复用全局传输配置(包含代理设置) + client := &http.Client{ + Timeout: 30 * time.Second, + Transport: GetGlobalHTTPClient().Transport, + } + + // 创建请求 + req, err := http.NewRequestWithContext( + context.Background(), + c.Request.Method, + authURL, + c.Request.Body, + ) + if err != nil { + c.String(http.StatusInternalServerError, "Failed to create request") + return + } + + // 复制请求头 + for key, values := range c.Request.Header { + for _, value := range values { + req.Header.Add(key, value) + } + } + + // 执行请求 + resp, err := client.Do(req) + if err != nil { + c.String(http.StatusBadGateway, "Auth request failed") + return + } + defer resp.Body.Close() + + // 获取当前代理的Host地址 + proxyHost := c.Request.Host + if proxyHost == "" { + // 使用配置中的服务器地址和端口 + cfg := GetConfig() + proxyHost = fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port) + if cfg.Server.Host == "0.0.0.0" { + proxyHost = fmt.Sprintf("localhost:%d", cfg.Server.Port) + } + } + + // 复制响应头并重写认证URL + for key, values := range resp.Header { + for _, value := range values { + // 重写WWW-Authenticate头中的realm URL + if key == "Www-Authenticate" { + // 支持多Registry的URL重写 + value = rewriteAuthHeader(value, proxyHost) + } + c.Header(key, value) + } + } + + // 返回响应 + c.Status(resp.StatusCode) + io.Copy(c.Writer, resp.Body) +} + +// rewriteAuthHeader 重写认证头 +func rewriteAuthHeader(authHeader, proxyHost string) string { + // 重写各种Registry的认证URL + authHeader = strings.ReplaceAll(authHeader, "https://auth.docker.io", "http://"+proxyHost) + authHeader = strings.ReplaceAll(authHeader, "https://ghcr.io", "http://"+proxyHost) + authHeader = strings.ReplaceAll(authHeader, "https://gcr.io", "http://"+proxyHost) + authHeader = strings.ReplaceAll(authHeader, "https://quay.io", "http://"+proxyHost) + + return authHeader +} + +// handleMultiRegistryRequest 处理多Registry请求 +func handleMultiRegistryRequest(c *gin.Context, registryDomain, remainingPath string) { + // 获取Registry映射配置 + mapping, exists := registryDetector.getRegistryMapping(registryDomain) + if !exists { + c.String(http.StatusBadRequest, "Registry not configured") + return + } + + // 解析剩余路径 + imageName, apiType, reference := parseRegistryPath(remainingPath) + if imageName == "" || apiType == "" { + c.String(http.StatusBadRequest, "Invalid path format") + return + } + + // 访问控制检查(使用完整的镜像路径) + fullImageName := registryDomain + "/" + imageName + if allowed, reason := GlobalAccessController.CheckDockerAccess(fullImageName); !allowed { + fmt.Printf("镜像 %s 访问被拒绝: %s\n", fullImageName, reason) + c.String(http.StatusForbidden, "镜像访问被限制") + return + } + + // 构建上游Registry引用 + upstreamImageRef := fmt.Sprintf("%s/%s", mapping.Upstream, imageName) + + // 根据API类型处理请求 + switch apiType { + case "manifests": + handleUpstreamManifestRequest(c, upstreamImageRef, reference, mapping) + case "blobs": + handleUpstreamBlobRequest(c, upstreamImageRef, reference, mapping) + case "tags": + handleUpstreamTagsRequest(c, upstreamImageRef, mapping) + default: + c.String(http.StatusNotFound, "API endpoint not found") + } +} + +// handleUpstreamManifestRequest 处理上游Registry的manifest请求 +func handleUpstreamManifestRequest(c *gin.Context, imageRef, reference string, mapping RegistryMapping) { + // Manifest缓存逻辑(仅对GET请求缓存) + if isCacheEnabled() && c.Request.Method == http.MethodGet { + cacheKey := buildManifestCacheKey(imageRef, reference) + + // 优先从缓存获取 + if cachedItem := globalCache.Get(cacheKey); cachedItem != nil { + writeCachedResponse(c, cachedItem) + return + } + } + + var ref name.Reference + var err error + + // 判断reference是digest还是tag + if strings.HasPrefix(reference, "sha256:") { + ref, err = name.NewDigest(fmt.Sprintf("%s@%s", imageRef, reference)) + } else { + ref, err = name.NewTag(fmt.Sprintf("%s:%s", imageRef, reference)) + } + + if err != nil { + fmt.Printf("解析镜像引用失败: %v\n", err) + c.String(http.StatusBadRequest, "Invalid reference") + return + } + + // 创建针对上游Registry的选项 + options := createUpstreamOptions(mapping) + + // 根据请求方法选择操作 + if c.Request.Method == http.MethodHead { + desc, err := remote.Head(ref, options...) + if err != nil { + fmt.Printf("HEAD请求失败: %v\n", err) + c.String(http.StatusNotFound, "Manifest not found") + return + } + + c.Header("Content-Type", string(desc.MediaType)) + c.Header("Docker-Content-Digest", desc.Digest.String()) + c.Header("Content-Length", fmt.Sprintf("%d", desc.Size)) + c.Status(http.StatusOK) + } else { + desc, err := remote.Get(ref, options...) + if err != nil { + fmt.Printf("GET请求失败: %v\n", err) + c.String(http.StatusNotFound, "Manifest not found") + return + } + + // 设置响应头 + headers := map[string]string{ + "Docker-Content-Digest": desc.Digest.String(), + "Content-Length": fmt.Sprintf("%d", len(desc.Manifest)), + } + + // 缓存响应 + if isCacheEnabled() { + cacheKey := buildManifestCacheKey(imageRef, reference) + ttl := getManifestTTL(reference) + globalCache.Set(cacheKey, desc.Manifest, string(desc.MediaType), headers, ttl) + } + + // 设置响应头 + c.Header("Content-Type", string(desc.MediaType)) + for key, value := range headers { + c.Header(key, value) + } + + c.Data(http.StatusOK, string(desc.MediaType), desc.Manifest) + } +} + +// handleUpstreamBlobRequest 处理上游Registry的blob请求 +func handleUpstreamBlobRequest(c *gin.Context, imageRef, digest string, mapping RegistryMapping) { + digestRef, err := name.NewDigest(fmt.Sprintf("%s@%s", imageRef, digest)) + if err != nil { + fmt.Printf("解析digest引用失败: %v\n", err) + c.String(http.StatusBadRequest, "Invalid digest reference") + return + } + + options := createUpstreamOptions(mapping) + layer, err := remote.Layer(digestRef, options...) + if err != nil { + fmt.Printf("获取layer失败: %v\n", err) + c.String(http.StatusNotFound, "Layer not found") + return + } + + size, err := layer.Size() + if err != nil { + fmt.Printf("获取layer大小失败: %v\n", err) + c.String(http.StatusInternalServerError, "Failed to get layer size") + return + } + + reader, err := layer.Compressed() + if err != nil { + fmt.Printf("获取layer内容失败: %v\n", err) + c.String(http.StatusInternalServerError, "Failed to get layer content") + return + } + defer reader.Close() + + c.Header("Content-Type", "application/octet-stream") + c.Header("Content-Length", fmt.Sprintf("%d", size)) + c.Header("Docker-Content-Digest", digest) + + c.Status(http.StatusOK) + io.Copy(c.Writer, reader) +} + +// handleUpstreamTagsRequest 处理上游Registry的tags请求 +func handleUpstreamTagsRequest(c *gin.Context, imageRef string, mapping RegistryMapping) { + repo, err := name.NewRepository(imageRef) + if err != nil { + fmt.Printf("解析repository失败: %v\n", err) + c.String(http.StatusBadRequest, "Invalid repository") + return + } + + options := createUpstreamOptions(mapping) + tags, err := remote.List(repo, options...) + if err != nil { + fmt.Printf("获取tags失败: %v\n", err) + c.String(http.StatusNotFound, "Tags not found") + return + } + + response := map[string]interface{}{ + "name": strings.TrimPrefix(imageRef, mapping.Upstream+"/"), + "tags": tags, + } + + c.JSON(http.StatusOK, response) +} + +// createUpstreamOptions 创建上游Registry选项 +func createUpstreamOptions(mapping RegistryMapping) []remote.Option { + options := []remote.Option{ + remote.WithAuth(authn.Anonymous), + remote.WithUserAgent("hubproxy/go-containerregistry"), + remote.WithTransport(GetGlobalHTTPClient().Transport), + } + + // 根据Registry类型添加特定的认证选项(方便后续扩展) + switch mapping.AuthType { + case "github": + case "google": + case "quay": + } + + return options +} diff --git a/src/imagetar.go b/src/imagetar.go index 6dfc585..54a18ea 100644 --- a/src/imagetar.go +++ b/src/imagetar.go @@ -52,28 +52,28 @@ func NewDownloadDebouncer(window time.Duration) *DownloadDebouncer { func (d *DownloadDebouncer) ShouldAllow(userID, contentKey string) bool { d.mu.Lock() defer d.mu.Unlock() - + key := userID + ":" + contentKey now := time.Now() - + if entry, exists := d.entries[key]; exists { if now.Sub(entry.LastRequest) < d.window { return false // 在防抖窗口内,拒绝请求 } } - + // 更新或创建条目 d.entries[key] = &DebounceEntry{ LastRequest: now, UserID: userID, } - + // 清理过期条目(每5分钟清理一次) if time.Since(d.lastCleanup) > 5*time.Minute { d.cleanup(now) d.lastCleanup = now } - + return true } @@ -92,10 +92,10 @@ func generateContentFingerprint(images []string, platform string) string { sortedImages := make([]string, len(images)) copy(sortedImages, images) sort.Strings(sortedImages) - + // 组合内容:镜像列表 + 平台信息 content := strings.Join(sortedImages, "|") + ":" + platform - + // 生成MD5哈希 hash := md5.Sum([]byte(content)) return hex.EncodeToString(hash[:]) @@ -107,14 +107,14 @@ func getUserID(c *gin.Context) string { if sessionID, err := c.Cookie("session_id"); err == nil && sessionID != "" { return "session:" + sessionID } - + // 备用方案:IP + User-Agent组合 ip := c.ClientIP() userAgent := c.GetHeader("User-Agent") if userAgent == "" { userAgent = "unknown" } - + // 生成简短标识 combined := ip + ":" + userAgent hash := md5.Sum([]byte(combined)) @@ -228,7 +228,7 @@ func (is *ImageStreamer) StreamImageToGin(ctx context.Context, imageRef string, filename := strings.ReplaceAll(imageRef, "/", "_") + ".tar" c.Header("Content-Type", "application/octet-stream") c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) - + if options.Compression { c.Header("Content-Encoding", "gzip") } @@ -295,18 +295,18 @@ func (is *ImageStreamer) streamDockerFormatWithReturn(ctx context.Context, tarWr if err != nil { return err } - + configData, err := json.Marshal(configFile) if err != nil { return err } - + configHeader := &tar.Header{ Name: configDigest.String() + ".json", Size: int64(len(configData)), Mode: 0644, } - + if err := tarWriter.WriteHeader(configHeader); err != nil { return err } @@ -335,14 +335,14 @@ func (is *ImageStreamer) streamDockerFormatWithReturn(ctx context.Context, tarWr Typeflag: tar.TypeDir, Mode: 0755, } - + if err := tarWriter.WriteHeader(layerHeader); err != nil { return err } var layerSize int64 var layerReader io.ReadCloser - + // 根据配置选择使用压缩层或未压缩层 if options != nil && options.UseCompressedLayers { layerSize, err = layer.Size() @@ -357,7 +357,7 @@ func (is *ImageStreamer) streamDockerFormatWithReturn(ctx context.Context, tarWr } layerReader, err = layer.Uncompressed() } - + if err != nil { return err } @@ -368,7 +368,7 @@ func (is *ImageStreamer) streamDockerFormatWithReturn(ctx context.Context, tarWr Size: layerSize, Mode: 0644, } - + if err := tarWriter.WriteHeader(layerTarHeader); err != nil { return err } @@ -385,12 +385,11 @@ func (is *ImageStreamer) streamDockerFormatWithReturn(ctx context.Context, tarWr log.Printf("已处理层 %d/%d", i+1, len(layers)) } - // 构建单个镜像的manifest信息 singleManifest := map[string]interface{}{ "Config": configDigest.String() + ".json", "RepoTags": []string{imageRef}, - "Layers": func() []string { + "Layers": func() []string { var layers []string for _, digest := range layerDigests { layers = append(layers, digest+"/layer.tar") @@ -417,22 +416,22 @@ func (is *ImageStreamer) streamDockerFormatWithReturn(ctx context.Context, tarWr // 单镜像下载,直接写入manifest.json manifest := []map[string]interface{}{singleManifest} - + manifestData, err := json.Marshal(manifest) if err != nil { return err } - + manifestHeader := &tar.Header{ Name: "manifest.json", Size: int64(len(manifestData)), Mode: 0644, } - + if err := tarWriter.WriteHeader(manifestHeader); err != nil { return err } - + if _, err := tarWriter.Write(manifestData); err != nil { return err } @@ -442,17 +441,17 @@ func (is *ImageStreamer) streamDockerFormatWithReturn(ctx context.Context, tarWr if err != nil { return err } - + repositoriesHeader := &tar.Header{ Name: "repositories", Size: int64(len(repositoriesData)), Mode: 0644, } - + if err := tarWriter.WriteHeader(repositoriesHeader); err != nil { return err } - + _, err = tarWriter.Write(repositoriesData) return err } @@ -473,12 +472,12 @@ func (is *ImageStreamer) processImageForBatch(ctx context.Context, img v1.Image, var manifest map[string]interface{} var repositories map[string]map[string]string - + err = is.streamDockerFormatWithReturn(ctx, tarWriter, img, layers, configFile, imageRef, &manifest, &repositories, options) if err != nil { return nil, nil, err } - + return manifest, repositories, nil } @@ -537,7 +536,7 @@ func (is *ImageStreamer) selectPlatformImage(desc *remote.Descriptor, options *S if m.Platform == nil { continue } - + if options.Platform != "" { platformParts := strings.Split(options.Platform, "/") if len(platformParts) >= 2 { @@ -547,10 +546,10 @@ func (is *ImageStreamer) selectPlatformImage(desc *remote.Descriptor, options *S if len(platformParts) >= 3 { targetVariant = platformParts[2] } - - if m.Platform.OS == targetOS && - m.Platform.Architecture == targetArch && - m.Platform.Variant == targetVariant { + + if m.Platform.OS == targetOS && + m.Platform.Architecture == targetArch && + m.Platform.Variant == targetVariant { selectedDesc = &m break } @@ -629,10 +628,10 @@ func handleDirectImageDownload(c *gin.Context) { // 防抖检查 userID := getUserID(c) contentKey := generateContentFingerprint([]string{imageRef}, platform) - + if !singleImageDebouncer.ShouldAllow(userID, contentKey) { c.JSON(http.StatusTooManyRequests, gin.H{ - "error": "请求过于频繁,请稍后再试", + "error": "请求过于频繁,请稍后再试", "retry_after": 5, }) return @@ -689,10 +688,10 @@ func handleSimpleBatchDownload(c *gin.Context) { // 批量下载防抖检查 userID := getUserID(c) contentKey := generateContentFingerprint(req.Images, req.Platform) - + if !batchImageDebouncer.ShouldAllow(userID, contentKey) { c.JSON(http.StatusTooManyRequests, gin.H{ - "error": "批量下载请求过于频繁,请稍后再试", + "error": "批量下载请求过于频繁,请稍后再试", "retry_after": 60, }) return @@ -713,7 +712,7 @@ func handleSimpleBatchDownload(c *gin.Context) { log.Printf("批量下载 %d 个镜像 (平台: %s)", len(req.Images), formatPlatformText(req.Platform)) filename := fmt.Sprintf("batch_%d_images.tar", len(req.Images)) - + c.Header("Content-Type", "application/octet-stream") c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) @@ -811,12 +810,12 @@ func (is *ImageStreamer) StreamMultipleImages(ctx context.Context, imageRefs []s } log.Printf("处理镜像 %d/%d: %s", i+1, len(imageRefs), imageRef) - + // 防止单个镜像处理时间过长 timeoutCtx, cancel := context.WithTimeout(ctx, 15*time.Minute) manifest, repositories, err := is.streamSingleImageForBatch(timeoutCtx, tarWriter, imageRef, options) cancel() - + if err != nil { log.Printf("下载镜像 %s 失败: %v", imageRef, err) return fmt.Errorf("下载镜像 %s 失败: %w", imageRef, err) @@ -845,17 +844,17 @@ func (is *ImageStreamer) StreamMultipleImages(ctx context.Context, imageRefs []s if err != nil { return fmt.Errorf("序列化manifest失败: %w", err) } - + manifestHeader := &tar.Header{ Name: "manifest.json", Size: int64(len(manifestData)), Mode: 0644, } - + if err := tarWriter.WriteHeader(manifestHeader); err != nil { return fmt.Errorf("写入manifest header失败: %w", err) } - + if _, err := tarWriter.Write(manifestData); err != nil { return fmt.Errorf("写入manifest数据失败: %w", err) } @@ -865,21 +864,21 @@ func (is *ImageStreamer) StreamMultipleImages(ctx context.Context, imageRefs []s if err != nil { return fmt.Errorf("序列化repositories失败: %w", err) } - + repositoriesHeader := &tar.Header{ Name: "repositories", Size: int64(len(repositoriesData)), Mode: 0644, } - + if err := tarWriter.WriteHeader(repositoriesHeader); err != nil { return fmt.Errorf("写入repositories header失败: %w", err) } - + if _, err := tarWriter.Write(repositoriesData); err != nil { return fmt.Errorf("写入repositories数据失败: %w", err) } log.Printf("批量下载完成,共处理 %d 个镜像", len(imageRefs)) return nil -} \ No newline at end of file +} diff --git a/src/main.go b/src/main.go index f4a43a4..a3ef587 100644 --- a/src/main.go +++ b/src/main.go @@ -1,382 +1,379 @@ -package main - -import ( - "embed" - "fmt" - "io" - "log" - "net/http" - "regexp" - "strconv" - "strings" - "time" - - "github.com/gin-gonic/gin" -) - -//go:embed public/* -var staticFiles embed.FS - -// 服务嵌入的静态文件 -func serveEmbedFile(c *gin.Context, filename string) { - data, err := staticFiles.ReadFile(filename) - if err != nil { - c.Status(404) - return - } - contentType := "text/html; charset=utf-8" - if strings.HasSuffix(filename, ".ico") { - contentType = "image/x-icon" - } - c.Data(200, contentType, data) -} - -var ( - exps = []*regexp.Regexp{ - regexp.MustCompile(`^(?:https?://)?github\.com/([^/]+)/([^/]+)/(?:releases|archive)/.*$`), - regexp.MustCompile(`^(?:https?://)?github\.com/([^/]+)/([^/]+)/(?:blob|raw)/.*$`), - regexp.MustCompile(`^(?:https?://)?github\.com/([^/]+)/([^/]+)/(?:info|git-).*$`), - regexp.MustCompile(`^(?:https?://)?raw\.github(?:usercontent|)\.com/([^/]+)/([^/]+)/.+?/.+$`), - regexp.MustCompile(`^(?:https?://)?gist\.github(?:usercontent|)\.com/([^/]+)/.+?/.+`), - regexp.MustCompile(`^(?:https?://)?api\.github\.com/repos/([^/]+)/([^/]+)/.*`), - regexp.MustCompile(`^(?:https?://)?huggingface\.co(?:/spaces)?/([^/]+)/(.+)$`), - regexp.MustCompile(`^(?:https?://)?cdn-lfs\.hf\.co(?:/spaces)?/([^/]+)/([^/]+)(?:/(.*))?$`), - regexp.MustCompile(`^(?:https?://)?download\.docker\.com/([^/]+)/.*\.(tgz|zip)$`), - regexp.MustCompile(`^(?:https?://)?(github|opengraph)\.githubassets\.com/([^/]+)/.+?$`), - } - globalLimiter *IPRateLimiter - - // 服务启动时间 - serviceStartTime = time.Now() -) - -func main() { - // 加载配置 - if err := LoadConfig(); err != nil { - fmt.Printf("配置加载失败: %v\n", err) - return - } - - // 初始化HTTP客户端 - initHTTPClients() - - // 初始化限流器 - initLimiter() - - // 初始化Docker流式代理 - initDockerProxy() - - // 初始化镜像流式下载器 - initImageStreamer() - - // 初始化防抖器 - initDebouncer() - - gin.SetMode(gin.ReleaseMode) - router := gin.Default() - - // 全局Panic恢复保护 - router.Use(gin.CustomRecovery(func(c *gin.Context, recovered interface{}) { - log.Printf("🚨 Panic recovered: %v", recovered) - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "Internal server error", - "code": "INTERNAL_ERROR", - }) - })) - - // 全局限流中间件 - 应用到所有路由 - router.Use(RateLimitMiddleware(globalLimiter)) - - // 初始化监控端点 - initHealthRoutes(router) - - // 初始化镜像tar下载路由 - initImageTarRoutes(router) - - // 静态文件路由 - router.GET("/", func(c *gin.Context) { - serveEmbedFile(c, "public/index.html") - }) - router.GET("/public/*filepath", func(c *gin.Context) { - filepath := strings.TrimPrefix(c.Param("filepath"), "/") - serveEmbedFile(c, "public/"+filepath) - }) - - router.GET("/images.html", func(c *gin.Context) { - serveEmbedFile(c, "public/images.html") - }) - router.GET("/search.html", func(c *gin.Context) { - serveEmbedFile(c, "public/search.html") - }) - router.GET("/favicon.ico", func(c *gin.Context) { - serveEmbedFile(c, "public/favicon.ico") - }) - - // 注册dockerhub搜索路由 - RegisterSearchRoute(router) - - // 注册Docker认证路由(/token*) - router.Any("/token", ProxyDockerAuthGin) - router.Any("/token/*path", ProxyDockerAuthGin) - - // 注册Docker Registry代理路由 - router.Any("/v2/*path", ProxyDockerRegistryGin) - - - // 注册NoRoute处理器 - router.NoRoute(handler) - - cfg := GetConfig() - fmt.Printf("🚀 HubProxy 启动成功\n") - fmt.Printf("📡 监听地址: %s:%d\n", cfg.Server.Host, cfg.Server.Port) - fmt.Printf("⚡ 限流配置: %d请求/%g小时\n", cfg.RateLimit.RequestLimit, cfg.RateLimit.PeriodHours) - fmt.Printf("🔗 项目地址: https://github.com/sky22333/hubproxy\n") - - err := router.Run(fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)) - if err != nil { - fmt.Printf("启动服务失败: %v\n", err) - } -} - -func handler(c *gin.Context) { - rawPath := strings.TrimPrefix(c.Request.URL.RequestURI(), "/") - - for strings.HasPrefix(rawPath, "/") { - rawPath = strings.TrimPrefix(rawPath, "/") - } - - if !strings.HasPrefix(rawPath, "http") { - c.String(http.StatusForbidden, "无效输入") - return - } - - matches := checkURL(rawPath) - if matches != nil { - // GitHub仓库访问控制检查 - if allowed, reason := GlobalAccessController.CheckGitHubAccess(matches); !allowed { - // 构建仓库名用于日志 - var repoPath string - if len(matches) >= 2 { - username := matches[0] - repoName := strings.TrimSuffix(matches[1], ".git") - repoPath = username + "/" + repoName - } - fmt.Printf("GitHub仓库 %s 访问被拒绝: %s\n", repoPath, reason) - c.String(http.StatusForbidden, reason) - return - } - } else { - c.String(http.StatusForbidden, "无效输入") - return - } - - if exps[1].MatchString(rawPath) { - rawPath = strings.Replace(rawPath, "/blob/", "/raw/", 1) - } - - proxyRequest(c, rawPath) -} - - -func proxyRequest(c *gin.Context, u string) { - proxyWithRedirect(c, u, 0) -} - - -func proxyWithRedirect(c *gin.Context, u string, redirectCount int) { - // 限制最大重定向次数,防止无限递归 - const maxRedirects = 20 - if redirectCount > maxRedirects { - c.String(http.StatusLoopDetected, "重定向次数过多,可能存在循环重定向") - return - } - req, err := http.NewRequest(c.Request.Method, u, c.Request.Body) - if err != nil { - c.String(http.StatusInternalServerError, fmt.Sprintf("server error %v", err)) - return - } - - for key, values := range c.Request.Header { - for _, value := range values { - req.Header.Add(key, value) - } - } - req.Header.Del("Host") - - resp, err := GetGlobalHTTPClient().Do(req) - if err != nil { - c.String(http.StatusInternalServerError, fmt.Sprintf("server error %v", err)) - return - } - defer func() { - if err := resp.Body.Close(); err != nil { - fmt.Printf("关闭响应体失败: %v\n", err) - } - }() - - // 检查文件大小限制 - cfg := GetConfig() - if contentLength := resp.Header.Get("Content-Length"); contentLength != "" { - if size, err := strconv.ParseInt(contentLength, 10, 64); err == nil && size > cfg.Server.FileSize { - c.String(http.StatusRequestEntityTooLarge, - fmt.Sprintf("文件过大,限制大小: %d MB", cfg.Server.FileSize/(1024*1024))) - return - } - } - - // 清理安全相关的头 - resp.Header.Del("Content-Security-Policy") - resp.Header.Del("Referrer-Policy") - resp.Header.Del("Strict-Transport-Security") - - // 获取真实域名 - realHost := c.Request.Header.Get("X-Forwarded-Host") - if realHost == "" { - realHost = c.Request.Host - } - // 如果域名中没有协议前缀,添加https:// - if !strings.HasPrefix(realHost, "http://") && !strings.HasPrefix(realHost, "https://") { - realHost = "https://" + realHost - } - - if strings.HasSuffix(strings.ToLower(u), ".sh") { - isGzipCompressed := resp.Header.Get("Content-Encoding") == "gzip" - - processedBody, processedSize, err := ProcessSmart(resp.Body, isGzipCompressed, realHost) - if err != nil { - fmt.Printf("智能处理失败,回退到直接代理: %v\n", err) - processedBody = resp.Body - processedSize = 0 - } - - // 智能设置响应头 - if processedSize > 0 { - resp.Header.Del("Content-Length") - resp.Header.Del("Content-Encoding") - resp.Header.Set("Transfer-Encoding", "chunked") - } - - // 复制其他响应头 - for key, values := range resp.Header { - for _, value := range values { - c.Header(key, value) - } - } - - if location := resp.Header.Get("Location"); location != "" { - if checkURL(location) != nil { - c.Header("Location", "/"+location) - } else { - proxyWithRedirect(c, location, redirectCount+1) - return - } - } - - c.Status(resp.StatusCode) - - // 输出处理后的内容 - if _, err := io.Copy(c.Writer, processedBody); err != nil { - return - } - } else { - for key, values := range resp.Header { - for _, value := range values { - c.Header(key, value) - } - } - - // 处理重定向 - if location := resp.Header.Get("Location"); location != "" { - if checkURL(location) != nil { - c.Header("Location", "/"+location) - } else { - proxyWithRedirect(c, location, redirectCount+1) - return - } - } - - c.Status(resp.StatusCode) - - // 直接流式转发 - io.Copy(c.Writer, resp.Body) - } -} - -func checkURL(u string) []string { - for _, exp := range exps { - if matches := exp.FindStringSubmatch(u); matches != nil { - return matches[1:] - } - } - return nil -} - -// 初始化健康监控路由 -func initHealthRoutes(router *gin.Engine) { - // 健康检查端点 - router.GET("/health", func(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{ - "status": "healthy", - "timestamp": time.Now().Unix(), - "uptime": time.Since(serviceStartTime).Seconds(), - "service": "hubproxy", - }) - }) - - // 就绪检查端点 - router.GET("/ready", func(c *gin.Context) { - checks := make(map[string]string) - allReady := true - - if GetConfig() != nil { - checks["config"] = "ok" - } else { - checks["config"] = "failed" - allReady = false - } - - // 检查全局缓存状态 - if globalCache != nil { - checks["cache"] = "ok" - } else { - checks["cache"] = "failed" - allReady = false - } - - // 检查限流器状态 - if globalLimiter != nil { - checks["ratelimiter"] = "ok" - } else { - checks["ratelimiter"] = "failed" - allReady = false - } - - // 检查镜像下载器状态 - if globalImageStreamer != nil { - checks["imagestreamer"] = "ok" - } else { - checks["imagestreamer"] = "failed" - allReady = false - } - - // 检查HTTP客户端状态 - if GetGlobalHTTPClient() != nil { - checks["httpclient"] = "ok" - } else { - checks["httpclient"] = "failed" - allReady = false - } - - status := http.StatusOK - if !allReady { - status = http.StatusServiceUnavailable - } - - c.JSON(status, gin.H{ - "ready": allReady, - "checks": checks, - "timestamp": time.Now().Unix(), - "uptime": time.Since(serviceStartTime).Seconds(), - }) - }) -} +package main + +import ( + "embed" + "fmt" + "io" + "log" + "net/http" + "regexp" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" +) + +//go:embed public/* +var staticFiles embed.FS + +// 服务嵌入的静态文件 +func serveEmbedFile(c *gin.Context, filename string) { + data, err := staticFiles.ReadFile(filename) + if err != nil { + c.Status(404) + return + } + contentType := "text/html; charset=utf-8" + if strings.HasSuffix(filename, ".ico") { + contentType = "image/x-icon" + } + c.Data(200, contentType, data) +} + +var ( + exps = []*regexp.Regexp{ + regexp.MustCompile(`^(?:https?://)?github\.com/([^/]+)/([^/]+)/(?:releases|archive)/.*$`), + regexp.MustCompile(`^(?:https?://)?github\.com/([^/]+)/([^/]+)/(?:blob|raw)/.*$`), + regexp.MustCompile(`^(?:https?://)?github\.com/([^/]+)/([^/]+)/(?:info|git-).*$`), + regexp.MustCompile(`^(?:https?://)?raw\.github(?:usercontent|)\.com/([^/]+)/([^/]+)/.+?/.+$`), + regexp.MustCompile(`^(?:https?://)?gist\.github(?:usercontent|)\.com/([^/]+)/.+?/.+`), + regexp.MustCompile(`^(?:https?://)?api\.github\.com/repos/([^/]+)/([^/]+)/.*`), + regexp.MustCompile(`^(?:https?://)?huggingface\.co(?:/spaces)?/([^/]+)/(.+)$`), + regexp.MustCompile(`^(?:https?://)?cdn-lfs\.hf\.co(?:/spaces)?/([^/]+)/([^/]+)(?:/(.*))?$`), + regexp.MustCompile(`^(?:https?://)?download\.docker\.com/([^/]+)/.*\.(tgz|zip)$`), + regexp.MustCompile(`^(?:https?://)?(github|opengraph)\.githubassets\.com/([^/]+)/.+?$`), + } + globalLimiter *IPRateLimiter + + // 服务启动时间 + serviceStartTime = time.Now() +) + +func main() { + // 加载配置 + if err := LoadConfig(); err != nil { + fmt.Printf("配置加载失败: %v\n", err) + return + } + + // 初始化HTTP客户端 + initHTTPClients() + + // 初始化限流器 + initLimiter() + + // 初始化Docker流式代理 + initDockerProxy() + + // 初始化镜像流式下载器 + initImageStreamer() + + // 初始化防抖器 + initDebouncer() + + gin.SetMode(gin.ReleaseMode) + router := gin.Default() + + // 全局Panic恢复保护 + router.Use(gin.CustomRecovery(func(c *gin.Context, recovered interface{}) { + log.Printf("🚨 Panic recovered: %v", recovered) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Internal server error", + "code": "INTERNAL_ERROR", + }) + })) + + // 全局限流中间件 - 应用到所有路由 + router.Use(RateLimitMiddleware(globalLimiter)) + + // 初始化监控端点 + initHealthRoutes(router) + + // 初始化镜像tar下载路由 + initImageTarRoutes(router) + + // 静态文件路由 + router.GET("/", func(c *gin.Context) { + serveEmbedFile(c, "public/index.html") + }) + router.GET("/public/*filepath", func(c *gin.Context) { + filepath := strings.TrimPrefix(c.Param("filepath"), "/") + serveEmbedFile(c, "public/"+filepath) + }) + + router.GET("/images.html", func(c *gin.Context) { + serveEmbedFile(c, "public/images.html") + }) + router.GET("/search.html", func(c *gin.Context) { + serveEmbedFile(c, "public/search.html") + }) + router.GET("/favicon.ico", func(c *gin.Context) { + serveEmbedFile(c, "public/favicon.ico") + }) + + // 注册dockerhub搜索路由 + RegisterSearchRoute(router) + + // 注册Docker认证路由(/token*) + router.Any("/token", ProxyDockerAuthGin) + router.Any("/token/*path", ProxyDockerAuthGin) + + // 注册Docker Registry代理路由 + router.Any("/v2/*path", ProxyDockerRegistryGin) + + // 注册NoRoute处理器 + router.NoRoute(handler) + + cfg := GetConfig() + fmt.Printf("🚀 HubProxy 启动成功\n") + fmt.Printf("📡 监听地址: %s:%d\n", cfg.Server.Host, cfg.Server.Port) + fmt.Printf("⚡ 限流配置: %d请求/%g小时\n", cfg.RateLimit.RequestLimit, cfg.RateLimit.PeriodHours) + fmt.Printf("🔗 项目地址: https://github.com/sky22333/hubproxy\n") + + err := router.Run(fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)) + if err != nil { + fmt.Printf("启动服务失败: %v\n", err) + } +} + +func handler(c *gin.Context) { + rawPath := strings.TrimPrefix(c.Request.URL.RequestURI(), "/") + + for strings.HasPrefix(rawPath, "/") { + rawPath = strings.TrimPrefix(rawPath, "/") + } + + if !strings.HasPrefix(rawPath, "http") { + c.String(http.StatusForbidden, "无效输入") + return + } + + matches := checkURL(rawPath) + if matches != nil { + // GitHub仓库访问控制检查 + if allowed, reason := GlobalAccessController.CheckGitHubAccess(matches); !allowed { + // 构建仓库名用于日志 + var repoPath string + if len(matches) >= 2 { + username := matches[0] + repoName := strings.TrimSuffix(matches[1], ".git") + repoPath = username + "/" + repoName + } + fmt.Printf("GitHub仓库 %s 访问被拒绝: %s\n", repoPath, reason) + c.String(http.StatusForbidden, reason) + return + } + } else { + c.String(http.StatusForbidden, "无效输入") + return + } + + if exps[1].MatchString(rawPath) { + rawPath = strings.Replace(rawPath, "/blob/", "/raw/", 1) + } + + proxyRequest(c, rawPath) +} + +func proxyRequest(c *gin.Context, u string) { + proxyWithRedirect(c, u, 0) +} + +func proxyWithRedirect(c *gin.Context, u string, redirectCount int) { + // 限制最大重定向次数,防止无限递归 + const maxRedirects = 20 + if redirectCount > maxRedirects { + c.String(http.StatusLoopDetected, "重定向次数过多,可能存在循环重定向") + return + } + req, err := http.NewRequest(c.Request.Method, u, c.Request.Body) + if err != nil { + c.String(http.StatusInternalServerError, fmt.Sprintf("server error %v", err)) + return + } + + for key, values := range c.Request.Header { + for _, value := range values { + req.Header.Add(key, value) + } + } + req.Header.Del("Host") + + resp, err := GetGlobalHTTPClient().Do(req) + if err != nil { + c.String(http.StatusInternalServerError, fmt.Sprintf("server error %v", err)) + return + } + defer func() { + if err := resp.Body.Close(); err != nil { + fmt.Printf("关闭响应体失败: %v\n", err) + } + }() + + // 检查文件大小限制 + cfg := GetConfig() + if contentLength := resp.Header.Get("Content-Length"); contentLength != "" { + if size, err := strconv.ParseInt(contentLength, 10, 64); err == nil && size > cfg.Server.FileSize { + c.String(http.StatusRequestEntityTooLarge, + fmt.Sprintf("文件过大,限制大小: %d MB", cfg.Server.FileSize/(1024*1024))) + return + } + } + + // 清理安全相关的头 + resp.Header.Del("Content-Security-Policy") + resp.Header.Del("Referrer-Policy") + resp.Header.Del("Strict-Transport-Security") + + // 获取真实域名 + realHost := c.Request.Header.Get("X-Forwarded-Host") + if realHost == "" { + realHost = c.Request.Host + } + // 如果域名中没有协议前缀,添加https:// + if !strings.HasPrefix(realHost, "http://") && !strings.HasPrefix(realHost, "https://") { + realHost = "https://" + realHost + } + + if strings.HasSuffix(strings.ToLower(u), ".sh") { + isGzipCompressed := resp.Header.Get("Content-Encoding") == "gzip" + + processedBody, processedSize, err := ProcessSmart(resp.Body, isGzipCompressed, realHost) + if err != nil { + fmt.Printf("智能处理失败,回退到直接代理: %v\n", err) + processedBody = resp.Body + processedSize = 0 + } + + // 智能设置响应头 + if processedSize > 0 { + resp.Header.Del("Content-Length") + resp.Header.Del("Content-Encoding") + resp.Header.Set("Transfer-Encoding", "chunked") + } + + // 复制其他响应头 + for key, values := range resp.Header { + for _, value := range values { + c.Header(key, value) + } + } + + if location := resp.Header.Get("Location"); location != "" { + if checkURL(location) != nil { + c.Header("Location", "/"+location) + } else { + proxyWithRedirect(c, location, redirectCount+1) + return + } + } + + c.Status(resp.StatusCode) + + // 输出处理后的内容 + if _, err := io.Copy(c.Writer, processedBody); err != nil { + return + } + } else { + for key, values := range resp.Header { + for _, value := range values { + c.Header(key, value) + } + } + + // 处理重定向 + if location := resp.Header.Get("Location"); location != "" { + if checkURL(location) != nil { + c.Header("Location", "/"+location) + } else { + proxyWithRedirect(c, location, redirectCount+1) + return + } + } + + c.Status(resp.StatusCode) + + // 直接流式转发 + io.Copy(c.Writer, resp.Body) + } +} + +func checkURL(u string) []string { + for _, exp := range exps { + if matches := exp.FindStringSubmatch(u); matches != nil { + return matches[1:] + } + } + return nil +} + +// 初始化健康监控路由 +func initHealthRoutes(router *gin.Engine) { + // 健康检查端点 + router.GET("/health", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "status": "healthy", + "timestamp": time.Now().Unix(), + "uptime": time.Since(serviceStartTime).Seconds(), + "service": "hubproxy", + }) + }) + + // 就绪检查端点 + router.GET("/ready", func(c *gin.Context) { + checks := make(map[string]string) + allReady := true + + if GetConfig() != nil { + checks["config"] = "ok" + } else { + checks["config"] = "failed" + allReady = false + } + + // 检查全局缓存状态 + if globalCache != nil { + checks["cache"] = "ok" + } else { + checks["cache"] = "failed" + allReady = false + } + + // 检查限流器状态 + if globalLimiter != nil { + checks["ratelimiter"] = "ok" + } else { + checks["ratelimiter"] = "failed" + allReady = false + } + + // 检查镜像下载器状态 + if globalImageStreamer != nil { + checks["imagestreamer"] = "ok" + } else { + checks["imagestreamer"] = "failed" + allReady = false + } + + // 检查HTTP客户端状态 + if GetGlobalHTTPClient() != nil { + checks["httpclient"] = "ok" + } else { + checks["httpclient"] = "failed" + allReady = false + } + + status := http.StatusOK + if !allReady { + status = http.StatusServiceUnavailable + } + + c.JSON(status, gin.H{ + "ready": allReady, + "checks": checks, + "timestamp": time.Now().Unix(), + "uptime": time.Since(serviceStartTime).Seconds(), + }) + }) +} diff --git a/src/proxysh.go b/src/proxysh.go index f3f7f58..e49e917 100644 --- a/src/proxysh.go +++ b/src/proxysh.go @@ -1,95 +1,95 @@ -package main - -import ( - "bytes" - "compress/gzip" - "fmt" - "io" - "regexp" - "strings" -) - -// GitHub URL正则表达式 -var githubRegex = regexp.MustCompile(`https?://(?:github\.com|raw\.githubusercontent\.com|raw\.github\.com|gist\.githubusercontent\.com|gist\.github\.com|api\.github\.com)[^\s'"]+`) - -// ProcessSmart Shell脚本智能处理函数 -func ProcessSmart(input io.ReadCloser, isCompressed bool, host string) (io.Reader, int64, error) { - defer input.Close() - - content, err := readShellContent(input, isCompressed) - if err != nil { - return nil, 0, fmt.Errorf("内容读取失败: %v", err) - } - - if len(content) == 0 { - return strings.NewReader(""), 0, nil - } - - if len(content) > 10*1024*1024 { - return strings.NewReader(content), int64(len(content)), nil - } - - if !strings.Contains(content, "github.com") && !strings.Contains(content, "githubusercontent.com") { - return strings.NewReader(content), int64(len(content)), nil - } - - processed := processGitHubURLs(content, host) - - return strings.NewReader(processed), int64(len(processed)), nil -} - -func readShellContent(input io.ReadCloser, isCompressed bool) (string, error) { - var reader io.Reader = input - - // 处理gzip压缩 - if isCompressed { - peek := make([]byte, 2) - n, err := input.Read(peek) - if err != nil && err != io.EOF { - return "", fmt.Errorf("读取数据失败: %v", err) - } - - if n >= 2 && peek[0] == 0x1f && peek[1] == 0x8b { - combinedReader := io.MultiReader(bytes.NewReader(peek[:n]), input) - gzReader, err := gzip.NewReader(combinedReader) - if err != nil { - return "", fmt.Errorf("gzip解压失败: %v", err) - } - defer gzReader.Close() - reader = gzReader - } else { - reader = io.MultiReader(bytes.NewReader(peek[:n]), input) - } - } - - data, err := io.ReadAll(reader) - if err != nil { - return "", fmt.Errorf("读取内容失败: %v", err) - } - - return string(data), nil -} - -func processGitHubURLs(content, host string) string { - return githubRegex.ReplaceAllStringFunc(content, func(url string) string { - return transformURL(url, host) - }) -} - -// transformURL URL转换函数 -func transformURL(url, host string) string { - if strings.Contains(url, host) { - return url - } - - if strings.HasPrefix(url, "http://") { - url = "https" + url[4:] - } else if !strings.HasPrefix(url, "https://") && !strings.HasPrefix(url, "//") { - url = "https://" + url - } - cleanHost := strings.TrimPrefix(host, "https://") - cleanHost = strings.TrimPrefix(cleanHost, "http://") - cleanHost = strings.TrimSuffix(cleanHost, "/") - - return cleanHost + "/" + url -} \ No newline at end of file +package main + +import ( + "bytes" + "compress/gzip" + "fmt" + "io" + "regexp" + "strings" +) + +// GitHub URL正则表达式 +var githubRegex = regexp.MustCompile(`https?://(?:github\.com|raw\.githubusercontent\.com|raw\.github\.com|gist\.githubusercontent\.com|gist\.github\.com|api\.github\.com)[^\s'"]+`) + +// ProcessSmart Shell脚本智能处理函数 +func ProcessSmart(input io.ReadCloser, isCompressed bool, host string) (io.Reader, int64, error) { + defer input.Close() + + content, err := readShellContent(input, isCompressed) + if err != nil { + return nil, 0, fmt.Errorf("内容读取失败: %v", err) + } + + if len(content) == 0 { + return strings.NewReader(""), 0, nil + } + + if len(content) > 10*1024*1024 { + return strings.NewReader(content), int64(len(content)), nil + } + + if !strings.Contains(content, "github.com") && !strings.Contains(content, "githubusercontent.com") { + return strings.NewReader(content), int64(len(content)), nil + } + + processed := processGitHubURLs(content, host) + + return strings.NewReader(processed), int64(len(processed)), nil +} + +func readShellContent(input io.ReadCloser, isCompressed bool) (string, error) { + var reader io.Reader = input + + // 处理gzip压缩 + if isCompressed { + peek := make([]byte, 2) + n, err := input.Read(peek) + if err != nil && err != io.EOF { + return "", fmt.Errorf("读取数据失败: %v", err) + } + + if n >= 2 && peek[0] == 0x1f && peek[1] == 0x8b { + combinedReader := io.MultiReader(bytes.NewReader(peek[:n]), input) + gzReader, err := gzip.NewReader(combinedReader) + if err != nil { + return "", fmt.Errorf("gzip解压失败: %v", err) + } + defer gzReader.Close() + reader = gzReader + } else { + reader = io.MultiReader(bytes.NewReader(peek[:n]), input) + } + } + + data, err := io.ReadAll(reader) + if err != nil { + return "", fmt.Errorf("读取内容失败: %v", err) + } + + return string(data), nil +} + +func processGitHubURLs(content, host string) string { + return githubRegex.ReplaceAllStringFunc(content, func(url string) string { + return transformURL(url, host) + }) +} + +// transformURL URL转换函数 +func transformURL(url, host string) string { + if strings.Contains(url, host) { + return url + } + + if strings.HasPrefix(url, "http://") { + url = "https" + url[4:] + } else if !strings.HasPrefix(url, "https://") && !strings.HasPrefix(url, "//") { + url = "https://" + url + } + cleanHost := strings.TrimPrefix(host, "https://") + cleanHost = strings.TrimPrefix(cleanHost, "http://") + cleanHost = strings.TrimSuffix(cleanHost, "/") + + return cleanHost + "/" + url +} diff --git a/src/ratelimiter.go b/src/ratelimiter.go index 671a6cd..9f7bdc8 100644 --- a/src/ratelimiter.go +++ b/src/ratelimiter.go @@ -1,303 +1,301 @@ -package main - -import ( - "fmt" - "net" - "strings" - "sync" - "time" - - "github.com/gin-gonic/gin" - "golang.org/x/time/rate" -) - -const ( - // 清理间隔 - CleanupInterval = 10 * time.Minute - MaxIPCacheSize = 10000 -) - -// IPRateLimiter IP限流器结构体 -type IPRateLimiter struct { - ips map[string]*rateLimiterEntry // IP到限流器的映射 - mu *sync.RWMutex // 读写锁,保证并发安全 - r rate.Limit // 速率限制(每秒允许的请求数) - b int // 令牌桶容量(突发请求数) - whitelist []*net.IPNet // 白名单IP段 - blacklist []*net.IPNet // 黑名单IP段 -} - -// rateLimiterEntry 限流器条目 -type rateLimiterEntry struct { - limiter *rate.Limiter - lastAccess time.Time -} - -// initGlobalLimiter 初始化全局限流器 -func initGlobalLimiter() *IPRateLimiter { - cfg := GetConfig() - - whitelist := make([]*net.IPNet, 0, len(cfg.Security.WhiteList)) - for _, item := range cfg.Security.WhiteList { - if item = strings.TrimSpace(item); item != "" { - if !strings.Contains(item, "/") { - item = item + "/32" // 单个IP转为CIDR格式 - } - _, ipnet, err := net.ParseCIDR(item) - if err == nil { - whitelist = append(whitelist, ipnet) - } else { - fmt.Printf("警告: 无效的白名单IP格式: %s\n", item) - } - } - } - - // 解析黑名单IP段 - blacklist := make([]*net.IPNet, 0, len(cfg.Security.BlackList)) - for _, item := range cfg.Security.BlackList { - if item = strings.TrimSpace(item); item != "" { - if !strings.Contains(item, "/") { - item = item + "/32" // 单个IP转为CIDR格式 - } - _, ipnet, err := net.ParseCIDR(item) - if err == nil { - blacklist = append(blacklist, ipnet) - } else { - fmt.Printf("警告: 无效的黑名单IP格式: %s\n", item) - } - } - } - - // 计算速率:将 "每N小时X个请求" 转换为 "每秒Y个请求" - ratePerSecond := rate.Limit(float64(cfg.RateLimit.RequestLimit) / (cfg.RateLimit.PeriodHours * 3600)) - - burstSize := cfg.RateLimit.RequestLimit - if burstSize < 1 { - burstSize = 1 - } - - limiter := &IPRateLimiter{ - ips: make(map[string]*rateLimiterEntry), - mu: &sync.RWMutex{}, - r: ratePerSecond, - b: burstSize, - whitelist: whitelist, - blacklist: blacklist, - } - - // 启动定期清理goroutine - go limiter.cleanupRoutine() - - return limiter -} - -// initLimiter 初始化限流器 -func initLimiter() { - globalLimiter = initGlobalLimiter() -} - -// cleanupRoutine 定期清理过期的限流器 -func (i *IPRateLimiter) cleanupRoutine() { - ticker := time.NewTicker(CleanupInterval) - defer ticker.Stop() - - for range ticker.C { - now := time.Now() - expired := make([]string, 0) - - // 查找过期的条目 - i.mu.RLock() - for ip, entry := range i.ips { - // 如果最后访问时间超过1小时,认为过期 - if now.Sub(entry.lastAccess) > 1*time.Hour { - expired = append(expired, ip) - } - } - i.mu.RUnlock() - - // 如果有过期条目或者缓存过大,进行清理 - if len(expired) > 0 || len(i.ips) > MaxIPCacheSize { - i.mu.Lock() - // 删除过期条目 - for _, ip := range expired { - delete(i.ips, ip) - } - - // 如果缓存仍然过大,全部清理 - if len(i.ips) > MaxIPCacheSize { - i.ips = make(map[string]*rateLimiterEntry) - } - i.mu.Unlock() - } - } -} - -// extractIPFromAddress 从地址中提取纯IP -func extractIPFromAddress(address string) string { - if host, _, err := net.SplitHostPort(address); err == nil { - return host - } - return address -} - -// normalizeIPForRateLimit 标准化IP地址用于限流:IPv4保持不变,IPv6标准化为/64网段 -func normalizeIPForRateLimit(ipStr string) string { - ip := net.ParseIP(ipStr) - if ip == nil { - return ipStr // 解析失败,返回原值 - } - - if ip.To4() != nil { - return ipStr // IPv4保持不变 - } - - // IPv6:标准化为 /64 网段 - ipv6 := ip.To16() - for i := 8; i < 16; i++ { - ipv6[i] = 0 // 清零后64位 - } - return ipv6.String() + "/64" -} - -// isIPInCIDRList 检查IP是否在CIDR列表中 -func isIPInCIDRList(ip string, cidrList []*net.IPNet) bool { - // 先提取纯IP地址 - cleanIP := extractIPFromAddress(ip) - parsedIP := net.ParseIP(cleanIP) - if parsedIP == nil { - return false - } - - for _, cidr := range cidrList { - if cidr.Contains(parsedIP) { - return true - } - } - return false -} - -// GetLimiter 获取指定IP的限流器,同时返回是否允许访问 -func (i *IPRateLimiter) GetLimiter(ip string) (*rate.Limiter, bool) { - // 提取纯IP地址 - cleanIP := extractIPFromAddress(ip) - - // 检查是否在黑名单中 - if isIPInCIDRList(cleanIP, i.blacklist) { - return nil, false - } - - // 检查是否在白名单中 - if isIPInCIDRList(cleanIP, i.whitelist) { - return rate.NewLimiter(rate.Inf, i.b), true - } - - // 标准化IP用于限流:IPv4保持不变,IPv6标准化为/64网段 - normalizedIP := normalizeIPForRateLimit(cleanIP) - - now := time.Now() - - i.mu.RLock() - entry, exists := i.ips[normalizedIP] - i.mu.RUnlock() - - if exists { - i.mu.Lock() - if entry, stillExists := i.ips[normalizedIP]; stillExists { - entry.lastAccess = now - i.mu.Unlock() - return entry.limiter, true - } - i.mu.Unlock() - } - - i.mu.Lock() - if entry, exists := i.ips[normalizedIP]; exists { - entry.lastAccess = now - i.mu.Unlock() - return entry.limiter, true - } - - entry = &rateLimiterEntry{ - limiter: rate.NewLimiter(i.r, i.b), - lastAccess: now, - } - i.ips[normalizedIP] = entry - i.mu.Unlock() - - return entry.limiter, true -} - -// RateLimitMiddleware 速率限制中间件 -func RateLimitMiddleware(limiter *IPRateLimiter) gin.HandlerFunc { - return func(c *gin.Context) { - // 静态文件豁免:跳过限流检查 - path := c.Request.URL.Path - if path == "/" || path == "/favicon.ico" || path == "/images.html" || path == "/search.html" || - strings.HasPrefix(path, "/public/") { - c.Next() - return - } - - // 获取客户端真实IP - var ip string - - // 优先尝试从请求头获取真实IP - if forwarded := c.GetHeader("X-Forwarded-For"); forwarded != "" { - // X-Forwarded-For可能包含多个IP,取第一个 - ips := strings.Split(forwarded, ",") - ip = strings.TrimSpace(ips[0]) - } else if realIP := c.GetHeader("X-Real-IP"); realIP != "" { - // 如果有X-Real-IP头 - ip = realIP - } else if remoteIP := c.GetHeader("X-Original-Forwarded-For"); remoteIP != "" { - // 某些代理可能使用此头 - ips := strings.Split(remoteIP, ",") - ip = strings.TrimSpace(ips[0]) - } else { - // 回退到ClientIP方法 - ip = c.ClientIP() - } - - // 提取纯IP地址(去除可能存在的端口) - cleanIP := extractIPFromAddress(ip) - - // 日志记录请求IP和头信息 - normalizedIP := normalizeIPForRateLimit(cleanIP) - if cleanIP != normalizedIP { - fmt.Printf("请求IP: %s (提纯后: %s, 限流段: %s), X-Forwarded-For: %s, X-Real-IP: %s\n", - ip, cleanIP, normalizedIP, - c.GetHeader("X-Forwarded-For"), - c.GetHeader("X-Real-IP")) - } else { - fmt.Printf("请求IP: %s (提纯后: %s), X-Forwarded-For: %s, X-Real-IP: %s\n", - ip, cleanIP, - c.GetHeader("X-Forwarded-For"), - c.GetHeader("X-Real-IP")) - } - - // 获取限流器并检查是否允许访问 - ipLimiter, allowed := limiter.GetLimiter(cleanIP) - - // 如果IP在黑名单中 - if !allowed { - c.JSON(403, gin.H{ - "error": "您已被限制访问", - }) - c.Abort() - return - } - - // 检查限流 - if !ipLimiter.Allow() { - c.JSON(429, gin.H{ - "error": "请求频率过快,暂时限制访问", - }) - c.Abort() - return - } - - c.Next() - } -} - - +package main + +import ( + "fmt" + "net" + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" + "golang.org/x/time/rate" +) + +const ( + // 清理间隔 + CleanupInterval = 10 * time.Minute + MaxIPCacheSize = 10000 +) + +// IPRateLimiter IP限流器结构体 +type IPRateLimiter struct { + ips map[string]*rateLimiterEntry // IP到限流器的映射 + mu *sync.RWMutex // 读写锁,保证并发安全 + r rate.Limit // 速率限制(每秒允许的请求数) + b int // 令牌桶容量(突发请求数) + whitelist []*net.IPNet // 白名单IP段 + blacklist []*net.IPNet // 黑名单IP段 +} + +// rateLimiterEntry 限流器条目 +type rateLimiterEntry struct { + limiter *rate.Limiter + lastAccess time.Time +} + +// initGlobalLimiter 初始化全局限流器 +func initGlobalLimiter() *IPRateLimiter { + cfg := GetConfig() + + whitelist := make([]*net.IPNet, 0, len(cfg.Security.WhiteList)) + for _, item := range cfg.Security.WhiteList { + if item = strings.TrimSpace(item); item != "" { + if !strings.Contains(item, "/") { + item = item + "/32" // 单个IP转为CIDR格式 + } + _, ipnet, err := net.ParseCIDR(item) + if err == nil { + whitelist = append(whitelist, ipnet) + } else { + fmt.Printf("警告: 无效的白名单IP格式: %s\n", item) + } + } + } + + // 解析黑名单IP段 + blacklist := make([]*net.IPNet, 0, len(cfg.Security.BlackList)) + for _, item := range cfg.Security.BlackList { + if item = strings.TrimSpace(item); item != "" { + if !strings.Contains(item, "/") { + item = item + "/32" // 单个IP转为CIDR格式 + } + _, ipnet, err := net.ParseCIDR(item) + if err == nil { + blacklist = append(blacklist, ipnet) + } else { + fmt.Printf("警告: 无效的黑名单IP格式: %s\n", item) + } + } + } + + // 计算速率:将 "每N小时X个请求" 转换为 "每秒Y个请求" + ratePerSecond := rate.Limit(float64(cfg.RateLimit.RequestLimit) / (cfg.RateLimit.PeriodHours * 3600)) + + burstSize := cfg.RateLimit.RequestLimit + if burstSize < 1 { + burstSize = 1 + } + + limiter := &IPRateLimiter{ + ips: make(map[string]*rateLimiterEntry), + mu: &sync.RWMutex{}, + r: ratePerSecond, + b: burstSize, + whitelist: whitelist, + blacklist: blacklist, + } + + // 启动定期清理goroutine + go limiter.cleanupRoutine() + + return limiter +} + +// initLimiter 初始化限流器 +func initLimiter() { + globalLimiter = initGlobalLimiter() +} + +// cleanupRoutine 定期清理过期的限流器 +func (i *IPRateLimiter) cleanupRoutine() { + ticker := time.NewTicker(CleanupInterval) + defer ticker.Stop() + + for range ticker.C { + now := time.Now() + expired := make([]string, 0) + + // 查找过期的条目 + i.mu.RLock() + for ip, entry := range i.ips { + // 如果最后访问时间超过1小时,认为过期 + if now.Sub(entry.lastAccess) > 1*time.Hour { + expired = append(expired, ip) + } + } + i.mu.RUnlock() + + // 如果有过期条目或者缓存过大,进行清理 + if len(expired) > 0 || len(i.ips) > MaxIPCacheSize { + i.mu.Lock() + // 删除过期条目 + for _, ip := range expired { + delete(i.ips, ip) + } + + // 如果缓存仍然过大,全部清理 + if len(i.ips) > MaxIPCacheSize { + i.ips = make(map[string]*rateLimiterEntry) + } + i.mu.Unlock() + } + } +} + +// extractIPFromAddress 从地址中提取纯IP +func extractIPFromAddress(address string) string { + if host, _, err := net.SplitHostPort(address); err == nil { + return host + } + return address +} + +// normalizeIPForRateLimit 标准化IP地址用于限流:IPv4保持不变,IPv6标准化为/64网段 +func normalizeIPForRateLimit(ipStr string) string { + ip := net.ParseIP(ipStr) + if ip == nil { + return ipStr // 解析失败,返回原值 + } + + if ip.To4() != nil { + return ipStr // IPv4保持不变 + } + + // IPv6:标准化为 /64 网段 + ipv6 := ip.To16() + for i := 8; i < 16; i++ { + ipv6[i] = 0 // 清零后64位 + } + return ipv6.String() + "/64" +} + +// isIPInCIDRList 检查IP是否在CIDR列表中 +func isIPInCIDRList(ip string, cidrList []*net.IPNet) bool { + // 先提取纯IP地址 + cleanIP := extractIPFromAddress(ip) + parsedIP := net.ParseIP(cleanIP) + if parsedIP == nil { + return false + } + + for _, cidr := range cidrList { + if cidr.Contains(parsedIP) { + return true + } + } + return false +} + +// GetLimiter 获取指定IP的限流器,同时返回是否允许访问 +func (i *IPRateLimiter) GetLimiter(ip string) (*rate.Limiter, bool) { + // 提取纯IP地址 + cleanIP := extractIPFromAddress(ip) + + // 检查是否在黑名单中 + if isIPInCIDRList(cleanIP, i.blacklist) { + return nil, false + } + + // 检查是否在白名单中 + if isIPInCIDRList(cleanIP, i.whitelist) { + return rate.NewLimiter(rate.Inf, i.b), true + } + + // 标准化IP用于限流:IPv4保持不变,IPv6标准化为/64网段 + normalizedIP := normalizeIPForRateLimit(cleanIP) + + now := time.Now() + + i.mu.RLock() + entry, exists := i.ips[normalizedIP] + i.mu.RUnlock() + + if exists { + i.mu.Lock() + if entry, stillExists := i.ips[normalizedIP]; stillExists { + entry.lastAccess = now + i.mu.Unlock() + return entry.limiter, true + } + i.mu.Unlock() + } + + i.mu.Lock() + if entry, exists := i.ips[normalizedIP]; exists { + entry.lastAccess = now + i.mu.Unlock() + return entry.limiter, true + } + + entry = &rateLimiterEntry{ + limiter: rate.NewLimiter(i.r, i.b), + lastAccess: now, + } + i.ips[normalizedIP] = entry + i.mu.Unlock() + + return entry.limiter, true +} + +// RateLimitMiddleware 速率限制中间件 +func RateLimitMiddleware(limiter *IPRateLimiter) gin.HandlerFunc { + return func(c *gin.Context) { + // 静态文件豁免:跳过限流检查 + path := c.Request.URL.Path + if path == "/" || path == "/favicon.ico" || path == "/images.html" || path == "/search.html" || + strings.HasPrefix(path, "/public/") { + c.Next() + return + } + + // 获取客户端真实IP + var ip string + + // 优先尝试从请求头获取真实IP + if forwarded := c.GetHeader("X-Forwarded-For"); forwarded != "" { + // X-Forwarded-For可能包含多个IP,取第一个 + ips := strings.Split(forwarded, ",") + ip = strings.TrimSpace(ips[0]) + } else if realIP := c.GetHeader("X-Real-IP"); realIP != "" { + // 如果有X-Real-IP头 + ip = realIP + } else if remoteIP := c.GetHeader("X-Original-Forwarded-For"); remoteIP != "" { + // 某些代理可能使用此头 + ips := strings.Split(remoteIP, ",") + ip = strings.TrimSpace(ips[0]) + } else { + // 回退到ClientIP方法 + ip = c.ClientIP() + } + + // 提取纯IP地址(去除可能存在的端口) + cleanIP := extractIPFromAddress(ip) + + // 日志记录请求IP和头信息 + normalizedIP := normalizeIPForRateLimit(cleanIP) + if cleanIP != normalizedIP { + fmt.Printf("请求IP: %s (提纯后: %s, 限流段: %s), X-Forwarded-For: %s, X-Real-IP: %s\n", + ip, cleanIP, normalizedIP, + c.GetHeader("X-Forwarded-For"), + c.GetHeader("X-Real-IP")) + } else { + fmt.Printf("请求IP: %s (提纯后: %s), X-Forwarded-For: %s, X-Real-IP: %s\n", + ip, cleanIP, + c.GetHeader("X-Forwarded-For"), + c.GetHeader("X-Real-IP")) + } + + // 获取限流器并检查是否允许访问 + ipLimiter, allowed := limiter.GetLimiter(cleanIP) + + // 如果IP在黑名单中 + if !allowed { + c.JSON(403, gin.H{ + "error": "您已被限制访问", + }) + c.Abort() + return + } + + // 检查限流 + if !ipLimiter.Allow() { + c.JSON(429, gin.H{ + "error": "请求频率过快,暂时限制访问", + }) + c.Abort() + return + } + + c.Next() + } +} diff --git a/src/search.go b/src/search.go index db284f4..1d06cc1 100644 --- a/src/search.go +++ b/src/search.go @@ -1,500 +1,500 @@ -package main - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "sort" - "strings" - "sync" - "time" - - "github.com/gin-gonic/gin" -) - -// SearchResult Docker Hub搜索结果 -type SearchResult struct { - Count int `json:"count"` - Next string `json:"next"` - Previous string `json:"previous"` - Results []Repository `json:"results"` -} - -// Repository 仓库信息 -type Repository struct { - Name string `json:"repo_name"` - Description string `json:"short_description"` - IsOfficial bool `json:"is_official"` - IsAutomated bool `json:"is_automated"` - StarCount int `json:"star_count"` - PullCount int `json:"pull_count"` - RepoOwner string `json:"repo_owner"` - LastUpdated string `json:"last_updated"` - Status int `json:"status"` - Organization string `json:"affiliation"` - PullsLastWeek int `json:"pulls_last_week"` - Namespace string `json:"namespace"` -} - -// TagInfo 标签信息 -type TagInfo struct { - Name string `json:"name"` - FullSize int64 `json:"full_size"` - LastUpdated time.Time `json:"last_updated"` - LastPusher string `json:"last_pusher"` - Images []Image `json:"images"` - Vulnerabilities struct { - Critical int `json:"critical"` - High int `json:"high"` - Medium int `json:"medium"` - Low int `json:"low"` - Unknown int `json:"unknown"` - } `json:"vulnerabilities"` -} - -// Image 镜像信息 -type Image struct { - Architecture string `json:"architecture"` - Features string `json:"features"` - Variant string `json:"variant,omitempty"` - Digest string `json:"digest"` - OS string `json:"os"` - OSFeatures string `json:"os_features"` - Size int64 `json:"size"` -} - -type cacheEntry struct { - data interface{} - timestamp time.Time -} - -const ( - maxCacheSize = 1000 // 最大缓存条目数 - cacheTTL = 30 * time.Minute -) - -type Cache struct { - data map[string]cacheEntry - mu sync.RWMutex - maxSize int -} - -var ( - searchCache = &Cache{ - data: make(map[string]cacheEntry), - maxSize: maxCacheSize, - } -) - -func (c *Cache) Get(key string) (interface{}, bool) { - c.mu.RLock() - entry, exists := c.data[key] - c.mu.RUnlock() - - if !exists { - return nil, false - } - - if time.Since(entry.timestamp) > cacheTTL { - c.mu.Lock() - delete(c.data, key) - c.mu.Unlock() - return nil, false - } - - return entry.data, true -} - -func (c *Cache) Set(key string, data interface{}) { - c.mu.Lock() - defer c.mu.Unlock() - - now := time.Now() - for k, v := range c.data { - if now.Sub(v.timestamp) > cacheTTL { - delete(c.data, k) - } - } - - if len(c.data) >= c.maxSize { - toDelete := len(c.data) / 4 - for k := range c.data { - if toDelete <= 0 { - break - } - delete(c.data, k) - toDelete-- - } - } - - c.data[key] = cacheEntry{ - data: data, - timestamp: now, - } -} - -func (c *Cache) Cleanup() { - c.mu.Lock() - defer c.mu.Unlock() - - now := time.Now() - for key, entry := range c.data { - if now.Sub(entry.timestamp) > cacheTTL { - delete(c.data, key) - } - } -} - -// 定期清理过期缓存 -func init() { - go func() { - ticker := time.NewTicker(5 * time.Minute) - for range ticker.C { - searchCache.Cleanup() - } - }() -} - -func filterSearchResults(results []Repository, query string) []Repository { - searchTerm := strings.ToLower(strings.TrimPrefix(query, "library/")) - filtered := make([]Repository, 0) - - for _, repo := range results { - // 标准化仓库名称 - repoName := strings.ToLower(repo.Name) - repoDesc := strings.ToLower(repo.Description) - - // 计算相关性得分 - score := 0 - - // 完全匹配 - if repoName == searchTerm { - score += 100 - } - - // 前缀匹配 - if strings.HasPrefix(repoName, searchTerm) { - score += 50 - } - - // 包含匹配 - if strings.Contains(repoName, searchTerm) { - score += 30 - } - - // 描述匹配 - if strings.Contains(repoDesc, searchTerm) { - score += 10 - } - - // 官方镜像加分 - if repo.IsOfficial { - score += 20 - } - - // 分数达到阈值的结果才保留 - if score > 0 { - filtered = append(filtered, repo) - } - } - - // 按相关性排序 - sort.Slice(filtered, func(i, j int) bool { - // 优先考虑官方镜像 - if filtered[i].IsOfficial != filtered[j].IsOfficial { - return filtered[i].IsOfficial - } - // 其次考虑拉取次数 - return filtered[i].PullCount > filtered[j].PullCount - }) - - return filtered -} - -// searchDockerHub 搜索镜像 -func searchDockerHub(ctx context.Context, query string, page, pageSize int) (*SearchResult, error) { - cacheKey := fmt.Sprintf("search:%s:%d:%d", query, page, pageSize) - - // 尝试从缓存获取 - if cached, ok := searchCache.Get(cacheKey); ok { - return cached.(*SearchResult), nil - } - - // 判断是否是用户/仓库格式的搜索 - isUserRepo := strings.Contains(query, "/") - var namespace, repoName string - - if isUserRepo { - parts := strings.Split(query, "/") - if len(parts) == 2 { - namespace = parts[0] - repoName = parts[1] - } - } - - // 构建搜索URL - baseURL := "https://registry.hub.docker.com/v2" - var fullURL string - var params url.Values - - if isUserRepo && namespace != "" { - // 如果是用户/仓库格式,使用repositories接口 - fullURL = fmt.Sprintf("%s/repositories/%s/", baseURL, namespace) - params = url.Values{ - "page": {fmt.Sprintf("%d", page)}, - "page_size": {fmt.Sprintf("%d", pageSize)}, - } - } else { - // 普通搜索 - fullURL = baseURL + "/search/repositories/" - params = url.Values{ - "query": {query}, - "page": {fmt.Sprintf("%d", page)}, - "page_size": {fmt.Sprintf("%d", pageSize)}, - } - } - - fullURL = fullURL + "?" + params.Encode() - - // 使用统一的搜索HTTP客户端 - resp, err := GetSearchHTTPClient().Get(fullURL) - if err != nil { - return nil, fmt.Errorf("请求Docker Hub API失败: %v", err) - } - defer func() { - if err := resp.Body.Close(); err != nil { - fmt.Printf("关闭搜索响应体失败: %v\n", err) - } - }() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("读取响应失败: %v", err) - } - - if resp.StatusCode != http.StatusOK { - switch resp.StatusCode { - case http.StatusTooManyRequests: - return nil, fmt.Errorf("请求过于频繁,请稍后重试") - case http.StatusNotFound: - if isUserRepo && namespace != "" { - // 如果用户仓库搜索失败,尝试普通搜索 - return searchDockerHub(ctx, repoName, page, pageSize) - } - return nil, fmt.Errorf("未找到相关镜像") - case http.StatusBadGateway, http.StatusServiceUnavailable: - return nil, fmt.Errorf("Docker Hub服务暂时不可用,请稍后重试") - default: - return nil, fmt.Errorf("请求失败: 状态码=%d, 响应=%s", resp.StatusCode, string(body)) - } - } - - // 解析响应 - var result *SearchResult - if isUserRepo && namespace != "" { - // 解析用户仓库列表响应 - var userRepos struct { - Count int `json:"count"` - Next string `json:"next"` - Previous string `json:"previous"` - Results []Repository `json:"results"` - } - if err := json.Unmarshal(body, &userRepos); err != nil { - return nil, fmt.Errorf("解析响应失败: %v", err) - } - - // 转换为SearchResult格式 - result = &SearchResult{ - Count: userRepos.Count, - Next: userRepos.Next, - Previous: userRepos.Previous, - Results: make([]Repository, 0), - } - - // 处理结果 - for _, repo := range userRepos.Results { - // 如果指定了仓库名,只保留匹配的结果 - if repoName == "" || strings.Contains(strings.ToLower(repo.Name), strings.ToLower(repoName)) { - // 确保设置正确的命名空间和名称 - repo.Namespace = namespace - if !strings.Contains(repo.Name, "/") { - repo.Name = fmt.Sprintf("%s/%s", namespace, repo.Name) - } - result.Results = append(result.Results, repo) - } - } - - // 如果没有找到结果,尝试普通搜索 - if len(result.Results) == 0 { - return searchDockerHub(ctx, repoName, page, pageSize) - } - - result.Count = len(result.Results) - } else { - // 解析普通搜索响应 - result = &SearchResult{} - if err := json.Unmarshal(body, &result); err != nil { - return nil, fmt.Errorf("解析响应失败: %v", err) - } - - // 处理搜索结果 - for i := range result.Results { - if result.Results[i].IsOfficial { - if !strings.Contains(result.Results[i].Name, "/") { - result.Results[i].Name = "library/" + result.Results[i].Name - } - result.Results[i].Namespace = "library" - } else { - parts := strings.Split(result.Results[i].Name, "/") - if len(parts) > 1 { - result.Results[i].Namespace = parts[0] - result.Results[i].Name = parts[1] - } else if result.Results[i].RepoOwner != "" { - result.Results[i].Namespace = result.Results[i].RepoOwner - result.Results[i].Name = fmt.Sprintf("%s/%s", result.Results[i].RepoOwner, result.Results[i].Name) - } - } - } - - // 如果是用户/仓库搜索,过滤结果 - if isUserRepo && namespace != "" { - filteredResults := make([]Repository, 0) - for _, repo := range result.Results { - if strings.EqualFold(repo.Namespace, namespace) { - filteredResults = append(filteredResults, repo) - } - } - result.Results = filteredResults - result.Count = len(filteredResults) - } - } - - // 缓存结果 - searchCache.Set(cacheKey, result) - return result, nil -} - -// 判断错误是否可重试 -func isRetryableError(err error) bool { - if err == nil { - return false - } - - // 网络错误、超时等可以重试 - if strings.Contains(err.Error(), "timeout") || - strings.Contains(err.Error(), "connection refused") || - strings.Contains(err.Error(), "no such host") || - strings.Contains(err.Error(), "too many requests") { - return true - } - - return false -} - -// getRepositoryTags 获取仓库标签信息 -func getRepositoryTags(ctx context.Context, namespace, name string) ([]TagInfo, error) { - if namespace == "" || name == "" { - return nil, fmt.Errorf("无效输入:命名空间和名称不能为空") - } - - cacheKey := fmt.Sprintf("tags:%s:%s", namespace, name) - if cached, ok := searchCache.Get(cacheKey); ok { - return cached.([]TagInfo), nil - } - - // 构建API URL - baseURL := fmt.Sprintf("https://registry.hub.docker.com/v2/repositories/%s/%s/tags", namespace, name) - params := url.Values{} - params.Set("page_size", "100") - params.Set("ordering", "last_updated") - - fullURL := baseURL + "?" + params.Encode() - - // 使用统一的搜索HTTP客户端 - resp, err := GetSearchHTTPClient().Get(fullURL) - if err != nil { - return nil, fmt.Errorf("发送请求失败: %v", err) - } - defer func() { - if err := resp.Body.Close(); err != nil { - fmt.Printf("关闭搜索响应体失败: %v\n", err) - } - }() - - // 读取响应体 - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("读取响应失败: %v", err) - } - - // 检查响应状态码 - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("请求失败: 状态码=%d, 响应=%s", resp.StatusCode, string(body)) - } - - // 解析响应 - var result struct { - Count int `json:"count"` - Next string `json:"next"` - Previous string `json:"previous"` - Results []TagInfo `json:"results"` - } - if err := json.Unmarshal(body, &result); err != nil { - return nil, fmt.Errorf("解析响应失败: %v", err) - } - - // 缓存结果 - searchCache.Set(cacheKey, result.Results) - return result.Results, nil -} - -// RegisterSearchRoute 注册搜索相关路由 -func RegisterSearchRoute(r *gin.Engine) { - // 搜索镜像 - r.GET("/search", func(c *gin.Context) { - query := c.Query("q") - if query == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "搜索关键词不能为空"}) - return - } - - page := 1 - pageSize := 25 - if p := c.Query("page"); p != "" { - fmt.Sscanf(p, "%d", &page) - } - if ps := c.Query("page_size"); ps != "" { - fmt.Sscanf(ps, "%d", &pageSize) - } - - result, err := searchDockerHub(c.Request.Context(), query, page, pageSize) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, result) - }) - - // 获取标签信息 - r.GET("/tags/:namespace/:name", func(c *gin.Context) { - namespace := c.Param("namespace") - name := c.Param("name") - - if namespace == "" || name == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "命名空间和名称不能为空"}) - return - } - - tags, err := getRepositoryTags(c.Request.Context(), namespace, name) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, tags) - }) -} +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" +) + +// SearchResult Docker Hub搜索结果 +type SearchResult struct { + Count int `json:"count"` + Next string `json:"next"` + Previous string `json:"previous"` + Results []Repository `json:"results"` +} + +// Repository 仓库信息 +type Repository struct { + Name string `json:"repo_name"` + Description string `json:"short_description"` + IsOfficial bool `json:"is_official"` + IsAutomated bool `json:"is_automated"` + StarCount int `json:"star_count"` + PullCount int `json:"pull_count"` + RepoOwner string `json:"repo_owner"` + LastUpdated string `json:"last_updated"` + Status int `json:"status"` + Organization string `json:"affiliation"` + PullsLastWeek int `json:"pulls_last_week"` + Namespace string `json:"namespace"` +} + +// TagInfo 标签信息 +type TagInfo struct { + Name string `json:"name"` + FullSize int64 `json:"full_size"` + LastUpdated time.Time `json:"last_updated"` + LastPusher string `json:"last_pusher"` + Images []Image `json:"images"` + Vulnerabilities struct { + Critical int `json:"critical"` + High int `json:"high"` + Medium int `json:"medium"` + Low int `json:"low"` + Unknown int `json:"unknown"` + } `json:"vulnerabilities"` +} + +// Image 镜像信息 +type Image struct { + Architecture string `json:"architecture"` + Features string `json:"features"` + Variant string `json:"variant,omitempty"` + Digest string `json:"digest"` + OS string `json:"os"` + OSFeatures string `json:"os_features"` + Size int64 `json:"size"` +} + +type cacheEntry struct { + data interface{} + timestamp time.Time +} + +const ( + maxCacheSize = 1000 // 最大缓存条目数 + cacheTTL = 30 * time.Minute +) + +type Cache struct { + data map[string]cacheEntry + mu sync.RWMutex + maxSize int +} + +var ( + searchCache = &Cache{ + data: make(map[string]cacheEntry), + maxSize: maxCacheSize, + } +) + +func (c *Cache) Get(key string) (interface{}, bool) { + c.mu.RLock() + entry, exists := c.data[key] + c.mu.RUnlock() + + if !exists { + return nil, false + } + + if time.Since(entry.timestamp) > cacheTTL { + c.mu.Lock() + delete(c.data, key) + c.mu.Unlock() + return nil, false + } + + return entry.data, true +} + +func (c *Cache) Set(key string, data interface{}) { + c.mu.Lock() + defer c.mu.Unlock() + + now := time.Now() + for k, v := range c.data { + if now.Sub(v.timestamp) > cacheTTL { + delete(c.data, k) + } + } + + if len(c.data) >= c.maxSize { + toDelete := len(c.data) / 4 + for k := range c.data { + if toDelete <= 0 { + break + } + delete(c.data, k) + toDelete-- + } + } + + c.data[key] = cacheEntry{ + data: data, + timestamp: now, + } +} + +func (c *Cache) Cleanup() { + c.mu.Lock() + defer c.mu.Unlock() + + now := time.Now() + for key, entry := range c.data { + if now.Sub(entry.timestamp) > cacheTTL { + delete(c.data, key) + } + } +} + +// 定期清理过期缓存 +func init() { + go func() { + ticker := time.NewTicker(5 * time.Minute) + for range ticker.C { + searchCache.Cleanup() + } + }() +} + +func filterSearchResults(results []Repository, query string) []Repository { + searchTerm := strings.ToLower(strings.TrimPrefix(query, "library/")) + filtered := make([]Repository, 0) + + for _, repo := range results { + // 标准化仓库名称 + repoName := strings.ToLower(repo.Name) + repoDesc := strings.ToLower(repo.Description) + + // 计算相关性得分 + score := 0 + + // 完全匹配 + if repoName == searchTerm { + score += 100 + } + + // 前缀匹配 + if strings.HasPrefix(repoName, searchTerm) { + score += 50 + } + + // 包含匹配 + if strings.Contains(repoName, searchTerm) { + score += 30 + } + + // 描述匹配 + if strings.Contains(repoDesc, searchTerm) { + score += 10 + } + + // 官方镜像加分 + if repo.IsOfficial { + score += 20 + } + + // 分数达到阈值的结果才保留 + if score > 0 { + filtered = append(filtered, repo) + } + } + + // 按相关性排序 + sort.Slice(filtered, func(i, j int) bool { + // 优先考虑官方镜像 + if filtered[i].IsOfficial != filtered[j].IsOfficial { + return filtered[i].IsOfficial + } + // 其次考虑拉取次数 + return filtered[i].PullCount > filtered[j].PullCount + }) + + return filtered +} + +// searchDockerHub 搜索镜像 +func searchDockerHub(ctx context.Context, query string, page, pageSize int) (*SearchResult, error) { + cacheKey := fmt.Sprintf("search:%s:%d:%d", query, page, pageSize) + + // 尝试从缓存获取 + if cached, ok := searchCache.Get(cacheKey); ok { + return cached.(*SearchResult), nil + } + + // 判断是否是用户/仓库格式的搜索 + isUserRepo := strings.Contains(query, "/") + var namespace, repoName string + + if isUserRepo { + parts := strings.Split(query, "/") + if len(parts) == 2 { + namespace = parts[0] + repoName = parts[1] + } + } + + // 构建搜索URL + baseURL := "https://registry.hub.docker.com/v2" + var fullURL string + var params url.Values + + if isUserRepo && namespace != "" { + // 如果是用户/仓库格式,使用repositories接口 + fullURL = fmt.Sprintf("%s/repositories/%s/", baseURL, namespace) + params = url.Values{ + "page": {fmt.Sprintf("%d", page)}, + "page_size": {fmt.Sprintf("%d", pageSize)}, + } + } else { + // 普通搜索 + fullURL = baseURL + "/search/repositories/" + params = url.Values{ + "query": {query}, + "page": {fmt.Sprintf("%d", page)}, + "page_size": {fmt.Sprintf("%d", pageSize)}, + } + } + + fullURL = fullURL + "?" + params.Encode() + + // 使用统一的搜索HTTP客户端 + resp, err := GetSearchHTTPClient().Get(fullURL) + if err != nil { + return nil, fmt.Errorf("请求Docker Hub API失败: %v", err) + } + defer func() { + if err := resp.Body.Close(); err != nil { + fmt.Printf("关闭搜索响应体失败: %v\n", err) + } + }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("读取响应失败: %v", err) + } + + if resp.StatusCode != http.StatusOK { + switch resp.StatusCode { + case http.StatusTooManyRequests: + return nil, fmt.Errorf("请求过于频繁,请稍后重试") + case http.StatusNotFound: + if isUserRepo && namespace != "" { + // 如果用户仓库搜索失败,尝试普通搜索 + return searchDockerHub(ctx, repoName, page, pageSize) + } + return nil, fmt.Errorf("未找到相关镜像") + case http.StatusBadGateway, http.StatusServiceUnavailable: + return nil, fmt.Errorf("Docker Hub服务暂时不可用,请稍后重试") + default: + return nil, fmt.Errorf("请求失败: 状态码=%d, 响应=%s", resp.StatusCode, string(body)) + } + } + + // 解析响应 + var result *SearchResult + if isUserRepo && namespace != "" { + // 解析用户仓库列表响应 + var userRepos struct { + Count int `json:"count"` + Next string `json:"next"` + Previous string `json:"previous"` + Results []Repository `json:"results"` + } + if err := json.Unmarshal(body, &userRepos); err != nil { + return nil, fmt.Errorf("解析响应失败: %v", err) + } + + // 转换为SearchResult格式 + result = &SearchResult{ + Count: userRepos.Count, + Next: userRepos.Next, + Previous: userRepos.Previous, + Results: make([]Repository, 0), + } + + // 处理结果 + for _, repo := range userRepos.Results { + // 如果指定了仓库名,只保留匹配的结果 + if repoName == "" || strings.Contains(strings.ToLower(repo.Name), strings.ToLower(repoName)) { + // 确保设置正确的命名空间和名称 + repo.Namespace = namespace + if !strings.Contains(repo.Name, "/") { + repo.Name = fmt.Sprintf("%s/%s", namespace, repo.Name) + } + result.Results = append(result.Results, repo) + } + } + + // 如果没有找到结果,尝试普通搜索 + if len(result.Results) == 0 { + return searchDockerHub(ctx, repoName, page, pageSize) + } + + result.Count = len(result.Results) + } else { + // 解析普通搜索响应 + result = &SearchResult{} + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("解析响应失败: %v", err) + } + + // 处理搜索结果 + for i := range result.Results { + if result.Results[i].IsOfficial { + if !strings.Contains(result.Results[i].Name, "/") { + result.Results[i].Name = "library/" + result.Results[i].Name + } + result.Results[i].Namespace = "library" + } else { + parts := strings.Split(result.Results[i].Name, "/") + if len(parts) > 1 { + result.Results[i].Namespace = parts[0] + result.Results[i].Name = parts[1] + } else if result.Results[i].RepoOwner != "" { + result.Results[i].Namespace = result.Results[i].RepoOwner + result.Results[i].Name = fmt.Sprintf("%s/%s", result.Results[i].RepoOwner, result.Results[i].Name) + } + } + } + + // 如果是用户/仓库搜索,过滤结果 + if isUserRepo && namespace != "" { + filteredResults := make([]Repository, 0) + for _, repo := range result.Results { + if strings.EqualFold(repo.Namespace, namespace) { + filteredResults = append(filteredResults, repo) + } + } + result.Results = filteredResults + result.Count = len(filteredResults) + } + } + + // 缓存结果 + searchCache.Set(cacheKey, result) + return result, nil +} + +// 判断错误是否可重试 +func isRetryableError(err error) bool { + if err == nil { + return false + } + + // 网络错误、超时等可以重试 + if strings.Contains(err.Error(), "timeout") || + strings.Contains(err.Error(), "connection refused") || + strings.Contains(err.Error(), "no such host") || + strings.Contains(err.Error(), "too many requests") { + return true + } + + return false +} + +// getRepositoryTags 获取仓库标签信息 +func getRepositoryTags(ctx context.Context, namespace, name string) ([]TagInfo, error) { + if namespace == "" || name == "" { + return nil, fmt.Errorf("无效输入:命名空间和名称不能为空") + } + + cacheKey := fmt.Sprintf("tags:%s:%s", namespace, name) + if cached, ok := searchCache.Get(cacheKey); ok { + return cached.([]TagInfo), nil + } + + // 构建API URL + baseURL := fmt.Sprintf("https://registry.hub.docker.com/v2/repositories/%s/%s/tags", namespace, name) + params := url.Values{} + params.Set("page_size", "100") + params.Set("ordering", "last_updated") + + fullURL := baseURL + "?" + params.Encode() + + // 使用统一的搜索HTTP客户端 + resp, err := GetSearchHTTPClient().Get(fullURL) + if err != nil { + return nil, fmt.Errorf("发送请求失败: %v", err) + } + defer func() { + if err := resp.Body.Close(); err != nil { + fmt.Printf("关闭搜索响应体失败: %v\n", err) + } + }() + + // 读取响应体 + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("读取响应失败: %v", err) + } + + // 检查响应状态码 + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("请求失败: 状态码=%d, 响应=%s", resp.StatusCode, string(body)) + } + + // 解析响应 + var result struct { + Count int `json:"count"` + Next string `json:"next"` + Previous string `json:"previous"` + Results []TagInfo `json:"results"` + } + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("解析响应失败: %v", err) + } + + // 缓存结果 + searchCache.Set(cacheKey, result.Results) + return result.Results, nil +} + +// RegisterSearchRoute 注册搜索相关路由 +func RegisterSearchRoute(r *gin.Engine) { + // 搜索镜像 + r.GET("/search", func(c *gin.Context) { + query := c.Query("q") + if query == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "搜索关键词不能为空"}) + return + } + + page := 1 + pageSize := 25 + if p := c.Query("page"); p != "" { + fmt.Sscanf(p, "%d", &page) + } + if ps := c.Query("page_size"); ps != "" { + fmt.Sscanf(ps, "%d", &pageSize) + } + + result, err := searchDockerHub(c.Request.Context(), query, page, pageSize) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, result) + }) + + // 获取标签信息 + r.GET("/tags/:namespace/:name", func(c *gin.Context) { + namespace := c.Param("namespace") + name := c.Param("name") + + if namespace == "" || name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "命名空间和名称不能为空"}) + return + } + + tags, err := getRepositoryTags(c.Request.Context(), namespace, name) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, tags) + }) +} diff --git a/src/token_cache.go b/src/token_cache.go index 77a56c7..5b4b730 100644 --- a/src/token_cache.go +++ b/src/token_cache.go @@ -13,10 +13,10 @@ import ( // CachedItem 通用缓存项,支持Token和Manifest type CachedItem struct { - Data []byte // 缓存数据(token字符串或manifest字节) - ContentType string // 内容类型 + Data []byte // 缓存数据(token字符串或manifest字节) + ContentType string // 内容类型 Headers map[string]string // 额外的响应头 - ExpiresAt time.Time // 过期时间 + ExpiresAt time.Time // 过期时间 } // UniversalCache 通用缓存,支持Token和Manifest @@ -79,18 +79,18 @@ func getManifestTTL(reference string) time.Duration { defaultTTL = parsed } } - + if strings.HasPrefix(reference, "sha256:") { return 24 * time.Hour } - + // mutable tag的智能判断 - if reference == "latest" || reference == "main" || reference == "master" || - reference == "dev" || reference == "develop" { + if reference == "latest" || reference == "main" || reference == "master" || + reference == "dev" || reference == "develop" { // 热门可变标签: 短期缓存 return 10 * time.Minute } - + return defaultTTL } @@ -99,17 +99,17 @@ func extractTTLFromResponse(responseBody []byte) time.Duration { var tokenResp struct { ExpiresIn int `json:"expires_in"` } - + // 默认30分钟TTL,确保稳定性 defaultTTL := 30 * time.Minute - + if json.Unmarshal(responseBody, &tokenResp) == nil && tokenResp.ExpiresIn > 0 { safeTTL := time.Duration(tokenResp.ExpiresIn-300) * time.Second if safeTTL > 5*time.Minute { return safeTTL } } - + return defaultTTL } @@ -122,12 +122,12 @@ func writeCachedResponse(c *gin.Context, item *CachedItem) { if item.ContentType != "" { c.Header("Content-Type", item.ContentType) } - + // 设置额外的响应头 for key, value := range item.Headers { c.Header(key, value) } - + // 返回数据 c.Data(200, item.ContentType, item.Data) } @@ -148,21 +148,21 @@ func init() { go func() { ticker := time.NewTicker(20 * time.Minute) defer ticker.Stop() - + for range ticker.C { now := time.Now() expiredKeys := make([]string, 0) - + globalCache.cache.Range(func(key, value interface{}) bool { if cached := value.(*CachedItem); now.After(cached.ExpiresAt) { expiredKeys = append(expiredKeys, key.(string)) } return true }) - + for _, key := range expiredKeys { globalCache.cache.Delete(key) } } }() -} \ No newline at end of file +} -- 2.49.1 From d373e0104d7e3fa27e06361812a06fad1b182baf Mon Sep 17 00:00:00 2001 From: user123456 Date: Fri, 20 Jun 2025 23:44:13 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E8=8E=B7=E5=8F=96=E6=9B=B4=E5=A4=9A?= =?UTF-8?q?=E9=95=9C=E5=83=8Ftag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/public/search.html | 339 +++++++++++++++++++++++++++++++---------- src/search.go | 314 ++++++++++++++++++++++++++------------ 2 files changed, 471 insertions(+), 182 deletions(-) diff --git a/src/public/search.html b/src/public/search.html index 0b4e279..957f792 100644 --- a/src/public/search.html +++ b/src/public/search.html @@ -778,7 +778,12 @@ -
+
+ +
@@ -853,6 +858,10 @@ let totalPages = 1; let currentQuery = ''; let currentRepo = null; + + // 标签分页相关变量 + let currentTagPage = 1; + let totalTagPages = 1; document.getElementById('searchButton').addEventListener('click', () => { currentPage = 1; @@ -884,6 +893,21 @@ showSearchResults(); }); + // 使用事件委托处理分页按钮点击(避免DOM重建导致事件丢失) + document.addEventListener('click', (e) => { + if (e.target.id === 'tagPrevPage') { + if (currentTagPage > 1) { + currentTagPage--; + loadTagPage(); + } + } else if (e.target.id === 'tagNextPage') { + if (currentTagPage < totalTagPages) { + currentTagPage++; + loadTagPage(); + } + } + }); + function showLoading() { document.querySelector('.loading').style.display = 'block'; } @@ -901,71 +925,135 @@ }, 3000); } - function updatePagination() { - const prevButton = document.getElementById('prevPage'); - const nextButton = document.getElementById('nextPage'); + // 统一分页更新函数(支持搜索和标签分页) + function updatePagination(config = {}) { + const { + currentPage: page = currentPage, + totalPages: total = totalPages, + prefix = '' + } = config; + + const prevButtonId = prefix ? `${prefix}PrevPage` : 'prevPage'; + const nextButtonId = prefix ? `${prefix}NextPage` : 'nextPage'; + const paginationId = prefix ? `${prefix}Pagination` : '.pagination'; + + const prevButton = document.getElementById(prevButtonId); + const nextButton = document.getElementById(nextButtonId); + const paginationDiv = prefix ? document.getElementById(paginationId) : document.querySelector(paginationId); - prevButton.disabled = currentPage <= 1; - nextButton.disabled = currentPage >= totalPages; + if (!prevButton || !nextButton || !paginationDiv) { + return; // 静默处理,避免控制台警告 + } + + // 更新按钮状态 + prevButton.disabled = page <= 1; + nextButton.disabled = page >= total; + + // 更新或创建页面信息 + const pageInfoId = prefix ? `${prefix}PageInfo` : 'pageInfo'; + let pageInfo = document.getElementById(pageInfoId); - const paginationDiv = document.querySelector('.pagination'); - let pageInfo = document.getElementById('pageInfo'); if (!pageInfo) { - const container = document.createElement('div'); - container.id = 'pageInfo'; - container.style.margin = '0 10px'; - container.style.display = 'flex'; - container.style.alignItems = 'center'; - container.style.gap = '10px'; - - const pageText = document.createElement('span'); - pageText.id = 'pageText'; - - const jumpInput = document.createElement('input'); - jumpInput.type = 'number'; - jumpInput.min = '1'; - jumpInput.id = 'jumpPage'; - jumpInput.style.width = '60px'; - jumpInput.style.padding = '4px'; - jumpInput.style.borderRadius = '4px'; - jumpInput.style.border = '1px solid var(--border)'; - jumpInput.style.backgroundColor = 'var(--input)'; - jumpInput.style.color = 'var(--foreground)'; - - const jumpButton = document.createElement('button'); - jumpButton.textContent = '跳转'; - jumpButton.className = 'btn search-button'; - jumpButton.style.padding = '4px 8px'; - jumpButton.onclick = () => { - const page = parseInt(jumpInput.value); - if (page && page >= 1 && page <= totalPages) { - currentPage = page; - performSearch(); - } else { - showToast('请输入有效的页码'); - } - }; - - container.appendChild(pageText); - container.appendChild(jumpInput); - container.appendChild(jumpButton); - - paginationDiv.insertBefore(container, nextButton); - pageInfo = container; + pageInfo = createPageInfo(pageInfoId, prefix, total); + paginationDiv.insertBefore(pageInfo, nextButton); } - const pageText = document.getElementById('pageText'); - pageText.textContent = `第 ${currentPage} / ${totalPages || 1} 页 共 ${totalPages || 1} 页`; - - const jumpInput = document.getElementById('jumpPage'); - if (jumpInput) { - jumpInput.max = totalPages; - jumpInput.value = currentPage; - } - - paginationDiv.style.display = totalPages > 1 ? 'flex' : 'none'; + updatePageInfo(pageInfo, page, total, prefix); + paginationDiv.style.display = total > 1 ? 'flex' : 'none'; } + // 创建页面信息元素 + function createPageInfo(pageInfoId, prefix, total) { + const container = document.createElement('div'); + container.id = pageInfoId; + container.style.cssText = 'margin: 0 10px; display: flex; align-items: center; gap: 10px;'; + + const pageText = document.createElement('span'); + pageText.id = prefix ? `${prefix}PageText` : 'pageText'; + + const jumpInput = document.createElement('input'); + jumpInput.type = 'number'; + jumpInput.min = '1'; + jumpInput.max = prefix === 'tag' ? total : Math.min(total, 100); // 搜索页面限制100页 + jumpInput.id = prefix ? `${prefix}JumpPage` : 'jumpPage'; + jumpInput.style.cssText = 'width: 60px; padding: 4px; border-radius: 4px; border: 1px solid var(--border); background-color: var(--input); color: var(--foreground);'; + + const jumpButton = document.createElement('button'); + jumpButton.textContent = '跳转'; + jumpButton.className = 'btn search-button'; + jumpButton.style.padding = '4px 8px'; + jumpButton.onclick = () => handlePageJump(jumpInput, prefix, total); + + container.append(pageText, jumpInput, jumpButton); + return container; + } + + // 更新页面信息显示 + function updatePageInfo(pageInfo, page, total, prefix) { + const pageText = pageInfo.querySelector('span'); + const jumpInput = pageInfo.querySelector('input'); + + // 标签分页显示策略:根据是否确定总页数显示不同格式 + const isTagPagination = prefix === 'tag'; + const maxDisplayPages = isTagPagination ? total : Math.min(total, 100); + const pageTextContent = isTagPagination + ? `第 ${page} 页` + (total > page ? ` (至少 ${total} 页)` : ` (共 ${total} 页)`) + : `第 ${page} / ${maxDisplayPages} 页 共 ${maxDisplayPages} 页` + (total > 100 ? ' (最多100页)' : ''); + + pageText.textContent = pageTextContent; + jumpInput.max = maxDisplayPages; + jumpInput.value = page; + } + + // 处理页面跳转 + function handlePageJump(jumpInput, prefix, total) { + const inputPage = parseInt(jumpInput.value); + const maxPage = prefix === 'tag' ? total : Math.min(total, 100); + if (!inputPage || inputPage < 1 || inputPage > maxPage) { + const limitText = prefix === 'tag' ? '页码' : '页码 (最多100页)'; + showToast(`请输入有效的${limitText}`); + return; + } + + if (prefix === 'tag') { + currentTagPage = inputPage; + loadTagPage(); + } else { + currentPage = inputPage; + performSearch(); + } + } + + // 统一仓库信息处理 + function parseRepositoryInfo(repo) { + const namespace = repo.namespace || (repo.is_official ? 'library' : ''); + let name = repo.name || repo.repo_name || ''; + + // 清理名称,确保不包含命名空间前缀 + if (name.includes('/')) { + const parts = name.split('/'); + name = parts[parts.length - 1]; + } + + const cleanName = name.replace(/^library\//, ''); + const fullRepoName = repo.is_official ? cleanName : `${namespace}/${cleanName}`; + + return { + namespace, + name, + cleanName, + fullRepoName + }; + } + + // 分页更新函数 + const updateSearchPagination = () => updatePagination(); + const updateTagPagination = () => updatePagination({ + currentPage: currentTagPage, + totalPages: totalTagPages, + prefix: 'tag' + }); + function showSearchResults() { document.querySelector('.search-results').style.display = 'block'; document.querySelector('.tag-list').style.display = 'none'; @@ -1006,7 +1094,7 @@ throw new Error(data.error || '搜索请求失败'); } - totalPages = Math.ceil(data.count / 25); + totalPages = Math.min(Math.ceil(data.count / 25), 100); updatePagination(); displayResults(data.results, targetRepo); @@ -1108,23 +1196,58 @@ }); } + // 内存管理 + async function loadTags(namespace, name) { + currentTagPage = 1; + await loadTagPage(namespace, name); + } + + async function loadTagPage(namespace = null, name = null) { showLoading(); try { - if (!namespace || !name) { + // 如果传入了新的namespace和name,更新currentRepo + if (namespace && name) { + // 清理旧数据,防止内存泄露 + cleanupOldTagData(); + } + + // 获取当前仓库信息 + const repoInfo = parseRepositoryInfo(currentRepo); + const currentNamespace = namespace || repoInfo.namespace; + const currentName = name || repoInfo.name; + + // 调试日志 + console.log(`loadTagPage: namespace=${currentNamespace}, name=${currentName}, page=${currentTagPage}`); + + if (!currentNamespace || !currentName) { showToast('命名空间和镜像名称不能为空'); return; } - const response = await fetch(`/tags/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}`); + const response = await fetch(`/tags/${encodeURIComponent(currentNamespace)}/${encodeURIComponent(currentName)}?page=${currentTagPage}&page_size=100`); if (!response.ok) { const errorText = await response.text(); throw new Error(errorText || '获取标签信息失败'); } const data = await response.json(); - displayTags(data); - showTagList(); + + // 改进的总页数计算:使用更准确的分页策略 + if (data.has_more) { + // 如果还有更多页面,至少有当前页+1页,但可能更多 + totalTagPages = Math.max(currentTagPage + 1, totalTagPages); + } else { + // 如果没有更多页面,当前页就是最后一页 + totalTagPages = currentTagPage; + } + + displayTags(data.tags, data.has_more); + updateTagPagination(); + + if (namespace && name) { + showTagList(); + } } catch (error) { console.error('加载标签错误:', error); showToast(error.message || '获取标签信息失败,请稍后重试'); @@ -1133,12 +1256,24 @@ } } - function displayTags(tags) { + function cleanupOldTagData() { + // 清理全局变量,释放内存 + if (window.currentPageTags) { + window.currentPageTags.length = 0; + window.currentPageTags = null; + } + + // 清理DOM缓存 + const tagsContainer = document.getElementById('tagsContainer'); + if (tagsContainer) { + tagsContainer.innerHTML = ''; + } + } + + function displayTags(tags, hasMore = false) { const tagList = document.getElementById('tagList'); - const namespace = currentRepo.namespace || (currentRepo.is_official ? 'library' : ''); - const name = currentRepo.name || currentRepo.repo_name || ''; - const cleanName = name.replace(/^library\//, ''); - const fullRepoName = currentRepo.is_official ? cleanName : `${namespace}/${cleanName}`; + const repoInfo = parseRepositoryInfo(currentRepo); + const { fullRepoName } = repoInfo; let header = `
@@ -1165,22 +1300,60 @@
+ `; tagList.innerHTML = header; - window.allTags = tags; + // 存储当前页标签数据 + window.currentPageTags = tags; renderFilteredTags(tags); } function renderFilteredTags(filteredTags) { const tagsContainer = document.getElementById('tagsContainer'); - const namespace = currentRepo.namespace || (currentRepo.is_official ? 'library' : ''); - const name = currentRepo.name || currentRepo.repo_name || ''; - const cleanName = name.replace(/^library\//, ''); - const fullRepoName = currentRepo.is_official ? cleanName : `${namespace}/${cleanName}`; + const repoInfo = parseRepositoryInfo(currentRepo); + const { fullRepoName } = repoInfo; - let tagsHtml = filteredTags.map(tag => { + if (filteredTags.length === 0) { + tagsContainer.innerHTML = '
未找到匹配的标签
'; + return; + } + + // 渐进式渲染:分批处理大数据集 + const BATCH_SIZE = 50; + + if (filteredTags.length <= BATCH_SIZE) { + // 小数据集:直接渲染 + renderTagsBatch(filteredTags, fullRepoName, tagsContainer, true); + } else { + // 大数据集:分批渲染 + tagsContainer.innerHTML = ''; // 清空容器 + let currentBatch = 0; + + function renderNextBatch() { + const start = currentBatch * BATCH_SIZE; + const end = Math.min(start + BATCH_SIZE, filteredTags.length); + const batch = filteredTags.slice(start, end); + + renderTagsBatch(batch, fullRepoName, tagsContainer, false); + + currentBatch++; + if (end < filteredTags.length) { + // 使用requestAnimationFrame确保UI响应性 + requestAnimationFrame(renderNextBatch); + } + } + + renderNextBatch(); + } + } + + function renderTagsBatch(tags, fullRepoName, container, replaceContent = false) { + const tagsHtml = tags.map(tag => { const vulnIndicators = Object.entries(tag.vulnerabilities || {}) .map(([level, count]) => count > 0 ? `` : '') .join(''); @@ -1212,23 +1385,23 @@ `; }).join(''); - if (filteredTags.length === 0) { - tagsHtml = '
未找到匹配的标签
'; + if (replaceContent) { + container.innerHTML = tagsHtml; + } else { + container.insertAdjacentHTML('beforeend', tagsHtml); } - - tagsContainer.innerHTML = tagsHtml; } function filterTags(searchText) { - if (!window.allTags) return; + if (!window.currentPageTags) return; const searchLower = searchText.toLowerCase(); let filteredTags; if (!searchText) { - filteredTags = window.allTags; + filteredTags = window.currentPageTags; } else { - const scoredTags = window.allTags.map(tag => { + const scoredTags = window.currentPageTags.map(tag => { const name = tag.name.toLowerCase(); let score = 0; @@ -1263,6 +1436,8 @@ } } + + function copyToClipboard(text) { navigator.clipboard.writeText(text).then(() => { showToast('已复制到剪贴板'); diff --git a/src/search.go b/src/search.go index db284f4..ebf2c18 100644 --- a/src/search.go +++ b/src/search.go @@ -66,14 +66,21 @@ type Image struct { Size int64 `json:"size"` } +// TagPageResult 分页标签结果 +type TagPageResult struct { + Tags []TagInfo `json:"tags"` + HasMore bool `json:"has_more"` +} + type cacheEntry struct { data interface{} - timestamp time.Time + expiresAt time.Time // 存储过期时间 } const ( - maxCacheSize = 1000 // 最大缓存条目数 - cacheTTL = 30 * time.Minute + maxCacheSize = 1000 // 最大缓存条目数 + maxPaginationCache = 200 // 分页缓存最大条目数 + cacheTTL = 30 * time.Minute ) type Cache struct { @@ -98,7 +105,8 @@ func (c *Cache) Get(key string) (interface{}, bool) { return nil, false } - if time.Since(entry.timestamp) > cacheTTL { + // 比较过期时间 + if time.Now().After(entry.expiresAt) { c.mu.Lock() delete(c.data, key) c.mu.Unlock() @@ -109,40 +117,36 @@ func (c *Cache) Get(key string) (interface{}, bool) { } func (c *Cache) Set(key string, data interface{}) { + c.SetWithTTL(key, data, cacheTTL) +} + +func (c *Cache) SetWithTTL(key string, data interface{}, ttl time.Duration) { c.mu.Lock() defer c.mu.Unlock() - now := time.Now() - for k, v := range c.data { - if now.Sub(v.timestamp) > cacheTTL { - delete(c.data, k) - } - } - + // 惰性清理:仅在容量超限时清理过期项 if len(c.data) >= c.maxSize { - toDelete := len(c.data) / 4 - for k := range c.data { - if toDelete <= 0 { - break - } - delete(c.data, k) - toDelete-- - } + c.cleanupExpiredLocked() } + // 计算过期时间 c.data[key] = cacheEntry{ data: data, - timestamp: now, + expiresAt: time.Now().Add(ttl), } } func (c *Cache) Cleanup() { c.mu.Lock() defer c.mu.Unlock() - + c.cleanupExpiredLocked() +} + +// cleanupExpiredLocked 清理过期缓存(需要已持有锁) +func (c *Cache) cleanupExpiredLocked() { now := time.Now() for key, entry := range c.data { - if now.Sub(entry.timestamp) > cacheTTL { + if now.After(entry.expiresAt) { delete(c.data, key) } } @@ -152,6 +156,8 @@ func (c *Cache) Cleanup() { func init() { go func() { ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() // 确保ticker资源释放 + for range ticker.C { searchCache.Cleanup() } @@ -214,8 +220,43 @@ func filterSearchResults(results []Repository, query string) []Repository { return filtered } +// normalizeRepository 统一规范化仓库信息(消除重复逻辑) +func normalizeRepository(repo *Repository) { + if repo.IsOfficial { + repo.Namespace = "library" + if !strings.Contains(repo.Name, "/") { + repo.Name = "library/" + repo.Name + } + } else { + // 处理用户仓库:设置命名空间但保持Name为纯仓库名 + if repo.Namespace == "" && repo.RepoOwner != "" { + repo.Namespace = repo.RepoOwner + } + + // 如果Name包含斜杠,提取纯仓库名 + if strings.Contains(repo.Name, "/") { + parts := strings.Split(repo.Name, "/") + if len(parts) > 1 { + if repo.Namespace == "" { + repo.Namespace = parts[0] + } + repo.Name = parts[len(parts)-1] // 取最后部分作为仓库名 + } + } + } +} + // searchDockerHub 搜索镜像 func searchDockerHub(ctx context.Context, query string, page, pageSize int) (*SearchResult, error) { + return searchDockerHubWithDepth(ctx, query, page, pageSize, 0) +} + +// searchDockerHubWithDepth 搜索镜像(带递归深度控制) +func searchDockerHubWithDepth(ctx context.Context, query string, page, pageSize int, depth int) (*SearchResult, error) { + // 防止无限递归:最多允许1次递归调用 + if depth > 1 { + return nil, fmt.Errorf("搜索请求过于复杂,请尝试更具体的关键词") + } cacheKey := fmt.Sprintf("search:%s:%d:%d", query, page, pageSize) // 尝试从缓存获取 @@ -264,11 +305,7 @@ func searchDockerHub(ctx context.Context, query string, page, pageSize int) (*Se if err != nil { return nil, fmt.Errorf("请求Docker Hub API失败: %v", err) } - defer func() { - if err := resp.Body.Close(); err != nil { - fmt.Printf("关闭搜索响应体失败: %v\n", err) - } - }() + defer safeCloseResponseBody(resp.Body, "搜索响应体") body, err := io.ReadAll(resp.Body) if err != nil { @@ -281,8 +318,8 @@ func searchDockerHub(ctx context.Context, query string, page, pageSize int) (*Se return nil, fmt.Errorf("请求过于频繁,请稍后重试") case http.StatusNotFound: if isUserRepo && namespace != "" { - // 如果用户仓库搜索失败,尝试普通搜索 - return searchDockerHub(ctx, repoName, page, pageSize) + // 如果用户仓库搜索失败,尝试普通搜索(递归调用) + return searchDockerHubWithDepth(ctx, repoName, page, pageSize, depth+1) } return nil, fmt.Errorf("未找到相关镜像") case http.StatusBadGateway, http.StatusServiceUnavailable: @@ -318,18 +355,16 @@ func searchDockerHub(ctx context.Context, query string, page, pageSize int) (*Se for _, repo := range userRepos.Results { // 如果指定了仓库名,只保留匹配的结果 if repoName == "" || strings.Contains(strings.ToLower(repo.Name), strings.ToLower(repoName)) { - // 确保设置正确的命名空间和名称 + // 设置命名空间并使用统一的规范化函数 repo.Namespace = namespace - if !strings.Contains(repo.Name, "/") { - repo.Name = fmt.Sprintf("%s/%s", namespace, repo.Name) - } + normalizeRepository(&repo) result.Results = append(result.Results, repo) } } - // 如果没有找到结果,尝试普通搜索 + // 如果没有找到结果,尝试普通搜索(递归调用) if len(result.Results) == 0 { - return searchDockerHub(ctx, repoName, page, pageSize) + return searchDockerHubWithDepth(ctx, repoName, page, pageSize, depth+1) } result.Count = len(result.Results) @@ -340,23 +375,9 @@ func searchDockerHub(ctx context.Context, query string, page, pageSize int) (*Se return nil, fmt.Errorf("解析响应失败: %v", err) } - // 处理搜索结果 + // 处理搜索结果:使用统一的规范化函数 for i := range result.Results { - if result.Results[i].IsOfficial { - if !strings.Contains(result.Results[i].Name, "/") { - result.Results[i].Name = "library/" + result.Results[i].Name - } - result.Results[i].Namespace = "library" - } else { - parts := strings.Split(result.Results[i].Name, "/") - if len(parts) > 1 { - result.Results[i].Namespace = parts[0] - result.Results[i].Name = parts[1] - } else if result.Results[i].RepoOwner != "" { - result.Results[i].Namespace = result.Results[i].RepoOwner - result.Results[i].Name = fmt.Sprintf("%s/%s", result.Results[i].RepoOwner, result.Results[i].Name) - } - } + normalizeRepository(&result.Results[i]) } // 如果是用户/仓库搜索,过滤结果 @@ -394,61 +415,150 @@ func isRetryableError(err error) bool { return false } -// getRepositoryTags 获取仓库标签信息 -func getRepositoryTags(ctx context.Context, namespace, name string) ([]TagInfo, error) { +// getRepositoryTags 获取仓库标签信息(支持分页) +func getRepositoryTags(ctx context.Context, namespace, name string, page, pageSize int) ([]TagInfo, bool, error) { if namespace == "" || name == "" { - return nil, fmt.Errorf("无效输入:命名空间和名称不能为空") + return nil, false, fmt.Errorf("无效输入:命名空间和名称不能为空") } - cacheKey := fmt.Sprintf("tags:%s:%s", namespace, name) + // 默认参数 + if page <= 0 { + page = 1 + } + if pageSize <= 0 || pageSize > 100 { + pageSize = 100 + } + + // 分页缓存key + cacheKey := fmt.Sprintf("tags:%s:%s:page_%d", namespace, name, page) if cached, ok := searchCache.Get(cacheKey); ok { - return cached.([]TagInfo), nil + result := cached.(TagPageResult) + return result.Tags, result.HasMore, nil } // 构建API URL baseURL := fmt.Sprintf("https://registry.hub.docker.com/v2/repositories/%s/%s/tags", namespace, name) params := url.Values{} - params.Set("page_size", "100") + params.Set("page", fmt.Sprintf("%d", page)) + params.Set("page_size", fmt.Sprintf("%d", pageSize)) params.Set("ordering", "last_updated") fullURL := baseURL + "?" + params.Encode() - // 使用统一的搜索HTTP客户端 - resp, err := GetSearchHTTPClient().Get(fullURL) + // 获取当前页数据 + pageResult, err := fetchTagPage(ctx, fullURL, 3) if err != nil { - return nil, fmt.Errorf("发送请求失败: %v", err) + return nil, false, fmt.Errorf("获取标签失败: %v", err) } - defer func() { - if err := resp.Body.Close(); err != nil { - fmt.Printf("关闭搜索响应体失败: %v\n", err) + + hasMore := pageResult.Next != "" + + // 缓存结果(分页缓存时间较短) + result := TagPageResult{Tags: pageResult.Results, HasMore: hasMore} + searchCache.SetWithTTL(cacheKey, result, 30*time.Minute) + + return pageResult.Results, hasMore, nil +} + +// fetchTagPage 获取单页标签数据,带重试机制 +func fetchTagPage(ctx context.Context, url string, maxRetries int) (*struct { + Count int `json:"count"` + Next string `json:"next"` + Previous string `json:"previous"` + Results []TagInfo `json:"results"` +}, error) { + var lastErr error + + for retry := 0; retry < maxRetries; retry++ { + if retry > 0 { + // 重试前等待一段时间 + time.Sleep(time.Duration(retry) * 500 * time.Millisecond) } - }() - // 读取响应体 - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("读取响应失败: %v", err) - } + resp, err := GetSearchHTTPClient().Get(url) + if err != nil { + lastErr = err + if isRetryableError(err) && retry < maxRetries-1 { + continue + } + return nil, fmt.Errorf("发送请求失败: %v", err) + } - // 检查响应状态码 - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("请求失败: 状态码=%d, 响应=%s", resp.StatusCode, string(body)) - } + // 读取响应体(立即关闭,避免defer在循环中累积) + body, err := func() ([]byte, error) { + defer safeCloseResponseBody(resp.Body, "标签响应体") + return io.ReadAll(resp.Body) + }() + + if err != nil { + lastErr = err + if retry < maxRetries-1 { + continue + } + return nil, fmt.Errorf("读取响应失败: %v", err) + } - // 解析响应 - var result struct { - Count int `json:"count"` - Next string `json:"next"` - Previous string `json:"previous"` - Results []TagInfo `json:"results"` - } - if err := json.Unmarshal(body, &result); err != nil { - return nil, fmt.Errorf("解析响应失败: %v", err) - } + // 检查响应状态码 + if resp.StatusCode != http.StatusOK { + lastErr = fmt.Errorf("状态码=%d, 响应=%s", resp.StatusCode, string(body)) + // 4xx错误通常不需要重试 + if resp.StatusCode >= 400 && resp.StatusCode < 500 && resp.StatusCode != 429 { + return nil, fmt.Errorf("请求失败: %v", lastErr) + } + if retry < maxRetries-1 { + continue + } + return nil, fmt.Errorf("请求失败: %v", lastErr) + } - // 缓存结果 - searchCache.Set(cacheKey, result.Results) - return result.Results, nil + // 解析响应 + var result struct { + Count int `json:"count"` + Next string `json:"next"` + Previous string `json:"previous"` + Results []TagInfo `json:"results"` + } + if err := json.Unmarshal(body, &result); err != nil { + lastErr = err + if retry < maxRetries-1 { + continue + } + return nil, fmt.Errorf("解析响应失败: %v", err) + } + + return &result, nil + } + + return nil, lastErr +} + +// parsePaginationParams 解析分页参数 +func parsePaginationParams(c *gin.Context, defaultPageSize int) (page, pageSize int) { + page = 1 + pageSize = defaultPageSize + + if p := c.Query("page"); p != "" { + fmt.Sscanf(p, "%d", &page) + } + if ps := c.Query("page_size"); ps != "" { + fmt.Sscanf(ps, "%d", &pageSize) + } + + return page, pageSize +} + +// safeCloseResponseBody 安全关闭HTTP响应体(统一资源管理) +func safeCloseResponseBody(body io.ReadCloser, context string) { + if body != nil { + if err := body.Close(); err != nil { + fmt.Printf("关闭%s失败: %v\n", context, err) + } + } +} + +// sendErrorResponse 统一错误响应处理 +func sendErrorResponse(c *gin.Context, message string) { + c.JSON(http.StatusBadRequest, gin.H{"error": message}) } // RegisterSearchRoute 注册搜索相关路由 @@ -457,22 +567,15 @@ func RegisterSearchRoute(r *gin.Engine) { r.GET("/search", func(c *gin.Context) { query := c.Query("q") if query == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "搜索关键词不能为空"}) + sendErrorResponse(c, "搜索关键词不能为空") return } - page := 1 - pageSize := 25 - if p := c.Query("page"); p != "" { - fmt.Sscanf(p, "%d", &page) - } - if ps := c.Query("page_size"); ps != "" { - fmt.Sscanf(ps, "%d", &pageSize) - } + page, pageSize := parsePaginationParams(c, 25) result, err := searchDockerHub(c.Request.Context(), query, page, pageSize) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + sendErrorResponse(c, err.Error()) return } @@ -485,16 +588,27 @@ func RegisterSearchRoute(r *gin.Engine) { name := c.Param("name") if namespace == "" || name == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "命名空间和名称不能为空"}) + sendErrorResponse(c, "命名空间和名称不能为空") return } - tags, err := getRepositoryTags(c.Request.Context(), namespace, name) + page, pageSize := parsePaginationParams(c, 100) + + tags, hasMore, err := getRepositoryTags(c.Request.Context(), namespace, name, page, pageSize) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + sendErrorResponse(c, err.Error()) return } - c.JSON(http.StatusOK, tags) + if c.Query("page") != "" || c.Query("page_size") != "" { + c.JSON(http.StatusOK, gin.H{ + "tags": tags, + "has_more": hasMore, + "page": page, + "page_size": pageSize, + }) + } else { + c.JSON(http.StatusOK, tags) + } }) } -- 2.49.1 From 35651e214f6edfecdc52cbddfceaef97009cb02a Mon Sep 17 00:00:00 2001 From: starry <115192496+sky22333@users.noreply.github.com> Date: Sat, 21 Jun 2025 00:15:27 +0800 Subject: [PATCH 4/4] =?UTF-8?q?proxy=E5=AD=97=E6=AE=B5=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config.go b/src/config.go index fd22dc5..1dbae37 100644 --- a/src/config.go +++ b/src/config.go @@ -41,7 +41,7 @@ type AppConfig struct { WhiteList []string `toml:"whiteList"` // 代理白名单(仓库级别) BlackList []string `toml:"blackList"` // 代理黑名单(仓库级别) Proxy string `toml:"proxy"` // 代理地址: 支持 http/https/socks5/socks5h - } `toml:"proxy"` + } `toml:"access"` Download struct { MaxImages int `toml:"maxImages"` // 单次下载最大镜像数量限制 -- 2.49.1