我们很高兴地宣布 GHProxy v2.4.1 正式发布!这个版本带来了更多自定义参数与前端切换支持

CHANGELOG (更新日志)

  • CHANGE: 重构路由匹配
  • CHANGE: 更新相关依赖以修复错误
  • ADD: 支持通过Smart-Git实现Git Clone缓存
  • CHANGE: 使用更高性能的Buffer Pool 实现, 调用 github.com/WJQSERVER-STUDIO/go-utils/copyb
  • CHANGE: 改进路由匹配
  • CHANGE: 更新依赖
  • CHANGE: 改进前端

项目地址

Demo

TG讨论群组

主要变更

Git Clone 缓存

Smart-Git实现

重写路径匹配路由

v2.4.1的路径匹配相较于v2.3.1改变巨大, 重写了匹配 不难看出, 新的实现代码量更大, 但更适合人类阅读, 同时去除了多余的重复匹配, 提升效率

v2.3.1 的实现

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
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/([^/]+)/([^/]+)/.*`),
}

func NoRouteHandler(cfg *config.Config, limiter *rate.RateLimiter, iplimiter *rate.IPRateLimiter, runMode string) gin.HandlerFunc {
	return func(c *gin.Context) {

		// 限制访问频率
		if cfg.RateLimit.Enabled {

			var allowed bool

			switch cfg.RateLimit.RateMethod {
			case "ip":
				allowed = iplimiter.Allow(c.ClientIP())
			case "total":
				allowed = limiter.Allow()
			default:
				logWarning("Invalid RateLimit Method")
				return
			}

			if !allowed {
				c.JSON(http.StatusTooManyRequests, gin.H{"error": "Too Many Requests"})
				logWarning("%s %s %s %s %s 429-TooManyRequests", c.ClientIP(), c.Request.Method, c.Request.URL.RequestURI(), c.Request.Header.Get("User-Agent"), c.Request.Proto)
				return
			}
		}

		rawPath := strings.TrimPrefix(c.Request.URL.RequestURI(), "/") // 去掉前缀/
		re := regexp.MustCompile(`^(http:|https:)?/?/?(.*)`)           // 匹配http://或https://开头的路径
		matches := re.FindStringSubmatch(rawPath)                      // 匹配路径

		// 匹配路径错误处理
		if len(matches) < 3 {
			errMsg := fmt.Sprintf("%s %s %s %s %s Invalid URL", c.ClientIP(), c.Request.Method, rawPath, c.Request.Header.Get("User-Agent"), c.Request.Proto)
			logWarning(errMsg)
			c.String(http.StatusForbidden, "Invalid URL Format. Path: %s", rawPath)
			return
		}

		// 制作url
		rawPath = "https://" + matches[2]

		username, repo := MatchUserRepo(rawPath, cfg, c, matches) // 匹配用户名和仓库名

		logInfo("%s %s %s %s %s Matched-Username: %s, Matched-Repo: %s", c.ClientIP(), c.Request.Method, rawPath, c.Request.Header.Get("User-Agent"), c.Request.Proto, username, repo)
		// dump log 记录详细信息 c.ClientIP(), c.Request.Method, rawPath, c.Request.Header.Get("User-Agent"), c.Request.Proto, full Header
		LogDump("%s %s %s %s %s %s", c.ClientIP(), c.Request.Method, rawPath, c.Request.Header.Get("User-Agent"), c.Request.Proto, c.Request.Header)
		repouser := fmt.Sprintf("%s/%s", username, repo)

		// 白名单检查
		if cfg.Whitelist.Enabled {
			whitelist := auth.CheckWhitelist(username, repo)
			if !whitelist {
				logErrMsg := fmt.Sprintf("%s %s %s %s %s Whitelist Blocked repo: %s", c.ClientIP(), c.Request.Method, rawPath, c.Request.Header.Get("User-Agent"), c.Request.Proto, repouser)
				errMsg := fmt.Sprintf("Whitelist Blocked repo: %s", repouser)
				c.JSON(http.StatusForbidden, gin.H{"error": errMsg})
				logWarning(logErrMsg)
				return
			}
		}

		// 黑名单检查
		if cfg.Blacklist.Enabled {
			blacklist := auth.CheckBlacklist(username, repo)
			if blacklist {
				logErrMsg := fmt.Sprintf("%s %s %s %s %s Blacklist Blocked repo: %s", c.ClientIP(), c.Request.Method, rawPath, c.Request.Header.Get("User-Agent"), c.Request.Proto, repouser)
				errMsg := fmt.Sprintf("Blacklist Blocked repo: %s", repouser)
				c.JSON(http.StatusForbidden, gin.H{"error": errMsg})
				logWarning(logErrMsg)
				return
			}
		}

		matches = CheckURL(rawPath, c)
		if matches == nil {
			c.AbortWithStatus(http.StatusNotFound)
			logWarning("%s %s %s %s %s 404-NOMATCH", c.ClientIP(), c.Request.Method, rawPath, c.Request.Header.Get("User-Agent"), c.Request.Proto)
			return
		}

		// 若匹配api.github.com/repos/用户名/仓库名/路径, 则检查是否开启HeaderAuth
		if exps[5].MatchString(rawPath) {
			if cfg.Auth.AuthMethod != "header" || !cfg.Auth.Enabled {
				c.JSON(http.StatusForbidden, gin.H{"error": "HeaderAuth is not enabled."})
				logError("%s %s %s %s %s HeaderAuth-Error: HeaderAuth is not enabled.", c.ClientIP(), c.Request.Method, rawPath, c.Request.Header.Get("User-Agent"), c.Request.Proto)
				return
			}
		}

		// 处理blob/raw路径
		if exps[1].MatchString(rawPath) {
			rawPath = strings.Replace(rawPath, "/blob/", "/raw/", 1)
		}

		// 鉴权
		authcheck, err := auth.AuthHandler(c, cfg)
		if !authcheck {
			c.AbortWithStatusJSON(401, gin.H{"error": "Unauthorized"})
			logWarning("%s %s %s %s %s Auth-Error: %v", c.ClientIP(), c.Request.Method, rawPath, c.Request.Header.Get("User-Agent"), c.Request.Proto, err)
			return
		}

		// IP METHOD URL USERAGENT PROTO MATCHES
		logDebug("%s %s %s %s %s Matches: %v", c.ClientIP(), c.Request.Method, rawPath, c.Request.Header.Get("User-Agent"), c.Request.Proto, matches)

		switch {
		case exps[0].MatchString(rawPath), exps[1].MatchString(rawPath), exps[3].MatchString(rawPath), exps[4].MatchString(rawPath):
			//ProxyRequest(c, rawPath, cfg, "chrome", runMode)
			ChunkedProxyRequest(c, rawPath, cfg, "chrome", runMode) // dev test chunk
		case exps[2].MatchString(rawPath):
			//ProxyRequest(c, rawPath, cfg, "git", runMode)
			GitReq(c, rawPath, cfg, "git", runMode)
		default:
			c.String(http.StatusForbidden, "Invalid input.")
			fmt.Println("Invalid input.")
			return
		}
	}
}

v2.4.1 的实现

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
var re = regexp.MustCompile(`^(http:|https:)?/?/?(.*)`) // 匹配http://或https://开头的路径

func NoRouteHandler(cfg *config.Config, limiter *rate.RateLimiter, iplimiter *rate.IPRateLimiter, runMode string) gin.HandlerFunc {
	return func(c *gin.Context) {

		// 限制访问频率
		if cfg.RateLimit.Enabled {

			var allowed bool

			switch cfg.RateLimit.RateMethod {
			case "ip":
				allowed = iplimiter.Allow(c.ClientIP())
			case "total":
				allowed = limiter.Allow()
			default:
				logWarning("Invalid RateLimit Method")
				return
			}

			if !allowed {
				c.JSON(http.StatusTooManyRequests, gin.H{"error": "Too Many Requests"})
				logWarning("%s %s %s %s %s 429-TooManyRequests", c.ClientIP(), c.Request.Method, c.Request.URL.RequestURI(), c.Request.Header.Get("User-Agent"), c.Request.Proto)
				return
			}
		}

		//rawPath := strings.TrimPrefix(c.Request.URL.Path, "/") // 去掉前缀/
		rawPath := strings.TrimPrefix(c.Request.URL.RequestURI(), "/") // 去掉前缀/
		matches := re.FindStringSubmatch(rawPath)                      // 匹配路径
		logInfo("Matches: %v", matches)

		// 匹配路径错误处理
		if len(matches) < 3 {
			errMsg := fmt.Sprintf("%s %s %s %s %s Invalid URL", c.ClientIP(), c.Request.Method, rawPath, c.Request.Header.Get("User-Agent"), c.Request.Proto)
			logWarning(errMsg)
			c.String(http.StatusForbidden, "Invalid URL Format. Path: %s", rawPath)
			return
		}

		// 制作url
		rawPath = "https://" + matches[2]

		var (
			user    string
			repo    string
			matcher string
		)

		// 匹配 "https://github.com"开头的链接
		if strings.HasPrefix(rawPath, "https://github.com") {
			remainingPath := strings.TrimPrefix(rawPath, "https://github.com")
			if strings.HasPrefix(remainingPath, "/") {
				remainingPath = strings.TrimPrefix(remainingPath, "/")
			}
			// 预期格式/user/repo/more...
			// 取出user和repo和最后部分
			parts := strings.Split(remainingPath, "/")
			if len(parts) <= 2 {
				logWarning("%s %s %s %s %s Invalid URL", c.ClientIP(), c.Request.Method, rawPath, c.Request.Header.Get("User-Agent"), c.Request.Proto)
				c.String(http.StatusForbidden, "Invalid URL Format. Path: %s", rawPath)
				return
			}
			user = parts[0]
			repo = parts[1]
			// 匹配 "https://github.com"开头的链接
			if len(parts) >= 3 {
				switch parts[2] {
				case "releases", "archive":
					matcher = "releases"
				case "blob", "raw":
					matcher = "blob"
				case "info", "git-upload-pack":
					matcher = "clone"
				default:
					fmt.Println("Invalid URL: Unknown type")
				}
			}
		}
		// 匹配 "https://raw"开头的链接
		if strings.HasPrefix(rawPath, "https://raw") {
			remainingPath := strings.TrimPrefix(rawPath, "https://")
			parts := strings.Split(remainingPath, "/")
			if len(parts) <= 3 {
				logWarning("%s %s %s %s %s Invalid URL", c.ClientIP(), c.Request.Method, rawPath, c.Request.Header.Get("User-Agent"), c.Request.Proto)
				c.String(http.StatusForbidden, "Invalid URL Format. Path: %s", rawPath)
				return
			}
			user = parts[1]
			repo = parts[2]
			matcher = "raw"
		}
		// 匹配 "https://gist"开头的链接
		if strings.HasPrefix(rawPath, "https://gist") {
			remainingPath := strings.TrimPrefix(rawPath, "https://")
			parts := strings.Split(remainingPath, "/")
			if len(parts) <= 3 {
				logWarning("%s %s %s %s %s Invalid URL", c.ClientIP(), c.Request.Method, rawPath, c.Request.Header.Get("User-Agent"), c.Request.Proto)
				c.String(http.StatusForbidden, "Invalid URL Format. Path: %s", rawPath)
				return
			}
			user = parts[1]
			matcher = "gist"
		}
		// 匹配 "https://api.github.com/"开头的链接
		if strings.HasPrefix(rawPath, "https://api.github.com/") {
			matcher = "api"
			remainingPath := strings.TrimPrefix(rawPath, "https://api.github.com/")

			parts := strings.Split(remainingPath, "/")
			if parts[0] == "repos" {
				user = parts[1]
				repo = parts[2]
			}
			if parts[0] == "users" {
				user = parts[1]
			}
			if cfg.Auth.AuthMethod != "header" || !cfg.Auth.Enabled {
				c.JSON(http.StatusForbidden, gin.H{"error": "HeaderAuth is not enabled."})
				logError("%s %s %s %s %s HeaderAuth-Error: HeaderAuth is not enabled.", c.ClientIP(), c.Request.Method, rawPath, c.Request.Header.Get("User-Agent"), c.Request.Proto)
				return
			}
		}

		username := user
		//username, repo := MatchUserRepo(rawPath, cfg, c, matches) // 匹配用户名和仓库名

		logInfo("%s %s %s %s %s Matched-Username: %s, Matched-Repo: %s", c.ClientIP(), c.Request.Method, rawPath, c.Request.Header.Get("User-Agent"), c.Request.Proto, username, repo)
		// dump log 记录详细信息 c.ClientIP(), c.Request.Method, rawPath, c.Request.Header.Get("User-Agent"), c.Request.Proto, full Header
		LogDump("%s %s %s %s %s %s", c.ClientIP(), c.Request.Method, rawPath, c.Request.Header.Get("User-Agent"), c.Request.Proto, c.Request.Header)
		repouser := fmt.Sprintf("%s/%s", username, repo)

		// 白名单检查
		if cfg.Whitelist.Enabled {
			whitelist := auth.CheckWhitelist(username, repo)
			if !whitelist {
				logErrMsg := fmt.Sprintf("%s %s %s %s %s Whitelist Blocked repo: %s", c.ClientIP(), c.Request.Method, rawPath, c.Request.Header.Get("User-Agent"), c.Request.Proto, repouser)
				errMsg := fmt.Sprintf("Whitelist Blocked repo: %s", repouser)
				c.JSON(http.StatusForbidden, gin.H{"error": errMsg})
				logWarning(logErrMsg)
				return
			}
		}

		// 黑名单检查
		if cfg.Blacklist.Enabled {
			blacklist := auth.CheckBlacklist(username, repo)
			if blacklist {
				logErrMsg := fmt.Sprintf("%s %s %s %s %s Blacklist Blocked repo: %s", c.ClientIP(), c.Request.Method, rawPath, c.Request.Header.Get("User-Agent"), c.Request.Proto, repouser)
				errMsg := fmt.Sprintf("Blacklist Blocked repo: %s", repouser)
				c.JSON(http.StatusForbidden, gin.H{"error": errMsg})
				logWarning(logErrMsg)
				return
			}
		}

		// 若匹配api.github.com/repos/用户名/仓库名/路径, 则检查是否开启HeaderAuth

		// 处理blob/raw路径
		if matcher == "blob" {
			rawPath = strings.Replace(rawPath, "/blob/", "/raw/", 1)
		}

		// 鉴权
		authcheck, err := auth.AuthHandler(c, cfg)
		if !authcheck {
			c.AbortWithStatusJSON(401, gin.H{"error": "Unauthorized"})
			logWarning("%s %s %s %s %s Auth-Error: %v", c.ClientIP(), c.Request.Method, rawPath, c.Request.Header.Get("User-Agent"), c.Request.Proto, err)
			return
		}

		// IP METHOD URL USERAGENT PROTO MATCHES
		logDebug("%s %s %s %s %s Matches: %v", c.ClientIP(), c.Request.Method, rawPath, c.Request.Header.Get("User-Agent"), c.Request.Proto, matches)

		switch matcher {
		case "releases", "blob", "raw", "gist", "api":
			//ProxyRequest(c, rawPath, cfg, "chrome", runMode)
			ChunkedProxyRequest(c, rawPath, cfg, "chrome", runMode) // dev test chunk
		case "clone":
			//ProxyRequest(c, rawPath, cfg, "git", runMode)
			GitReq(c, rawPath, cfg, "git", runMode)
		default:
			c.String(http.StatusForbidden, "Invalid input.")
			fmt.Println("Invalid input.")
			return
		}
	}
}

使用更加高效的Copy实现

使用WJQSERVER-STUDIO/go-utils内的copyB包替换io.CopyBuffer

新增Gitclone配置块

用于连接smart-git实例

1
2
3
4
[gitclone]
mode = "bypass" # bypass / cache 运行模式, cache模式依赖smart-git
smartGitAddr = "http://127.0.0.1:8080" # smart-git组件地址
ForceH2C = true # 强制使用H2C连接

完整配置

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
[server]
host = "0.0.0.0"  # 监听地址
port = 8080  # 监听端口
sizeLimit = 125 # 125MB
enableH2C = "on"  # 是否开启H2C传输(latest和dev版本请开启) on/off

[httpc]
mode = "auto" # "auto" or "advanced" HTTP客户端模式 自动/高级模式
maxIdleConns = 100 # only for advanced mode 仅用于高级模式
maxIdleConnsPerHost = 60 # only for advanced mode 仅用于高级模式
maxConnsPerHost = 0 # only for advanced mode 仅用于高级模式

[pages]
enabled = false  # 是否开启外置静态页面(非特殊需求请关闭此项)
theme = "bootstrap" # "bootstrap" or "nebula" 内置主题
staticPath = "/data/www"  # 静态页面文件路径

[log]
logFilePath = "/data/ghproxy/log/ghproxy.log" # 日志文件路径
maxLogSize = 5 # MB 日志文件最大大小
level = "info"  # 日志级别 dump, debug, info, warn, error, none

[cors]
enabled = true  # 是否开启跨域

[auth]
authMethod = "parameters" # 鉴权方式,支持parameters,header
authToken = "token"  # 用户鉴权Token
enabled = false  # 是否开启用户鉴权

[blacklist]
blacklistFile = "/data/ghproxy/config/blacklist.json"  # 黑名单文件路径
enabled = false  # 是否开启黑名单

[whitelist]
enabled = false  # 是否开启白名单
whitelistFile = "/data/ghproxy/config/whitelist.json"  # 白名单文件路径

[rateLimit]
enabled = false  # 是否开启速率限制
rateMethod = "total" # "ip" or "total" 速率限制方式
ratePerMinute = 180  # 每分钟限制请求数量
burst = 5  # 突发请求数量

[outbound]
enabled = false # 是否使用自定义代理出站
url = "socks5://127.0.0.1:1080" # "http://127.0.0.1:7890" 支持Socks5/HTTP(S)出站传输

前端

Bootstrap主题

ghproxy-demo.png ghproxy-demo-dark.png

Nebula主题

nebula-dark-v2.3.0.png nebula-light-v2.3.0.png

结语

GHProxy v2.4.1 带来了更多功能的同时, 也对性能和资源消耗进行了优化.

我们感谢所有用户的支持与反馈,并期待 GhProxy v2.4.1 能为您带来更大的便利和效率!若此项目对您有所帮助,请不要忘记 star 本项目,您的支持是我们前进的动力!