4 Commits

Author SHA1 Message Date
user123
3917b2503a 版本注入 2026-01-26 23:49:53 +08:00
user123
bb61eb5025 更新文档 2026-01-26 23:27:58 +08:00
user123
11c34459ca 支持禁用前端静态文件路由 2026-01-26 23:06:05 +08:00
user123
6659e977ae 优化代码质量 2026-01-25 14:03:21 +08:00
12 changed files with 148 additions and 127 deletions

View File

@@ -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

View File

@@ -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"]

View File

@@ -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 {

View File

@@ -6,6 +6,7 @@ port = 5000
fileSize = 2147483648
# HTTP/2 多路复用
enableH2C = false
enableFrontend = true
[rateLimit]
# 每个IP每周期允许的请求数

View File

@@ -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

View File

@@ -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请求

View File

@@ -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)
}
}
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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镜像信息

View File

@@ -104,4 +104,4 @@ func transformURL(url, host string) string {
host = strings.TrimSuffix(host, "/")
return host + "/" + url
}
}

View File

@@ -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 {