Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3917b2503a | ||
|
|
bb61eb5025 | ||
|
|
11c34459ca | ||
|
|
6659e977ae | ||
|
|
f77d951500 | ||
|
|
685388fff9 | ||
|
|
c6d95e683f | ||
|
|
f8828ccb74 | ||
|
|
fdc156adad | ||
|
|
80b0173d7c |
13
.github/workflows/docker-ghcr.yml
vendored
13
.github/workflows/docker-ghcr.yml
vendored
@@ -3,9 +3,9 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
version:
|
version:
|
||||||
description: 'Version number'
|
description: '版本号 (例如: v1.0.0)'
|
||||||
required: true
|
required: true
|
||||||
default: 'latest'
|
default: 'v1.0.0'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -36,7 +36,12 @@ jobs:
|
|||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set version from input
|
- name: Set version from input
|
||||||
run: echo "VERSION=${{ github.event.inputs.version }}" >> $GITHUB_ENV
|
run: |
|
||||||
|
VERSION=${{ github.event.inputs.version }}
|
||||||
|
if [[ $VERSION == v* ]]; then
|
||||||
|
VERSION=${VERSION:1}
|
||||||
|
fi
|
||||||
|
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Convert repository name to lowercase
|
- name: Convert repository name to lowercase
|
||||||
run: |
|
run: |
|
||||||
@@ -53,4 +58,4 @@ jobs:
|
|||||||
--build-arg VERSION=${{ env.VERSION }} \
|
--build-arg VERSION=${{ env.VERSION }} \
|
||||||
-f Dockerfile .
|
-f Dockerfile .
|
||||||
env:
|
env:
|
||||||
GHCR_PUBLIC: true # 将镜像设置为公开
|
GHCR_PUBLIC: true
|
||||||
9
.github/workflows/release.yml
vendored
9
.github/workflows/release.yml
vendored
@@ -1,7 +1,7 @@
|
|||||||
name: 发布二进制文件
|
name: 发布二进制文件
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch: # 手动触发
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
version:
|
version:
|
||||||
description: '版本号 (例如: v1.0.0)'
|
description: '版本号 (例如: v1.0.0)'
|
||||||
@@ -18,7 +18,7 @@ jobs:
|
|||||||
- name: 检出代码
|
- name: 检出代码
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # 获取完整历史,用于生成变更日志
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: 设置Go环境
|
- name: 设置Go环境
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
@@ -62,12 +62,13 @@ jobs:
|
|||||||
- name: 编译二进制文件
|
- name: 编译二进制文件
|
||||||
run: |
|
run: |
|
||||||
cd src
|
cd src
|
||||||
|
VERSION=${{ steps.version.outputs.version }}
|
||||||
|
|
||||||
# Linux AMD64
|
# 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
|
# 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
|
upx -9 ../build/hubproxy/hubproxy-linux-amd64
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
FROM golang:1.25-alpine AS builder
|
FROM golang:1.25-alpine AS builder
|
||||||
|
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
|
ARG VERSION=dev
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY src/go.mod src/go.sum ./
|
COPY src/go.mod src/go.sum ./
|
||||||
@@ -8,7 +9,7 @@ RUN go mod download && apk add upx
|
|||||||
|
|
||||||
COPY src/ .
|
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
|
FROM alpine
|
||||||
|
|
||||||
@@ -17,4 +18,4 @@ WORKDIR /root/
|
|||||||
COPY --from=builder /app/hubproxy .
|
COPY --from=builder /app/hubproxy .
|
||||||
COPY --from=builder /app/config.toml .
|
COPY --from=builder /app/config.toml .
|
||||||
|
|
||||||
CMD ["./hubproxy"]
|
CMD ["./hubproxy"]
|
||||||
|
|||||||
19
README.md
19
README.md
@@ -114,6 +114,8 @@ port = 5000
|
|||||||
fileSize = 2147483648
|
fileSize = 2147483648
|
||||||
# HTTP/2 多路复用,提升下载速度
|
# HTTP/2 多路复用,提升下载速度
|
||||||
enableH2C = false
|
enableH2C = false
|
||||||
|
# 是否启用前端静态页面
|
||||||
|
enableFrontend = true
|
||||||
|
|
||||||
[rateLimit]
|
[rateLimit]
|
||||||
# 每个IP每周期允许的请求数(注意Docker镜像会有多个层,会消耗多个次数)
|
# 每个IP每周期允许的请求数(注意Docker镜像会有多个层,会消耗多个次数)
|
||||||
@@ -204,6 +206,23 @@ defaultTTL = "20m"
|
|||||||
|
|
||||||
脚本部署配置文件位于 `/opt/hubproxy/config.toml`
|
脚本部署配置文件位于 `/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为例:
|
为了IP限流能够正常运行,反向代理需要传递IP头用来获取访客真实IP,以caddy为例:
|
||||||
```
|
```
|
||||||
example.com {
|
example.com {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ port = 5000
|
|||||||
fileSize = 2147483648
|
fileSize = 2147483648
|
||||||
# HTTP/2 多路复用
|
# HTTP/2 多路复用
|
||||||
enableH2C = false
|
enableH2C = false
|
||||||
|
enableFrontend = true
|
||||||
|
|
||||||
[rateLimit]
|
[rateLimit]
|
||||||
# 每个IP每周期允许的请求数
|
# 每个IP每周期允许的请求数
|
||||||
|
|||||||
@@ -22,10 +22,11 @@ type RegistryMapping struct {
|
|||||||
// AppConfig 应用配置结构体
|
// AppConfig 应用配置结构体
|
||||||
type AppConfig struct {
|
type AppConfig struct {
|
||||||
Server struct {
|
Server struct {
|
||||||
Host string `toml:"host"`
|
Host string `toml:"host"`
|
||||||
Port int `toml:"port"`
|
Port int `toml:"port"`
|
||||||
FileSize int64 `toml:"fileSize"`
|
FileSize int64 `toml:"fileSize"`
|
||||||
EnableH2C bool `toml:"enableH2C"`
|
EnableH2C bool `toml:"enableH2C"`
|
||||||
|
EnableFrontend bool `toml:"enableFrontend"`
|
||||||
} `toml:"server"`
|
} `toml:"server"`
|
||||||
|
|
||||||
RateLimit struct {
|
RateLimit struct {
|
||||||
@@ -70,15 +71,17 @@ var (
|
|||||||
func DefaultConfig() *AppConfig {
|
func DefaultConfig() *AppConfig {
|
||||||
return &AppConfig{
|
return &AppConfig{
|
||||||
Server: struct {
|
Server: struct {
|
||||||
Host string `toml:"host"`
|
Host string `toml:"host"`
|
||||||
Port int `toml:"port"`
|
Port int `toml:"port"`
|
||||||
FileSize int64 `toml:"fileSize"`
|
FileSize int64 `toml:"fileSize"`
|
||||||
EnableH2C bool `toml:"enableH2C"`
|
EnableH2C bool `toml:"enableH2C"`
|
||||||
|
EnableFrontend bool `toml:"enableFrontend"`
|
||||||
}{
|
}{
|
||||||
Host: "0.0.0.0",
|
Host: "0.0.0.0",
|
||||||
Port: 5000,
|
Port: 5000,
|
||||||
FileSize: 2 * 1024 * 1024 * 1024, // 2GB
|
FileSize: 2 * 1024 * 1024 * 1024,
|
||||||
EnableH2C: false, // 默认关闭H2C
|
EnableH2C: false,
|
||||||
|
EnableFrontend: true,
|
||||||
},
|
},
|
||||||
RateLimit: struct {
|
RateLimit: struct {
|
||||||
RequestLimit int `toml:"requestLimit"`
|
RequestLimit int `toml:"requestLimit"`
|
||||||
@@ -227,6 +230,11 @@ func overrideFromEnv(cfg *AppConfig) {
|
|||||||
cfg.Server.EnableH2C = enable
|
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 val := os.Getenv("MAX_FILE_SIZE"); val != "" {
|
||||||
if size, err := strconv.ParseInt(val, 10, 64); err == nil && size > 0 {
|
if size, err := strconv.ParseInt(val, 10, 64); err == nil && size > 0 {
|
||||||
cfg.Server.FileSize = size
|
cfg.Server.FileSize = size
|
||||||
|
|||||||
@@ -28,9 +28,16 @@ var dockerProxy *DockerProxy
|
|||||||
type RegistryDetector struct{}
|
type RegistryDetector struct{}
|
||||||
|
|
||||||
// detectRegistryDomain 检测Registry域名并返回域名和剩余路径
|
// detectRegistryDomain 检测Registry域名并返回域名和剩余路径
|
||||||
func (rd *RegistryDetector) detectRegistryDomain(path string) (string, string) {
|
func (rd *RegistryDetector) detectRegistryDomain(c *gin.Context, path string) (string, string) {
|
||||||
cfg := config.GetConfig()
|
cfg := config.GetConfig()
|
||||||
|
|
||||||
|
// 兼容Containerd的ns参数
|
||||||
|
if ns := c.Query("ns"); ns != "" {
|
||||||
|
if mapping, exists := cfg.Registries[ns]; exists && mapping.Enabled {
|
||||||
|
return ns, path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for domain := range cfg.Registries {
|
for domain := range cfg.Registries {
|
||||||
if strings.HasPrefix(path, domain+"/") {
|
if strings.HasPrefix(path, domain+"/") {
|
||||||
remainingPath := strings.TrimPrefix(path, domain+"/")
|
remainingPath := strings.TrimPrefix(path, domain+"/")
|
||||||
@@ -99,7 +106,7 @@ func ProxyDockerRegistryGin(c *gin.Context) {
|
|||||||
func handleRegistryRequest(c *gin.Context, path string) {
|
func handleRegistryRequest(c *gin.Context, path string) {
|
||||||
pathWithoutV2 := strings.TrimPrefix(path, "/v2/")
|
pathWithoutV2 := strings.TrimPrefix(path, "/v2/")
|
||||||
|
|
||||||
if registryDomain, remainingPath := registryDetector.detectRegistryDomain(pathWithoutV2); registryDomain != "" {
|
if registryDomain, remainingPath := registryDetector.detectRegistryDomain(c, pathWithoutV2); registryDomain != "" {
|
||||||
if registryDetector.isRegistryEnabled(registryDomain) {
|
if registryDetector.isRegistryEnabled(registryDomain) {
|
||||||
c.Set("target_registry_domain", registryDomain)
|
c.Set("target_registry_domain", registryDomain)
|
||||||
c.Set("target_path", remainingPath)
|
c.Set("target_path", remainingPath)
|
||||||
@@ -267,7 +274,9 @@ func handleBlobRequest(c *gin.Context, imageRef, digest string) {
|
|||||||
c.Header("Docker-Content-Digest", digest)
|
c.Header("Docker-Content-Digest", digest)
|
||||||
|
|
||||||
c.Status(http.StatusOK)
|
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列表请求
|
// handleTagsRequest 处理tags列表请求
|
||||||
@@ -409,7 +418,9 @@ func proxyDockerAuthOriginal(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.Status(resp.StatusCode)
|
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 重写认证头
|
// rewriteAuthHeader 重写认证头
|
||||||
@@ -562,7 +573,9 @@ func handleUpstreamBlobRequest(c *gin.Context, imageRef, digest string, mapping
|
|||||||
c.Header("Docker-Content-Digest", digest)
|
c.Header("Docker-Content-Digest", digest)
|
||||||
|
|
||||||
c.Status(http.StatusOK)
|
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请求
|
// handleUpstreamTagsRequest 处理上游Registry的tags请求
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ func proxyGitHubWithRedirect(c *gin.Context, u string, redirectCount int) {
|
|||||||
fmt.Printf("关闭响应体失败: %v\n", err)
|
fmt.Printf("关闭响应体失败: %v\n", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// 检查并处理被阻止的内容类型
|
// 检查并处理被阻止的内容类型
|
||||||
if c.Request.Method == "GET" {
|
if c.Request.Method == "GET" {
|
||||||
if contentType := resp.Header.Get("Content-Type"); blockedContentTypes[strings.ToLower(strings.Split(contentType, ";")[0])] {
|
if contentType := resp.Header.Get("Content-Type"); blockedContentTypes[strings.ToLower(strings.Split(contentType, ";")[0])] {
|
||||||
@@ -171,9 +171,9 @@ func proxyGitHubWithRedirect(c *gin.Context, u string, redirectCount int) {
|
|||||||
|
|
||||||
processedBody, processedSize, err := utils.ProcessSmart(resp.Body, isGzipCompressed, realHost)
|
processedBody, processedSize, err := utils.ProcessSmart(resp.Body, isGzipCompressed, realHost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("智能处理失败,回退到直接代理: %v\n", err)
|
fmt.Printf("脚本处理失败: %v\n", err)
|
||||||
processedBody = resp.Body
|
c.String(http.StatusBadGateway, "Script processing failed: %v", err)
|
||||||
processedSize = 0
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 智能设置响应头
|
// 智能设置响应头
|
||||||
@@ -227,6 +227,8 @@ func proxyGitHubWithRedirect(c *gin.Context, u string, redirectCount int) {
|
|||||||
c.Status(resp.StatusCode)
|
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"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"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 统一规范化仓库信息
|
// normalizeRepository 统一规范化仓库信息
|
||||||
func normalizeRepository(repo *Repository) {
|
func normalizeRepository(repo *Repository) {
|
||||||
if repo.IsOfficial {
|
if repo.IsOfficial {
|
||||||
@@ -487,10 +441,14 @@ func parsePaginationParams(c *gin.Context, defaultPageSize int) (page, pageSize
|
|||||||
pageSize = defaultPageSize
|
pageSize = defaultPageSize
|
||||||
|
|
||||||
if p := c.Query("page"); p != "" {
|
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 != "" {
|
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
|
return page, pageSize
|
||||||
|
|||||||
134
src/main.go
134
src/main.go
@@ -40,6 +40,82 @@ var (
|
|||||||
serviceStartTime = time.Now()
|
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() {
|
func main() {
|
||||||
// 加载配置
|
// 加载配置
|
||||||
if err := config.LoadConfig(); err != nil {
|
if err := config.LoadConfig(); err != nil {
|
||||||
@@ -62,60 +138,9 @@ func main() {
|
|||||||
// 初始化防抖器
|
// 初始化防抖器
|
||||||
handlers.InitDebouncer()
|
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()
|
cfg := config.GetConfig()
|
||||||
|
router := buildRouter(cfg)
|
||||||
|
|
||||||
fmt.Printf("HubProxy 启动成功\n")
|
fmt.Printf("HubProxy 启动成功\n")
|
||||||
fmt.Printf("监听地址: %s:%d\n", cfg.Server.Host, cfg.Server.Port)
|
fmt.Printf("监听地址: %s:%d\n", cfg.Server.Host, cfg.Server.Port)
|
||||||
fmt.Printf("限流配置: %d请求/%g小时\n", cfg.RateLimit.RequestLimit, cfg.RateLimit.PeriodHours)
|
fmt.Printf("限流配置: %d请求/%g小时\n", cfg.RateLimit.RequestLimit, cfg.RateLimit.PeriodHours)
|
||||||
@@ -125,7 +150,7 @@ func main() {
|
|||||||
fmt.Printf("H2c: 已启用\n")
|
fmt.Printf("H2c: 已启用\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("版本号: v1.2.0\n")
|
fmt.Printf("版本号: %s\n", Version)
|
||||||
fmt.Printf("项目地址: https://github.com/sky22333/hubproxy\n")
|
fmt.Printf("项目地址: https://github.com/sky22333/hubproxy\n")
|
||||||
|
|
||||||
// 创建HTTP2服务器
|
// 创建HTTP2服务器
|
||||||
@@ -182,6 +207,7 @@ func initHealthRoutes(router *gin.Engine) {
|
|||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"ready": true,
|
"ready": true,
|
||||||
"service": "hubproxy",
|
"service": "hubproxy",
|
||||||
|
"version": Version,
|
||||||
"start_time_unix": serviceStartTime.Unix(),
|
"start_time_unix": serviceStartTime.Unix(),
|
||||||
"uptime_sec": uptimeSec,
|
"uptime_sec": uptimeSec,
|
||||||
"uptime_human": uptimeHuman,
|
"uptime_human": uptimeHuman,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package utils
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
|
|
||||||
"hubproxy/config"
|
"hubproxy/config"
|
||||||
)
|
)
|
||||||
@@ -17,7 +16,6 @@ const (
|
|||||||
|
|
||||||
// AccessController 统一访问控制器
|
// AccessController 统一访问控制器
|
||||||
type AccessController struct {
|
type AccessController struct {
|
||||||
mu sync.RWMutex
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DockerImageInfo Docker镜像信息
|
// DockerImageInfo Docker镜像信息
|
||||||
@@ -200,6 +198,13 @@ func (ac *AccessController) checkList(matches, list []string) bool {
|
|||||||
if strings.HasPrefix(fullRepo, item+"/") {
|
if strings.HasPrefix(fullRepo, item+"/") {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(item, "*/") {
|
||||||
|
p := item[2:]
|
||||||
|
if p == repoName || (strings.HasSuffix(p, "*") && strings.HasPrefix(repoName, p[:len(p)-1])) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,47 +12,44 @@ import (
|
|||||||
// GitHub URL正则表达式
|
// GitHub URL正则表达式
|
||||||
var githubRegex = regexp.MustCompile(`(?:^|[\s'"(=,\[{;|&<>])https?://(?:github\.com|raw\.githubusercontent\.com|raw\.github\.com|gist\.githubusercontent\.com|gist\.github\.com|api\.github\.com)[^\s'")]*`)
|
var githubRegex = regexp.MustCompile(`(?:^|[\s'"(=,\[{;|&<>])https?://(?:github\.com|raw\.githubusercontent\.com|raw\.github\.com|gist\.githubusercontent\.com|gist\.github\.com|api\.github\.com)[^\s'")]*`)
|
||||||
|
|
||||||
// ProcessSmart Shell脚本智能处理函数
|
// MaxShellSize 限制最大处理大小为 10MB
|
||||||
func ProcessSmart(input io.ReadCloser, isCompressed bool, host string) (io.Reader, int64, error) {
|
const MaxShellSize = 10 * 1024 * 1024
|
||||||
defer input.Close()
|
|
||||||
|
|
||||||
|
// ProcessSmart Shell脚本智能处理函数
|
||||||
|
func ProcessSmart(input io.Reader, isCompressed bool, host string) (io.Reader, int64, error) {
|
||||||
content, err := readShellContent(input, isCompressed)
|
content, err := readShellContent(input, isCompressed)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, fmt.Errorf("内容读取失败: %v", err)
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(content) == 0 {
|
if len(content) == 0 {
|
||||||
return strings.NewReader(""), 0, nil
|
return strings.NewReader(""), 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(content) > 10*1024*1024 {
|
if !bytes.Contains(content, []byte("github.com")) && !bytes.Contains(content, []byte("githubusercontent.com")) {
|
||||||
return strings.NewReader(content), int64(len(content)), nil
|
return bytes.NewReader(content), int64(len(content)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.Contains(content, "github.com") && !strings.Contains(content, "githubusercontent.com") {
|
processed := processGitHubURLs(string(content), host)
|
||||||
return strings.NewReader(content), int64(len(content)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
processed := processGitHubURLs(content, host)
|
|
||||||
|
|
||||||
return strings.NewReader(processed), int64(len(processed)), nil
|
return strings.NewReader(processed), int64(len(processed)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func readShellContent(input io.ReadCloser, isCompressed bool) (string, error) {
|
func readShellContent(input io.Reader, isCompressed bool) ([]byte, error) {
|
||||||
var reader io.Reader = input
|
var reader io.Reader = input
|
||||||
|
|
||||||
if isCompressed {
|
if isCompressed {
|
||||||
peek := make([]byte, 2)
|
peek := make([]byte, 2)
|
||||||
n, err := input.Read(peek)
|
n, err := input.Read(peek)
|
||||||
if err != nil && err != io.EOF {
|
if err != nil && err != io.EOF {
|
||||||
return "", fmt.Errorf("读取数据失败: %v", err)
|
return nil, fmt.Errorf("读取数据失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if n >= 2 && peek[0] == 0x1f && peek[1] == 0x8b {
|
if n >= 2 && peek[0] == 0x1f && peek[1] == 0x8b {
|
||||||
combinedReader := io.MultiReader(bytes.NewReader(peek[:n]), input)
|
combinedReader := io.MultiReader(bytes.NewReader(peek[:n]), input)
|
||||||
gzReader, err := gzip.NewReader(combinedReader)
|
gzReader, err := gzip.NewReader(combinedReader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("gzip解压失败: %v", err)
|
return nil, fmt.Errorf("gzip解压失败: %v", err)
|
||||||
}
|
}
|
||||||
defer gzReader.Close()
|
defer gzReader.Close()
|
||||||
reader = gzReader
|
reader = gzReader
|
||||||
@@ -61,12 +58,19 @@ func readShellContent(input io.ReadCloser, isCompressed bool) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := io.ReadAll(reader)
|
limit := int64(MaxShellSize + 1)
|
||||||
|
limitedReader := io.LimitReader(reader, limit)
|
||||||
|
|
||||||
|
data, err := io.ReadAll(limitedReader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("读取内容失败: %v", err)
|
return nil, fmt.Errorf("读取内容失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return string(data), nil
|
if int64(len(data)) > MaxShellSize {
|
||||||
|
return nil, fmt.Errorf("脚本文件过大,超过 %d MB 限制", MaxShellSize/1024/1024)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func processGitHubURLs(content, host string) string {
|
func processGitHubURLs(content, host string) string {
|
||||||
@@ -100,4 +104,4 @@ func transformURL(url, host string) string {
|
|||||||
host = strings.TrimSuffix(host, "/")
|
host = strings.TrimSuffix(host, "/")
|
||||||
|
|
||||||
return host + "/" + url
|
return host + "/" + url
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,8 +176,9 @@ func (i *IPRateLimiter) GetLimiter(ip string) (*rate.Limiter, bool) {
|
|||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
|
var entry *rateLimiterEntry
|
||||||
i.mu.RLock()
|
i.mu.RLock()
|
||||||
entry, exists := i.ips[normalizedIP]
|
_, exists := i.ips[normalizedIP]
|
||||||
i.mu.RUnlock()
|
i.mu.RUnlock()
|
||||||
|
|
||||||
if exists {
|
if exists {
|
||||||
|
|||||||
Reference in New Issue
Block a user