Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3917b2503a | ||
|
|
bb61eb5025 | ||
|
|
11c34459ca | ||
|
|
6659e977ae |
5
.github/workflows/release.yml
vendored
5
.github/workflows/release.yml
vendored
@@ -62,12 +62,13 @@ jobs:
|
||||
- name: 编译二进制文件
|
||||
run: |
|
||||
cd src
|
||||
VERSION=${{ steps.version.outputs.version }}
|
||||
|
||||
# Linux AMD64
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o ../build/hubproxy/hubproxy-linux-amd64 .
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.Version=${VERSION}" -o ../build/hubproxy/hubproxy-linux-amd64 .
|
||||
|
||||
# Linux ARM64
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o ../build/hubproxy/hubproxy-linux-arm64 .
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w -X main.Version=${VERSION}" -o ../build/hubproxy/hubproxy-linux-arm64 .
|
||||
|
||||
# 压缩二进制文件
|
||||
upx -9 ../build/hubproxy/hubproxy-linux-amd64
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG VERSION=dev
|
||||
|
||||
WORKDIR /app
|
||||
COPY src/go.mod src/go.sum ./
|
||||
@@ -8,7 +9,7 @@ RUN go mod download && apk add upx
|
||||
|
||||
COPY src/ .
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -ldflags="-s -w" -trimpath -o hubproxy . && upx -9 hubproxy
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -ldflags="-s -w -X main.Version=${VERSION}" -trimpath -o hubproxy . && upx -9 hubproxy
|
||||
|
||||
FROM alpine
|
||||
|
||||
@@ -17,4 +18,4 @@ WORKDIR /root/
|
||||
COPY --from=builder /app/hubproxy .
|
||||
COPY --from=builder /app/config.toml .
|
||||
|
||||
CMD ["./hubproxy"]
|
||||
CMD ["./hubproxy"]
|
||||
|
||||
19
README.md
19
README.md
@@ -114,6 +114,8 @@ port = 5000
|
||||
fileSize = 2147483648
|
||||
# HTTP/2 多路复用,提升下载速度
|
||||
enableH2C = false
|
||||
# 是否启用前端静态页面
|
||||
enableFrontend = true
|
||||
|
||||
[rateLimit]
|
||||
# 每个IP每周期允许的请求数(注意Docker镜像会有多个层,会消耗多个次数)
|
||||
@@ -204,6 +206,23 @@ defaultTTL = "20m"
|
||||
|
||||
脚本部署配置文件位于 `/opt/hubproxy/config.toml`
|
||||
|
||||
### 环境变量(可选)
|
||||
|
||||
支持通过环境变量覆盖部分配置,优先级高于`config.toml`,以下是默认值:
|
||||
|
||||
```
|
||||
SERVER_HOST=0.0.0.0 # 监听地址
|
||||
SERVER_PORT=5000 # 监听端口
|
||||
ENABLE_H2C=false # 是否启用 H2C
|
||||
ENABLE_FRONTEND=true # 是否启用前端静态页面
|
||||
MAX_FILE_SIZE=2147483648 # GitHub 文件大小限制(字节)
|
||||
RATE_LIMIT=500 # 每周期请求数
|
||||
RATE_PERIOD_HOURS=3 # 限流周期(小时)
|
||||
IP_WHITELIST=127.0.0.1,192.168.1.0/24 # IP 白名单(逗号分隔)
|
||||
IP_BLACKLIST=192.168.100.1,192.168.100.0/24 # IP 黑名单(逗号分隔)
|
||||
MAX_IMAGES=10 # 批量下载镜像数量限制
|
||||
```
|
||||
|
||||
为了IP限流能够正常运行,反向代理需要传递IP头用来获取访客真实IP,以caddy为例:
|
||||
```
|
||||
example.com {
|
||||
|
||||
@@ -6,6 +6,7 @@ port = 5000
|
||||
fileSize = 2147483648
|
||||
# HTTP/2 多路复用
|
||||
enableH2C = false
|
||||
enableFrontend = true
|
||||
|
||||
[rateLimit]
|
||||
# 每个IP每周期允许的请求数
|
||||
|
||||
@@ -22,10 +22,11 @@ type RegistryMapping struct {
|
||||
// AppConfig 应用配置结构体
|
||||
type AppConfig struct {
|
||||
Server struct {
|
||||
Host string `toml:"host"`
|
||||
Port int `toml:"port"`
|
||||
FileSize int64 `toml:"fileSize"`
|
||||
EnableH2C bool `toml:"enableH2C"`
|
||||
Host string `toml:"host"`
|
||||
Port int `toml:"port"`
|
||||
FileSize int64 `toml:"fileSize"`
|
||||
EnableH2C bool `toml:"enableH2C"`
|
||||
EnableFrontend bool `toml:"enableFrontend"`
|
||||
} `toml:"server"`
|
||||
|
||||
RateLimit struct {
|
||||
@@ -70,15 +71,17 @@ var (
|
||||
func DefaultConfig() *AppConfig {
|
||||
return &AppConfig{
|
||||
Server: struct {
|
||||
Host string `toml:"host"`
|
||||
Port int `toml:"port"`
|
||||
FileSize int64 `toml:"fileSize"`
|
||||
EnableH2C bool `toml:"enableH2C"`
|
||||
Host string `toml:"host"`
|
||||
Port int `toml:"port"`
|
||||
FileSize int64 `toml:"fileSize"`
|
||||
EnableH2C bool `toml:"enableH2C"`
|
||||
EnableFrontend bool `toml:"enableFrontend"`
|
||||
}{
|
||||
Host: "0.0.0.0",
|
||||
Port: 5000,
|
||||
FileSize: 2 * 1024 * 1024 * 1024, // 2GB
|
||||
EnableH2C: false, // 默认关闭H2C
|
||||
Host: "0.0.0.0",
|
||||
Port: 5000,
|
||||
FileSize: 2 * 1024 * 1024 * 1024,
|
||||
EnableH2C: false,
|
||||
EnableFrontend: true,
|
||||
},
|
||||
RateLimit: struct {
|
||||
RequestLimit int `toml:"requestLimit"`
|
||||
@@ -227,6 +230,11 @@ func overrideFromEnv(cfg *AppConfig) {
|
||||
cfg.Server.EnableH2C = enable
|
||||
}
|
||||
}
|
||||
if val := os.Getenv("ENABLE_FRONTEND"); val != "" {
|
||||
if enable, err := strconv.ParseBool(val); err == nil {
|
||||
cfg.Server.EnableFrontend = enable
|
||||
}
|
||||
}
|
||||
if val := os.Getenv("MAX_FILE_SIZE"); val != "" {
|
||||
if size, err := strconv.ParseInt(val, 10, 64); err == nil && size > 0 {
|
||||
cfg.Server.FileSize = size
|
||||
|
||||
@@ -274,7 +274,9 @@ func handleBlobRequest(c *gin.Context, imageRef, digest string) {
|
||||
c.Header("Docker-Content-Digest", digest)
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
io.Copy(c.Writer, reader)
|
||||
if _, err := io.Copy(c.Writer, reader); err != nil {
|
||||
fmt.Printf("复制layer内容失败: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleTagsRequest 处理tags列表请求
|
||||
@@ -416,7 +418,9 @@ func proxyDockerAuthOriginal(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.Status(resp.StatusCode)
|
||||
io.Copy(c.Writer, resp.Body)
|
||||
if _, err := io.Copy(c.Writer, resp.Body); err != nil {
|
||||
fmt.Printf("复制认证响应失败: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// rewriteAuthHeader 重写认证头
|
||||
@@ -569,7 +573,9 @@ func handleUpstreamBlobRequest(c *gin.Context, imageRef, digest string, mapping
|
||||
c.Header("Docker-Content-Digest", digest)
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
io.Copy(c.Writer, reader)
|
||||
if _, err := io.Copy(c.Writer, reader); err != nil {
|
||||
fmt.Printf("复制layer内容失败: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleUpstreamTagsRequest 处理上游Registry的tags请求
|
||||
|
||||
@@ -129,7 +129,7 @@ func proxyGitHubWithRedirect(c *gin.Context, u string, redirectCount int) {
|
||||
fmt.Printf("关闭响应体失败: %v\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
// 检查并处理被阻止的内容类型
|
||||
if c.Request.Method == "GET" {
|
||||
if contentType := resp.Header.Get("Content-Type"); blockedContentTypes[strings.ToLower(strings.Split(contentType, ";")[0])] {
|
||||
@@ -227,6 +227,8 @@ func proxyGitHubWithRedirect(c *gin.Context, u string, redirectCount int) {
|
||||
c.Status(resp.StatusCode)
|
||||
|
||||
// 直接流式转发
|
||||
io.Copy(c.Writer, resp.Body)
|
||||
if _, err := io.Copy(c.Writer, resp.Body); err != nil {
|
||||
fmt.Printf("转发响应体失败: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -160,51 +159,6 @@ func init() {
|
||||
}()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// normalizeRepository 统一规范化仓库信息
|
||||
func normalizeRepository(repo *Repository) {
|
||||
if repo.IsOfficial {
|
||||
@@ -487,10 +441,14 @@ func parsePaginationParams(c *gin.Context, defaultPageSize int) (page, pageSize
|
||||
pageSize = defaultPageSize
|
||||
|
||||
if p := c.Query("page"); p != "" {
|
||||
fmt.Sscanf(p, "%d", &page)
|
||||
if _, err := fmt.Sscanf(p, "%d", &page); err != nil {
|
||||
fmt.Printf("解析page参数失败: %v\n", err)
|
||||
}
|
||||
}
|
||||
if ps := c.Query("page_size"); ps != "" {
|
||||
fmt.Sscanf(ps, "%d", &pageSize)
|
||||
if _, err := fmt.Sscanf(ps, "%d", &pageSize); err != nil {
|
||||
fmt.Printf("解析page_size参数失败: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
return page, pageSize
|
||||
|
||||
134
src/main.go
134
src/main.go
@@ -40,6 +40,82 @@ var (
|
||||
serviceStartTime = time.Now()
|
||||
)
|
||||
|
||||
var Version = "dev"
|
||||
|
||||
func buildRouter(cfg *config.AppConfig) *gin.Engine {
|
||||
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(utils.RateLimitMiddleware(globalLimiter))
|
||||
|
||||
// 初始化监控端点
|
||||
initHealthRoutes(router)
|
||||
|
||||
// 初始化镜像tar下载路由
|
||||
handlers.InitImageTarRoutes(router)
|
||||
|
||||
if cfg.Server.EnableFrontend {
|
||||
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")
|
||||
})
|
||||
} else {
|
||||
router.GET("/", func(c *gin.Context) {
|
||||
c.Status(http.StatusNotFound)
|
||||
})
|
||||
router.GET("/public/*filepath", func(c *gin.Context) {
|
||||
c.Status(http.StatusNotFound)
|
||||
})
|
||||
router.GET("/images.html", func(c *gin.Context) {
|
||||
c.Status(http.StatusNotFound)
|
||||
})
|
||||
router.GET("/search.html", func(c *gin.Context) {
|
||||
c.Status(http.StatusNotFound)
|
||||
})
|
||||
router.GET("/favicon.ico", func(c *gin.Context) {
|
||||
c.Status(http.StatusNotFound)
|
||||
})
|
||||
}
|
||||
|
||||
// 注册dockerhub搜索路由
|
||||
handlers.RegisterSearchRoute(router)
|
||||
|
||||
// 注册Docker认证路由
|
||||
router.Any("/token", handlers.ProxyDockerAuthGin)
|
||||
router.Any("/token/*path", handlers.ProxyDockerAuthGin)
|
||||
|
||||
// 注册Docker Registry代理路由
|
||||
router.Any("/v2/*path", handlers.ProxyDockerRegistryGin)
|
||||
|
||||
// 注册GitHub代理路由(NoRoute处理器)
|
||||
router.NoRoute(handlers.GitHubProxyHandler)
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
func main() {
|
||||
// 加载配置
|
||||
if err := config.LoadConfig(); err != nil {
|
||||
@@ -62,60 +138,9 @@ func main() {
|
||||
// 初始化防抖器
|
||||
handlers.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(utils.RateLimitMiddleware(globalLimiter))
|
||||
|
||||
// 初始化监控端点
|
||||
initHealthRoutes(router)
|
||||
|
||||
// 初始化镜像tar下载路由
|
||||
handlers.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搜索路由
|
||||
handlers.RegisterSearchRoute(router)
|
||||
|
||||
// 注册Docker认证路由
|
||||
router.Any("/token", handlers.ProxyDockerAuthGin)
|
||||
router.Any("/token/*path", handlers.ProxyDockerAuthGin)
|
||||
|
||||
// 注册Docker Registry代理路由
|
||||
router.Any("/v2/*path", handlers.ProxyDockerRegistryGin)
|
||||
|
||||
// 注册GitHub代理路由(NoRoute处理器)
|
||||
router.NoRoute(handlers.GitHubProxyHandler)
|
||||
|
||||
cfg := config.GetConfig()
|
||||
router := buildRouter(cfg)
|
||||
|
||||
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)
|
||||
@@ -125,7 +150,7 @@ func main() {
|
||||
fmt.Printf("H2c: 已启用\n")
|
||||
}
|
||||
|
||||
fmt.Printf("版本号: v1.2.1\n")
|
||||
fmt.Printf("版本号: %s\n", Version)
|
||||
fmt.Printf("项目地址: https://github.com/sky22333/hubproxy\n")
|
||||
|
||||
// 创建HTTP2服务器
|
||||
@@ -182,6 +207,7 @@ func initHealthRoutes(router *gin.Engine) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ready": true,
|
||||
"service": "hubproxy",
|
||||
"version": Version,
|
||||
"start_time_unix": serviceStartTime.Unix(),
|
||||
"uptime_sec": uptimeSec,
|
||||
"uptime_human": uptimeHuman,
|
||||
|
||||
@@ -2,7 +2,6 @@ package utils
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"hubproxy/config"
|
||||
)
|
||||
@@ -17,7 +16,6 @@ const (
|
||||
|
||||
// AccessController 统一访问控制器
|
||||
type AccessController struct {
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// DockerImageInfo Docker镜像信息
|
||||
|
||||
@@ -104,4 +104,4 @@ func transformURL(url, host string) string {
|
||||
host = strings.TrimSuffix(host, "/")
|
||||
|
||||
return host + "/" + url
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,8 +176,9 @@ func (i *IPRateLimiter) GetLimiter(ip string) (*rate.Limiter, bool) {
|
||||
|
||||
now := time.Now()
|
||||
|
||||
var entry *rateLimiterEntry
|
||||
i.mu.RLock()
|
||||
entry, exists := i.ips[normalizedIP]
|
||||
_, exists := i.ips[normalizedIP]
|
||||
i.mu.RUnlock()
|
||||
|
||||
if exists {
|
||||
|
||||
Reference in New Issue
Block a user