Files
ryan d0414b402a canonicalheader 8→0 + usestdlibvars 3→0:header key 改为 Go 规范大小写(wire 格式本就如此,纯代码修正)、HTTP 方法常量替代字符串字面量。
Result: {"status":"keep","total_issues":45,"golint_canonicalheader":0,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":3,"golint_modernize":5,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":0,"golint_wastedassign":7,"golint_total":44,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":38}
2026-08-16 17:38:18 +08:00

313 lines
8.7 KiB
Go

// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package githubrelease
import (
"context"
"crypto/tls"
"errors"
"math"
"net"
"net/http"
"net/netip"
"net/url"
"os"
"strconv"
"strings"
"time"
"github.com/Rain-kl/Wavelet/pkg/httppool"
)
const (
clientTimeout = 10 * time.Minute
dialTimeout = 30 * time.Second
dialKeepAlive = 30 * time.Second
responseHeaderTimeout = 30 * time.Second
maxRedirects = 5
maxRetryAfterSeconds = math.MaxInt64 / int64(time.Second)
)
var (
errBlockedTarget = errors.New("GitHub Release 请求目标不是公网地址")
errResolveTarget = errors.New("GitHub Release 请求目标解析失败")
errRedirectLimit = errors.New("GitHub Release asset 重定向次数过多")
publicIPv6Prefix = netip.MustParsePrefix("2000::/3")
nonPublicPrefixes = []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/8"),
netip.MustParsePrefix("10.0.0.0/8"),
netip.MustParsePrefix("100.64.0.0/10"),
netip.MustParsePrefix("127.0.0.0/8"),
netip.MustParsePrefix("169.254.0.0/16"),
netip.MustParsePrefix("172.16.0.0/12"),
netip.MustParsePrefix("192.0.0.0/24"),
netip.MustParsePrefix("192.0.2.0/24"),
netip.MustParsePrefix("192.168.0.0/16"),
netip.MustParsePrefix("198.18.0.0/15"),
netip.MustParsePrefix("198.51.100.0/24"),
netip.MustParsePrefix("203.0.113.0/24"),
netip.MustParsePrefix("224.0.0.0/4"),
netip.MustParsePrefix("240.0.0.0/4"),
netip.MustParsePrefix("::/128"),
netip.MustParsePrefix("::1/128"),
netip.MustParsePrefix("::ffff:0:0/96"),
netip.MustParsePrefix("64:ff9b::/96"),
netip.MustParsePrefix("100::/64"),
netip.MustParsePrefix("2001:db8::/32"),
netip.MustParsePrefix("fc00::/7"),
netip.MustParsePrefix("fe80::/10"),
netip.MustParsePrefix("ff00::/8"),
}
)
type resolver interface {
LookupNetIP(context.Context, string, string) ([]netip.Addr, error)
}
type clientOptions struct {
baseURL string
resolver resolver
dialContext func(context.Context, string, string) (net.Conn, error)
tlsConfig *tls.Config
allowHTTP bool
createTemp func(string, string) (*os.File, error)
now func() time.Time
clientTimeout time.Duration
}
func defaultClientOptions() clientOptions {
dialer := &net.Dialer{Timeout: dialTimeout, KeepAlive: dialKeepAlive}
return clientOptions{
baseURL: defaultAPIBaseURL,
resolver: net.DefaultResolver,
dialContext: dialer.DialContext,
tlsConfig: &tls.Config{MinVersion: tls.VersionTLS12},
createTemp: os.CreateTemp,
now: time.Now,
clientTimeout: clientTimeout,
}
}
func newClient(options clientOptions) *Client {
if options.baseURL == "" {
options.baseURL = defaultAPIBaseURL
}
if options.resolver == nil {
options.resolver = net.DefaultResolver
}
if options.dialContext == nil {
dialer := &net.Dialer{Timeout: dialTimeout, KeepAlive: dialKeepAlive}
options.dialContext = dialer.DialContext
}
if options.tlsConfig == nil {
options.tlsConfig = &tls.Config{MinVersion: tls.VersionTLS12}
}
if options.createTemp == nil {
options.createTemp = os.CreateTemp
}
if options.now == nil {
options.now = time.Now
}
if options.clientTimeout <= 0 {
options.clientTimeout = clientTimeout
}
secureDial := publicDialer(options.resolver, options.dialContext)
transport := httppool.NewTransport(httppool.TransportOptions{
Proxy: nil,
DialContext: secureDial,
TLSClientConfig: options.tlsConfig,
ResponseHeaderTimeout: responseHeaderTimeout,
TraceFilter: func(request *http.Request) bool {
return request.URL == nil || request.URL.RawQuery == ""
},
})
httpClient := &http.Client{Timeout: options.clientTimeout, Transport: transport}
httpClient.CheckRedirect = func(next *http.Request, previous []*http.Request) error {
if len(previous) > maxRedirects {
return errRedirectLimit
}
if err := validateTarget(next.Context(), next.URL, options.resolver, options.allowHTTP); err != nil {
return err
}
if len(previous) > 0 && !sameHost(previous[len(previous)-1].URL, next.URL) {
stripCrossHostHeaders(next)
}
return nil
}
return &Client{
httpClient: httpClient,
baseURL: strings.TrimRight(options.baseURL, "/"),
createTemp: options.createTemp,
now: options.now,
}
}
func applyMetadataHeaders(request *http.Request, etag string) {
request.Header.Set("Accept", metadataAccept)
request.Header.Set("User-Agent", defaultUserAgent)
request.Header.Set("X-Github-Api-Version", APIVersion)
if etag = safeETag(etag); etag != "" {
request.Header.Set("If-None-Match", etag)
}
}
func applyAssetHeaders(request *http.Request) {
request.Header.Set("Accept", assetAccept)
request.Header.Set("Accept-Encoding", "identity")
request.Header.Set("User-Agent", defaultUserAgent)
request.Header.Set("X-Github-Api-Version", APIVersion)
}
func stripCrossHostHeaders(request *http.Request) {
for _, header := range []string{
"Authorization",
"Cookie",
"Proxy-Authorization",
"Referer",
"If-None-Match",
"If-Modified-Since",
"X-GitHub-Api-Version",
} {
request.Header.Del(header)
}
}
func sameHost(left *url.URL, right *url.URL) bool {
if left == nil || right == nil {
return false
}
return strings.EqualFold(left.Hostname(), right.Hostname()) && effectivePort(left) == effectivePort(right)
}
func effectivePort(target *url.URL) string {
if port := target.Port(); port != "" {
return port
}
if strings.EqualFold(target.Scheme, "https") {
return "443"
}
return "80"
}
func validateTarget(ctx context.Context, target *url.URL, targetResolver resolver, allowHTTP bool) error {
if target == nil || target.User != nil || target.Fragment != "" || target.Opaque != "" || target.Hostname() == "" {
return errBlockedTarget
}
isHTTPS := strings.EqualFold(target.Scheme, "https")
isAllowedHTTP := allowHTTP && strings.EqualFold(target.Scheme, "http")
if !isHTTPS && !isAllowedHTTP {
return errBlockedTarget
}
_, err := resolvePublicIPs(ctx, targetResolver, target.Hostname())
return err
}
func publicDialer(
targetResolver resolver,
directDial func(context.Context, string, string) (net.Conn, error),
) func(context.Context, string, string) (net.Conn, error) {
return func(ctx context.Context, network string, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, errResolveTarget
}
addresses, err := resolvePublicIPs(ctx, targetResolver, host)
if err != nil {
return nil, err
}
for _, resolved := range addresses {
if !ipMatchesNetwork(resolved, network) {
continue
}
connection, dialErr := directDial(ctx, network, net.JoinHostPort(resolved.String(), port))
if dialErr == nil {
return connection, nil
}
}
return nil, errDownload
}
}
func resolvePublicIPs(ctx context.Context, targetResolver resolver, host string) ([]netip.Addr, error) {
if strings.Contains(host, "%") {
return nil, errBlockedTarget
}
if literal, err := netip.ParseAddr(host); err == nil {
if !isPublicIP(literal) {
return nil, errBlockedTarget
}
return []netip.Addr{literal}, nil
}
if targetResolver == nil {
return nil, errResolveTarget
}
addresses, err := targetResolver.LookupNetIP(ctx, "ip", host)
if err != nil || len(addresses) == 0 {
return nil, errResolveTarget
}
for _, address := range addresses {
if !isPublicIP(address) {
return nil, errBlockedTarget
}
}
return addresses, nil
}
func isPublicIP(address netip.Addr) bool {
if !address.IsValid() || address.Zone() != "" {
return false
}
address = address.Unmap()
if !address.IsGlobalUnicast() {
return false
}
if address.Is6() && !publicIPv6Prefix.Contains(address) {
return false
}
for _, prefix := range nonPublicPrefixes {
if prefix.Contains(address) {
return false
}
}
return true
}
func ipMatchesNetwork(address netip.Addr, network string) bool {
switch network {
case "tcp4":
return address.Unmap().Is4()
case "tcp6":
return address.Unmap().Is6()
default:
return true
}
}
func responseRetryAt(response *http.Response, now time.Time) *time.Time {
if response == nil {
return nil
}
if retryAfter := strings.TrimSpace(response.Header.Get("Retry-After")); retryAfter != "" {
if seconds, err := strconv.ParseInt(retryAfter, 10, 64); err == nil && seconds >= 0 && seconds <= maxRetryAfterSeconds {
retryAt := now.Add(time.Duration(seconds) * time.Second)
return &retryAt
}
if retryAt, err := http.ParseTime(retryAfter); err == nil {
retryAt = retryAt.UTC()
return &retryAt
}
}
if strings.TrimSpace(response.Header.Get("X-Ratelimit-Remaining")) != "0" {
return nil
}
reset, err := strconv.ParseInt(strings.TrimSpace(response.Header.Get("X-Ratelimit-Reset")), 10, 64)
if err != nil || reset <= 0 {
return nil
}
retryAt := time.Unix(reset, 0).UTC()
return &retryAt
}