diff --git a/.github/workflows/docker-ghcr.yml b/.github/workflows/docker-ghcr.yml index d711391..f4dfd04 100644 --- a/.github/workflows/docker-ghcr.yml +++ b/.github/workflows/docker-ghcr.yml @@ -15,13 +15,13 @@ jobs: packages: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Cache Docker layers - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: /tmp/.buildx-cache key: ${{ runner.os }}-buildx-${{ github.sha }} @@ -29,7 +29,7 @@ jobs: ${{ runner.os }}-buildx- - name: Log in to GitHub Docker Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} @@ -58,4 +58,4 @@ jobs: --build-arg VERSION=${{ env.VERSION }} \ -f Dockerfile . env: - GHCR_PUBLIC: true \ No newline at end of file + GHCR_PUBLIC: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2336b24..9609b6a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,12 +16,12 @@ jobs: steps: - name: 检出代码 - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 - name: 设置Go环境 - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version-file: "src/go.mod" cache-dependency-path: "src/go.sum" @@ -55,7 +55,7 @@ jobs: mkdir -p build/hubproxy - name: 安装 UPX - uses: crazy-max/ghaction-upx@v3 + uses: crazy-max/ghaction-upx@v4 with: install-only: true @@ -118,7 +118,7 @@ jobs: cat checksums.txt - name: 创建或更新Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: tag_name: ${{ steps.version.outputs.version }} name: "HubProxy ${{ steps.version.outputs.version }}" diff --git a/Dockerfile b/Dockerfile index 704ddb7..f81133b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,11 @@ -FROM golang:1.25-alpine AS builder +FROM golang:1.26-alpine AS builder ARG TARGETARCH ARG VERSION=dev WORKDIR /app COPY src/go.mod src/go.sum ./ -RUN go mod download && apk add upx +RUN apk add --no-cache upx && go mod download COPY src/ . @@ -13,7 +13,7 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -ldflags="-s -w -X ma FROM alpine -WORKDIR /root/ +WORKDIR /app COPY --from=builder /app/hubproxy . COPY --from=builder /app/config.toml . diff --git a/README.md b/README.md index ba99ee5..016f4b3 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,7 @@ defaultTTL = "20m" -容器内的配置文件位于 `/root/config.toml` +容器内的配置文件位于 `/app/config.toml` 脚本部署配置文件位于 `/opt/hubproxy/config.toml` @@ -211,6 +211,7 @@ defaultTTL = "20m" 支持通过环境变量覆盖部分配置,优先级高于`config.toml`,以下是默认值: ``` +CONFIG_PATH=config.toml # 配置文件路径 SERVER_HOST=0.0.0.0 # 监听地址 SERVER_PORT=5000 # 监听端口 ENABLE_H2C=false # 是否启用 H2C @@ -221,6 +222,7 @@ RATE_PERIOD_HOURS=3 # 限流周期(小时) IP_WHITELIST=127.0.0.1,192.168.1.0/24 # IP 白名单(逗号分隔) IP_BLACKLIST=192.168.100.1,192.168.100.0/24 # IP 黑名单(逗号分隔) MAX_IMAGES=10 # 批量下载镜像数量限制 +ACCESS_PROXY= # 代理配置,例如 socks5://127.0.0.1:1080 ``` 为了IP限流能够正常运行,反向代理需要传递IP头用来获取访客真实IP,以caddy为例: diff --git a/docker-compose.yml b/docker-compose.yml index 681fe6a..4479a64 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,7 @@ services: ports: - "5000:5000" volumes: - - ./src/config.toml:/root/config.toml + - ./src/config.toml:/app/config.toml:ro logging: driver: json-file options: diff --git a/src/config.toml b/src/config.toml index eb3813f..74d0d39 100644 --- a/src/config.toml +++ b/src/config.toml @@ -1,4 +1,5 @@ [server] +# 可通过 CONFIG_PATH 环境变量指定配置文件路径,默认读取当前工作目录下的 config.toml host = "0.0.0.0" # 监听端口 port = 5000 diff --git a/src/config/config.go b/src/config/config.go index f0f6e41..2c1e75e 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -197,16 +197,23 @@ func setConfig(cfg *AppConfig) { configCacheMutex.Unlock() } -// LoadConfig 加载配置文件 +func configFilePath() string { + if path := strings.TrimSpace(os.Getenv("CONFIG_PATH")); path != "" { + return path + } + return "config.toml" +} + func LoadConfig() error { cfg := DefaultConfig() + path := configFilePath() - if data, err := os.ReadFile("config.toml"); err == nil { + if data, err := os.ReadFile(path); err == nil { if err := toml.Unmarshal(data, cfg); err != nil { - return fmt.Errorf("解析配置文件失败: %v", err) + return fmt.Errorf("解析配置文件 %s 失败: %v", path, err) } } else { - fmt.Println("未找到config.toml,使用默认配置") + fmt.Printf("未找到配置文件 %s,使用默认配置\n", path) } overrideFromEnv(cfg) @@ -259,6 +266,10 @@ func overrideFromEnv(cfg *AppConfig) { cfg.Security.BlackList = append(cfg.Security.BlackList, strings.Split(val, ",")...) } + if val, ok := os.LookupEnv("ACCESS_PROXY"); ok { + cfg.Access.Proxy = strings.TrimSpace(val) + } + if val := os.Getenv("MAX_IMAGES"); val != "" { if maxImages, err := strconv.Atoi(val); err == nil && maxImages > 0 { cfg.Download.MaxImages = maxImages diff --git a/src/config/config_test.go b/src/config/config_test.go new file mode 100644 index 0000000..c4d2c73 --- /dev/null +++ b/src/config/config_test.go @@ -0,0 +1,41 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadConfigUsesConfigPathAndEnvOverrides(t *testing.T) { + path := filepath.Join(t.TempDir(), "custom.toml") + data := []byte(` +[server] +host = "127.0.0.1" +port = 5999 + +[access] +proxy = "socks5://127.0.0.1:1080" +`) + if err := os.WriteFile(path, data, 0644); err != nil { + t.Fatal(err) + } + + t.Setenv("CONFIG_PATH", path) + t.Setenv("SERVER_PORT", "6001") + t.Setenv("ACCESS_PROXY", "") + + if err := LoadConfig(); err != nil { + t.Fatal(err) + } + + cfg := GetConfig() + if cfg.Server.Host != "127.0.0.1" { + t.Fatalf("Server.Host = %q", cfg.Server.Host) + } + if cfg.Server.Port != 6001 { + t.Fatalf("Server.Port = %d, want 6001", cfg.Server.Port) + } + if cfg.Access.Proxy != "" { + t.Fatalf("Access.Proxy = %q, want empty override", cfg.Access.Proxy) + } +} diff --git a/src/go.mod b/src/go.mod index 0d84ce4..478033e 100644 --- a/src/go.mod +++ b/src/go.mod @@ -1,33 +1,33 @@ module hubproxy -go 1.25 +go 1.26 require ( - github.com/gin-gonic/gin v1.10.1 - github.com/google/go-containerregistry v0.20.6 - github.com/pelletier/go-toml/v2 v2.2.4 - golang.org/x/net v0.43.0 - golang.org/x/time v0.12.0 + github.com/gin-gonic/gin v1.12.0 + github.com/google/go-containerregistry v0.21.5 + github.com/pelletier/go-toml/v2 v2.3.1 + golang.org/x/net v0.53.0 + golang.org/x/time v0.15.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.2.2+incompatible // indirect - github.com/docker/distribution v2.8.3+incompatible // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/containerd/stargz-snapshotter/estargz v0.18.2 // indirect + github.com/docker/cli v29.4.0+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/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.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/go-playground/validator/v10 v10.30.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.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/klauspost/compress v1.18.5 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // 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 @@ -35,16 +35,18 @@ require ( 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/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/sirupsen/logrus v1.9.4 // 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.41.0 // indirect - golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.35.0 // indirect - golang.org/x/text v0.28.0 // indirect - google.golang.org/protobuf v1.36.3 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + github.com/vbatts/tar-split v0.12.2 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect + gotest.tools/v3 v3.5.2 // indirect ) diff --git a/src/go.sum b/src/go.sum index a4a5258..c7f9129 100644 --- a/src/go.sum +++ b/src/go.sum @@ -1,51 +1,49 @@ -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/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/containerd/stargz-snapshotter/estargz v0.18.2 h1:yXkZFYIzz3eoLwlTUZKz2iQ4MrckBxJjkmD16ynUTrw= +github.com/containerd/stargz-snapshotter/estargz v0.18.2/go.mod h1:XyVU5tcJ3PRpkA9XS2T5us6Eg35yM0214Y+wvrZTBrY= 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.2.2+incompatible h1:qzx5BNUDFqlvyq4AHzdNB7gSyVTmU4cgsyN9SdInc1A= -github.com/docker/cli v28.2.2+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/cli v29.4.0+incompatible h1:+IjXULMetlvWJiuSI0Nbor36lcJ5BTcVpUmB21KBoVM= +github.com/docker/cli v29.4.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= 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.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= -github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= 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/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= 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.6 h1:cvWX87UxxLgaH76b4hIvya6Dzz9qHB31qAwjAohdSTU= -github.com/google/go-containerregistry v0.20.6/go.mod h1:T0x8MuoAoKX/873bkeSfLD2FAkwCDf9/HZgsFJ02E2Y= +github.com/google/go-containerregistry v0.21.5 h1:KTJG9Pn/jC0VdZR6ctV3/jcN+q6/Iqlx0sTVz3ywZlM= +github.com/google/go-containerregistry v0.21.5/go.mod h1:ySvMuiWg+dOsRW0Hw8GYwfMwBlNRTmpYBFJPlkco5zU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 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/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 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= @@ -61,56 +59,57 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 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.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= -github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= +github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 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/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= 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.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 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.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= -golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.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= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CPa4= +github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= -golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= -golang.org/x/time v0.12.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= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 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= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= diff --git a/src/handlers/docker_test.go b/src/handlers/docker_test.go new file mode 100644 index 0000000..e4c2f9d --- /dev/null +++ b/src/handlers/docker_test.go @@ -0,0 +1,30 @@ +package handlers + +import "testing" + +func TestParseRegistryPath(t *testing.T) { + tests := []struct { + path string + image string + apiType string + reference string + }{ + {"library/nginx/manifests/latest", "library/nginx", "manifests", "latest"}, + {"library/nginx/blobs/sha256:abc", "library/nginx", "blobs", "sha256:abc"}, + {"library/nginx/tags/list", "library/nginx", "tags", "list"}, + } + + for _, tt := range tests { + image, apiType, reference := parseRegistryPath(tt.path) + if image != tt.image || apiType != tt.apiType || reference != tt.reference { + t.Fatalf("parseRegistryPath(%q) = %q %q %q", tt.path, image, apiType, reference) + } + } +} + +func TestParseRegistryPathInvalid(t *testing.T) { + image, apiType, reference := parseRegistryPath("library/nginx/unknown/latest") + if image != "" || apiType != "" || reference != "" { + t.Fatalf("invalid path parsed as %q %q %q", image, apiType, reference) + } +} diff --git a/src/handlers/github_test.go b/src/handlers/github_test.go new file mode 100644 index 0000000..d0acf35 --- /dev/null +++ b/src/handlers/github_test.go @@ -0,0 +1,32 @@ +package handlers + +import "testing" + +func TestCheckGitHubURL(t *testing.T) { + tests := []struct { + name string + url string + user string + repo string + }{ + {"release", "https://github.com/user/repo/releases/download/v1/file.tar.gz", "user", "repo"}, + {"raw", "https://raw.githubusercontent.com/user/repo/main/file.sh", "user", "repo"}, + {"api", "https://api.github.com/repos/user/repo/releases/latest", "user", "repo"}, + {"huggingface", "https://huggingface.co/user/model/resolve/main/file", "user", "model/resolve/main/file"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := CheckGitHubURL(tt.url) + if len(got) < 2 || got[0] != tt.user || got[1] != tt.repo { + t.Fatalf("CheckGitHubURL(%q) = %#v", tt.url, got) + } + }) + } +} + +func TestCheckGitHubURLRejectsOtherHosts(t *testing.T) { + if got := CheckGitHubURL("https://example.com/user/repo/file"); got != nil { + t.Fatalf("unexpected match: %#v", got) + } +} diff --git a/src/handlers/imagetar_test.go b/src/handlers/imagetar_test.go new file mode 100644 index 0000000..1d52199 --- /dev/null +++ b/src/handlers/imagetar_test.go @@ -0,0 +1,60 @@ +package handlers + +import ( + "testing" + "time" +) + +func TestDownloadDebouncer(t *testing.T) { + d := NewDownloadDebouncer(time.Minute) + if !d.ShouldAllow("user", "content") { + t.Fatal("first request denied") + } + if d.ShouldAllow("user", "content") { + t.Fatal("duplicate request allowed") + } + if !d.ShouldAllow("other", "content") { + t.Fatal("different user denied") + } +} + +func TestTokenStoreCreateConsume(t *testing.T) { + store := newTokenStore[SingleDownloadRequest]() + req := SingleDownloadRequest{Image: "nginx:latest", Platform: "linux/amd64", UseCompressedLayers: true} + + token, err := store.create(req, "127.0.0.1", "ua") + if err != nil { + t.Fatal(err) + } + + got, ok := store.consume(token, "127.0.0.1", "ua") + if !ok { + t.Fatal("token not consumed") + } + if got != req { + t.Fatalf("request = %#v, want %#v", got, req) + } + if _, ok := store.consume(token, "127.0.0.1", "ua"); ok { + t.Fatal("token consumed twice") + } +} + +func TestTokenStoreRejectsDifferentClient(t *testing.T) { + store := newTokenStore[SingleDownloadRequest]() + token, err := store.create(SingleDownloadRequest{Image: "nginx:latest"}, "127.0.0.1", "ua") + if err != nil { + t.Fatal(err) + } + if _, ok := store.consume(token, "127.0.0.2", "ua"); ok { + t.Fatal("token accepted for different IP") + } +} + +func TestGenerateContentFingerprintStable(t *testing.T) { + a := generateContentFingerprint([]string{"b:1", "a:1"}, "linux/amd64") + b := generateContentFingerprint([]string{"a:1", "b:1"}, "linux/amd64") + c := generateContentFingerprint([]string{"a:1", "b:1"}, "linux/arm64") + if a != b || a == c { + t.Fatalf("unexpected fingerprints: %q %q %q", a, b, c) + } +} diff --git a/src/handlers/search_test.go b/src/handlers/search_test.go new file mode 100644 index 0000000..8ebf806 --- /dev/null +++ b/src/handlers/search_test.go @@ -0,0 +1,45 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" +) + +func TestNormalizeRepository(t *testing.T) { + official := &Repository{Name: "nginx", IsOfficial: true} + normalizeRepository(official) + if official.Namespace != "library" || official.Name != "library/nginx" { + t.Fatalf("official normalized to %#v", official) + } + + userRepo := &Repository{Name: "owner/app", RepoOwner: "owner"} + normalizeRepository(userRepo) + if userRepo.Namespace != "owner" || userRepo.Name != "app" { + t.Fatalf("user repo normalized to %#v", userRepo) + } +} + +func TestParsePaginationParams(t *testing.T) { + gin.SetMode(gin.TestMode) + req := httptest.NewRequest(http.MethodGet, "/?page=3&page_size=50", nil) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = req + + page, pageSize := parsePaginationParams(c, 25) + if page != 3 || pageSize != 50 { + t.Fatalf("pagination = %d %d", page, pageSize) + } +} + +func TestSearchCacheExpires(t *testing.T) { + cache := &Cache{data: make(map[string]cacheEntry), maxSize: 10} + cache.SetWithTTL("k", "v", -time.Second) + + if got, ok := cache.Get("k"); ok || got != nil { + t.Fatalf("expired cache returned: %#v", got) + } +} diff --git a/src/main.go b/src/main.go index 04f016d..d757d59 100644 --- a/src/main.go +++ b/src/main.go @@ -19,49 +19,42 @@ import ( //go:embed public/* var staticFiles embed.FS -// 服务嵌入的静态文件 -func serveEmbedFile(c *gin.Context, filename string) { - data, err := staticFiles.ReadFile(filename) - if err != nil { - c.Status(404) - return - } - contentType := "text/html; charset=utf-8" - if strings.HasSuffix(filename, ".ico") { - contentType = "image/x-icon" - } - c.Data(200, contentType, data) -} - var ( - globalLimiter *utils.IPRateLimiter - - // 服务启动时间 + globalLimiter *utils.IPRateLimiter serviceStartTime = time.Now() ) var Version = "dev" +func serveEmbedFile(c *gin.Context, filename string) { + data, err := staticFiles.ReadFile(filename) + if err != nil { + c.Status(http.StatusNotFound) + return + } + + contentType := "text/html; charset=utf-8" + if strings.HasSuffix(filename, ".ico") { + contentType = "image/x-icon" + } + c.Data(http.StatusOK, contentType, data) +} + func buildRouter(cfg *config.AppConfig) *gin.Engine { gin.SetMode(gin.ReleaseMode) router := gin.Default() - // 全局Panic恢复保护 router.Use(gin.CustomRecovery(func(c *gin.Context, recovered interface{}) { - log.Printf("🚨 Panic recovered: %v", recovered) + log.Printf("Panic 已恢复: %v", recovered) c.JSON(http.StatusInternalServerError, gin.H{ "error": "Internal server error", "code": "INTERNAL_ERROR", }) })) - // 全局限流中间件 router.Use(utils.RateLimitMiddleware(globalLimiter)) - // 初始化监控端点 initHealthRoutes(router) - - // 初始化镜像tar下载路由 handlers.InitImageTarRoutes(router) if cfg.Server.EnableFrontend { @@ -72,7 +65,6 @@ func buildRouter(cfg *config.AppConfig) *gin.Engine { filepath := strings.TrimPrefix(c.Param("filepath"), "/") serveEmbedFile(c, "public/"+filepath) }) - router.GET("/images.html", func(c *gin.Context) { serveEmbedFile(c, "public/images.html") }) @@ -83,59 +75,33 @@ func buildRouter(cfg *config.AppConfig) *gin.Engine { serveEmbedFile(c, "public/favicon.ico") }) } else { - router.GET("/", func(c *gin.Context) { - c.Status(http.StatusNotFound) - }) - router.GET("/public/*filepath", func(c *gin.Context) { - c.Status(http.StatusNotFound) - }) - router.GET("/images.html", func(c *gin.Context) { - c.Status(http.StatusNotFound) - }) - router.GET("/search.html", func(c *gin.Context) { - c.Status(http.StatusNotFound) - }) - router.GET("/favicon.ico", func(c *gin.Context) { - c.Status(http.StatusNotFound) - }) + router.GET("/", func(c *gin.Context) { c.Status(http.StatusNotFound) }) + router.GET("/public/*filepath", func(c *gin.Context) { c.Status(http.StatusNotFound) }) + router.GET("/images.html", func(c *gin.Context) { c.Status(http.StatusNotFound) }) + router.GET("/search.html", func(c *gin.Context) { c.Status(http.StatusNotFound) }) + router.GET("/favicon.ico", func(c *gin.Context) { c.Status(http.StatusNotFound) }) } - // 注册dockerhub搜索路由 handlers.RegisterSearchRoute(router) - // 注册Docker认证路由 router.Any("/token", handlers.ProxyDockerAuthGin) router.Any("/token/*path", handlers.ProxyDockerAuthGin) - - // 注册Docker Registry代理路由 router.Any("/v2/*path", handlers.ProxyDockerRegistryGin) - - // 注册GitHub代理路由(NoRoute处理器) router.NoRoute(handlers.GitHubProxyHandler) return router } func main() { - // 加载配置 if err := config.LoadConfig(); err != nil { fmt.Printf("配置加载失败: %v\n", err) return } - // 初始化HTTP客户端 utils.InitHTTPClients() - - // 初始化限流器 globalLimiter = utils.InitGlobalLimiter() - - // 初始化Docker流式代理 handlers.InitDockerProxy() - - // 初始化镜像流式下载器 handlers.InitImageStreamer() - - // 初始化防抖器 handlers.InitDebouncer() cfg := config.GetConfig() @@ -144,16 +110,12 @@ func main() { fmt.Printf("HubProxy 启动成功\n") fmt.Printf("监听地址: %s:%d\n", cfg.Server.Host, cfg.Server.Port) fmt.Printf("限流配置: %d请求/%g小时\n", cfg.RateLimit.RequestLimit, cfg.RateLimit.PeriodHours) - - // 显示HTTP/2支持状态 if cfg.Server.EnableH2C { fmt.Printf("H2c: 已启用\n") } - fmt.Printf("版本号: %s\n", Version) fmt.Printf("项目地址: https://github.com/sky22333/hubproxy\n") - // 创建HTTP2服务器 server := &http.Server{ Addr: fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port), ReadTimeout: 60 * time.Second, @@ -161,39 +123,37 @@ func main() { IdleTimeout: 120 * time.Second, } - // 根据配置决定是否启用H2C if cfg.Server.EnableH2C { - h2cHandler := h2c.NewHandler(router, &http2.Server{ + server.Handler = h2c.NewHandler(router, &http2.Server{ MaxConcurrentStreams: 250, IdleTimeout: 300 * time.Second, MaxReadFrameSize: 4 << 20, MaxUploadBufferPerConnection: 8 << 20, MaxUploadBufferPerStream: 2 << 20, }) - server.Handler = h2cHandler } else { server.Handler = router } - err := server.ListenAndServe() - if err != nil { + if err := server.ListenAndServe(); err != nil { fmt.Printf("启动服务失败: %v\n", err) } } -// 简单的健康检查 func formatDuration(d time.Duration) string { if d < time.Minute { return fmt.Sprintf("%d秒", int(d.Seconds())) - } else if d < time.Hour { - return fmt.Sprintf("%d分钟%d秒", int(d.Minutes()), int(d.Seconds())%60) - } else if d < 24*time.Hour { - return fmt.Sprintf("%d小时%d分钟", int(d.Hours()), int(d.Minutes())%60) - } else { - days := int(d.Hours()) / 24 - hours := int(d.Hours()) % 24 - return fmt.Sprintf("%d天%d小时", days, hours) } + if d < time.Hour { + return fmt.Sprintf("%d分钟%d秒", int(d.Minutes()), int(d.Seconds())%60) + } + if d < 24*time.Hour { + return fmt.Sprintf("%d小时%d分钟", int(d.Hours()), int(d.Minutes())%60) + } + + days := int(d.Hours()) / 24 + hours := int(d.Hours()) % 24 + return fmt.Sprintf("%d天%d小时", days, hours) } func getUptimeInfo() (time.Duration, float64, string) { diff --git a/src/main_test.go b/src/main_test.go new file mode 100644 index 0000000..0d3df0f --- /dev/null +++ b/src/main_test.go @@ -0,0 +1,172 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "hubproxy/config" + "hubproxy/handlers" + "hubproxy/utils" +) + +func newTestRouter(t *testing.T, configBody string) *gin.Engine { + t.Helper() + + path := filepath.Join(t.TempDir(), "config.toml") + if err := os.WriteFile(path, []byte(configBody), 0644); err != nil { + t.Fatal(err) + } + + t.Setenv("CONFIG_PATH", path) + if err := config.LoadConfig(); err != nil { + t.Fatal(err) + } + + utils.InitHTTPClients() + globalLimiter = utils.InitGlobalLimiter() + handlers.InitDockerProxy() + handlers.InitImageStreamer() + handlers.InitDebouncer() + + return buildRouter(config.GetConfig()) +} + +func performRequest(router http.Handler, method, path, body string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, path, strings.NewReader(body)) + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("User-Agent", "hubproxy-test") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + return w +} + +func TestReadyRoute(t *testing.T) { + router := newTestRouter(t, "") + + w := performRequest(router, http.MethodGet, "/ready", "") + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String()) + } + + var got map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got["ready"] != true || got["service"] != "hubproxy" { + t.Fatalf("unexpected ready response: %#v", got) + } +} + +func TestFrontendDisabledRoutesReturnNotFound(t *testing.T) { + router := newTestRouter(t, ` +[server] +enableFrontend = false +`) + + for _, path := range []string{"/", "/images.html", "/search.html", "/favicon.ico"} { + w := performRequest(router, http.MethodGet, path, "") + if w.Code != http.StatusNotFound { + t.Fatalf("%s status = %d, want 404", path, w.Code) + } + } +} + +func TestSingleImageDownloadPrepareReturnsURL(t *testing.T) { + router := newTestRouter(t, "") + + w := performRequest(router, http.MethodGet, "/api/image/download/nginx?mode=prepare", "") + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String()) + } + + var got struct { + DownloadURL string `json:"download_url"` + } + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(got.DownloadURL, "/api/image/download/nginx?token=") { + t.Fatalf("download_url = %q", got.DownloadURL) + } +} + +func TestBatchImageDownloadPrepareReturnsURL(t *testing.T) { + router := newTestRouter(t, "") + + body := `{"images":["nginx"],"useCompressedLayers":true}` + w := performRequest(router, http.MethodPost, "/api/image/batch?mode=prepare", body) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String()) + } + + var got struct { + DownloadURL string `json:"download_url"` + } + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(got.DownloadURL, "/api/image/batch?token=") { + t.Fatalf("download_url = %q", got.DownloadURL) + } +} + +func TestBatchImageDownloadRejectsTooManyImages(t *testing.T) { + router := newTestRouter(t, ` +[download] +maxImages = 1 +`) + + body := `{"images":["nginx","redis"],"useCompressedLayers":true}` + w := performRequest(router, http.MethodPost, "/api/image/batch?mode=prepare", body) + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", w.Code, w.Body.String()) + } +} + +func TestGitHubNoRouteRejectsUnsupportedHost(t *testing.T) { + router := newTestRouter(t, "") + + w := performRequest(router, http.MethodGet, "/https://example.com/file.zip", "") + if w.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403; body=%s", w.Code, w.Body.String()) + } +} + +func TestDockerV2PingAndInvalidPath(t *testing.T) { + router := newTestRouter(t, "") + + w := performRequest(router, http.MethodGet, "/v2/", "") + if w.Code != http.StatusOK { + t.Fatalf("/v2/ status = %d, want 200; body=%s", w.Code, w.Body.String()) + } + + w = performRequest(router, http.MethodGet, "/v2/library/nginx/unknown/latest", "") + if w.Code != http.StatusBadRequest { + t.Fatalf("invalid v2 status = %d, want 400; body=%s", w.Code, w.Body.String()) + } +} + +func TestSearchRouteRejectsMissingQuery(t *testing.T) { + router := newTestRouter(t, "") + + w := performRequest(router, http.MethodGet, "/search", "") + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", w.Code, w.Body.String()) + } + + var got map[string]string + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got["error"] == "" { + t.Fatalf("missing error response: %#v", got) + } +} diff --git a/src/utils/access_control_test.go b/src/utils/access_control_test.go new file mode 100644 index 0000000..30a4bb7 --- /dev/null +++ b/src/utils/access_control_test.go @@ -0,0 +1,86 @@ +package utils + +import ( + "os" + "path/filepath" + "testing" + + "hubproxy/config" +) + +func TestParseDockerImage(t *testing.T) { + tests := []struct { + name string + image string + namespace string + repository string + tag string + fullName string + }{ + {"official", "nginx", "library", "nginx", "latest", "library/nginx"}, + {"tagged", "redis:7", "library", "redis", "7", "library/redis"}, + {"namespaced", "user/app:v1", "user", "app", "v1", "user/app"}, + {"registry", "ghcr.io/user/app:v2", "user", "app", "v2", "user/app"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := GlobalAccessController.ParseDockerImage(tt.image) + if got.Namespace != tt.namespace || got.Repository != tt.repository || got.Tag != tt.tag || got.FullName != tt.fullName { + t.Fatalf("ParseDockerImage(%q) = %#v", tt.image, got) + } + }) + } +} + +func TestDockerAccessLists(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.toml") + data := []byte(` +[access] +whiteList = ["library/*", "good/*"] +blackList = ["good/bad"] +`) + if err := os.WriteFile(path, data, 0644); err != nil { + t.Fatal(err) + } + t.Setenv("CONFIG_PATH", path) + if err := config.LoadConfig(); err != nil { + t.Fatal(err) + } + + if allowed, reason := GlobalAccessController.CheckDockerAccess("nginx"); !allowed { + t.Fatalf("nginx denied: %s", reason) + } + if allowed, _ := GlobalAccessController.CheckDockerAccess("good/bad:latest"); allowed { + t.Fatal("blacklisted image allowed") + } + if allowed, _ := GlobalAccessController.CheckDockerAccess("other/app"); allowed { + t.Fatal("image outside whitelist allowed") + } +} + +func TestGitHubAccessLists(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.toml") + data := []byte(` +[access] +whiteList = ["allowed/*"] +blackList = ["allowed/blocked"] +`) + if err := os.WriteFile(path, data, 0644); err != nil { + t.Fatal(err) + } + t.Setenv("CONFIG_PATH", path) + if err := config.LoadConfig(); err != nil { + t.Fatal(err) + } + + if allowed, reason := GlobalAccessController.CheckGitHubAccess([]string{"allowed", "repo"}); !allowed { + t.Fatalf("allowed/repo denied: %s", reason) + } + if allowed, _ := GlobalAccessController.CheckGitHubAccess([]string{"allowed", "blocked"}); allowed { + t.Fatal("blacklisted repo allowed") + } + if allowed, _ := GlobalAccessController.CheckGitHubAccess([]string{"other", "repo"}); allowed { + t.Fatal("repo outside whitelist allowed") + } +} diff --git a/src/utils/cache_test.go b/src/utils/cache_test.go new file mode 100644 index 0000000..e31a81e --- /dev/null +++ b/src/utils/cache_test.go @@ -0,0 +1,49 @@ +package utils + +import ( + "testing" + "time" +) + +func TestUniversalCacheSetGetAndExpire(t *testing.T) { + cache := &UniversalCache{} + + cache.Set("k", []byte("v"), "text/plain", map[string]string{"X-Test": "1"}, time.Minute) + if got := cache.Get("k"); got == nil || string(got.Data) != "v" || got.Headers["X-Test"] != "1" { + t.Fatalf("cache hit mismatch: %#v", got) + } + + cache.Set("expired", []byte("v"), "", nil, -time.Second) + if got := cache.Get("expired"); got != nil { + t.Fatalf("expired item returned: %#v", got) + } +} + +func TestTokenCacheHelpers(t *testing.T) { + cache := &UniversalCache{} + cache.SetToken("token", `{"token":"abc"}`, time.Minute) + + if got := cache.GetToken("token"); got != `{"token":"abc"}` { + t.Fatalf("GetToken = %q", got) + } +} + +func TestExtractTTLFromResponse(t *testing.T) { + ttl := ExtractTTLFromResponse([]byte(`{"expires_in":3600}`)) + if ttl != 55*time.Minute { + t.Fatalf("TTL = %s, want 55m", ttl) + } + + if ttl := ExtractTTLFromResponse([]byte(`{}`)); ttl != 30*time.Minute { + t.Fatalf("default TTL = %s", ttl) + } +} + +func TestBuildCacheKeyStable(t *testing.T) { + a := BuildCacheKey("p", "query") + b := BuildCacheKey("p", "query") + c := BuildCacheKey("p", "other") + if a != b || a == c { + t.Fatalf("unexpected keys: %q %q %q", a, b, c) + } +} diff --git a/src/utils/proxy_shell_test.go b/src/utils/proxy_shell_test.go new file mode 100644 index 0000000..1021c02 --- /dev/null +++ b/src/utils/proxy_shell_test.go @@ -0,0 +1,69 @@ +package utils + +import ( + "compress/gzip" + "io" + "strings" + "testing" +) + +func TestProcessSmartRewritesGitHubURLs(t *testing.T) { + input := `curl -L https://github.com/user/repo/releases/download/v1/file.sh` + reader, size, err := ProcessSmart(strings.NewReader(input), false, "proxy.example.com") + if err != nil { + t.Fatal(err) + } + + buf := new(strings.Builder) + if _, err := io.Copy(buf, reader); err != nil { + t.Fatal(err) + } + + want := "https://proxy.example.com/https://github.com/user/repo/releases/download/v1/file.sh" + if !strings.Contains(buf.String(), want) { + t.Fatalf("processed script = %q, want contains %q", buf.String(), want) + } + if size != int64(len(buf.String())) { + t.Fatalf("size = %d, want %d", size, len(buf.String())) + } +} + +func TestProcessSmartKeepsNonGitHubContent(t *testing.T) { + input := "echo hello" + reader, _, err := ProcessSmart(strings.NewReader(input), false, "proxy.example.com") + if err != nil { + t.Fatal(err) + } + + buf := new(strings.Builder) + if _, err := io.Copy(buf, reader); err != nil { + t.Fatal(err) + } + if buf.String() != input { + t.Fatalf("content changed: %q", buf.String()) + } +} + +func TestReadShellContentGzip(t *testing.T) { + var compressed strings.Builder + gz := gzip.NewWriter(&compressed) + if _, err := gz.Write([]byte("echo https://github.com/u/r")); err != nil { + t.Fatal(err) + } + if err := gz.Close(); err != nil { + t.Fatal(err) + } + + reader, _, err := ProcessSmart(strings.NewReader(compressed.String()), true, "proxy.example.com") + if err != nil { + t.Fatal(err) + } + + buf := new(strings.Builder) + if _, err := io.Copy(buf, reader); err != nil { + t.Fatal(err) + } + if !strings.Contains(buf.String(), "https://proxy.example.com/https://github.com/u/r") { + t.Fatalf("gzip content not rewritten: %q", buf.String()) + } +} diff --git a/src/utils/ratelimiter_test.go b/src/utils/ratelimiter_test.go new file mode 100644 index 0000000..0277d69 --- /dev/null +++ b/src/utils/ratelimiter_test.go @@ -0,0 +1,21 @@ +package utils + +import "testing" + +func TestExtractIPFromAddress(t *testing.T) { + if got := extractIPFromAddress("127.0.0.1:5000"); got != "127.0.0.1" { + t.Fatalf("extract IPv4 = %q", got) + } + if got := extractIPFromAddress("[2001:db8::1]:5000"); got != "2001:db8::1" { + t.Fatalf("extract IPv6 = %q", got) + } +} + +func TestNormalizeIPv6ForRateLimit(t *testing.T) { + if got := normalizeIPForRateLimit("192.168.1.2"); got != "192.168.1.2" { + t.Fatalf("IPv4 normalized = %q", got) + } + if got := normalizeIPForRateLimit("2001:db8::1"); got != "2001:db8::/64" { + t.Fatalf("IPv6 normalized = %q", got) + } +}