diff --git a/.github/workflows/build-dev.yml b/.github/workflows/build-dev.yml index 24e61c7..c1cb9d1 100644 --- a/.github/workflows/build-dev.yml +++ b/.github/workflows/build-dev.yml @@ -73,7 +73,7 @@ jobs: GOOS: ${{ matrix.goos }} GOARCH: ${{ matrix.goarch }} run: | - CGO_ENABLED=0 go build -ldflags "-X main.version=${{ env.VERSION }} -X main.dev=true" -o ${{ env.OUTPUT_BINARY }}-${{matrix.goos}}-${{matrix.goarch}} . + CGO_ENABLED=0 go build -ldflags "-X main.version=${{ env.VERSION }} -X main.dev=true" -o ${{ env.OUTPUT_BINARY }}-${{matrix.goos}}-${{matrix.goarch}} ./main.go - name: 打包 run: | mkdir ghproxyd diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f847575..57bda5f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -74,7 +74,7 @@ jobs: GOOS: ${{ matrix.goos }} GOARCH: ${{ matrix.goarch }} run: | - CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${{ env.VERSION }}" -o ${{ env.OUTPUT_BINARY }}-${{matrix.goos}}-${{matrix.goarch}} . + CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${{ env.VERSION }}" -o ${{ env.OUTPUT_BINARY }}-${{matrix.goos}}-${{matrix.goarch}} ./main.go - name: 打包 run: | mkdir ghproxyd diff --git a/CHANGELOG.md b/CHANGELOG.md index 031cc7c..525bb62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,16 +1,5 @@ # 更新日志 -4.2.3 - 2025-07-27 ---- -- CHANGE: 改进错误页面加载器, 避免在选择`external`模式时错误页面渲染回退到json输出 -- CHANGE: 完善OCI(Docker)镜像代理默认target逻辑 - -4.2.3-rc.0 - 2025-07-27 ---- -- PRE-RELEASE: v4.2.3-rc.0是v4.2.3预发布版本,请勿在生产环境中使用; -- CHANGE: 改进错误页面加载器, 避免在选择`external`模式时错误页面渲染回退到json输出 -- CHANGE: 完善OCI(Docker)镜像代理默认target逻辑 - 4.2.2 - 2025-07-25 --- - CHANGE: 重构OCI镜像代理部分, 完善对`ghcr`,`gcr`,`k8s.gcr`等上游源特殊处理的适配 diff --git a/DEV-VERSION b/DEV-VERSION index f435fb6..c6cdc65 100644 --- a/DEV-VERSION +++ b/DEV-VERSION @@ -1 +1 @@ -4.2.3-rc.0 \ No newline at end of file +4.2.2-rc.0 \ No newline at end of file diff --git a/VERSION b/VERSION index ec87108..078bf8b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.2.3 \ No newline at end of file +4.2.2 \ No newline at end of file diff --git a/main.go b/main.go index 65a6e31..e09426e 100644 --- a/main.go +++ b/main.go @@ -53,21 +53,12 @@ var ( ) var ( - // supportedThemes 定义了所有支持的主题, 用于验证配置和动态加载 - supportedThemes = map[string]struct{}{ - "bootstrap": {}, - "nebula": {}, - "design": {}, - "metro": {}, - "classic": {}, - "mino": {}, - "hub": {}, - "free": {}, - } -) - -var ( - logger *reco.Logger + logger *reco.Logger + logDump = logger.Debugf + logDebug = logger.Debugf + logInfo = logger.Infof + logWarning = logger.Warnf + logError = logger.Errorf ) func readFlag() { @@ -120,7 +111,7 @@ func loadConfig() { cfg, err = config.LoadConfig(cfgfile) if err != nil { fmt.Printf("Failed to load config: %v\n", err) - // 如果配置文件加载失败, 也显示帮助信息并退出 + // 如果配置文件加载失败,也显示帮助信息并退出 flag.Usage() os.Exit(1) } @@ -159,7 +150,7 @@ func setupLogger(cfg *config.Config) { func setMemLimit(cfg *config.Config) { if cfg.Server.MemLimit > 0 { debug.SetMemoryLimit((cfg.Server.MemLimit) * 1024 * 1024) - logger.Infof("Set Memory Limit to %d MB", cfg.Server.MemLimit) + logInfo("Set Memory Limit to %d MB", cfg.Server.MemLimit) } } @@ -184,52 +175,60 @@ func InitReq(cfg *config.Config) { } } -// initializeErrorPages 初始化嵌入的错误页面资源 -// 无论页面模式(internal/external)如何, 都应执行此操作, 以确保统一的错误页面处理 -func initializeErrorPages() { - pageFS := modembed.NewModTimeFS(pagesFS, time.Now()) - if err := proxy.InitErrPagesFS(pageFS); err != nil { - // 这是一个警告而不是致命错误, 因为即使没有自定义错误页面, 服务器也能运行 - logger.Warnf("failed to initialize embedded error pages: %v", err) - } -} - -// loadEmbeddedPages 使用 map 替代 switch, 动态加载嵌入式页面和资源文件系统 +// loadEmbeddedPages 加载嵌入式页面资源 func loadEmbeddedPages(cfg *config.Config) (fs.FS, fs.FS, error) { pageFS := modembed.NewModTimeFS(pagesFS, time.Now()) - theme := cfg.Pages.Theme - - // 检查主题是否受支持, 如果不支持则使用默认主题 - if _, ok := supportedThemes[theme]; !ok { - logger.Warnf("Invalid Pages Theme: %s, using default theme 'design'", theme) - theme = "design" // 默认主题 + var pages fs.FS + var err error + switch cfg.Pages.Theme { + case "bootstrap": + pages, err = fs.Sub(pageFS, "pages/bootstrap") + case "nebula": + pages, err = fs.Sub(pageFS, "pages/nebula") + case "design": + pages, err = fs.Sub(pageFS, "pages/design") + case "metro": + pages, err = fs.Sub(pageFS, "pages/metro") + case "classic": + pages, err = fs.Sub(pageFS, "pages/classic") + case "mino": + pages, err = fs.Sub(pageFS, "pages/mino") + case "hub": + pages, err = fs.Sub(pageFS, "pages/hub") + case "free": + pages, err = fs.Sub(pageFS, "pages/free") + default: + pages, err = fs.Sub(pageFS, "pages/design") // 默认主题 + logWarning("Invalid Pages Theme: %s, using default theme 'design'", cfg.Pages.Theme) } - // 从嵌入式文件系统中获取主题子目录 - themePath := fmt.Sprintf("pages/%s", theme) - pages, err := fs.Sub(pageFS, themePath) if err != nil { - return nil, nil, fmt.Errorf("failed to load embedded theme '%s': %w", theme, err) + return nil, nil, fmt.Errorf("failed to load embedded pages: %w", err) } - // 加载共享资源文件 - assets, err := fs.Sub(pageFS, "pages/assets") + // 初始化errPagesFs + errPagesInitErr := proxy.InitErrPagesFS(pageFS) + if errPagesInitErr != nil { + logWarning("errPagesInitErr: %s", errPagesInitErr) + } + + var assets fs.FS + assets, err = fs.Sub(pageFS, "pages/assets") if err != nil { return nil, nil, fmt.Errorf("failed to load embedded assets: %w", err) } - return pages, assets, nil } -// setupPages 设置页面路由, 增强了错误处理 +// setupPages 设置页面路由 func setupPages(cfg *config.Config, r *touka.Engine) { switch cfg.Pages.Mode { case "internal": err := setInternalRoute(cfg, r) if err != nil { - logger.Errorf("Failed to set up internal pages, server cannot start: %s", err) - fmt.Printf("Failed to set up internal pages, server cannot start: %s", err) - os.Exit(1) + logError("Failed when processing internal pages: %s", err) + fmt.Println(err.Error()) + return } case "external": @@ -237,13 +236,15 @@ func setupPages(cfg *config.Config, r *touka.Engine) { default: // 处理无效的Pages Mode - logger.Warnf("Invalid Pages Mode: %s, using default embedded theme", cfg.Pages.Mode) + logWarning("Invalid Pages Mode: %s, using default embedded theme", cfg.Pages.Mode) + err := setInternalRoute(cfg, r) if err != nil { - logger.Errorf("Failed to set up internal pages, server cannot start: %s", err) - fmt.Printf("Failed to set up internal pages, server cannot start: %s", err) - os.Exit(1) + logError("Failed when processing internal pages: %s", err) + fmt.Println(err.Error()) + return } + } } @@ -265,9 +266,11 @@ func viaHeader() func(c *touka.Context) { } func setInternalRoute(cfg *config.Config, r *touka.Engine) error { + // 加载嵌入式资源 pages, assets, err := loadEmbeddedPages(cfg) if err != nil { + logError("Failed when processing pages: %s", err) return err } @@ -285,13 +288,13 @@ func init() { readFlag() flag.Parse() - // 如果设置了 -h, 则显示帮助信息并退出 + // 如果设置了 -h,则显示帮助信息并退出 if showHelp { flag.Usage() os.Exit(0) } - // 如果设置了 -v, 则显示版本号并退出 + // 如果设置了 -v,则显示版本号并退出 if showVersion { fmt.Printf("GHProxy Version: %s \n", version) os.Exit(0) @@ -300,7 +303,6 @@ func init() { loadConfig() if cfg != nil { // 在setupLogger前添加空值检查 setupLogger(cfg) - initializeErrorPages() InitReq(cfg) setMemLimit(cfg) loadlist(cfg) @@ -315,7 +317,7 @@ func init() { } if cfg.Server.Debug { - version = "Dev" // 如果是Debug模式, 版本设置为"Dev" + version = "Dev" // 如果是Debug模式,版本设置为"Dev" } } } @@ -490,7 +492,7 @@ func main() { addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port) err := r.RunShutdown(addr) if err != nil { - logger.Errorf("Server Run Error: %v", err) + logError("Server Run Error: %v", err) fmt.Printf("Server Run Error: %v\n", err) } diff --git a/proxy/docker.go b/proxy/docker.go index 3287342..4712ae3 100644 --- a/proxy/docker.go +++ b/proxy/docker.go @@ -43,57 +43,42 @@ func InitWeakCache() *weakcache.Cache[string] { // GhcrWithImageRouting 处理带有镜像路由的请求, 根据目标路由到不同的Docker注册表 func GhcrWithImageRouting(cfg *config.Config) touka.HandlerFunc { return func(c *touka.Context) { - // 从 main.go 中固定的路由 "/v2/:target/:user/:repo/*filepath" 获取参数 - reqTarget := c.Param("target") - reqImageUser := c.Param("user") - reqImageName := c.Param("repo") - reqFilePath := c.Param("filepath") + reqTarget := c.Param("target") // 请求中指定的目标 (如 docker.io, ghcr.io, gcr.io) + reqImageUser := c.Param("user") // 镜像用户 + reqImageName := c.Param("repo") // 镜像仓库名 + reqFilePath := c.Param("filepath") // 镜像文件路径 - var upstreamTarget string - var requestPath string - var imageNameForAuth string + // 构造完整的镜像路径 + path := fmt.Sprintf("%s/%s%s", reqImageUser, reqImageName, reqFilePath) + var target string - // 关键逻辑: 判断 reqTarget 是真实主机名还是镜像名的一部分 - // 依据: 真实主机名/IP通常包含'.'或':' - if strings.Contains(reqTarget, ".") || strings.Contains(reqTarget, ":") { - // 情况 A: reqTarget 是一个显式指定的主机名 (例如 "ghcr.io", "my-registry.com", "127.0.0.1:5000") - c.Debugf("Request target '%s' identified as an explicit hostname.", reqTarget) - upstreamTarget = reqTarget - // 上游请求的路径是主机名之后的部分 - requestPath = fmt.Sprintf("%s/%s%s", reqImageUser, reqImageName, reqFilePath) - // 用于认证的镜像名是 user/repo - imageNameForAuth = fmt.Sprintf("%s/%s", reqImageUser, reqImageName) - } else { - // 情况 B: reqTarget 是镜像名的一部分 (例如 "wjqserver", "library") - c.Debugf("Request target '%s' identified as part of an image name. Using default registry.", reqTarget) - // 使用配置文件中的默认目标 - switch cfg.Docker.Target { - case "ghcr": - upstreamTarget = ghcrTarget - case "dockerhub": - upstreamTarget = dockerhubTarget - case "": - ErrorPage(c, NewErrorWithStatusLookup(500, "Default Docker Target is not configured in config file")) - return - default: - upstreamTarget = cfg.Docker.Target + // 根据 reqTarget 智能判断实际的目标注册表 + switch { + case reqTarget == "docker.io": + target = dockerhubTarget // Docker Hub + case reqTarget == "ghcr.io": + target = ghcrTarget // GitHub Container Registry + case strings.HasSuffix(reqTarget, ".gcr.io"), reqTarget == "gcr.io": + target = reqTarget // Google Container Registry 及其子域名 + default: + // 如果 reqTarget 包含点, 则假定它是一个完整的域名 + for _, r := range reqTarget { + if r == '.' { + target = reqTarget + break + } } - // 必须将路由错误分割的所有部分重新组合成完整的镜像路径 - requestPath = fmt.Sprintf("%s/%s/%s%s", reqTarget, reqImageUser, reqImageName, reqFilePath) - // 用于认证的镜像名是 target/user (例如 "wjqserver/ghproxy", "library/ubuntu") - imageNameForAuth = fmt.Sprintf("%s/%s", reqTarget, reqImageUser) } - // 清理路径, 防止出现 "//" - requestPath = strings.TrimPrefix(requestPath, "/") - - // 为认证和缓存准备镜像信息 + // 封装镜像信息 image := &imageInfo{ - Image: imageNameForAuth, + User: reqImageUser, + Repo: reqImageName, + Image: fmt.Sprintf("%s/%s", reqImageUser, reqImageName), } // 调用 GhcrToTarget 处理实际的代理请求 - GhcrToTarget(c, cfg, upstreamTarget, requestPath, image) + GhcrToTarget(c, cfg, target, path, image) } } @@ -105,17 +90,39 @@ func GhcrToTarget(c *touka.Context, cfg *config.Config, target string, path stri return } + var destUrl string // 最终代理的目标URL + var upstreamTarget string // 实际的上游目标域名 var ctx = c.Request.Context() - // 构造目标URL. 这里的target和path都是由GhcrWithImageRouting正确解析得来的. - destUrl := "https://" + target + "/v2/" + path - if query := c.GetReqQueryString(); query != "" { - destUrl += "?" + query + // 根据是否指定 target 来确定上游目标和目标URL + if target != "" { + upstreamTarget = target + // 构造目标URL, 拼接 v2/ 路径和原始查询参数 + destUrl = "https://" + upstreamTarget + "/v2/" + path + if query := c.GetReqQueryString(); query != "" { + destUrl += "?" + query + } + c.Debugf("Proxying to target %s: %s", upstreamTarget, destUrl) + } else { + // 如果未指定 target, 则根据配置的默认目标进行代理 + switch cfg.Docker.Target { + case "ghcr": + upstreamTarget = ghcrTarget + case "dockerhub": + upstreamTarget = dockerhubTarget + case "": + ErrorPage(c, NewErrorWithStatusLookup(403, "Docker Target is not set")) + return + default: + upstreamTarget = cfg.Docker.Target + } + // 使用原始请求URI构建目标URL + destUrl = "https://" + upstreamTarget + c.GetRequestURI() + c.Debugf("Proxying to default target %s: %s", upstreamTarget, destUrl) } - c.Debugf("Proxying to target '%s' with path '%s'. Final URL: %s", target, path, destUrl) // 执行实际的代理请求 - GhcrRequest(ctx, c, destUrl, image, cfg, target) + GhcrRequest(ctx, c, destUrl, image, cfg, upstreamTarget) } // GhcrRequest 执行对Docker注册表的HTTP请求, 处理认证和重定向 @@ -159,7 +166,7 @@ func GhcrRequest(ctx context.Context, c *touka.Context, u string, image *imageIn req.Header.Set("Host", target) // 尝试从缓存中获取并使用认证令牌 - if image != nil && image.Image != "" { + if image != nil { token, exist := cache.Get(image.Image) if exist { req.Header.Set("Authorization", "Bearer "+token) @@ -181,7 +188,7 @@ func GhcrRequest(ctx context.Context, c *touka.Context, u string, image *imageIn c.Debugf("Initial request failed with status %d. Retry eligibility: %t", originalStatusCode, shouldRetry) if shouldRetry { - if image == nil || image.Image == "" { + if image == nil { _ = resp.Body.Close() // 终止流程, 关闭当前响应体 ErrorPage(c, NewErrorWithStatusLookup(originalStatusCode, "Unauthorized")) return