Files
ghproxy/main.go
2025-08-20 15:48:00 +08:00

528 lines
14 KiB
Go

package main
import (
"embed"
"flag"
"fmt"
"io/fs"
"net/http"
"os"
"runtime/debug"
"strings"
"time"
"ghproxy/api"
"ghproxy/auth"
"ghproxy/config"
"ghproxy/proxy"
"github.com/WJQSERVER-STUDIO/httpc"
"github.com/fenthope/bauth"
"ghproxy/weakcache"
"github.com/fenthope/ikumi"
"github.com/fenthope/ipfilter"
"github.com/fenthope/reco"
"github.com/fenthope/record"
"github.com/infinite-iroha/touka"
"github.com/wjqserver/modembed"
"golang.org/x/time/rate"
_ "net/http/pprof"
)
var (
cfg *config.Config
r *touka.Engine
configfile = "/data/ghproxy/config/config.toml"
httpClient *httpc.Client
cfgfile string
version string
runMode string
showVersion bool
showHelp bool
)
var (
//go:embed pages/*
pagesFS embed.FS
)
var (
wcache *weakcache.Cache[string] // docker token缓存
)
var (
// supportedThemes 定义了所有支持的主题, 用于验证配置和动态加载
supportedThemes = map[string]struct{}{
"bootstrap": {},
"nebula": {},
"design": {},
"metro": {},
"classic": {},
"mino": {},
"hub": {},
"free": {},
}
)
var (
logger *reco.Logger
)
func readFlag() {
flag.StringVar(&cfgfile, "c", configfile, "config file path")
flag.Func("cfg", "exit", func(s string) error {
// 被弃用的flag, fail退出
fmt.Printf("\n")
fmt.Println("[ERROR] cfg flag is deprecated, please use -c instead")
fmt.Printf("\n")
flag.Usage()
os.Exit(2)
return nil
})
flag.BoolVar(&showVersion, "v", false, "show version and exit") // 添加-v标志
flag.BoolVar(&showHelp, "h", false, "show help message and exit") // 添加-h标志
// 捕获未定义的 flag
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage of %s:\n", os.Args[0])
flag.PrintDefaults()
fmt.Fprintln(os.Stderr, "\nInvalid flags:")
// 检查未定义的flags
invalidFlags := []string{}
for _, arg := range os.Args[1:] {
if arg[0] == '-' && arg != "-h" && arg != "-v" { // 检查是否是flag, 排除 -h 和 -v
defined := false
flag.VisitAll(func(f *flag.Flag) {
if "-"+f.Name == arg {
defined = true
}
})
if !defined {
invalidFlags = append(invalidFlags, arg)
}
}
}
for _, flag := range invalidFlags {
fmt.Fprintf(os.Stderr, " %s\n", flag)
}
if len(invalidFlags) > 0 {
os.Exit(2)
}
}
}
func loadConfig() {
var err error
cfg, err = config.LoadConfig(cfgfile)
if err != nil {
fmt.Printf("Failed to load config: %v\n", err)
// 如果配置文件加载失败, 也显示帮助信息并退出
flag.Usage()
os.Exit(1)
}
if cfg != nil && cfg.Server.Debug { // 确保 cfg 不为 nil
fmt.Println("Config File Path: ", cfgfile)
fmt.Printf("Loaded config: %v\n", cfg)
}
}
func setupLogger(cfg *config.Config) {
var err error
if cfg.Log.Level == "" {
cfg.Log.Level = "info"
}
recoLevel := reco.ParseLevel(cfg.Log.Level)
logger, err = reco.New(reco.Config{
Level: recoLevel,
Mode: reco.ModeText,
FilePath: cfg.Log.LogFilePath,
MaxFileSizeMB: cfg.Log.MaxLogSize,
EnableRotation: true,
Async: true,
})
if err != nil {
fmt.Printf("Failed to initialize logger: %v\n", err)
os.Exit(1)
}
logger.SetLevel(recoLevel)
fmt.Printf("Log Level: %s\n", cfg.Log.Level)
logger.Debugf("Config File Path: %s", cfgfile)
logger.Debugf("Loaded config: %v", cfg)
logger.Infof("Logger Initialized Successfully")
}
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)
}
}
func loadlist(cfg *config.Config) {
err := auth.ListInit(cfg)
if err != nil {
logger.Errorf("Failed to initialize list: %v", err)
}
}
func setupApi(cfg *config.Config, r *touka.Engine, version string) {
api.InitHandleRouter(cfg, r, version)
}
func InitReq(cfg *config.Config) {
var err error
httpClient, err = proxy.InitReq(cfg)
if err != nil {
fmt.Printf("Failed to initialize request: %v\n", err)
os.Exit(1)
}
}
// 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())
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 theme '%s': %w", theme, err)
}
// 加载共享资源文件
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 设置页面路由, 增强了错误处理
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)
}
case "external":
if cfg.Pages.StaticDir == "" {
logger.Errorf("Pages Mode is 'external' but StaticDir is empty. Using embedded pages instead.")
err := setInternalRoute(cfg, r)
if err != nil {
logger.Errorf("Failed to load embedded pages: %s", err)
fmt.Printf("Failed to load embedded pages: %s", err)
os.Exit(1)
}
} else {
extPageFS := os.DirFS(cfg.Pages.StaticDir)
r.SetUnMatchFS(http.FS(extPageFS))
}
default:
// 处理无效的Pages Mode
logger.Warnf("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)
}
}
}
var viaString string = "WJQSERVER-STUDIO/GHProxy"
func pageCacheHeader() func(c *touka.Context) {
return func(c *touka.Context) {
c.AddHeader("Cache-Control", "public, max-age=3600, must-revalidate")
c.Next()
}
}
func viaHeader() func(c *touka.Context) {
return func(c *touka.Context) {
protoVersion := fmt.Sprintf("%d.%d", c.Request.ProtoMajor, c.Request.ProtoMinor)
c.AddHeader("Via", protoVersion+" "+viaString)
c.Next()
}
}
func setInternalRoute(cfg *config.Config, r *touka.Engine) error {
// 加载嵌入式资源
pages, assets, err := loadEmbeddedPages(cfg)
if err != nil {
return err
}
r.HandleFunc([]string{"GET"}, "/favicon.ico", pageCacheHeader(), touka.FileServer(http.FS(assets)))
r.HandleFunc([]string{"GET"}, "/", pageCacheHeader(), touka.FileServer(http.FS(pages)))
r.HandleFunc([]string{"GET"}, "/script.js", pageCacheHeader(), touka.FileServer(http.FS(pages)))
r.HandleFunc([]string{"GET"}, "/style.css", pageCacheHeader(), touka.FileServer(http.FS(pages)))
r.HandleFunc([]string{"GET"}, "/bootstrap.min.css", pageCacheHeader(), touka.FileServer(http.FS(assets)))
r.HandleFunc([]string{"GET"}, "/bootstrap.bundle.min.js", pageCacheHeader(), touka.FileServer(http.FS(assets)))
return nil
}
func init() {
readFlag()
flag.Parse()
// 如果设置了 -h, 则显示帮助信息并退出
if showHelp {
flag.Usage()
os.Exit(0)
}
// 如果设置了 -v, 则显示版本号并退出
if showVersion {
fmt.Printf("GHProxy Version: %s \n", version)
os.Exit(0)
}
loadConfig()
if cfg != nil { // 在setupLogger前添加空值检查
setupLogger(cfg)
initializeErrorPages()
InitReq(cfg)
setMemLimit(cfg)
loadlist(cfg)
if cfg.Docker.Enabled {
wcache = proxy.InitWeakCache()
}
if cfg.Server.Debug {
runMode = "dev"
} else {
runMode = "release"
}
if cfg.Server.Debug {
version = "Dev" // 如果是Debug模式, 版本设置为"Dev"
}
}
}
func main() {
if showVersion || showHelp {
return
}
if cfg == nil {
fmt.Println("Config not loaded, exiting.")
return
}
r := touka.Default()
r.SetProtocols(&touka.ProtocolsConfig{
Http1: true,
Http2_Cleartext: true,
})
r.Use(touka.Recovery()) // Recovery中间件
r.SetLogger(logger)
r.SetErrorHandler(proxy.UnifiedToukaErrorHandler)
r.SetHTTPClient(httpClient)
r.Use(record.Middleware()) // log中间件
r.Use(viaHeader())
/*
r.Use(compress.Compression(compress.CompressOptions{
Algorithms: map[string]compress.AlgorithmConfig{
compress.EncodingGzip: {
Level: gzip.BestCompression, // Gzip最高压缩比
PoolEnabled: true, // 启用Gzip压缩器的对象池
},
compress.EncodingDeflate: {
Level: flate.DefaultCompression, // Deflate默认压缩比
PoolEnabled: false, // Deflate不启用对象池
},
compress.EncodingZstd: {
Level: int(zstd.SpeedBestCompression), // Zstandard最佳压缩比
PoolEnabled: true, // 启用Zstandard压缩器的对象池
},
},
}))
*/
if cfg.RateLimit.Enabled {
r.Use(ikumi.TokenRateLimit(ikumi.TokenRateLimiterOptions{
Limit: rate.Limit(cfg.RateLimit.RatePerMinute),
Burst: cfg.RateLimit.Burst,
}))
}
if cfg.IPFilter.Enabled {
var err error
ipAllowList, ipBlockList, err := auth.ReadIPFilterList(cfg)
if err != nil {
fmt.Printf("Failed to read IP filter list: %v\n", err)
logger.Errorf("Failed to read IP filter list: %v", err)
os.Exit(1)
}
ipBlockFilter, err := ipfilter.NewIPFilter(ipfilter.IPFilterConfig{
EnableAllowList: cfg.IPFilter.EnableAllowList,
EnableBlockList: cfg.IPFilter.EnableBlockList,
AllowList: ipAllowList,
BlockList: ipBlockList,
})
if err != nil {
fmt.Printf("Failed to initialize IP filter: %v\n", err)
logger.Errorf("Failed to initialize IP filter: %v", err)
os.Exit(1)
} else {
r.Use(ipBlockFilter)
}
}
setupApi(cfg, r, version)
setupPages(cfg, r)
r.SetRedirectTrailingSlash(false)
r.GET("/github.com/:user/:repo/releases/*filepath", func(c *touka.Context) {
// 规范化路径: 移除前导斜杠, 简化后续处理
filepath := c.Param("filepath")
if len(filepath) > 0 && filepath[0] == '/' {
filepath = filepath[1:]
}
isValidDownload := false
// 检查两种合法的下载链接格式
// 情况 A: "download/..."
if strings.HasPrefix(filepath, "download/") {
isValidDownload = true
} else {
// 情况 B: ":tag/download/..."
slashIndex := strings.IndexByte(filepath, '/')
// 确保 tag 部分存在 (slashIndex > 0)
if slashIndex > 0 {
pathAfterTag := filepath[slashIndex+1:]
if strings.HasPrefix(pathAfterTag, "download/") {
isValidDownload = true
}
}
}
// 根据匹配结果执行最终操作
if isValidDownload {
c.Set("matcher", "releases")
proxy.RoutingHandler(cfg)(c)
} else {
// 任何不符合下载链接格式的 'releases' 路径都被视为浏览页面并拒绝
proxy.ErrorPage(c, proxy.NewErrorWithStatusLookup(400, "unsupported releases page, only download links are allowed"))
return
}
})
r.GET("/github.com/:user/:repo/archive/*filepath", func(c *touka.Context) {
c.Set("matcher", "releases")
proxy.RoutingHandler(cfg)(c)
})
r.GET("/github.com/:user/:repo/blob/*filepath", func(c *touka.Context) {
c.Set("matcher", "blob")
proxy.RoutingHandler(cfg)(c)
})
r.GET("/github.com/:user/:repo/raw/*filepath", func(c *touka.Context) {
c.Set("matcher", "raw")
proxy.RoutingHandler(cfg)(c)
})
r.GET("/github.com/:user/:repo/info/*filepath", func(c *touka.Context) {
c.Set("matcher", "clone")
proxy.RoutingHandler(cfg)(c)
})
r.GET("/github.com/:user/:repo/git-upload-pack", func(c *touka.Context) {
c.Set("matcher", "clone")
proxy.RoutingHandler(cfg)(c)
})
r.POST("/github.com/:user/:repo/git-upload-pack", func(c *touka.Context) {
c.Set("matcher", "clone")
proxy.RoutingHandler(cfg)(c)
})
r.GET("/raw.githubusercontent.com/:user/:repo/*filepath", func(c *touka.Context) {
c.Set("matcher", "raw")
proxy.RoutingHandler(cfg)(c)
})
r.GET("/gist.githubusercontent.com/:user/*filepath", func(c *touka.Context) {
c.Set("matcher", "gist")
proxy.NoRouteHandler(cfg)(c)
})
r.ANY("/api.github.com/repos/:user/:repo/*filepath", func(c *touka.Context) {
c.Set("matcher", "api")
proxy.RoutingHandler(cfg)(c)
})
r.ANY("/v2/*path",
r.UseIf(cfg.Docker.Auth, func() touka.HandlerFunc {
return bauth.BasicAuthForStatic(cfg.Docker.Credentials, "GHProxy Docker Proxy")
}),
proxy.OciWithImageRouting(cfg),
)
r.GET("/v2", func(c *touka.Context) {
// 重定向到 /v2/
c.Redirect(http.StatusMovedPermanently, "/v2/")
})
r.NoRoute(func(c *touka.Context) {
proxy.NoRouteHandler(cfg)(c)
})
fmt.Printf("GHProxy Version: %s\n", version)
fmt.Printf("A Go Based High-Performance Github Proxy \n")
fmt.Printf("Made by WJQSERVER-STUDIO\n")
fmt.Printf("Power by Touka\n")
if cfg.Server.Debug {
go func() {
http.ListenAndServe("localhost:6060", nil)
}()
}
if wcache != nil {
defer wcache.StopCleanup()
}
defer logger.Close()
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)
fmt.Printf("Server Run Error: %v\n", err)
}
fmt.Println("Program Exit")
}