mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 05:46:36 +08:00
d0414b402a
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}
313 lines
8.7 KiB
Go
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
|
|
}
|