mirror of
https://github.com/XTLS/Xray-core.git
synced 2026-10-29 06:31:56 +00:00
519 lines
15 KiB
Go
519 lines
15 KiB
Go
package xdrive
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/xml"
|
|
stderrors "errors"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/xtls/xray-core/common/errors"
|
|
"github.com/xtls/xray-core/transport/internet"
|
|
"golang.org/x/net/http/httpguts"
|
|
)
|
|
|
|
type s3Storage struct {
|
|
*s3Shared
|
|
client *http.Client
|
|
inflight chan struct{}
|
|
release func()
|
|
}
|
|
|
|
type s3Shared struct {
|
|
conns *connPool
|
|
base url.URL
|
|
prefix string
|
|
region string
|
|
accessKey, secretKey string
|
|
token string
|
|
backoff time.Duration
|
|
}
|
|
|
|
func s3Region(region string) string {
|
|
if region == "" {
|
|
return "us-east-1"
|
|
}
|
|
return region
|
|
}
|
|
|
|
func ValidateS3Config(c *Config) error {
|
|
if c == nil || c.S3 == nil {
|
|
return errors.New(`service "s3" needs an "s3" object`)
|
|
}
|
|
u, err := url.Parse(c.S3.Endpoint)
|
|
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Hostname() == "" ||
|
|
u.User != nil || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || u.Opaque != "" {
|
|
return errors.New("S3 endpoint must be an http(s) URL without credentials, query or fragment")
|
|
}
|
|
if !httpguts.ValidHeaderFieldValue(c.S3.Region) || strings.ContainsAny(c.S3.Region, "/, \t") {
|
|
return errors.New("invalid S3 signing region")
|
|
}
|
|
bucket := c.S3.Bucket
|
|
if !validS3Name(bucket) || strings.Contains(bucket, "/") {
|
|
return errors.New("S3 needs a bucket name without path separators")
|
|
}
|
|
if !c.S3.PathStyle {
|
|
if net.ParseIP(u.Hostname()) != nil {
|
|
return errors.New("S3 virtual-hosted addressing requires a DNS endpoint")
|
|
}
|
|
// In this mode the bucket is part of the HTTP authority, not its path.
|
|
if strings.Trim(bucket, "abcdefghijklmnopqrstuvwxyz0123456789-.") != "" || strings.Contains(bucket, "..") ||
|
|
strings.HasPrefix(bucket, ".") || strings.HasSuffix(bucket, ".") {
|
|
return errors.New("S3 virtual-hosted addressing requires a DNS-compatible bucket name")
|
|
}
|
|
}
|
|
if !validS3Name(strings.TrimSuffix(c.RemoteFolder, "/")) {
|
|
return errors.New("S3 remoteFolder must be a nonempty relative prefix without empty, '.' or '..' components")
|
|
}
|
|
if len(c.Secrets) < 2 || len(c.Secrets) > 3 || c.Secrets[0] == "" || c.Secrets[1] == "" {
|
|
return errors.New("S3 needs AccessKeyID, SecretAccessKey and optionally SessionToken in secrets")
|
|
}
|
|
for _, secret := range c.Secrets {
|
|
if !httpguts.ValidHeaderFieldValue(secret) {
|
|
return errors.New("invalid S3 secret")
|
|
}
|
|
}
|
|
if strings.ContainsAny(c.Secrets[0], "/ ,\t") {
|
|
return errors.New("invalid S3 AccessKeyID")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validS3Name(name string) bool {
|
|
for _, ch := range name {
|
|
if ch < 0x20 || ch == 0x7f || ch == '\\' {
|
|
return false
|
|
}
|
|
}
|
|
for _, part := range strings.Split(name, "/") {
|
|
if part == "" || part == "." || part == ".." {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func newS3Storage(streamSettings *internet.MemoryStreamConfig, c *Config) (*s3Storage, error) {
|
|
if err := ValidateS3Config(c); err != nil {
|
|
return nil, err
|
|
}
|
|
base, _ := url.Parse(c.S3.Endpoint)
|
|
// Encode the endpoint prefix for SigV4 without turning escaped slashes into separators.
|
|
segments := strings.Split(base.EscapedPath(), "/")
|
|
for i, segment := range segments {
|
|
decoded, _ := url.PathUnescape(segment)
|
|
segments[i] = strings.ReplaceAll(s3EscapePath(decoded), "/", "%2F")
|
|
}
|
|
base.RawPath = strings.Join(segments, "/")
|
|
if !strings.HasSuffix(base.RawPath, "/") {
|
|
base.Path += "/"
|
|
base.RawPath += "/"
|
|
}
|
|
if c.S3.PathStyle {
|
|
base.Path += c.S3.Bucket + "/"
|
|
base.RawPath += s3EscapePath(c.S3.Bucket) + "/"
|
|
} else {
|
|
host := c.S3.Bucket + "." + base.Hostname()
|
|
if base.Port() != "" {
|
|
host = net.JoinHostPort(host, base.Port())
|
|
}
|
|
base.Host = host
|
|
}
|
|
if streamSettings == nil {
|
|
streamSettings = &internet.MemoryStreamConfig{}
|
|
}
|
|
h1Hosts := new(sync.Map)
|
|
newClient := func(ctx context.Context) *http.Client {
|
|
client := newServiceClient(ctx, h1Hosts, streamSettings, driveTimeout, driveMaxInflight)
|
|
// A redirect changes the signed host/path. Do not follow it with the old
|
|
// signature or send the session token to a different endpoint.
|
|
client.CheckRedirect = s3NoRedirect
|
|
return client
|
|
}
|
|
s := &s3Storage{
|
|
s3Shared: &s3Shared{
|
|
conns: newConnPool(c.Xmux, newClient),
|
|
base: *base,
|
|
prefix: strings.TrimSuffix(c.RemoteFolder, "/") + "/",
|
|
region: s3Region(c.S3.Region),
|
|
accessKey: c.Secrets[0],
|
|
secretKey: c.Secrets[1],
|
|
backoff: 200 * time.Millisecond,
|
|
},
|
|
client: newClient(context.Background()),
|
|
inflight: make(chan struct{}, driveMaxInflight),
|
|
}
|
|
if len(c.Secrets) == 3 {
|
|
s.token = c.Secrets[2]
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func s3NoRedirect(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }
|
|
|
|
func (s *s3Storage) session() Storage {
|
|
transport := &sessionTransport{pool: s.conns}
|
|
return &s3Storage{
|
|
s3Shared: s.s3Shared,
|
|
client: &http.Client{Transport: transport, CheckRedirect: s3NoRedirect},
|
|
inflight: make(chan struct{}, cap(s.inflight)),
|
|
release: transport.Close,
|
|
}
|
|
}
|
|
|
|
func (s *s3Storage) Close() error {
|
|
if s.release != nil {
|
|
s.release()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SigV4 requires %20 for spaces, uppercase escapes, and unescaped key separators.
|
|
func s3EscapePath(value string) string {
|
|
const digits = "0123456789ABCDEF"
|
|
var out strings.Builder
|
|
for i := 0; i < len(value); i++ {
|
|
ch := value[i]
|
|
if ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z' || ch >= '0' && ch <= '9' ||
|
|
ch == '-' || ch == '_' || ch == '.' || ch == '~' || ch == '/' {
|
|
out.WriteByte(ch)
|
|
} else {
|
|
out.WriteByte('%')
|
|
out.WriteByte(digits[ch>>4])
|
|
out.WriteByte(digits[ch&15])
|
|
}
|
|
}
|
|
return out.String()
|
|
}
|
|
|
|
func s3Hash(body []byte) string {
|
|
sum := sha256.Sum256(body)
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func s3HMAC(key []byte, value string) []byte {
|
|
h := hmac.New(sha256.New, key)
|
|
h.Write([]byte(value))
|
|
return h.Sum(nil)
|
|
}
|
|
|
|
func (s *s3Storage) sign(req *http.Request, payloadHash string, now time.Time) {
|
|
stamp, date := now.UTC().Format("20060102T150405Z"), now.UTC().Format("20060102")
|
|
req.Header.Set("X-Amz-Date", stamp)
|
|
req.Header.Set("X-Amz-Content-Sha256", payloadHash)
|
|
headers := "host:" + req.URL.Host + "\nx-amz-content-sha256:" + payloadHash + "\nx-amz-date:" + stamp + "\n"
|
|
signed := "host;x-amz-content-sha256;x-amz-date"
|
|
if contentType := req.Header.Get("Content-Type"); contentType != "" {
|
|
headers = "content-type:" + contentType + "\n" + headers
|
|
signed = "content-type;" + signed
|
|
}
|
|
if s.token != "" {
|
|
req.Header.Set("X-Amz-Security-Token", s.token)
|
|
headers += "x-amz-security-token:" + strings.Join(strings.Fields(s.token), " ") + "\n"
|
|
signed += ";x-amz-security-token"
|
|
}
|
|
// Sign the escaped path exactly as sent.
|
|
canonical := req.Method + "\n" + req.URL.EscapedPath() + "\n" + req.URL.RawQuery + "\n" +
|
|
headers + "\n" + signed + "\n" + payloadHash
|
|
scope := date + "/" + s.region + "/s3/aws4_request"
|
|
toSign := "AWS4-HMAC-SHA256\n" + stamp + "\n" + scope + "\n" + s3Hash([]byte(canonical))
|
|
key := s3HMAC([]byte("AWS4"+s.secretKey), date)
|
|
key = s3HMAC(key, s.region)
|
|
key = s3HMAC(key, "s3")
|
|
key = s3HMAC(key, "aws4_request")
|
|
req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential="+s.accessKey+"/"+scope+
|
|
", SignedHeaders="+signed+", Signature="+hex.EncodeToString(s3HMAC(key, toSign)))
|
|
}
|
|
|
|
func (s *s3Storage) target(key string, query url.Values) string {
|
|
u := s.base
|
|
u.Path += key
|
|
u.RawPath += s3EscapePath(key)
|
|
// Parameters have fixed ASCII names and one value each; SigV4 uses %20 for spaces.
|
|
u.RawQuery = strings.ReplaceAll(query.Encode(), "+", "%20")
|
|
return u.String()
|
|
}
|
|
|
|
func (s *s3Storage) objectKey(name string) (string, error) {
|
|
if !validS3Name(name) {
|
|
return "", errors.New("invalid relative S3 object name")
|
|
}
|
|
key := s.prefix + name
|
|
if len(key) > 1024 {
|
|
return "", errors.New("S3 object key exceeds 1024 bytes")
|
|
}
|
|
return key, nil
|
|
}
|
|
|
|
// Retries use fresh signatures and hold a concurrency slot until the response body is read.
|
|
func (s *s3Storage) do(ctx context.Context, method, key string, query url.Values, body []byte) (int, []byte, error) {
|
|
payloadHash := s3Hash(body)
|
|
backoff := s.backoff
|
|
var lastErr error
|
|
var retryAfter time.Duration
|
|
for attempt := 0; attempt < driveMaxAttempts; attempt++ {
|
|
if attempt > 0 {
|
|
delay := max(jitter(backoff), retryAfter)
|
|
timer := time.NewTimer(min(delay, driveMaxBackoff))
|
|
select {
|
|
case <-ctx.Done():
|
|
timer.Stop()
|
|
return 0, nil, ctx.Err()
|
|
case <-timer.C:
|
|
}
|
|
backoff = min(backoff*2, driveMaxBackoff)
|
|
}
|
|
select {
|
|
case s.inflight <- struct{}{}:
|
|
case <-ctx.Done():
|
|
return 0, nil, ctx.Err()
|
|
}
|
|
status, payload, after, err := s.attempt(ctx, method, key, query, body, payloadHash)
|
|
<-s.inflight
|
|
if ctx.Err() != nil {
|
|
return 0, nil, ctx.Err()
|
|
}
|
|
if err != nil && (!retryS3Error(err) || status >= 300 && !retryableStatus(status)) {
|
|
return 0, nil, err
|
|
}
|
|
if err == nil && !retryableStatus(status) {
|
|
return status, payload, nil
|
|
}
|
|
lastErr, retryAfter = err, after
|
|
if lastErr == nil {
|
|
lastErr = s3ResponseError(method, status, payload)
|
|
}
|
|
}
|
|
return 0, nil, lastErr
|
|
}
|
|
|
|
func retryS3Error(err error) bool {
|
|
var dns *net.DNSError
|
|
if stderrors.As(err, &dns) {
|
|
return dns.IsTimeout || dns.IsTemporary
|
|
}
|
|
var op *net.OpError
|
|
var network net.Error
|
|
return stderrors.Is(err, io.EOF) || stderrors.Is(err, io.ErrUnexpectedEOF) || stderrors.Is(err, errStalled) ||
|
|
stderrors.As(err, &op) || stderrors.As(err, &network) && network.Timeout()
|
|
}
|
|
|
|
func (s *s3Storage) attempt(ctx context.Context, method, key string, query url.Values, body []byte, hash string) (int, []byte, time.Duration, error) {
|
|
req, err := http.NewRequestWithContext(ctx, method, s.target(key, query), bytes.NewReader(body))
|
|
if err != nil {
|
|
return 0, nil, 0, errors.New("failed to build S3 request")
|
|
}
|
|
if method == http.MethodPut {
|
|
req.Header.Set("Content-Type", "application/octet-stream")
|
|
}
|
|
s.sign(req, hash, time.Now())
|
|
resp, err := s.client.Do(req)
|
|
if err != nil {
|
|
if urlErr, ok := err.(*url.Error); ok {
|
|
err = urlErr.Err
|
|
}
|
|
return 0, nil, 0, errors.New("S3 request failed").Base(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
limit := int64(maxSegmentBytes)
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
limit = 64 << 10
|
|
}
|
|
payload, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
|
|
if err != nil {
|
|
return resp.StatusCode, nil, 0, errors.New("failed to read S3 response (HTTP ", resp.StatusCode, ")").Base(err)
|
|
}
|
|
if int64(len(payload)) > limit {
|
|
return 0, nil, 0, errors.New("S3 response exceeds size limit")
|
|
}
|
|
var after time.Duration
|
|
if raw := resp.Header.Get("Retry-After"); raw != "" {
|
|
if seconds, err := strconv.ParseInt(raw, 10, 32); err == nil && seconds > 0 {
|
|
after = min(time.Duration(seconds)*time.Second, driveMaxBackoff)
|
|
} else if at, err := http.ParseTime(raw); err == nil {
|
|
after = min(max(time.Until(at), 0), driveMaxBackoff)
|
|
}
|
|
}
|
|
return resp.StatusCode, payload, after, nil
|
|
}
|
|
|
|
func s3Missing(status int, payload []byte) bool {
|
|
if status != http.StatusNotFound {
|
|
return false
|
|
}
|
|
code := s3ErrorCode(payload)
|
|
return code == "NoSuchKey" || code == "NotFound"
|
|
}
|
|
|
|
func s3ErrorCode(payload []byte) string {
|
|
var response struct {
|
|
XMLName xml.Name `xml:"Error"`
|
|
Code string
|
|
}
|
|
if xml.Unmarshal(payload, &response) != nil {
|
|
return ""
|
|
}
|
|
return response.Code
|
|
}
|
|
|
|
func s3ResponseError(method string, status int, payload []byte) error {
|
|
if code := s3ErrorCode(payload); code != "" {
|
|
return errors.New("S3 ", method, " answered ", status, " (", strconv.Quote(code), ")")
|
|
}
|
|
return errors.New("S3 ", method, " answered ", status)
|
|
}
|
|
|
|
func (s *s3Storage) Put(ctx context.Context, name string, data []byte) error {
|
|
key, err := s.objectKey(name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
status, payload, err := s.do(ctx, http.MethodPut, key, nil, data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if status != http.StatusOK {
|
|
return s3ResponseError("PUT", status, payload)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *s3Storage) Get(ctx context.Context, name string) ([]byte, error) {
|
|
key, err := s.objectKey(name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
status, payload, err := s.do(ctx, http.MethodGet, key, nil, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if s3Missing(status, payload) {
|
|
return nil, errNotFound
|
|
}
|
|
if status != http.StatusOK {
|
|
return nil, s3ResponseError("GET", status, payload)
|
|
}
|
|
return payload, nil
|
|
}
|
|
|
|
type s3ListPage struct {
|
|
XMLName xml.Name `xml:"ListBucketResult"`
|
|
IsTruncated bool
|
|
NextContinuationToken string
|
|
Contents []struct{ Key string }
|
|
CommonPrefixes []struct{ Prefix string }
|
|
}
|
|
|
|
func (s *s3Storage) list(ctx context.Context, prefix, delimiter string, visit func(*s3ListPage) error) error {
|
|
query := url.Values{"list-type": {"2"}, "prefix": {prefix}, "max-keys": {"1000"}}
|
|
if delimiter != "" {
|
|
query.Set("delimiter", delimiter)
|
|
}
|
|
seen := make(map[string]bool)
|
|
for {
|
|
status, payload, err := s.do(ctx, http.MethodGet, "", query, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if status != http.StatusOK {
|
|
return s3ResponseError("LIST", status, payload)
|
|
}
|
|
var page s3ListPage
|
|
if err := xml.Unmarshal(payload, &page); err != nil {
|
|
return errors.New("invalid S3 list response")
|
|
}
|
|
// Validate all keys before deleting any objects.
|
|
for _, item := range page.Contents {
|
|
if !strings.HasPrefix(item.Key, prefix) {
|
|
return errors.New("S3 returned an object outside the requested prefix")
|
|
}
|
|
}
|
|
for _, item := range page.CommonPrefixes {
|
|
if !strings.HasPrefix(item.Prefix, prefix) {
|
|
return errors.New("S3 returned a directory outside the requested prefix")
|
|
}
|
|
}
|
|
if page.IsTruncated {
|
|
if page.NextContinuationToken == "" || seen[page.NextContinuationToken] {
|
|
return errors.New("S3 list pagination did not advance")
|
|
}
|
|
seen[page.NextContinuationToken] = true
|
|
}
|
|
if err := visit(&page); err != nil {
|
|
return err
|
|
}
|
|
if !page.IsTruncated {
|
|
return nil
|
|
}
|
|
query.Set("continuation-token", page.NextContinuationToken)
|
|
}
|
|
}
|
|
|
|
func (s *s3Storage) List(ctx context.Context, prefix string) ([]Entry, error) {
|
|
key, err := s.objectKey(prefix)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
key += "/"
|
|
seen := make(map[string]bool)
|
|
var entries []Entry
|
|
add := func(full string) {
|
|
rest := strings.TrimPrefix(full, key)
|
|
name, _, _ := strings.Cut(rest, "/")
|
|
if name != "" && validS3Name(name) && !seen[name] {
|
|
seen[name] = true
|
|
entries = append(entries, Entry{Name: name})
|
|
}
|
|
}
|
|
err = s.list(ctx, key, "/", func(page *s3ListPage) error {
|
|
for _, item := range page.Contents {
|
|
add(item.Key)
|
|
}
|
|
for _, item := range page.CommonPrefixes {
|
|
add(item.Prefix)
|
|
}
|
|
return nil
|
|
})
|
|
return entries, err
|
|
}
|
|
|
|
func (s *s3Storage) deleteObject(ctx context.Context, key string) error {
|
|
status, payload, err := s.do(ctx, http.MethodDelete, key, nil, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if status != http.StatusNoContent && status != http.StatusOK && !s3Missing(status, payload) {
|
|
return s3ResponseError("DELETE", status, payload)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// The trailing "/" prevents deleting streams/abcd when removing streams/abc.
|
|
func (s *s3Storage) Delete(ctx context.Context, name string) error {
|
|
key, err := s.objectKey(name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.deleteObject(ctx, key); err != nil {
|
|
return err
|
|
}
|
|
return s.list(ctx, key+"/", "", func(page *s3ListPage) error {
|
|
for _, item := range page.Contents {
|
|
if err := s.deleteObject(ctx, item.Key); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|