Merge pull request #149 from WJQSERVER-STUDIO/dev

4.2.3
This commit is contained in:
WJQSERVER
2025-07-27 15:46:42 +08:00
committed by GitHub
7 changed files with 119 additions and 117 deletions

View File

@@ -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}} ./main.go
CGO_ENABLED=0 go build -ldflags "-X main.version=${{ env.VERSION }} -X main.dev=true" -o ${{ env.OUTPUT_BINARY }}-${{matrix.goos}}-${{matrix.goarch}} .
- name: 打包
run: |
mkdir ghproxyd

View File

@@ -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}} ./main.go
CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${{ env.VERSION }}" -o ${{ env.OUTPUT_BINARY }}-${{matrix.goos}}-${{matrix.goarch}} .
- name: 打包
run: |
mkdir ghproxyd

View File

@@ -1,5 +1,16 @@
# 更新日志
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`等上游源特殊处理的适配

View File

@@ -1 +1 @@
4.2.2-rc.0
4.2.3-rc.0

View File

@@ -1 +1 @@
4.2.2
4.2.3

110
main.go
View File

@@ -53,12 +53,21 @@ var (
)
var (
logger *reco.Logger
logDump = logger.Debugf
logDebug = logger.Debugf
logInfo = logger.Infof
logWarning = logger.Warnf
logError = logger.Errorf
// supportedThemes 定义了所有支持的主题, 用于验证配置和动态加载
supportedThemes = map[string]struct{}{
"bootstrap": {},
"nebula": {},
"design": {},
"metro": {},
"classic": {},
"mino": {},
"hub": {},
"free": {},
}
)
var (
logger *reco.Logger
)
func readFlag() {
@@ -111,7 +120,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)
}
@@ -150,7 +159,7 @@ func setupLogger(cfg *config.Config) {
func setMemLimit(cfg *config.Config) {
if cfg.Server.MemLimit > 0 {
debug.SetMemoryLimit((cfg.Server.MemLimit) * 1024 * 1024)
logInfo("Set Memory Limit to %d MB", cfg.Server.MemLimit)
logger.Infof("Set Memory Limit to %d MB", cfg.Server.MemLimit)
}
}
@@ -175,60 +184,52 @@ func InitReq(cfg *config.Config) {
}
}
// loadEmbeddedPages 加载嵌入式页面资源
// 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, 动态加载嵌入式页面和资源文件系统
func loadEmbeddedPages(cfg *config.Config) (fs.FS, fs.FS, error) {
pageFS := modembed.NewModTimeFS(pagesFS, time.Now())
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)
theme := cfg.Pages.Theme
// 检查主题是否受支持, 如果不支持则使用默认主题
if _, ok := supportedThemes[theme]; !ok {
logger.Warnf("Invalid Pages Theme: %s, using default theme 'design'", theme)
theme = "design" // 默认主题
}
// 从嵌入式文件系统中获取主题子目录
themePath := fmt.Sprintf("pages/%s", theme)
pages, err := fs.Sub(pageFS, themePath)
if err != nil {
return nil, nil, fmt.Errorf("failed to load embedded pages: %w", err)
return nil, nil, fmt.Errorf("failed to load embedded theme '%s': %w", theme, err)
}
// 初始化errPagesFs
errPagesInitErr := proxy.InitErrPagesFS(pageFS)
if errPagesInitErr != nil {
logWarning("errPagesInitErr: %s", errPagesInitErr)
}
var assets fs.FS
assets, err = fs.Sub(pageFS, "pages/assets")
// 加载共享资源文件
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 {
logError("Failed when processing internal pages: %s", err)
fmt.Println(err.Error())
return
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)
}
case "external":
@@ -236,15 +237,13 @@ func setupPages(cfg *config.Config, r *touka.Engine) {
default:
// 处理无效的Pages Mode
logWarning("Invalid Pages Mode: %s, using default embedded theme", cfg.Pages.Mode)
logger.Warnf("Invalid Pages Mode: %s, using default embedded theme", cfg.Pages.Mode)
err := setInternalRoute(cfg, r)
if err != nil {
logError("Failed when processing internal pages: %s", err)
fmt.Println(err.Error())
return
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)
}
}
}
@@ -266,11 +265,9 @@ 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
}
@@ -288,13 +285,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)
@@ -303,6 +300,7 @@ func init() {
loadConfig()
if cfg != nil { // 在setupLogger前添加空值检查
setupLogger(cfg)
initializeErrorPages()
InitReq(cfg)
setMemLimit(cfg)
loadlist(cfg)
@@ -317,7 +315,7 @@ func init() {
}
if cfg.Server.Debug {
version = "Dev" // 如果是Debug模式版本设置为"Dev"
version = "Dev" // 如果是Debug模式, 版本设置为"Dev"
}
}
}
@@ -492,7 +490,7 @@ func main() {
addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
err := r.RunShutdown(addr)
if err != nil {
logError("Server Run Error: %v", err)
logger.Errorf("Server Run Error: %v", err)
fmt.Printf("Server Run Error: %v\n", err)
}

View File

@@ -43,42 +43,57 @@ func InitWeakCache() *weakcache.Cache[string] {
// GhcrWithImageRouting 处理带有镜像路由的请求, 根据目标路由到不同的Docker注册表
func GhcrWithImageRouting(cfg *config.Config) touka.HandlerFunc {
return func(c *touka.Context) {
reqTarget := c.Param("target") // 请求中指定的目标 (如 docker.io, ghcr.io, gcr.io)
reqImageUser := c.Param("user") // 镜像用户
reqImageName := c.Param("repo") // 镜像仓库名
reqFilePath := c.Param("filepath") // 镜像文件路径
// 从 main.go 中固定的路由 "/v2/:target/:user/:repo/*filepath" 获取参数
reqTarget := c.Param("target")
reqImageUser := c.Param("user")
reqImageName := c.Param("repo")
reqFilePath := c.Param("filepath")
// 构造完整的镜像路径
path := fmt.Sprintf("%s/%s%s", reqImageUser, reqImageName, reqFilePath)
var target string
var upstreamTarget string
var requestPath string
var imageNameForAuth string
// 根据 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
}
// 关键逻辑: 判断 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
}
// 必须将路由错误分割的所有部分重新组合成完整的镜像路径
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{
User: reqImageUser,
Repo: reqImageName,
Image: fmt.Sprintf("%s/%s", reqImageUser, reqImageName),
Image: imageNameForAuth,
}
// 调用 GhcrToTarget 处理实际的代理请求
GhcrToTarget(c, cfg, target, path, image)
GhcrToTarget(c, cfg, upstreamTarget, requestPath, image)
}
}
@@ -90,39 +105,17 @@ 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()
// 根据是否指定 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)
// 构造目标URL. 这里的target和path都是由GhcrWithImageRouting正确解析得来的.
destUrl := "https://" + target + "/v2/" + path
if query := c.GetReqQueryString(); query != "" {
destUrl += "?" + query
}
c.Debugf("Proxying to target '%s' with path '%s'. Final URL: %s", target, path, destUrl)
// 执行实际的代理请求
GhcrRequest(ctx, c, destUrl, image, cfg, upstreamTarget)
GhcrRequest(ctx, c, destUrl, image, cfg, target)
}
// GhcrRequest 执行对Docker注册表的HTTP请求, 处理认证和重定向
@@ -166,7 +159,7 @@ func GhcrRequest(ctx context.Context, c *touka.Context, u string, image *imageIn
req.Header.Set("Host", target)
// 尝试从缓存中获取并使用认证令牌
if image != nil {
if image != nil && image.Image != "" {
token, exist := cache.Get(image.Image)
if exist {
req.Header.Set("Authorization", "Bearer "+token)
@@ -188,7 +181,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 {
if image == nil || image.Image == "" {
_ = resp.Body.Close() // 终止流程, 关闭当前响应体
ErrorPage(c, NewErrorWithStatusLookup(originalStatusCode, "Unauthorized"))
return