重构优化
This commit is contained in:
21
src/Dockerfile
Normal file
21
src/Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
||||
FROM golang:1.24-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -trimpath -o hubproxy .
|
||||
|
||||
FROM alpine
|
||||
|
||||
WORKDIR /root/
|
||||
|
||||
# 安装skopeo
|
||||
RUN apk add --no-cache skopeo && mkdir -p temp && chmod 700 temp
|
||||
|
||||
COPY --from=builder /app/hubproxy .
|
||||
COPY --from=builder /app/config.toml .
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
CMD ["./hubproxy"]
|
||||
107
src/LICENSE
Normal file
107
src/LICENSE
Normal file
@@ -0,0 +1,107 @@
|
||||
WJQserver Studio 开源许可证
|
||||
版本 1.2
|
||||
|
||||
版权所有 © WJQserver Studio 2024
|
||||
|
||||
定义
|
||||
许可:指在本许可证内定义的使用、复制、分发与修改的条款与要求。
|
||||
授权方:指拥有版权的个人或组织,亦或是拥有版权的个人或组织所指派的实体。
|
||||
您:指行使本许可授予的权限的个人或法律实体。
|
||||
开源与自由软件
|
||||
本项目为开源软件,允许用户在遵循本许可证的前提下访问和使用源代码。
|
||||
本项目不等同于自由软件,使用权限受到本许可证条款的限制。
|
||||
强调版权所有,所有权利均由 WJQserver Studio 保留。
|
||||
许可证条款
|
||||
1. 使用权限
|
||||
1.1 您被授予在私人环境中自由使用本软件的权限。
|
||||
|
||||
1.2 您可以在不修改关键声明的前提下进行商用。
|
||||
|
||||
2. 复制与分发
|
||||
2.1 您可以复制和分发本软件的原始版本,前提是必须保留所有版权声明和本许可证。
|
||||
|
||||
3. 修改权限
|
||||
3.1 您可以在非商业用途下修改本软件,前提是继承本许可证并保留原版权声明。
|
||||
|
||||
3.2 禁止在修改后进行商业用途。
|
||||
|
||||
4. 专利引用
|
||||
4.1 若项目被专利相关引用,必须保留来源声明。
|
||||
|
||||
4.2 若为商业场景,需按照商用处理。
|
||||
|
||||
5. 免责声明
|
||||
5.1 本软件按“现状”提供,不提供任何明示或暗示的保证,包括但不限于适销性、特定用途适用性及非侵权性。
|
||||
|
||||
5.2 在任何情况下,授权方均不对因使用或无法使用本软件而产生的任何直接、间接、偶然、特殊、惩罚性或后果性损害负责,即使已被告知可能发生此类损害。
|
||||
|
||||
5.3 用户需根据当地法律对待本项目,确保遵守所有适用法规。
|
||||
|
||||
6. 许可证期限
|
||||
6.1 本许可证自2024年开始生效,有效期暂为无限。
|
||||
|
||||
6.2 项目所有方有权修改许可证相关条例而不另行通知。
|
||||
|
||||
条款修订
|
||||
7.1 授权方保留随时修改本许可证条款的权利,以便更好地适应法律和技术的发展。
|
||||
|
||||
7.2 修订后的条款将在发布时生效,继续使用本软件即表示接受修订后的条款。
|
||||
|
||||
其他
|
||||
8.1 本许可证不影响您作为最终用户的法定权利。
|
||||
|
||||
8.2 若本许可证的某些条款被认定为不可执行,其余条款仍然有效。
|
||||
|
||||
WJQserver Studio Open Source License
|
||||
Version 1.2
|
||||
|
||||
Copyright © WJQserver Studio 2024
|
||||
|
||||
Definitions
|
||||
License: The terms and conditions defined within this license for use, copying, distribution, and modification.
|
||||
Licensor: The individual or organization holding the copyright, or the entity designated by them.
|
||||
You: The individual or legal entity exercising the permissions granted by this license.
|
||||
Open Source vs. Free Software
|
||||
This project is open source, allowing users to access and use the source code under the terms of this license.
|
||||
This project is not equivalent to free software; usage rights are restricted by this license.
|
||||
Copyright is emphasized, with all rights reserved by WJQserver Studio.
|
||||
License Terms
|
||||
1. Usage Rights
|
||||
1.1 You are granted the right to use this software freely in a private environment.
|
||||
|
||||
1.2 You may use it commercially without modifying key statements.
|
||||
|
||||
2. Copying and Distribution
|
||||
2.1 You may copy and distribute the original version of this software, provided all copyright notices and this license are retained.
|
||||
|
||||
3. Modification Rights
|
||||
3.1 You may modify this software for non-commercial purposes, provided you inherit this license and retain the original copyright notice.
|
||||
|
||||
3.2 Modifications cannot be used commercially.
|
||||
|
||||
4. Patent References
|
||||
4.1 If the project is cited in patent-related contexts, the source statement must be retained.
|
||||
|
||||
4.2 For commercial scenarios, it must be treated as a commercial use.
|
||||
|
||||
5. Disclaimer
|
||||
5.1 This software is provided "as is", without any express or implied warranties, including but not limited to merchantability, fitness for a particular purpose, and non-infringement.
|
||||
|
||||
5.2 In no event shall the licensor be liable for any direct, indirect, incidental, special, punitive, or consequential damages arising out of the use or inability to use this software, even if advised of the possibility of such damages.
|
||||
|
||||
5.3 Users must comply with all applicable laws regarding this project.
|
||||
|
||||
6. License Duration
|
||||
6.1 This license is effective from 2024, with an indefinite duration.
|
||||
|
||||
6.2 The project owner reserves the right to modify the license terms without prior notice.
|
||||
|
||||
Amendments
|
||||
7.1 The licensor reserves the right to amend this license at any time to better adapt to legal and technological developments.
|
||||
|
||||
7.2 Revised terms become effective upon publication, and continued use of the software indicates acceptance of the revised terms.
|
||||
|
||||
Miscellaneous
|
||||
8.1 This license does not affect your statutory rights as an end user.
|
||||
|
||||
8.2 If any provision of this license is held to be unenforceable, the remaining provisions shall remain in effect.
|
||||
226
src/access_control.go
Normal file
226
src/access_control.go
Normal file
@@ -0,0 +1,226 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ResourceType 资源类型
|
||||
type ResourceType string
|
||||
|
||||
const (
|
||||
ResourceTypeGitHub ResourceType = "github"
|
||||
ResourceTypeDocker ResourceType = "docker"
|
||||
)
|
||||
|
||||
// AccessController 统一访问控制器
|
||||
type AccessController struct {
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// DockerImageInfo Docker镜像信息
|
||||
type DockerImageInfo struct {
|
||||
Namespace string
|
||||
Repository string
|
||||
Tag string
|
||||
FullName string
|
||||
}
|
||||
|
||||
// 全局访问控制器实例
|
||||
var GlobalAccessController = &AccessController{}
|
||||
|
||||
// ParseDockerImage 解析Docker镜像名称
|
||||
func (ac *AccessController) ParseDockerImage(image string) DockerImageInfo {
|
||||
// 移除可能的协议前缀
|
||||
image = strings.TrimPrefix(image, "docker://")
|
||||
|
||||
// 分离标签
|
||||
var tag string
|
||||
if idx := strings.LastIndex(image, ":"); idx != -1 {
|
||||
// 检查是否是端口号而不是标签(包含斜杠)
|
||||
part := image[idx+1:]
|
||||
if !strings.Contains(part, "/") {
|
||||
tag = part
|
||||
image = image[:idx]
|
||||
}
|
||||
}
|
||||
if tag == "" {
|
||||
tag = "latest"
|
||||
}
|
||||
|
||||
// 分离命名空间和仓库名
|
||||
var namespace, repository string
|
||||
if strings.Contains(image, "/") {
|
||||
// 处理自定义registry的情况,如 registry.com/user/repo
|
||||
parts := strings.Split(image, "/")
|
||||
if len(parts) >= 2 {
|
||||
// 检查第一部分是否是域名(包含.)
|
||||
if strings.Contains(parts[0], ".") {
|
||||
// 跳过registry域名,取用户名和仓库名
|
||||
if len(parts) >= 3 {
|
||||
namespace = parts[1]
|
||||
repository = parts[2]
|
||||
} else {
|
||||
namespace = "library"
|
||||
repository = parts[1]
|
||||
}
|
||||
} else {
|
||||
// 标准格式:user/repo
|
||||
namespace = parts[0]
|
||||
repository = parts[1]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 官方镜像,如 nginx
|
||||
namespace = "library"
|
||||
repository = image
|
||||
}
|
||||
|
||||
fullName := namespace + "/" + repository
|
||||
|
||||
return DockerImageInfo{
|
||||
Namespace: namespace,
|
||||
Repository: repository,
|
||||
Tag: tag,
|
||||
FullName: fullName,
|
||||
}
|
||||
}
|
||||
|
||||
// CheckDockerAccess 检查Docker镜像访问权限
|
||||
func (ac *AccessController) CheckDockerAccess(image string) (allowed bool, reason string) {
|
||||
cfg := GetConfig()
|
||||
|
||||
// 解析镜像名称
|
||||
imageInfo := ac.ParseDockerImage(image)
|
||||
|
||||
// 检查白名单(如果配置了白名单,则只允许白名单中的镜像)
|
||||
if len(cfg.Proxy.WhiteList) > 0 {
|
||||
if !ac.matchImageInList(imageInfo, cfg.Proxy.WhiteList) {
|
||||
return false, "不在Docker镜像白名单内"
|
||||
}
|
||||
}
|
||||
|
||||
// 检查黑名单
|
||||
if len(cfg.Proxy.BlackList) > 0 {
|
||||
if ac.matchImageInList(imageInfo, cfg.Proxy.BlackList) {
|
||||
return false, "Docker镜像在黑名单内"
|
||||
}
|
||||
}
|
||||
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// CheckGitHubAccess 检查GitHub仓库访问权限
|
||||
func (ac *AccessController) CheckGitHubAccess(matches []string) (allowed bool, reason string) {
|
||||
if len(matches) < 2 {
|
||||
return false, "无效的GitHub仓库格式"
|
||||
}
|
||||
|
||||
cfg := GetConfig()
|
||||
|
||||
// 检查白名单
|
||||
if len(cfg.Proxy.WhiteList) > 0 && !ac.checkList(matches, cfg.Proxy.WhiteList) {
|
||||
return false, "不在GitHub仓库白名单内"
|
||||
}
|
||||
|
||||
// 检查黑名单
|
||||
if len(cfg.Proxy.BlackList) > 0 && ac.checkList(matches, cfg.Proxy.BlackList) {
|
||||
return false, "GitHub仓库在黑名单内"
|
||||
}
|
||||
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// matchImageInList 检查Docker镜像是否在指定列表中
|
||||
func (ac *AccessController) matchImageInList(imageInfo DockerImageInfo, list []string) bool {
|
||||
fullName := strings.ToLower(imageInfo.FullName)
|
||||
namespace := strings.ToLower(imageInfo.Namespace)
|
||||
|
||||
for _, item := range list {
|
||||
item = strings.ToLower(strings.TrimSpace(item))
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if fullName == item {
|
||||
return true
|
||||
}
|
||||
|
||||
if item == namespace || item == namespace+"/*" {
|
||||
return true
|
||||
}
|
||||
|
||||
if strings.HasSuffix(item, "*") {
|
||||
prefix := strings.TrimSuffix(item, "*")
|
||||
if strings.HasPrefix(fullName, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if strings.HasPrefix(item, "*/") {
|
||||
repoPattern := strings.TrimPrefix(item, "*/")
|
||||
if strings.HasSuffix(repoPattern, "*") {
|
||||
repoPrefix := strings.TrimSuffix(repoPattern, "*")
|
||||
if strings.HasPrefix(imageInfo.Repository, repoPrefix) {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
if strings.ToLower(imageInfo.Repository) == repoPattern {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 子仓库匹配(防止 user/repo 匹配到 user/repo-fork)
|
||||
if strings.HasPrefix(fullName, item+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// checkList GitHub仓库检查逻辑
|
||||
func (ac *AccessController) checkList(matches, list []string) bool {
|
||||
if len(matches) < 2 {
|
||||
return false
|
||||
}
|
||||
|
||||
// 组合用户名和仓库名,处理.git后缀
|
||||
username := strings.ToLower(strings.TrimSpace(matches[0]))
|
||||
repoName := strings.ToLower(strings.TrimSpace(strings.TrimSuffix(matches[1], ".git")))
|
||||
fullRepo := username + "/" + repoName
|
||||
|
||||
for _, item := range list {
|
||||
item = strings.ToLower(strings.TrimSpace(item))
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 支持多种匹配模式:
|
||||
// 1. 精确匹配: "vaxilu/x-ui"
|
||||
// 2. 用户级匹配: "vaxilu/*" 或 "vaxilu"
|
||||
// 3. 前缀匹配: "vaxilu/x-ui-*"
|
||||
if fullRepo == item {
|
||||
return true
|
||||
}
|
||||
|
||||
// 用户级匹配
|
||||
if item == username || item == username+"/*" {
|
||||
return true
|
||||
}
|
||||
|
||||
// 前缀匹配(支持通配符)
|
||||
if strings.HasSuffix(item, "*") {
|
||||
prefix := strings.TrimSuffix(item, "*")
|
||||
if strings.HasPrefix(fullRepo, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 子仓库匹配(防止 user/repo 匹配到 user/repo-fork)
|
||||
if strings.HasPrefix(fullRepo, item+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
195
src/config.go
Normal file
195
src/config.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
)
|
||||
|
||||
// AppConfig 应用配置结构体
|
||||
type AppConfig struct {
|
||||
Server struct {
|
||||
Host string `toml:"host"` // 监听地址
|
||||
Port int `toml:"port"` // 监听端口
|
||||
FileSize int64 `toml:"fileSize"` // 文件大小限制(字节)
|
||||
} `toml:"server"`
|
||||
|
||||
RateLimit struct {
|
||||
RequestLimit int `toml:"requestLimit"` // 每小时请求限制
|
||||
PeriodHours float64 `toml:"periodHours"` // 限制周期(小时)
|
||||
} `toml:"rateLimit"`
|
||||
|
||||
Security struct {
|
||||
WhiteList []string `toml:"whiteList"` // 白名单IP/CIDR列表
|
||||
BlackList []string `toml:"blackList"` // 黑名单IP/CIDR列表
|
||||
} `toml:"security"`
|
||||
|
||||
Proxy struct {
|
||||
WhiteList []string `toml:"whiteList"` // 代理白名单(仓库级别)
|
||||
BlackList []string `toml:"blackList"` // 代理黑名单(仓库级别)
|
||||
} `toml:"proxy"`
|
||||
|
||||
Download struct {
|
||||
MaxImages int `toml:"maxImages"` // 单次下载最大镜像数量限制
|
||||
} `toml:"download"`
|
||||
}
|
||||
|
||||
var (
|
||||
appConfig *AppConfig
|
||||
appConfigLock sync.RWMutex
|
||||
)
|
||||
|
||||
// DefaultConfig 返回默认配置
|
||||
func DefaultConfig() *AppConfig {
|
||||
return &AppConfig{
|
||||
Server: struct {
|
||||
Host string `toml:"host"`
|
||||
Port int `toml:"port"`
|
||||
FileSize int64 `toml:"fileSize"`
|
||||
}{
|
||||
Host: "0.0.0.0",
|
||||
Port: 5000,
|
||||
FileSize: 2 * 1024 * 1024 * 1024, // 2GB
|
||||
},
|
||||
RateLimit: struct {
|
||||
RequestLimit int `toml:"requestLimit"`
|
||||
PeriodHours float64 `toml:"periodHours"`
|
||||
}{
|
||||
RequestLimit: 20,
|
||||
PeriodHours: 1.0,
|
||||
},
|
||||
Security: struct {
|
||||
WhiteList []string `toml:"whiteList"`
|
||||
BlackList []string `toml:"blackList"`
|
||||
}{
|
||||
WhiteList: []string{},
|
||||
BlackList: []string{},
|
||||
},
|
||||
Proxy: struct {
|
||||
WhiteList []string `toml:"whiteList"`
|
||||
BlackList []string `toml:"blackList"`
|
||||
}{
|
||||
WhiteList: []string{},
|
||||
BlackList: []string{},
|
||||
},
|
||||
Download: struct {
|
||||
MaxImages int `toml:"maxImages"`
|
||||
}{
|
||||
MaxImages: 10, // 默认值:最多同时下载10个镜像
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetConfig 安全地获取配置副本
|
||||
func GetConfig() *AppConfig {
|
||||
appConfigLock.RLock()
|
||||
defer appConfigLock.RUnlock()
|
||||
|
||||
if appConfig == nil {
|
||||
return DefaultConfig()
|
||||
}
|
||||
|
||||
// 返回配置的深拷贝
|
||||
configCopy := *appConfig
|
||||
configCopy.Security.WhiteList = append([]string(nil), appConfig.Security.WhiteList...)
|
||||
configCopy.Security.BlackList = append([]string(nil), appConfig.Security.BlackList...)
|
||||
configCopy.Proxy.WhiteList = append([]string(nil), appConfig.Proxy.WhiteList...)
|
||||
configCopy.Proxy.BlackList = append([]string(nil), appConfig.Proxy.BlackList...)
|
||||
|
||||
return &configCopy
|
||||
}
|
||||
|
||||
// setConfig 安全地设置配置
|
||||
func setConfig(cfg *AppConfig) {
|
||||
appConfigLock.Lock()
|
||||
defer appConfigLock.Unlock()
|
||||
appConfig = cfg
|
||||
}
|
||||
|
||||
// LoadConfig 加载配置文件
|
||||
func LoadConfig() error {
|
||||
// 首先使用默认配置
|
||||
cfg := DefaultConfig()
|
||||
|
||||
// 尝试加载TOML配置文件
|
||||
if data, err := os.ReadFile("config.toml"); err == nil {
|
||||
if err := toml.Unmarshal(data, cfg); err != nil {
|
||||
return fmt.Errorf("解析配置文件失败: %v", err)
|
||||
}
|
||||
} else {
|
||||
fmt.Println("未找到config.toml,使用默认配置")
|
||||
}
|
||||
|
||||
// 从环境变量覆盖配置
|
||||
overrideFromEnv(cfg)
|
||||
|
||||
// 设置配置
|
||||
setConfig(cfg)
|
||||
|
||||
fmt.Printf("配置加载成功: 监听 %s:%d, 文件大小限制 %d MB, 限流 %d请求/%g小时, 离线镜像并发数 %d\n",
|
||||
cfg.Server.Host, cfg.Server.Port, cfg.Server.FileSize/(1024*1024),
|
||||
cfg.RateLimit.RequestLimit, cfg.RateLimit.PeriodHours, cfg.Download.MaxImages)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// overrideFromEnv 从环境变量覆盖配置
|
||||
func overrideFromEnv(cfg *AppConfig) {
|
||||
// 服务器配置
|
||||
if val := os.Getenv("SERVER_HOST"); val != "" {
|
||||
cfg.Server.Host = val
|
||||
}
|
||||
if val := os.Getenv("SERVER_PORT"); val != "" {
|
||||
if port, err := strconv.Atoi(val); err == nil && port > 0 {
|
||||
cfg.Server.Port = port
|
||||
}
|
||||
}
|
||||
if val := os.Getenv("MAX_FILE_SIZE"); val != "" {
|
||||
if size, err := strconv.ParseInt(val, 10, 64); err == nil && size > 0 {
|
||||
cfg.Server.FileSize = size
|
||||
}
|
||||
}
|
||||
|
||||
// 限流配置
|
||||
if val := os.Getenv("RATE_LIMIT"); val != "" {
|
||||
if limit, err := strconv.Atoi(val); err == nil && limit > 0 {
|
||||
cfg.RateLimit.RequestLimit = limit
|
||||
}
|
||||
}
|
||||
if val := os.Getenv("RATE_PERIOD_HOURS"); val != "" {
|
||||
if period, err := strconv.ParseFloat(val, 64); err == nil && period > 0 {
|
||||
cfg.RateLimit.PeriodHours = period
|
||||
}
|
||||
}
|
||||
|
||||
// IP限制配置
|
||||
if val := os.Getenv("IP_WHITELIST"); val != "" {
|
||||
cfg.Security.WhiteList = append(cfg.Security.WhiteList, strings.Split(val, ",")...)
|
||||
}
|
||||
if val := os.Getenv("IP_BLACKLIST"); val != "" {
|
||||
cfg.Security.BlackList = append(cfg.Security.BlackList, strings.Split(val, ",")...)
|
||||
}
|
||||
|
||||
// 下载限制配置
|
||||
if val := os.Getenv("MAX_IMAGES"); val != "" {
|
||||
if maxImages, err := strconv.Atoi(val); err == nil && maxImages > 0 {
|
||||
cfg.Download.MaxImages = maxImages
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CreateDefaultConfigFile 创建默认配置文件
|
||||
func CreateDefaultConfigFile() error {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
data, err := toml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("序列化默认配置失败: %v", err)
|
||||
}
|
||||
|
||||
return os.WriteFile("config.toml", data, 0644)
|
||||
}
|
||||
45
src/config.toml
Normal file
45
src/config.toml
Normal file
@@ -0,0 +1,45 @@
|
||||
[server]
|
||||
# 监听地址,默认监听所有接口
|
||||
host = "0.0.0.0"
|
||||
# 监听端口
|
||||
port = 5000
|
||||
# 文件大小限制(字节),默认2GB
|
||||
fileSize = 2147483648
|
||||
|
||||
[rateLimit]
|
||||
# 每个IP每小时允许的请求数
|
||||
requestLimit = 200
|
||||
# 限流周期(小时)
|
||||
periodHours = 1.0
|
||||
|
||||
[security]
|
||||
# IP白名单,支持单个IP或CIDR格式
|
||||
# 白名单中的IP不受限流限制
|
||||
whiteList = [
|
||||
"127.0.0.1",
|
||||
"192.168.1.0/24"
|
||||
]
|
||||
|
||||
# IP黑名单,支持单个IP或CIDR格式
|
||||
# 黑名单中的IP将被直接拒绝访问
|
||||
blackList = [
|
||||
"192.168.100.1"
|
||||
]
|
||||
|
||||
[proxy]
|
||||
# 代理服务白名单(支持GitHub仓库和Docker镜像,支持通配符)
|
||||
# 只允许访问白名单中的仓库/镜像,为空时不限制
|
||||
whiteList = []
|
||||
|
||||
# 代理服务黑名单(支持GitHub仓库和Docker镜像,支持通配符)
|
||||
# 禁止访问黑名单中的仓库/镜像
|
||||
blackList = [
|
||||
"baduser/malicious-repo",
|
||||
"thesadboy/x-ui",
|
||||
"vaxilu/x-ui",
|
||||
"vaxilu/*"
|
||||
]
|
||||
|
||||
[download]
|
||||
# 单次并发下载离线镜像数量限制
|
||||
maxImages = 10
|
||||
8
src/docker-compose.yml
Normal file
8
src/docker-compose.yml
Normal file
@@ -0,0 +1,8 @@
|
||||
services:
|
||||
ghproxy:
|
||||
build: .
|
||||
restart: always
|
||||
ports:
|
||||
- '5000:5000'
|
||||
volumes:
|
||||
- ./config.toml:/root/config.toml
|
||||
323
src/docker.go
Normal file
323
src/docker.go
Normal file
@@ -0,0 +1,323 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/go-containerregistry/pkg/authn"
|
||||
"github.com/google/go-containerregistry/pkg/name"
|
||||
"github.com/google/go-containerregistry/pkg/v1/remote"
|
||||
)
|
||||
|
||||
// DockerProxy Docker代理配置
|
||||
type DockerProxy struct {
|
||||
registry name.Registry
|
||||
options []remote.Option
|
||||
}
|
||||
|
||||
var dockerProxy *DockerProxy
|
||||
|
||||
// 初始化Docker代理
|
||||
func initDockerProxy() {
|
||||
// 创建目标registry
|
||||
registry, err := name.NewRegistry("registry-1.docker.io")
|
||||
if err != nil {
|
||||
fmt.Printf("创建Docker registry失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 配置代理选项
|
||||
options := []remote.Option{
|
||||
remote.WithAuth(authn.Anonymous),
|
||||
remote.WithUserAgent("ghproxy/go-containerregistry"),
|
||||
}
|
||||
|
||||
dockerProxy = &DockerProxy{
|
||||
registry: registry,
|
||||
options: options,
|
||||
}
|
||||
|
||||
fmt.Printf("Docker代理已初始化\n")
|
||||
}
|
||||
|
||||
// ProxyDockerRegistryGin 标准Docker Registry API v2代理
|
||||
func ProxyDockerRegistryGin(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
|
||||
// 处理 /v2/ API版本检查
|
||||
if path == "/v2/" {
|
||||
c.JSON(http.StatusOK, gin.H{})
|
||||
return
|
||||
}
|
||||
|
||||
// 处理不同的API端点
|
||||
if strings.HasPrefix(path, "/v2/") {
|
||||
handleRegistryRequest(c, path)
|
||||
} else {
|
||||
c.String(http.StatusNotFound, "Docker Registry API v2 only")
|
||||
}
|
||||
}
|
||||
|
||||
// handleRegistryRequest 处理Registry请求
|
||||
func handleRegistryRequest(c *gin.Context, path string) {
|
||||
// 移除 /v2/ 前缀
|
||||
pathWithoutV2 := strings.TrimPrefix(path, "/v2/")
|
||||
|
||||
// 解析路径
|
||||
imageName, apiType, reference := parseRegistryPath(pathWithoutV2)
|
||||
if imageName == "" || apiType == "" {
|
||||
c.String(http.StatusBadRequest, "Invalid path format")
|
||||
return
|
||||
}
|
||||
|
||||
// 自动处理官方镜像的library命名空间
|
||||
if !strings.Contains(imageName, "/") {
|
||||
imageName = "library/" + imageName
|
||||
}
|
||||
|
||||
// Docker镜像访问控制检查
|
||||
if allowed, reason := GlobalAccessController.CheckDockerAccess(imageName); !allowed {
|
||||
fmt.Printf("Docker镜像 %s 访问被拒绝: %s\n", imageName, reason)
|
||||
c.String(http.StatusForbidden, "镜像访问被限制")
|
||||
return
|
||||
}
|
||||
|
||||
// 构建完整的镜像引用
|
||||
imageRef := fmt.Sprintf("%s/%s", dockerProxy.registry.Name(), imageName)
|
||||
|
||||
switch apiType {
|
||||
case "manifests":
|
||||
handleManifestRequest(c, imageRef, reference)
|
||||
case "blobs":
|
||||
handleBlobRequest(c, imageRef, reference)
|
||||
case "tags":
|
||||
handleTagsRequest(c, imageRef)
|
||||
default:
|
||||
c.String(http.StatusNotFound, "API endpoint not found")
|
||||
}
|
||||
}
|
||||
|
||||
// parseRegistryPath 解析Registry路径
|
||||
func parseRegistryPath(path string) (imageName, apiType, reference string) {
|
||||
// 查找API端点关键字
|
||||
if idx := strings.Index(path, "/manifests/"); idx != -1 {
|
||||
imageName = path[:idx]
|
||||
apiType = "manifests"
|
||||
reference = path[idx+len("/manifests/"):]
|
||||
return
|
||||
}
|
||||
|
||||
if idx := strings.Index(path, "/blobs/"); idx != -1 {
|
||||
imageName = path[:idx]
|
||||
apiType = "blobs"
|
||||
reference = path[idx+len("/blobs/"):]
|
||||
return
|
||||
}
|
||||
|
||||
if idx := strings.Index(path, "/tags/list"); idx != -1 {
|
||||
imageName = path[:idx]
|
||||
apiType = "tags"
|
||||
reference = "list"
|
||||
return
|
||||
}
|
||||
|
||||
return "", "", ""
|
||||
}
|
||||
|
||||
// handleManifestRequest 处理manifest请求
|
||||
func handleManifestRequest(c *gin.Context, imageRef, reference string) {
|
||||
var ref name.Reference
|
||||
var err error
|
||||
|
||||
// 判断reference是digest还是tag
|
||||
if strings.HasPrefix(reference, "sha256:") {
|
||||
// 是digest
|
||||
ref, err = name.NewDigest(fmt.Sprintf("%s@%s", imageRef, reference))
|
||||
} else {
|
||||
// 是tag
|
||||
ref, err = name.NewTag(fmt.Sprintf("%s:%s", imageRef, reference))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("解析镜像引用失败: %v\n", err)
|
||||
c.String(http.StatusBadRequest, "Invalid reference")
|
||||
return
|
||||
}
|
||||
|
||||
// 根据请求方法选择操作
|
||||
if c.Request.Method == http.MethodHead {
|
||||
// HEAD请求,使用remote.Head
|
||||
desc, err := remote.Head(ref, dockerProxy.options...)
|
||||
if err != nil {
|
||||
fmt.Printf("HEAD请求失败: %v\n", err)
|
||||
c.String(http.StatusNotFound, "Manifest not found")
|
||||
return
|
||||
}
|
||||
|
||||
// 设置响应头
|
||||
c.Header("Content-Type", string(desc.MediaType))
|
||||
c.Header("Docker-Content-Digest", desc.Digest.String())
|
||||
c.Header("Content-Length", fmt.Sprintf("%d", desc.Size))
|
||||
c.Status(http.StatusOK)
|
||||
} else {
|
||||
// GET请求,使用remote.Get
|
||||
desc, err := remote.Get(ref, dockerProxy.options...)
|
||||
if err != nil {
|
||||
fmt.Printf("GET请求失败: %v\n", err)
|
||||
c.String(http.StatusNotFound, "Manifest not found")
|
||||
return
|
||||
}
|
||||
|
||||
// 设置响应头
|
||||
c.Header("Content-Type", string(desc.MediaType))
|
||||
c.Header("Docker-Content-Digest", desc.Digest.String())
|
||||
c.Header("Content-Length", fmt.Sprintf("%d", len(desc.Manifest)))
|
||||
|
||||
// 返回manifest内容
|
||||
c.Data(http.StatusOK, string(desc.MediaType), desc.Manifest)
|
||||
}
|
||||
}
|
||||
|
||||
// handleBlobRequest 处理blob请求
|
||||
func handleBlobRequest(c *gin.Context, imageRef, digest string) {
|
||||
// 构建digest引用
|
||||
digestRef, err := name.NewDigest(fmt.Sprintf("%s@%s", imageRef, digest))
|
||||
if err != nil {
|
||||
fmt.Printf("解析digest引用失败: %v\n", err)
|
||||
c.String(http.StatusBadRequest, "Invalid digest reference")
|
||||
return
|
||||
}
|
||||
|
||||
// 使用remote.Layer获取layer
|
||||
layer, err := remote.Layer(digestRef, dockerProxy.options...)
|
||||
if err != nil {
|
||||
fmt.Printf("获取layer失败: %v\n", err)
|
||||
c.String(http.StatusNotFound, "Layer not found")
|
||||
return
|
||||
}
|
||||
|
||||
// 获取layer信息
|
||||
size, err := layer.Size()
|
||||
if err != nil {
|
||||
fmt.Printf("获取layer大小失败: %v\n", err)
|
||||
c.String(http.StatusInternalServerError, "Failed to get layer size")
|
||||
return
|
||||
}
|
||||
|
||||
// 获取layer内容
|
||||
reader, err := layer.Compressed()
|
||||
if err != nil {
|
||||
fmt.Printf("获取layer内容失败: %v\n", err)
|
||||
c.String(http.StatusInternalServerError, "Failed to get layer content")
|
||||
return
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
// 设置响应头
|
||||
c.Header("Content-Type", "application/octet-stream")
|
||||
c.Header("Content-Length", fmt.Sprintf("%d", size))
|
||||
c.Header("Docker-Content-Digest", digest)
|
||||
|
||||
// 流式传输blob内容
|
||||
c.Status(http.StatusOK)
|
||||
io.Copy(c.Writer, reader)
|
||||
}
|
||||
|
||||
// handleTagsRequest 处理tags列表请求
|
||||
func handleTagsRequest(c *gin.Context, imageRef string) {
|
||||
// 解析repository
|
||||
repo, err := name.NewRepository(imageRef)
|
||||
if err != nil {
|
||||
fmt.Printf("解析repository失败: %v\n", err)
|
||||
c.String(http.StatusBadRequest, "Invalid repository")
|
||||
return
|
||||
}
|
||||
|
||||
// 使用remote.List获取tags
|
||||
tags, err := remote.List(repo, dockerProxy.options...)
|
||||
if err != nil {
|
||||
fmt.Printf("获取tags失败: %v\n", err)
|
||||
c.String(http.StatusNotFound, "Tags not found")
|
||||
return
|
||||
}
|
||||
|
||||
// 构建响应
|
||||
response := map[string]interface{}{
|
||||
"name": strings.TrimPrefix(imageRef, dockerProxy.registry.Name()+"/"),
|
||||
"tags": tags,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// ProxyDockerAuthGin Docker认证代理
|
||||
func ProxyDockerAuthGin(c *gin.Context) {
|
||||
// 构建认证URL
|
||||
authURL := "https://auth.docker.io" + c.Request.URL.Path
|
||||
if c.Request.URL.RawQuery != "" {
|
||||
authURL += "?" + c.Request.URL.RawQuery
|
||||
}
|
||||
|
||||
// 创建HTTP客户端
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
// 创建请求
|
||||
req, err := http.NewRequestWithContext(
|
||||
context.Background(),
|
||||
c.Request.Method,
|
||||
authURL,
|
||||
c.Request.Body,
|
||||
)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "Failed to create request")
|
||||
return
|
||||
}
|
||||
|
||||
// 复制请求头
|
||||
for key, values := range c.Request.Header {
|
||||
for _, value := range values {
|
||||
req.Header.Add(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
// 执行请求
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadGateway, "Auth request failed")
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 获取当前代理的Host地址
|
||||
proxyHost := c.Request.Host
|
||||
if proxyHost == "" {
|
||||
// 使用配置中的服务器地址和端口
|
||||
cfg := GetConfig()
|
||||
proxyHost = fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
|
||||
if cfg.Server.Host == "0.0.0.0" {
|
||||
proxyHost = fmt.Sprintf("localhost:%d", cfg.Server.Port)
|
||||
}
|
||||
}
|
||||
|
||||
// 复制响应头并重写认证URL
|
||||
for key, values := range resp.Header {
|
||||
for _, value := range values {
|
||||
// 重写WWW-Authenticate头中的realm URL
|
||||
if key == "Www-Authenticate" && strings.Contains(value, "auth.docker.io") {
|
||||
value = strings.ReplaceAll(value, "https://auth.docker.io", "http://"+proxyHost)
|
||||
}
|
||||
c.Header(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
// 返回响应
|
||||
c.Status(resp.StatusCode)
|
||||
io.Copy(c.Writer, resp.Body)
|
||||
}
|
||||
51
src/go.mod
Normal file
51
src/go.mod
Normal file
@@ -0,0 +1,51 @@
|
||||
module hubproxy
|
||||
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/google/go-containerregistry v0.20.5
|
||||
github.com/gorilla/websocket v1.5.1
|
||||
github.com/pelletier/go-toml/v2 v2.2.2
|
||||
golang.org/x/sync v0.14.0
|
||||
golang.org/x/time v0.11.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect
|
||||
github.com/docker/cli v28.1.1+incompatible // indirect
|
||||
github.com/docker/distribution v2.8.3+incompatible // indirect
|
||||
github.com/docker/docker-credential-helpers v0.9.3 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/vbatts/tar-split v0.12.1 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/crypto v0.23.0 // indirect
|
||||
golang.org/x/net v0.25.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/text v0.15.0 // indirect
|
||||
google.golang.org/protobuf v1.36.3 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
120
src/go.sum
Normal file
120
src/go.sum
Normal file
@@ -0,0 +1,120 @@
|
||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/containerd/stargz-snapshotter/estargz v0.16.3 h1:7evrXtoh1mSbGj/pfRccTampEyKpjpOnS3CyiV1Ebr8=
|
||||
github.com/containerd/stargz-snapshotter/estargz v0.16.3/go.mod h1:uyr4BfYfOj3G9WBVE8cOlQmXAbPN9VEQpBBeJIuOipU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/docker/cli v28.1.1+incompatible h1:eyUemzeI45DY7eDPuwUcmDyDj1pM98oD5MdSpiItp8k=
|
||||
github.com/docker/cli v28.1.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||
github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk=
|
||||
github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
|
||||
github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8=
|
||||
github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-containerregistry v0.20.5 h1:4RnlYcDs5hoA++CeFjlbZ/U9Yp1EuWr+UhhTyYQjOP0=
|
||||
github.com/google/go-containerregistry v0.20.5/go.mod h1:Q14vdOOzug02bwnhMkZKD4e30pDaD9W65qzXpyzF49E=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
|
||||
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/vbatts/tar-split v0.12.1 h1:CqKoORW7BUWBe7UL/iqTVvkTBOF8UvOMKOIZykxnnbo=
|
||||
github.com/vbatts/tar-split v0.12.1/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
||||
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
|
||||
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU=
|
||||
google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0=
|
||||
gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
59
src/http_client.go
Normal file
59
src/http_client.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
// 全局HTTP客户端 - 用于代理请求(长超时)
|
||||
globalHTTPClient *http.Client
|
||||
// 搜索HTTP客户端 - 用于API请求(短超时)
|
||||
searchHTTPClient *http.Client
|
||||
)
|
||||
|
||||
// initHTTPClients 初始化HTTP客户端
|
||||
func initHTTPClients() {
|
||||
// 代理客户端配置 - 适用于大文件传输
|
||||
globalHTTPClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
MaxIdleConns: 1000,
|
||||
MaxIdleConnsPerHost: 1000,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
ResponseHeaderTimeout: 300 * time.Second,
|
||||
},
|
||||
}
|
||||
|
||||
// 搜索客户端配置 - 适用于API调用
|
||||
searchHTTPClient = &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 5 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 10,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 5 * time.Second,
|
||||
DisableCompression: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetGlobalHTTPClient 获取全局HTTP客户端(用于代理)
|
||||
func GetGlobalHTTPClient() *http.Client {
|
||||
return globalHTTPClient
|
||||
}
|
||||
|
||||
// GetSearchHTTPClient 获取搜索HTTP客户端(用于API调用)
|
||||
func GetSearchHTTPClient() *http.Client {
|
||||
return searchHTTPClient
|
||||
}
|
||||
246
src/main.go
Normal file
246
src/main.go
Normal file
@@ -0,0 +1,246 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
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/([^/]+)/([^/]+)/.*`),
|
||||
regexp.MustCompile(`^(?:https?://)?huggingface\.co(?:/spaces)?/([^/]+)/(.+)$`),
|
||||
regexp.MustCompile(`^(?:https?://)?cdn-lfs\.hf\.co(?:/spaces)?/([^/]+)/([^/]+)(?:/(.*))?$`),
|
||||
regexp.MustCompile(`^(?:https?://)?download\.docker\.com/([^/]+)/.*\.(tgz|zip)$`),
|
||||
regexp.MustCompile(`^(?:https?://)?(github|opengraph)\.githubassets\.com/([^/]+)/.+?$`),
|
||||
}
|
||||
globalLimiter *IPRateLimiter
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 加载配置
|
||||
if err := LoadConfig(); err != nil {
|
||||
fmt.Printf("配置加载失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 初始化HTTP客户端
|
||||
initHTTPClients()
|
||||
|
||||
// 初始化限流器
|
||||
initLimiter()
|
||||
|
||||
// 初始化Docker流式代理
|
||||
initDockerProxy()
|
||||
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
router := gin.Default()
|
||||
|
||||
// 初始化skopeo路由(静态文件和API路由)
|
||||
initSkopeoRoutes(router)
|
||||
|
||||
// 单独处理根路径请求
|
||||
router.GET("/", func(c *gin.Context) {
|
||||
c.File("./public/index.html")
|
||||
})
|
||||
|
||||
// 指定具体的静态文件路径
|
||||
router.Static("/public", "./public")
|
||||
router.GET("/skopeo.html", func(c *gin.Context) {
|
||||
c.File("./public/skopeo.html")
|
||||
})
|
||||
router.GET("/search.html", func(c *gin.Context) {
|
||||
c.File("./public/search.html")
|
||||
})
|
||||
router.GET("/favicon.ico", func(c *gin.Context) {
|
||||
c.File("./public/favicon.ico")
|
||||
})
|
||||
|
||||
// 注册dockerhub搜索路由
|
||||
RegisterSearchRoute(router)
|
||||
|
||||
// 注册Docker认证路由(/token*)
|
||||
router.Any("/token", RateLimitMiddleware(globalLimiter), ProxyDockerAuthGin)
|
||||
router.Any("/token/*path", RateLimitMiddleware(globalLimiter), ProxyDockerAuthGin)
|
||||
|
||||
// 注册Docker Registry代理路由
|
||||
router.Any("/v2/*path", RateLimitMiddleware(globalLimiter), ProxyDockerRegistryGin)
|
||||
|
||||
|
||||
// 注册NoRoute处理器,应用限流中间件
|
||||
router.NoRoute(RateLimitMiddleware(globalLimiter), handler)
|
||||
|
||||
cfg := GetConfig()
|
||||
fmt.Printf("启动成功,项目地址:https://github.com/sky22333/hubproxy \n")
|
||||
|
||||
err := router.Run(fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port))
|
||||
if err != nil {
|
||||
fmt.Printf("启动服务失败: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func handler(c *gin.Context) {
|
||||
rawPath := strings.TrimPrefix(c.Request.URL.RequestURI(), "/")
|
||||
|
||||
for strings.HasPrefix(rawPath, "/") {
|
||||
rawPath = strings.TrimPrefix(rawPath, "/")
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(rawPath, "http") {
|
||||
c.String(http.StatusForbidden, "无效输入")
|
||||
return
|
||||
}
|
||||
|
||||
matches := checkURL(rawPath)
|
||||
if matches != nil {
|
||||
// GitHub仓库访问控制检查
|
||||
if allowed, reason := GlobalAccessController.CheckGitHubAccess(matches); !allowed {
|
||||
// 构建仓库名用于日志
|
||||
var repoPath string
|
||||
if len(matches) >= 2 {
|
||||
username := matches[0]
|
||||
repoName := strings.TrimSuffix(matches[1], ".git")
|
||||
repoPath = username + "/" + repoName
|
||||
}
|
||||
fmt.Printf("GitHub仓库 %s 访问被拒绝: %s\n", repoPath, reason)
|
||||
c.String(http.StatusForbidden, reason)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
c.String(http.StatusForbidden, "无效输入")
|
||||
return
|
||||
}
|
||||
|
||||
if exps[1].MatchString(rawPath) {
|
||||
rawPath = strings.Replace(rawPath, "/blob/", "/raw/", 1)
|
||||
}
|
||||
|
||||
proxy(c, rawPath)
|
||||
}
|
||||
|
||||
|
||||
func proxy(c *gin.Context, u string) {
|
||||
proxyWithRedirect(c, u, 0)
|
||||
}
|
||||
|
||||
|
||||
func proxyWithRedirect(c *gin.Context, u string, redirectCount int) {
|
||||
// 限制最大重定向次数,防止无限递归
|
||||
const maxRedirects = 20
|
||||
if redirectCount > maxRedirects {
|
||||
c.String(http.StatusLoopDetected, "重定向次数过多,可能存在循环重定向")
|
||||
return
|
||||
}
|
||||
req, err := http.NewRequest(c.Request.Method, u, c.Request.Body)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, fmt.Sprintf("server error %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
for key, values := range c.Request.Header {
|
||||
for _, value := range values {
|
||||
req.Header.Add(key, value)
|
||||
}
|
||||
}
|
||||
req.Header.Del("Host")
|
||||
|
||||
resp, err := GetGlobalHTTPClient().Do(req)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, fmt.Sprintf("server error %v", err))
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
fmt.Printf("关闭响应体失败: %v\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// 检查文件大小限制
|
||||
cfg := GetConfig()
|
||||
if contentLength := resp.Header.Get("Content-Length"); contentLength != "" {
|
||||
if size, err := strconv.ParseInt(contentLength, 10, 64); err == nil && size > cfg.Server.FileSize {
|
||||
c.String(http.StatusRequestEntityTooLarge,
|
||||
fmt.Sprintf("文件过大,限制大小: %d MB", cfg.Server.FileSize/(1024*1024)))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 清理安全相关的头
|
||||
resp.Header.Del("Content-Security-Policy")
|
||||
resp.Header.Del("Referrer-Policy")
|
||||
resp.Header.Del("Strict-Transport-Security")
|
||||
|
||||
// 对于需要处理的shell文件,使用chunked传输
|
||||
isShellFile := strings.HasSuffix(strings.ToLower(u), ".sh")
|
||||
if isShellFile {
|
||||
resp.Header.Del("Content-Length")
|
||||
resp.Header.Set("Transfer-Encoding", "chunked")
|
||||
}
|
||||
|
||||
// 复制其他响应头
|
||||
for key, values := range resp.Header {
|
||||
for _, value := range values {
|
||||
c.Header(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
if location := resp.Header.Get("Location"); location != "" {
|
||||
if checkURL(location) != nil {
|
||||
c.Header("Location", "/"+location)
|
||||
} else {
|
||||
// 递归处理重定向,增加计数防止无限循环
|
||||
proxyWithRedirect(c, location, redirectCount+1)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.Status(resp.StatusCode)
|
||||
|
||||
// 处理响应体
|
||||
if isShellFile {
|
||||
// 获取真实域名
|
||||
realHost := c.Request.Header.Get("X-Forwarded-Host")
|
||||
if realHost == "" {
|
||||
realHost = c.Request.Host
|
||||
}
|
||||
// 如果域名中没有协议前缀,添加https://
|
||||
if !strings.HasPrefix(realHost, "http://") && !strings.HasPrefix(realHost, "https://") {
|
||||
realHost = "https://" + realHost
|
||||
}
|
||||
// 使用ProcessGitHubURLs处理.sh文件
|
||||
processedBody, _, err := ProcessGitHubURLs(resp.Body, resp.Header.Get("Content-Encoding") == "gzip", realHost, true)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, fmt.Sprintf("处理shell文件时发生错误: %v", err))
|
||||
return
|
||||
}
|
||||
if _, err := io.Copy(c.Writer, processedBody); err != nil {
|
||||
c.String(http.StatusInternalServerError, fmt.Sprintf("写入响应时发生错误: %v", err))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 对于非.sh文件,直接复制响应体
|
||||
if _, err := io.Copy(c.Writer, resp.Body); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func checkURL(u string) []string {
|
||||
for _, exp := range exps {
|
||||
if matches := exp.FindStringSubmatch(u); matches != nil {
|
||||
return matches[1:]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
192
src/proxysh.go
Normal file
192
src/proxysh.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
// gitHubDomains 定义所有支持的GitHub相关域名
|
||||
gitHubDomains = []string{
|
||||
"github.com",
|
||||
"raw.githubusercontent.com",
|
||||
"raw.github.com",
|
||||
"gist.githubusercontent.com",
|
||||
"gist.github.com",
|
||||
"api.github.com",
|
||||
}
|
||||
|
||||
// urlPattern 使用gitHubDomains构建正则表达式
|
||||
urlPattern = regexp.MustCompile(`https?://(?:` + strings.Join(gitHubDomains, "|") + `)[^\s'"]+`)
|
||||
|
||||
// 是否启用脚本嵌套代理的调试日志
|
||||
DebugLog = true
|
||||
)
|
||||
|
||||
// 打印调试日志的辅助函数
|
||||
func debugPrintf(format string, args ...interface{}) {
|
||||
if DebugLog {
|
||||
fmt.Printf(format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessGitHubURLs 处理数据流中的GitHub URL,将其替换为代理URL。
|
||||
// 此处思路借鉴了 https://github.com/WJQSERVER-STUDIO/ghproxy/blob/main/proxy/nest.go
|
||||
|
||||
func ProcessGitHubURLs(input io.ReadCloser, isCompressed bool, host string, isShellFile bool) (io.Reader, int64, error) {
|
||||
debugPrintf("开始处理文件: isCompressed=%v, host=%s, isShellFile=%v\n", isCompressed, host, isShellFile)
|
||||
|
||||
if !isShellFile {
|
||||
debugPrintf("非shell文件,跳过处理\n")
|
||||
return input, 0, nil
|
||||
}
|
||||
|
||||
// 使用更大的缓冲区以提高性能
|
||||
pipeReader, pipeWriter := io.Pipe()
|
||||
var written int64
|
||||
|
||||
go func() {
|
||||
var err error
|
||||
defer func() {
|
||||
if err != nil {
|
||||
debugPrintf("处理过程中发生错误: %v\n", err)
|
||||
_ = pipeWriter.CloseWithError(err)
|
||||
} else {
|
||||
_ = pipeWriter.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
defer input.Close()
|
||||
|
||||
var reader io.Reader = input
|
||||
if isCompressed {
|
||||
debugPrintf("检测到压缩文件,进行解压处理\n")
|
||||
gzipReader, gzipErr := gzip.NewReader(input)
|
||||
if gzipErr != nil {
|
||||
err = gzipErr
|
||||
return
|
||||
}
|
||||
defer gzipReader.Close()
|
||||
reader = gzipReader
|
||||
}
|
||||
|
||||
// 使用更大的缓冲区
|
||||
bufReader := bufio.NewReaderSize(reader, 32*1024) // 32KB buffer
|
||||
var writer io.Writer = pipeWriter
|
||||
|
||||
if isCompressed {
|
||||
gzipWriter := gzip.NewWriter(writer)
|
||||
defer gzipWriter.Close()
|
||||
writer = gzipWriter
|
||||
}
|
||||
|
||||
bufWriter := bufio.NewWriterSize(writer, 32*1024) // 32KB buffer
|
||||
defer bufWriter.Flush()
|
||||
|
||||
written, err = processContent(bufReader, bufWriter, host)
|
||||
if err != nil {
|
||||
debugPrintf("处理内容时发生错误: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
debugPrintf("文件处理完成,共处理 %d 字节\n", written)
|
||||
}()
|
||||
|
||||
return pipeReader, written, nil
|
||||
}
|
||||
|
||||
// processContent 优化处理文件内容的函数
|
||||
func processContent(reader *bufio.Reader, writer *bufio.Writer, host string) (int64, error) {
|
||||
var written int64
|
||||
lineNum := 0
|
||||
|
||||
for {
|
||||
lineNum++
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil && err != io.EOF {
|
||||
return written, fmt.Errorf("读取行时发生错误: %w", err)
|
||||
}
|
||||
|
||||
if line != "" {
|
||||
// 在处理前先检查是否包含GitHub URL
|
||||
if strings.Contains(line, "github.com") ||
|
||||
strings.Contains(line, "raw.githubusercontent.com") {
|
||||
matches := urlPattern.FindAllString(line, -1)
|
||||
if len(matches) > 0 {
|
||||
debugPrintf("\n在第 %d 行发现 %d 个GitHub URL:\n", lineNum, len(matches))
|
||||
for _, match := range matches {
|
||||
debugPrintf("原始URL: %s\n", match)
|
||||
}
|
||||
}
|
||||
|
||||
modifiedLine := processLine(line, host, lineNum)
|
||||
n, writeErr := writer.WriteString(modifiedLine)
|
||||
if writeErr != nil {
|
||||
return written, fmt.Errorf("写入修改后的行时发生错误: %w", writeErr)
|
||||
}
|
||||
written += int64(n)
|
||||
} else {
|
||||
// 如果行中没有GitHub URL,直接写入
|
||||
n, writeErr := writer.WriteString(line)
|
||||
if writeErr != nil {
|
||||
return written, fmt.Errorf("写入原始行时发生错误: %w", writeErr)
|
||||
}
|
||||
written += int64(n)
|
||||
}
|
||||
}
|
||||
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 确保所有数据都被写入
|
||||
if err := writer.Flush(); err != nil {
|
||||
return written, fmt.Errorf("刷新缓冲区时发生错误: %w", err)
|
||||
}
|
||||
|
||||
return written, nil
|
||||
}
|
||||
|
||||
// processLine 处理单行文本,替换所有匹配的GitHub URL
|
||||
func processLine(line string, host string, lineNum int) string {
|
||||
return urlPattern.ReplaceAllStringFunc(line, func(url string) string {
|
||||
newURL := modifyGitHubURL(url, host)
|
||||
if newURL != url {
|
||||
debugPrintf("第 %d 行URL替换:\n 原始: %s\n 替换后: %s\n", lineNum, url, newURL)
|
||||
}
|
||||
return newURL
|
||||
})
|
||||
}
|
||||
|
||||
// 判断代理域名前缀
|
||||
func modifyGitHubURL(url string, host string) string {
|
||||
for _, domain := range gitHubDomains {
|
||||
hasHttps := strings.HasPrefix(url, "https://"+domain)
|
||||
hasHttp := strings.HasPrefix(url, "http://"+domain)
|
||||
|
||||
if hasHttps || hasHttp || strings.HasPrefix(url, domain) {
|
||||
if !hasHttps && !hasHttp {
|
||||
url = "https://" + url
|
||||
}
|
||||
if hasHttp {
|
||||
url = "https://" + strings.TrimPrefix(url, "http://")
|
||||
}
|
||||
// 移除host开头的协议头(如果有)
|
||||
host = strings.TrimPrefix(host, "https://")
|
||||
host = strings.TrimPrefix(host, "http://")
|
||||
// 返回组合后的URL
|
||||
return host + "/" + url
|
||||
}
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
// IsShellFile 检查文件是否为shell文件(基于文件名)
|
||||
func IsShellFile(filename string) bool {
|
||||
return strings.HasSuffix(filename, ".sh")
|
||||
}
|
||||
BIN
src/public/favicon.ico
Normal file
BIN
src/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
542
src/public/index.html
Normal file
542
src/public/index.html
Normal file
@@ -0,0 +1,542 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="Github文件加速,docker镜像加速">
|
||||
<meta name="keywords" content="Github,文件加速,ghproxy,docker镜像加速">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<title>Github文件加速</title>
|
||||
<link rel="icon" href="./favicon.ico">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.5.3/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://font.sec.miui.com/font/css?family=MiSans:400,700:MiSans">
|
||||
<style>
|
||||
:root {
|
||||
--color: #fafafa;
|
||||
--fontcolor: #333;
|
||||
--inputcolor: #f5f5f5;
|
||||
--inputcolor-font: #333;
|
||||
--card-bg: #f8f9fa;
|
||||
--border-color: #dee2e6;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color: #53535338;
|
||||
--fontcolor: #b8b8b8;
|
||||
--inputcolor: #012333;
|
||||
--inputcolor-font: #969696d8;
|
||||
--card-bg: #012333;
|
||||
--border-color: #2d3338;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color);
|
||||
color: var(--fontcolor);
|
||||
font-family: 'Misans', Arial, sans-serif;
|
||||
padding: 30px;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
height: 10px;
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: #39c5bb;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 80%;
|
||||
text-align: center;
|
||||
min-height: 65%;
|
||||
line-height: 1.25;
|
||||
margin-top: 60px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: var(--fontcolor);
|
||||
font-weight: bold;
|
||||
margin-bottom: 20%;
|
||||
}
|
||||
|
||||
.rounded-button {
|
||||
border-radius: 6px;
|
||||
transition: background-color 0.3s, transform 0.2s;
|
||||
padding: 10px 30px;
|
||||
background-color: #555c5c;
|
||||
color: rgb(255, 255, 255);
|
||||
border: none;
|
||||
margin-bottom: 3%;
|
||||
}
|
||||
|
||||
.rounded-button:hover {
|
||||
background-color: #39c5bcda;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.tips>p:first-child::before {
|
||||
position: sticky;
|
||||
color: #7b7b7b;
|
||||
margin-bottom: 1%;
|
||||
font-size: 60%;
|
||||
}
|
||||
|
||||
footer {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
line-height: 1.25;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #012333;
|
||||
color: #39c5bc;
|
||||
padding: 15px 20px 15px 20px;
|
||||
margin: 0px 0;
|
||||
border-radius: 0.5rem;
|
||||
overflow-x: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
pre::before {
|
||||
content: " ";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #bd3c35;
|
||||
border-radius: 50%;
|
||||
box-shadow: 20px 0 0 #d69f27, 40px 0 0 #39c5bb;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace;
|
||||
font-size: 0.9em;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
max-width: 100%;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.container {
|
||||
max-width: 65%;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 10%;
|
||||
}
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 3%;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
background-color: var(--inputcolor);
|
||||
color: var(--inputcolor-font);
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
background-color: var(--inputcolor);
|
||||
color: var(--inputcolor-font);
|
||||
}
|
||||
|
||||
.tips-content {
|
||||
margin-bottom: 0px;
|
||||
margin-top: 20px;
|
||||
font-size: clamp(0.8rem, 2vw, 1.1rem);
|
||||
}
|
||||
|
||||
.status-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-bottom: 1px;
|
||||
margin-top: -2%;
|
||||
font-size: clamp(0.8rem, 2vw, 1.05rem);
|
||||
}
|
||||
|
||||
.code {
|
||||
position: relative;
|
||||
padding-right: 0px;
|
||||
}
|
||||
|
||||
.copy-button {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: rgba(0, 217, 224, 0.822);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.3s;
|
||||
z-index: 1;
|
||||
font-size: 0.85rem;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.redir-button {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 65px;
|
||||
background: rgba(0, 217, 224, 0.822);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.3s;
|
||||
z-index: 1;
|
||||
font-size: 0.85rem;
|
||||
display: none;
|
||||
}
|
||||
|
||||
pre:hover .copy-button {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#visitor-info {
|
||||
margin-top: 10px;
|
||||
text-align: center;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
#toast {
|
||||
position: fixed;
|
||||
top: 10%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background-color: #39c5bcde;
|
||||
color: white;
|
||||
padding: 15px 20px;
|
||||
border-radius: 10px;
|
||||
font-size: 90%;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.docker-button {
|
||||
width: 200px;
|
||||
height: 50px;
|
||||
border-radius: 10px;
|
||||
background-color: #f6f6f6;
|
||||
border: 2px solid #ececec;
|
||||
color: #333;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.docker-button:hover {
|
||||
background-color: #39c5bc;
|
||||
color: white;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
position: relative;
|
||||
background-color: #fff;
|
||||
margin: 10% auto;
|
||||
padding: 30px;
|
||||
width: 80%;
|
||||
max-width: 600px;
|
||||
border-radius: 15px;
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
top: 15px;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.domain-container {
|
||||
margin-top: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.domain-item {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
padding: 15px;
|
||||
margin: 10px 0;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.domain-item:hover {
|
||||
background-color: #e9e9e9;
|
||||
}
|
||||
|
||||
.domain-text {
|
||||
font-family: 'Consolas', monospace;
|
||||
color: #333;
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.modal h1 {
|
||||
color: #333;
|
||||
font-size: 24px;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hosts-button {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: 2px 8px;
|
||||
background-color: #f5f5f5;
|
||||
border: 0px solid #fafafa;
|
||||
color: #333;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.hosts-button:hover {
|
||||
background-color: #39c5bc;
|
||||
color: white;
|
||||
transform: scale(1.05);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.download-button {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 80px;
|
||||
padding: 2px 8px;
|
||||
background-color: #f5f5f5;
|
||||
border: 0px solid #fafafa;
|
||||
color: #333;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.download-button:hover {
|
||||
background-color: #39c5bc;
|
||||
color: white;
|
||||
transform: scale(1.05);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.github-link {
|
||||
display: inline-block;
|
||||
position: static;
|
||||
color: var(--fontcolor);
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.github-link:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.github-link svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<a href="https://gitee.com/if-the-wind/github-hosts/raw/main/hosts" target="_blank" class="hosts-button">hosts</a>
|
||||
<a href="/skopeo.html" class="download-button">镜像包下载</a>
|
||||
<a href="/search.html" style="right: 180px;" class="download-button">镜像搜索</a>
|
||||
<div class="container">
|
||||
<h1>Github文件加速</h1>
|
||||
<div class="form-group">
|
||||
<input type="text" class="form-control" id="githubLinkInput" placeholder="输入需要加速的Github链接">
|
||||
</div>
|
||||
<button class="btn rounded-button" id="formatButton">获取加速链接</button>
|
||||
|
||||
<div class="code" id="outputBlock">
|
||||
<button class="copy-button" id="copyButton">复制</button>
|
||||
<button class="redir-button" id="redirButton">打开</button>
|
||||
<pre id="formattedLinkOutput"></pre>
|
||||
</div>
|
||||
<div class="tips">
|
||||
<div class="tips-content">
|
||||
<p>支持release、archive文件,支持git clone、wget、curl等等操作<br>支持Al模型库Hugging Face</p><br>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="docker-button" id="dockerButton">Docker镜像加速</button>
|
||||
</div>
|
||||
|
||||
<div id="dockerModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close-button" id="closeModal">×</span>
|
||||
<h1>Docker镜像加速</h1>
|
||||
<h5>请根据对应的仓库使用对应的加速域名</h5>
|
||||
<div class="domain-container">
|
||||
<div class="domain-item">
|
||||
<div class="domain-text">docker.<span class="domain-base"></span></div>
|
||||
</div>
|
||||
<div class="domain-item">
|
||||
<div class="domain-text">ghcr.<span class="domain-base"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="toast" style="display:none;">
|
||||
链接已复制到剪贴板
|
||||
</div>
|
||||
<footer>
|
||||
<a href="https://github.com/sky22333/hubproxy" target="_blank" class="github-link">
|
||||
<svg height="32" viewBox="0 0 16 16" width="32">
|
||||
<path fill="currentColor" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
function getRootDomain() {
|
||||
let hostname = window.location.hostname;
|
||||
let parts = hostname.split('.');
|
||||
if(parts.length > 2) {
|
||||
return parts.slice(-2).join('.');
|
||||
}
|
||||
return hostname;
|
||||
}
|
||||
|
||||
let rootDomain = getRootDomain();
|
||||
document.querySelectorAll('.domain-base').forEach(span => {
|
||||
span.textContent = rootDomain;
|
||||
});
|
||||
|
||||
const modal = document.getElementById('dockerModal');
|
||||
const dockerButton = document.getElementById('dockerButton');
|
||||
const closeButton = document.getElementById('closeModal');
|
||||
|
||||
dockerButton.onclick = function() {
|
||||
modal.style.display = "block";
|
||||
}
|
||||
|
||||
closeButton.onclick = function() {
|
||||
modal.style.display = "none";
|
||||
}
|
||||
|
||||
window.onclick = function(event) {
|
||||
if (event.target == modal) {
|
||||
modal.style.display = "none";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function formatGithubLink() {
|
||||
var githubLinkInput = document.getElementById('githubLinkInput');
|
||||
var currentHost = window.location.host;
|
||||
var formattedLink = "";
|
||||
var link = githubLinkInput.value.trim();
|
||||
|
||||
if (link.startsWith("https://") || link.startsWith("http://")) {
|
||||
formattedLink = "https://" + currentHost + "/" + link;
|
||||
} else if (
|
||||
link.startsWith("github.com/") ||
|
||||
link.startsWith("raw.githubusercontent.com/") ||
|
||||
link.startsWith("gist.githubusercontent.com/") ||
|
||||
link.startsWith("huggingface.co/") ||
|
||||
link.startsWith("cdn-lfs.hf.co/") ||
|
||||
link.startsWith("download.docker.com/")
|
||||
) {
|
||||
formattedLink = "https://" + currentHost + "/https://" + link;
|
||||
} else {
|
||||
showToast('请输入有效的链接');
|
||||
return;
|
||||
}
|
||||
|
||||
var formattedLinkOutput = document.getElementById('formattedLinkOutput');
|
||||
formattedLinkOutput.textContent = formattedLink;
|
||||
displayButton();
|
||||
}
|
||||
|
||||
|
||||
function displayButton() {
|
||||
var copyButton = document.getElementById('copyButton');
|
||||
var redirButton = document.getElementById('redirButton');
|
||||
copyButton.style.display = 'block';
|
||||
redirButton.style.display = 'block';
|
||||
}
|
||||
|
||||
function redirToFormattedLink() {
|
||||
var formattedLinkOutput = document.getElementById('formattedLinkOutput');
|
||||
console.log(formattedLinkOutput.textContent);
|
||||
window.open(formattedLinkOutput.textContent);
|
||||
}
|
||||
|
||||
document.getElementById('formatButton').addEventListener('click', formatGithubLink);
|
||||
document.getElementById('copyButton').addEventListener('click', function () {
|
||||
const output = document.getElementById('formattedLinkOutput');
|
||||
const range = document.createRange();
|
||||
range.selectNode(output);
|
||||
window.getSelection().removeAllRanges();
|
||||
window.getSelection().addRange(range);
|
||||
document.execCommand('copy');
|
||||
window.getSelection().removeAllRanges();
|
||||
showToast('链接已复制到剪贴板');
|
||||
});
|
||||
document.getElementById('redirButton').addEventListener('click', redirToFormattedLink);
|
||||
|
||||
function showToast(message) {
|
||||
const toast = document.getElementById('toast');
|
||||
toast.textContent = message;
|
||||
toast.style.display = 'block';
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.display = 'none';
|
||||
}, 2000);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
1071
src/public/search.html
Normal file
1071
src/public/search.html
Normal file
File diff suppressed because it is too large
Load Diff
505
src/public/skopeo.html
Normal file
505
src/public/skopeo.html
Normal file
@@ -0,0 +1,505 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="Docker镜像批量下载工具,docker镜像打包下载">
|
||||
<meta name="keywords" content="Docker,镜像下载,skopeo,docker镜像打包">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<title>Docker镜像批量下载</title>
|
||||
<link rel="icon" href="./favicon.ico">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.5.3/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://font.sec.miui.com/font/css?family=MiSans:400,700:MiSans">
|
||||
<style>
|
||||
:root {
|
||||
--color: #fafafa;
|
||||
--fontcolor: #333;
|
||||
--inputcolor: #f5f5f5;
|
||||
--inputcolor-font: #333;
|
||||
--card-bg: #f8f9fa;
|
||||
--border-color: #dee2e6;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color: #53535338;
|
||||
--fontcolor: #b8b8b8;
|
||||
--inputcolor: #012333;
|
||||
--inputcolor-font: #969696d8;
|
||||
--card-bg: #012333;
|
||||
--border-color: #2d3338;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color);
|
||||
color: var(--fontcolor);
|
||||
font-family: 'Misans', Arial, sans-serif;
|
||||
padding: 30px;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
height: 10px;
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: #39c5bb;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 80%;
|
||||
text-align: center;
|
||||
min-height: 65%;
|
||||
line-height: 1.25;
|
||||
margin-top: 60px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: var(--fontcolor);
|
||||
font-weight: bold;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.rounded-button {
|
||||
border-radius: 6px;
|
||||
transition: background-color 0.3s, transform 0.2s;
|
||||
padding: 10px 30px;
|
||||
background-color: #555c5c;
|
||||
color: rgb(255, 255, 255);
|
||||
border: none;
|
||||
margin-bottom: 3%;
|
||||
}
|
||||
|
||||
.rounded-button:hover {
|
||||
background-color: #39c5bcda;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
footer {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
line-height: 1.25;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #012333;
|
||||
color: #39c5bc;
|
||||
padding: 15px 20px 15px 20px;
|
||||
margin: 0px 0;
|
||||
border-radius: 0.5rem;
|
||||
overflow-x: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
pre::before {
|
||||
content: " ";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #bd3c35;
|
||||
border-radius: 50%;
|
||||
box-shadow: 20px 0 0 #d69f27, 40px 0 0 #39c5bb;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace;
|
||||
font-size: 0.9em;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
max-width: 100%;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.container {
|
||||
max-width: 65%;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 3%;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
background-color: var(--inputcolor);
|
||||
color: var(--inputcolor-font);
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
background-color: var(--inputcolor);
|
||||
color: var(--inputcolor-font);
|
||||
}
|
||||
|
||||
#toast {
|
||||
position: fixed;
|
||||
top: 10%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background-color: #39c5bcde;
|
||||
color: white;
|
||||
padding: 15px 20px;
|
||||
border-radius: 10px;
|
||||
font-size: 90%;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
padding: 2px 8px;
|
||||
background-color: #f5f5f5;
|
||||
border: 0px solid #fafafa;
|
||||
color: #333;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background-color: #39c5bc;
|
||||
color: white;
|
||||
transform: scale(1.05);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
margin-top: 20px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.total-progress-text {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 20px;
|
||||
padding: 10px;
|
||||
background-color: var(--inputcolor);
|
||||
border-radius: 10px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.image-progress {
|
||||
margin-top: 15px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.image-progress-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.image-progress-name {
|
||||
flex: 0 0 200px;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.image-progress-bar-container {
|
||||
flex-grow: 1;
|
||||
height: 15px;
|
||||
background-color: #ddd;
|
||||
border-radius: 5px;
|
||||
margin: 0 10px;
|
||||
}
|
||||
|
||||
.image-progress-bar {
|
||||
height: 100%;
|
||||
background-color: #39c5bb;
|
||||
border-radius: 5px;
|
||||
width: 0%;
|
||||
}
|
||||
|
||||
.image-progress-text {
|
||||
flex: 0 0 50px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.download-button {
|
||||
display: none;
|
||||
margin-top: 20px;
|
||||
padding: 10px 20px;
|
||||
background-color: #39c5bb;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.download-button:hover {
|
||||
background-color: #2ea89e;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
textarea.form-control {
|
||||
min-height: 150px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.info-text {
|
||||
font-size: 0.9rem;
|
||||
color: var(--fontcolor);
|
||||
opacity: 0.8;
|
||||
margin-bottom: 15px;
|
||||
text-align: left;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<a href="/" class="back-button">返回</a>
|
||||
<div class="container">
|
||||
<h1>Docker离线镜像包下载</h1>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="info-text">每行输入一个镜像,跟docker pull的格式一样,多个镜像会自动打包到一起为zip包,单个镜像为tar包。导入镜像后需要手动为镜像添加名称和标签,例如:docker tag 1856948a5aa7 镜像名称:标签</div>
|
||||
<textarea class="form-control" id="imageInput" placeholder="例如: nginx stilleshan/frpc stilleshan/frpc:0.62.1"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="info-text">镜像架构,默认为 amd64</div>
|
||||
<input type="text" class="form-control" id="platformInput" placeholder="输入架构,例如:amd64, arm64等" value="amd64">
|
||||
</div>
|
||||
|
||||
<button class="btn rounded-button" id="downloadButton">开始下载</button>
|
||||
|
||||
<div class="progress-container" id="progressContainer">
|
||||
<div class="total-progress-text" id="totalProgressText">0/0 - 0%</div>
|
||||
|
||||
<div class="image-progress" id="imageProgressList">
|
||||
<!-- Image progress items will be added here -->
|
||||
</div>
|
||||
|
||||
<button class="download-button" id="getFileButton">下载文件</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast" style="display:none;"></div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const imageInput = document.getElementById('imageInput');
|
||||
const platformInput = document.getElementById('platformInput');
|
||||
const downloadButton = document.getElementById('downloadButton');
|
||||
const progressContainer = document.getElementById('progressContainer');
|
||||
const totalProgressText = document.getElementById('totalProgressText');
|
||||
const imageProgressList = document.getElementById('imageProgressList');
|
||||
const getFileButton = document.getElementById('getFileButton');
|
||||
|
||||
let images = [];
|
||||
let currentTaskId = null;
|
||||
let websocket = null;
|
||||
|
||||
function parseImageList() {
|
||||
const text = imageInput.value.trim();
|
||||
if (!text) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return text.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0);
|
||||
}
|
||||
|
||||
function startDownload() {
|
||||
images = parseImageList();
|
||||
|
||||
if (images.length === 0) {
|
||||
showToast('请至少输入一个镜像');
|
||||
return;
|
||||
}
|
||||
|
||||
const platform = platformInput.value.trim() || 'amd64';
|
||||
const requestData = {
|
||||
images: images,
|
||||
platform: platform
|
||||
};
|
||||
|
||||
fetch('/api/download', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(requestData)
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.taskId) {
|
||||
currentTaskId = data.taskId;
|
||||
showProgressUI();
|
||||
connectWebSocket(currentTaskId);
|
||||
|
||||
const totalCount = data.totalCount || images.length;
|
||||
totalProgressText.textContent = `0/${totalCount} - 0%`;
|
||||
} else {
|
||||
showToast('下载任务创建失败');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
showToast('请求失败: ' + error.message);
|
||||
});
|
||||
}
|
||||
|
||||
function showProgressUI() {
|
||||
progressContainer.style.display = 'block';
|
||||
downloadButton.style.display = 'none';
|
||||
imageInput.disabled = true;
|
||||
platformInput.disabled = true;
|
||||
|
||||
const totalCount = images.length;
|
||||
totalProgressText.textContent = `0/${totalCount} - 0%`;
|
||||
|
||||
imageProgressList.innerHTML = '';
|
||||
images.forEach(image => {
|
||||
addImageProgressItem(image, 0);
|
||||
});
|
||||
}
|
||||
|
||||
function addImageProgressItem(image, progress) {
|
||||
const itemDiv = document.createElement('div');
|
||||
itemDiv.className = 'image-progress-item';
|
||||
itemDiv.id = `progress-${image.replace(/[\/\.:]/g, '_')}`;
|
||||
|
||||
const nameDiv = document.createElement('div');
|
||||
nameDiv.className = 'image-progress-name';
|
||||
nameDiv.title = image;
|
||||
nameDiv.textContent = image;
|
||||
|
||||
const barContainerDiv = document.createElement('div');
|
||||
barContainerDiv.className = 'image-progress-bar-container';
|
||||
|
||||
const barDiv = document.createElement('div');
|
||||
barDiv.className = 'image-progress-bar';
|
||||
barDiv.style.width = `${progress}%`;
|
||||
|
||||
const textDiv = document.createElement('div');
|
||||
textDiv.className = 'image-progress-text';
|
||||
textDiv.textContent = `${Math.round(progress)}%`;
|
||||
|
||||
barContainerDiv.appendChild(barDiv);
|
||||
itemDiv.appendChild(nameDiv);
|
||||
itemDiv.appendChild(barContainerDiv);
|
||||
itemDiv.appendChild(textDiv);
|
||||
|
||||
imageProgressList.appendChild(itemDiv);
|
||||
}
|
||||
|
||||
function updateImageProgress(image, progress, status) {
|
||||
const itemId = `progress-${image.replace(/[\/\.:]/g, '_')}`;
|
||||
const item = document.getElementById(itemId);
|
||||
|
||||
if (item) {
|
||||
const bar = item.querySelector('.image-progress-bar');
|
||||
const text = item.querySelector('.image-progress-text');
|
||||
|
||||
bar.style.width = `${progress}%`;
|
||||
text.textContent = `${Math.round(progress)}%`;
|
||||
|
||||
if (status === 'failed') {
|
||||
bar.style.backgroundColor = '#bd3c35';
|
||||
text.textContent = '失败';
|
||||
} else if (status === 'completed') {
|
||||
bar.style.backgroundColor = '#4CAF50';
|
||||
text.textContent = '完成';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function connectWebSocket(taskId) {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws/${taskId}`;
|
||||
|
||||
websocket = new WebSocket(wsUrl);
|
||||
|
||||
websocket.onopen = function() {
|
||||
console.log('ws');
|
||||
};
|
||||
|
||||
websocket.onmessage = function(event) {
|
||||
const data = JSON.parse(event.data);
|
||||
updateProgress(data);
|
||||
};
|
||||
|
||||
websocket.onerror = function(error) {
|
||||
console.error('WebSocket错误:', error);
|
||||
showToast('WebSocket连接错误');
|
||||
};
|
||||
|
||||
websocket.onclose = function() {
|
||||
console.log('WebSocket连接已关闭');
|
||||
};
|
||||
}
|
||||
|
||||
function updateProgress(data) {
|
||||
const progressPercent = data.totalCount > 0 ? (data.completedCount / data.totalCount) * 100 : 0;
|
||||
totalProgressText.textContent = `${data.completedCount}/${data.totalCount} - ${Math.round(progressPercent)}%`;
|
||||
|
||||
data.images.forEach(imgData => {
|
||||
updateImageProgress(imgData.image, imgData.progress, imgData.status);
|
||||
});
|
||||
|
||||
if (data.status === 'completed') {
|
||||
getFileButton.style.display = 'inline-block';
|
||||
|
||||
if (websocket) {
|
||||
websocket.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function downloadFile() {
|
||||
if (!currentTaskId) {
|
||||
showToast('没有可下载的文件');
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = `/api/files/${currentTaskId}_file`;
|
||||
}
|
||||
|
||||
function showToast(message) {
|
||||
const toast = document.getElementById('toast');
|
||||
toast.textContent = message;
|
||||
toast.style.display = 'block';
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.display = 'none';
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
downloadButton.addEventListener('click', startDownload);
|
||||
getFileButton.addEventListener('click', downloadFile);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
298
src/ratelimiter.go
Normal file
298
src/ratelimiter.go
Normal file
@@ -0,0 +1,298 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
const (
|
||||
// 清理间隔
|
||||
CleanupInterval = 10 * time.Minute
|
||||
// 最大IP缓存数量,防止内存过度占用
|
||||
MaxIPCacheSize = 10000
|
||||
)
|
||||
|
||||
// IPRateLimiter IP限流器结构体
|
||||
type IPRateLimiter struct {
|
||||
ips map[string]*rateLimiterEntry // IP到限流器的映射
|
||||
mu *sync.RWMutex // 读写锁,保证并发安全
|
||||
r rate.Limit // 速率限制(每秒允许的请求数)
|
||||
b int // 令牌桶容量(突发请求数)
|
||||
whitelist []*net.IPNet // 白名单IP段
|
||||
blacklist []*net.IPNet // 黑名单IP段
|
||||
}
|
||||
|
||||
// rateLimiterEntry 限流器条目
|
||||
type rateLimiterEntry struct {
|
||||
limiter *rate.Limiter // 限流器
|
||||
lastAccess time.Time // 最后访问时间
|
||||
}
|
||||
|
||||
// initGlobalLimiter 初始化全局限流器
|
||||
func initGlobalLimiter() *IPRateLimiter {
|
||||
// 获取配置
|
||||
cfg := GetConfig()
|
||||
|
||||
// 解析白名单IP段
|
||||
whitelist := make([]*net.IPNet, 0, len(cfg.Security.WhiteList))
|
||||
for _, item := range cfg.Security.WhiteList {
|
||||
if item = strings.TrimSpace(item); item != "" {
|
||||
if !strings.Contains(item, "/") {
|
||||
item = item + "/32" // 单个IP转为CIDR格式
|
||||
}
|
||||
_, ipnet, err := net.ParseCIDR(item)
|
||||
if err == nil {
|
||||
whitelist = append(whitelist, ipnet)
|
||||
} else {
|
||||
fmt.Printf("警告: 无效的白名单IP格式: %s\n", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 解析黑名单IP段
|
||||
blacklist := make([]*net.IPNet, 0, len(cfg.Security.BlackList))
|
||||
for _, item := range cfg.Security.BlackList {
|
||||
if item = strings.TrimSpace(item); item != "" {
|
||||
if !strings.Contains(item, "/") {
|
||||
item = item + "/32" // 单个IP转为CIDR格式
|
||||
}
|
||||
_, ipnet, err := net.ParseCIDR(item)
|
||||
if err == nil {
|
||||
blacklist = append(blacklist, ipnet)
|
||||
} else {
|
||||
fmt.Printf("警告: 无效的黑名单IP格式: %s\n", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 计算速率:将 "每N小时X个请求" 转换为 "每秒Y个请求"
|
||||
ratePerSecond := rate.Limit(float64(cfg.RateLimit.RequestLimit) / (cfg.RateLimit.PeriodHours * 3600))
|
||||
|
||||
// 令牌桶容量设置为最大突发请求数,建议设为限制值的一半以允许合理突发
|
||||
burstSize := cfg.RateLimit.RequestLimit
|
||||
if burstSize < 1 {
|
||||
burstSize = 1 // 至少允许1个请求
|
||||
}
|
||||
|
||||
limiter := &IPRateLimiter{
|
||||
ips: make(map[string]*rateLimiterEntry),
|
||||
mu: &sync.RWMutex{},
|
||||
r: ratePerSecond,
|
||||
b: burstSize,
|
||||
whitelist: whitelist,
|
||||
blacklist: blacklist,
|
||||
}
|
||||
|
||||
// 启动定期清理goroutine
|
||||
go limiter.cleanupRoutine()
|
||||
|
||||
fmt.Printf("限流器初始化: %d请求/%g小时, 白名单 %d个, 黑名单 %d个\n",
|
||||
cfg.RateLimit.RequestLimit, cfg.RateLimit.PeriodHours, len(whitelist), len(blacklist))
|
||||
|
||||
return limiter
|
||||
}
|
||||
|
||||
// initLimiter 初始化限流器(保持向后兼容)
|
||||
func initLimiter() {
|
||||
globalLimiter = initGlobalLimiter()
|
||||
}
|
||||
|
||||
// cleanupRoutine 定期清理过期的限流器
|
||||
func (i *IPRateLimiter) cleanupRoutine() {
|
||||
ticker := time.NewTicker(CleanupInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
now := time.Now()
|
||||
expired := make([]string, 0)
|
||||
|
||||
// 查找过期的条目
|
||||
i.mu.RLock()
|
||||
for ip, entry := range i.ips {
|
||||
// 如果最后访问时间超过1小时,认为过期
|
||||
if now.Sub(entry.lastAccess) > 1*time.Hour {
|
||||
expired = append(expired, ip)
|
||||
}
|
||||
}
|
||||
i.mu.RUnlock()
|
||||
|
||||
// 如果有过期条目或者缓存过大,进行清理
|
||||
if len(expired) > 0 || len(i.ips) > MaxIPCacheSize {
|
||||
i.mu.Lock()
|
||||
// 删除过期条目
|
||||
for _, ip := range expired {
|
||||
delete(i.ips, ip)
|
||||
}
|
||||
|
||||
// 如果缓存仍然过大,全部清理
|
||||
if len(i.ips) > MaxIPCacheSize {
|
||||
i.ips = make(map[string]*rateLimiterEntry)
|
||||
}
|
||||
i.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// extractIPFromAddress 从地址中提取纯IP,去除端口号
|
||||
func extractIPFromAddress(address string) string {
|
||||
// 处理IPv6地址 [::1]:8080 格式
|
||||
if strings.HasPrefix(address, "[") {
|
||||
if endIndex := strings.Index(address, "]"); endIndex != -1 {
|
||||
return address[1:endIndex]
|
||||
}
|
||||
}
|
||||
|
||||
// 处理IPv4地址 192.168.1.1:8080 格式
|
||||
if lastColon := strings.LastIndex(address, ":"); lastColon != -1 {
|
||||
return address[:lastColon]
|
||||
}
|
||||
|
||||
// 如果没有端口号,直接返回
|
||||
return address
|
||||
}
|
||||
|
||||
// isIPInCIDRList 检查IP是否在CIDR列表中
|
||||
func isIPInCIDRList(ip string, cidrList []*net.IPNet) bool {
|
||||
// 先提取纯IP地址
|
||||
cleanIP := extractIPFromAddress(ip)
|
||||
parsedIP := net.ParseIP(cleanIP)
|
||||
if parsedIP == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, cidr := range cidrList {
|
||||
if cidr.Contains(parsedIP) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetLimiter 获取指定IP的限流器,同时返回是否允许访问
|
||||
func (i *IPRateLimiter) GetLimiter(ip string) (*rate.Limiter, bool) {
|
||||
// 提取纯IP地址
|
||||
cleanIP := extractIPFromAddress(ip)
|
||||
|
||||
// 检查是否在黑名单中
|
||||
if isIPInCIDRList(cleanIP, i.blacklist) {
|
||||
return nil, false // 黑名单中的IP不允许访问
|
||||
}
|
||||
|
||||
// 检查是否在白名单中
|
||||
if isIPInCIDRList(cleanIP, i.whitelist) {
|
||||
return rate.NewLimiter(rate.Inf, i.b), true // 白名单中的IP不受限制
|
||||
}
|
||||
|
||||
// 使用纯IP作为缓存键
|
||||
i.mu.RLock()
|
||||
entry, exists := i.ips[cleanIP]
|
||||
i.mu.RUnlock()
|
||||
|
||||
now := time.Now()
|
||||
|
||||
if !exists {
|
||||
// 创建新的限流器
|
||||
i.mu.Lock()
|
||||
entry = &rateLimiterEntry{
|
||||
limiter: rate.NewLimiter(i.r, i.b),
|
||||
lastAccess: now,
|
||||
}
|
||||
i.ips[cleanIP] = entry
|
||||
i.mu.Unlock()
|
||||
} else {
|
||||
// 更新最后访问时间
|
||||
i.mu.Lock()
|
||||
entry.lastAccess = now
|
||||
i.mu.Unlock()
|
||||
}
|
||||
|
||||
return entry.limiter, true
|
||||
}
|
||||
|
||||
// RateLimitMiddleware 速率限制中间件
|
||||
func RateLimitMiddleware(limiter *IPRateLimiter) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 获取客户端真实IP
|
||||
var ip string
|
||||
|
||||
// 优先尝试从请求头获取真实IP
|
||||
if forwarded := c.GetHeader("X-Forwarded-For"); forwarded != "" {
|
||||
// X-Forwarded-For可能包含多个IP,取第一个
|
||||
ips := strings.Split(forwarded, ",")
|
||||
ip = strings.TrimSpace(ips[0])
|
||||
} else if realIP := c.GetHeader("X-Real-IP"); realIP != "" {
|
||||
// 如果有X-Real-IP头
|
||||
ip = realIP
|
||||
} else if remoteIP := c.GetHeader("X-Original-Forwarded-For"); remoteIP != "" {
|
||||
// 某些代理可能使用此头
|
||||
ips := strings.Split(remoteIP, ",")
|
||||
ip = strings.TrimSpace(ips[0])
|
||||
} else {
|
||||
// 回退到ClientIP方法
|
||||
ip = c.ClientIP()
|
||||
}
|
||||
|
||||
// 提取纯IP地址(去除端口号)
|
||||
cleanIP := extractIPFromAddress(ip)
|
||||
|
||||
// 日志记录请求IP和头信息
|
||||
fmt.Printf("请求IP: %s (去除端口后: %s), X-Forwarded-For: %s, X-Real-IP: %s\n",
|
||||
ip,
|
||||
cleanIP,
|
||||
c.GetHeader("X-Forwarded-For"),
|
||||
c.GetHeader("X-Real-IP"))
|
||||
|
||||
// 获取限流器并检查是否允许访问
|
||||
ipLimiter, allowed := limiter.GetLimiter(cleanIP)
|
||||
|
||||
// 如果IP在黑名单中
|
||||
if !allowed {
|
||||
c.JSON(403, gin.H{
|
||||
"error": "您已被限制访问",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否允许本次请求
|
||||
if !ipLimiter.Allow() {
|
||||
c.JSON(429, gin.H{
|
||||
"error": "请求频率过快,暂时限制访问",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 允许请求继续处理
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyRateLimit 应用限流到特定路由
|
||||
func ApplyRateLimit(router *gin.Engine, path string, method string, handler gin.HandlerFunc) {
|
||||
// 使用全局限流器
|
||||
limiter := globalLimiter
|
||||
if limiter == nil {
|
||||
limiter = initGlobalLimiter()
|
||||
}
|
||||
|
||||
// 根据HTTP方法应用限流
|
||||
switch method {
|
||||
case "GET":
|
||||
router.GET(path, RateLimitMiddleware(limiter), handler)
|
||||
case "POST":
|
||||
router.POST(path, RateLimitMiddleware(limiter), handler)
|
||||
case "PUT":
|
||||
router.PUT(path, RateLimitMiddleware(limiter), handler)
|
||||
case "DELETE":
|
||||
router.DELETE(path, RateLimitMiddleware(limiter), handler)
|
||||
default:
|
||||
router.Any(path, RateLimitMiddleware(limiter), handler)
|
||||
}
|
||||
}
|
||||
498
src/search.go
Normal file
498
src/search.go
Normal file
@@ -0,0 +1,498 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// SearchResult Docker Hub搜索结果
|
||||
type SearchResult struct {
|
||||
Count int `json:"count"`
|
||||
Next string `json:"next"`
|
||||
Previous string `json:"previous"`
|
||||
Results []Repository `json:"results"`
|
||||
}
|
||||
|
||||
// Repository 仓库信息
|
||||
type Repository struct {
|
||||
Name string `json:"repo_name"`
|
||||
Description string `json:"short_description"`
|
||||
IsOfficial bool `json:"is_official"`
|
||||
IsAutomated bool `json:"is_automated"`
|
||||
StarCount int `json:"star_count"`
|
||||
PullCount int `json:"pull_count"`
|
||||
RepoOwner string `json:"repo_owner"`
|
||||
LastUpdated string `json:"last_updated"`
|
||||
Status int `json:"status"`
|
||||
Organization string `json:"affiliation"`
|
||||
PullsLastWeek int `json:"pulls_last_week"`
|
||||
Namespace string `json:"namespace"`
|
||||
}
|
||||
|
||||
// TagInfo 标签信息
|
||||
type TagInfo struct {
|
||||
Name string `json:"name"`
|
||||
FullSize int64 `json:"full_size"`
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
LastPusher string `json:"last_pusher"`
|
||||
Images []Image `json:"images"`
|
||||
Vulnerabilities struct {
|
||||
Critical int `json:"critical"`
|
||||
High int `json:"high"`
|
||||
Medium int `json:"medium"`
|
||||
Low int `json:"low"`
|
||||
Unknown int `json:"unknown"`
|
||||
} `json:"vulnerabilities"`
|
||||
}
|
||||
|
||||
// Image 镜像信息
|
||||
type Image struct {
|
||||
Architecture string `json:"architecture"`
|
||||
Features string `json:"features"`
|
||||
Variant string `json:"variant,omitempty"`
|
||||
Digest string `json:"digest"`
|
||||
OS string `json:"os"`
|
||||
OSFeatures string `json:"os_features"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
type cacheEntry struct {
|
||||
data interface{}
|
||||
timestamp time.Time
|
||||
}
|
||||
|
||||
const (
|
||||
maxCacheSize = 1000 // 最大缓存条目数
|
||||
cacheTTL = 30 * time.Minute
|
||||
)
|
||||
|
||||
type Cache struct {
|
||||
data map[string]cacheEntry
|
||||
mu sync.RWMutex
|
||||
maxSize int
|
||||
}
|
||||
|
||||
var (
|
||||
searchCache = &Cache{
|
||||
data: make(map[string]cacheEntry),
|
||||
maxSize: maxCacheSize,
|
||||
}
|
||||
)
|
||||
|
||||
// HTTP客户端配置在 http_client.go 中统一管理
|
||||
|
||||
func (c *Cache) Get(key string) (interface{}, bool) {
|
||||
c.mu.RLock()
|
||||
entry, exists := c.data[key]
|
||||
c.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if time.Since(entry.timestamp) > cacheTTL {
|
||||
c.mu.Lock()
|
||||
delete(c.data, key)
|
||||
c.mu.Unlock()
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return entry.data, true
|
||||
}
|
||||
|
||||
func (c *Cache) Set(key string, data interface{}) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
// 如果缓存已满,删除最旧的条目
|
||||
if len(c.data) >= c.maxSize {
|
||||
oldest := time.Now()
|
||||
var oldestKey string
|
||||
for k, v := range c.data {
|
||||
if v.timestamp.Before(oldest) {
|
||||
oldest = v.timestamp
|
||||
oldestKey = k
|
||||
}
|
||||
}
|
||||
delete(c.data, oldestKey)
|
||||
}
|
||||
|
||||
c.data[key] = cacheEntry{
|
||||
data: data,
|
||||
timestamp: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cache) Cleanup() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
for key, entry := range c.data {
|
||||
if now.Sub(entry.timestamp) > cacheTTL {
|
||||
delete(c.data, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 定期清理过期缓存
|
||||
func init() {
|
||||
go func() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
for range ticker.C {
|
||||
searchCache.Cleanup()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// 改进的搜索结果过滤函数
|
||||
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
|
||||
}
|
||||
|
||||
// searchDockerHub 搜索镜像
|
||||
func searchDockerHub(ctx context.Context, query string, page, pageSize int) (*SearchResult, error) {
|
||||
cacheKey := fmt.Sprintf("search:%s:%d:%d", query, page, pageSize)
|
||||
|
||||
// 尝试从缓存获取
|
||||
if cached, ok := searchCache.Get(cacheKey); ok {
|
||||
return cached.(*SearchResult), nil
|
||||
}
|
||||
|
||||
// 判断是否是用户/仓库格式的搜索
|
||||
isUserRepo := strings.Contains(query, "/")
|
||||
var namespace, repoName string
|
||||
|
||||
if isUserRepo {
|
||||
parts := strings.Split(query, "/")
|
||||
if len(parts) == 2 {
|
||||
namespace = parts[0]
|
||||
repoName = parts[1]
|
||||
}
|
||||
}
|
||||
|
||||
// 构建搜索URL
|
||||
baseURL := "https://registry.hub.docker.com/v2"
|
||||
var fullURL string
|
||||
var params url.Values
|
||||
|
||||
if isUserRepo && namespace != "" {
|
||||
// 如果是用户/仓库格式,使用repositories接口
|
||||
fullURL = fmt.Sprintf("%s/repositories/%s/", baseURL, namespace)
|
||||
params = url.Values{
|
||||
"page": {fmt.Sprintf("%d", page)},
|
||||
"page_size": {fmt.Sprintf("%d", pageSize)},
|
||||
}
|
||||
} else {
|
||||
// 普通搜索
|
||||
fullURL = baseURL + "/search/repositories/"
|
||||
params = url.Values{
|
||||
"query": {query},
|
||||
"page": {fmt.Sprintf("%d", page)},
|
||||
"page_size": {fmt.Sprintf("%d", pageSize)},
|
||||
}
|
||||
}
|
||||
|
||||
fullURL = fullURL + "?" + params.Encode()
|
||||
|
||||
// 使用统一的搜索HTTP客户端
|
||||
resp, err := GetSearchHTTPClient().Get(fullURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求Docker Hub API失败: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
fmt.Printf("关闭搜索响应体失败: %v\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取响应失败: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
switch resp.StatusCode {
|
||||
case http.StatusTooManyRequests:
|
||||
return nil, fmt.Errorf("请求过于频繁,请稍后重试")
|
||||
case http.StatusNotFound:
|
||||
if isUserRepo && namespace != "" {
|
||||
// 如果用户仓库搜索失败,尝试普通搜索
|
||||
return searchDockerHub(ctx, repoName, page, pageSize)
|
||||
}
|
||||
return nil, fmt.Errorf("未找到相关镜像")
|
||||
case http.StatusBadGateway, http.StatusServiceUnavailable:
|
||||
return nil, fmt.Errorf("Docker Hub服务暂时不可用,请稍后重试")
|
||||
default:
|
||||
return nil, fmt.Errorf("请求失败: 状态码=%d, 响应=%s", resp.StatusCode, string(body))
|
||||
}
|
||||
}
|
||||
|
||||
// 解析响应
|
||||
var result *SearchResult
|
||||
if isUserRepo && namespace != "" {
|
||||
// 解析用户仓库列表响应
|
||||
var userRepos struct {
|
||||
Count int `json:"count"`
|
||||
Next string `json:"next"`
|
||||
Previous string `json:"previous"`
|
||||
Results []Repository `json:"results"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &userRepos); err != nil {
|
||||
return nil, fmt.Errorf("解析响应失败: %v", err)
|
||||
}
|
||||
|
||||
// 转换为SearchResult格式
|
||||
result = &SearchResult{
|
||||
Count: userRepos.Count,
|
||||
Next: userRepos.Next,
|
||||
Previous: userRepos.Previous,
|
||||
Results: make([]Repository, 0),
|
||||
}
|
||||
|
||||
// 处理结果
|
||||
for _, repo := range userRepos.Results {
|
||||
// 如果指定了仓库名,只保留匹配的结果
|
||||
if repoName == "" || strings.Contains(strings.ToLower(repo.Name), strings.ToLower(repoName)) {
|
||||
// 确保设置正确的命名空间和名称
|
||||
repo.Namespace = namespace
|
||||
if !strings.Contains(repo.Name, "/") {
|
||||
repo.Name = fmt.Sprintf("%s/%s", namespace, repo.Name)
|
||||
}
|
||||
result.Results = append(result.Results, repo)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有找到结果,尝试普通搜索
|
||||
if len(result.Results) == 0 {
|
||||
return searchDockerHub(ctx, repoName, page, pageSize)
|
||||
}
|
||||
|
||||
result.Count = len(result.Results)
|
||||
} else {
|
||||
// 解析普通搜索响应
|
||||
result = &SearchResult{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析响应失败: %v", err)
|
||||
}
|
||||
|
||||
// 处理搜索结果
|
||||
for i := range result.Results {
|
||||
if result.Results[i].IsOfficial {
|
||||
if !strings.Contains(result.Results[i].Name, "/") {
|
||||
result.Results[i].Name = "library/" + result.Results[i].Name
|
||||
}
|
||||
result.Results[i].Namespace = "library"
|
||||
} else {
|
||||
parts := strings.Split(result.Results[i].Name, "/")
|
||||
if len(parts) > 1 {
|
||||
result.Results[i].Namespace = parts[0]
|
||||
result.Results[i].Name = parts[1]
|
||||
} else if result.Results[i].RepoOwner != "" {
|
||||
result.Results[i].Namespace = result.Results[i].RepoOwner
|
||||
result.Results[i].Name = fmt.Sprintf("%s/%s", result.Results[i].RepoOwner, result.Results[i].Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果是用户/仓库搜索,过滤结果
|
||||
if isUserRepo && namespace != "" {
|
||||
filteredResults := make([]Repository, 0)
|
||||
for _, repo := range result.Results {
|
||||
if strings.EqualFold(repo.Namespace, namespace) {
|
||||
filteredResults = append(filteredResults, repo)
|
||||
}
|
||||
}
|
||||
result.Results = filteredResults
|
||||
result.Count = len(filteredResults)
|
||||
}
|
||||
}
|
||||
|
||||
// 缓存结果
|
||||
searchCache.Set(cacheKey, result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// 判断错误是否可重试
|
||||
func isRetryableError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// 网络错误、超时等可以重试
|
||||
if strings.Contains(err.Error(), "timeout") ||
|
||||
strings.Contains(err.Error(), "connection refused") ||
|
||||
strings.Contains(err.Error(), "no such host") ||
|
||||
strings.Contains(err.Error(), "too many requests") {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// getRepositoryTags 获取仓库标签信息
|
||||
func getRepositoryTags(ctx context.Context, namespace, name string) ([]TagInfo, error) {
|
||||
if namespace == "" || name == "" {
|
||||
return nil, fmt.Errorf("无效输入:命名空间和名称不能为空")
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("tags:%s:%s", namespace, name)
|
||||
if cached, ok := searchCache.Get(cacheKey); ok {
|
||||
return cached.([]TagInfo), nil
|
||||
}
|
||||
|
||||
// 构建API URL
|
||||
baseURL := fmt.Sprintf("https://registry.hub.docker.com/v2/repositories/%s/%s/tags", namespace, name)
|
||||
params := url.Values{}
|
||||
params.Set("page_size", "100")
|
||||
params.Set("ordering", "last_updated")
|
||||
|
||||
fullURL := baseURL + "?" + params.Encode()
|
||||
|
||||
// 使用统一的搜索HTTP客户端
|
||||
resp, err := GetSearchHTTPClient().Get(fullURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("发送请求失败: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
fmt.Printf("关闭搜索响应体失败: %v\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// 读取响应体
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取响应失败: %v", err)
|
||||
}
|
||||
|
||||
// 检查响应状态码
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("请求失败: 状态码=%d, 响应=%s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// 解析响应
|
||||
var result struct {
|
||||
Count int `json:"count"`
|
||||
Next string `json:"next"`
|
||||
Previous string `json:"previous"`
|
||||
Results []TagInfo `json:"results"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析响应失败: %v", err)
|
||||
}
|
||||
|
||||
// 缓存结果
|
||||
searchCache.Set(cacheKey, result.Results)
|
||||
return result.Results, nil
|
||||
}
|
||||
|
||||
// RegisterSearchRoute 注册搜索相关路由
|
||||
func RegisterSearchRoute(r *gin.Engine) {
|
||||
// 搜索镜像
|
||||
r.GET("/search", func(c *gin.Context) {
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "搜索关键词不能为空"})
|
||||
return
|
||||
}
|
||||
|
||||
page := 1
|
||||
pageSize := 25
|
||||
if p := c.Query("page"); p != "" {
|
||||
fmt.Sscanf(p, "%d", &page)
|
||||
}
|
||||
if ps := c.Query("page_size"); ps != "" {
|
||||
fmt.Sscanf(ps, "%d", &pageSize)
|
||||
}
|
||||
|
||||
result, err := searchDockerHub(c.Request.Context(), query, page, pageSize)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
})
|
||||
|
||||
// 获取标签信息
|
||||
r.GET("/tags/:namespace/:name", func(c *gin.Context) {
|
||||
namespace := c.Param("namespace")
|
||||
name := c.Param("name")
|
||||
|
||||
if namespace == "" || name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "命名空间和名称不能为空"})
|
||||
return
|
||||
}
|
||||
|
||||
tags, err := getRepositoryTags(c.Request.Context(), namespace, name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, tags)
|
||||
})
|
||||
}
|
||||
1371
src/skopeo_service.go
Normal file
1371
src/skopeo_service.go
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user