mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-03 22:57:23 +00:00
* volume: build the guarded remote storage client through a shared helper Fold the endpoint validation, credential check and rebinding-safe dialer that FetchAndWriteNeedle applies before dialing a caller-supplied remote storage endpoint into a single BuildGuardedRemoteStorageClient helper, so other callers that dial the same endpoints can reuse it. No behavior change on this path. Claude-Session: https://claude.ai/code/session_01AiH1FU3rmshSbFFTbJpaZN * filer: build the remote-mount stream client through the guarded helper streamFromRemote serves a cold remote-only entry straight from its mounted origin. Build its client through BuildGuardedRemoteStorageClient so the same endpoint checks the volume server applies cover this read path too. Claude-Session: https://claude.ai/code/session_01AiH1FU3rmshSbFFTbJpaZN * s3: build the remote-mount stream client through the guarded helper openRemoteStream serves a remote-mounted object straight from its origin when the local read cannot. Build its client through the same guarded helper so the endpoint checks apply here as well. Claude-Session: https://claude.ai/code/session_01AiH1FU3rmshSbFFTbJpaZN
518 lines
20 KiB
Go
518 lines
20 KiB
Go
package weed_server
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/operation"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/remote_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/remote_storage"
|
|
azureremote "github.com/seaweedfs/seaweedfs/weed/remote_storage/azure"
|
|
gcsremote "github.com/seaweedfs/seaweedfs/weed/remote_storage/gcs"
|
|
s3remote "github.com/seaweedfs/seaweedfs/weed/remote_storage/s3"
|
|
"github.com/seaweedfs/seaweedfs/weed/security"
|
|
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
|
|
"github.com/seaweedfs/seaweedfs/weed/storage/types"
|
|
)
|
|
|
|
// lookupIPAddrFunc resolves a host to one or more IP addresses. It is a
|
|
// package-level variable so tests can substitute a deterministic resolver.
|
|
var lookupIPAddrFunc = net.DefaultResolver.LookupIPAddr
|
|
|
|
// blockedIMDSHosts lists hostnames that target cloud instance metadata
|
|
// services (IMDS). These are blocked regardless of how they happen to
|
|
// resolve, because some environments alias the IMDS address under a name.
|
|
var blockedIMDSHosts = map[string]struct{}{
|
|
"metadata.google.internal": {},
|
|
"metadata": {},
|
|
}
|
|
|
|
// validateRemoteEndpoint returns an error if the supplied S3 endpoint is not
|
|
// safe to dial from a server that has network access to cluster-internal
|
|
// hosts. It rejects empty/non-http(s) schemes, loopback/link-local/
|
|
// unspecified addresses, RFC 1918 + CGNAT ranges, and well-known IMDS
|
|
// hostnames. Operators that legitimately fetch from private hosts can opt
|
|
// out with -volume.allowUntrustedRemoteEndpoints.
|
|
func validateRemoteEndpoint(ctx context.Context, endpoint string) error {
|
|
if strings.TrimSpace(endpoint) == "" {
|
|
return fmt.Errorf("remote endpoint is empty")
|
|
}
|
|
u, parseErr := url.Parse(endpoint)
|
|
if parseErr != nil {
|
|
return fmt.Errorf("parse remote endpoint %q: %w", endpoint, parseErr)
|
|
}
|
|
scheme := strings.ToLower(u.Scheme)
|
|
if scheme != "http" && scheme != "https" {
|
|
return fmt.Errorf("remote endpoint %q must use http or https, got %q", endpoint, u.Scheme)
|
|
}
|
|
host := u.Hostname()
|
|
if host == "" {
|
|
return fmt.Errorf("remote endpoint %q has no host", endpoint)
|
|
}
|
|
lowerHost := strings.ToLower(host)
|
|
if _, ok := blockedIMDSHosts[lowerHost]; ok {
|
|
return fmt.Errorf("remote endpoint %q targets instance metadata service", endpoint)
|
|
}
|
|
if ip := net.ParseIP(host); ip != nil {
|
|
if err := checkBlockedIP(endpoint, ip); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
resolveCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
|
defer cancel()
|
|
addrs, lookupErr := lookupIPAddrFunc(resolveCtx, host)
|
|
if lookupErr != nil {
|
|
return fmt.Errorf("resolve remote endpoint host %q: %w", host, lookupErr)
|
|
}
|
|
for _, addr := range addrs {
|
|
if err := checkBlockedIP(endpoint, addr.IP); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// imdsIPv4 is the AWS/Azure/GCP IPv4 IMDS address. It is link-local and is
|
|
// already covered by IsLinkLocalUnicast, but is named explicitly so the
|
|
// error message is unambiguous in logs.
|
|
var imdsIPv4 = net.ParseIP("169.254.169.254")
|
|
|
|
// cgnatNet is the RFC 6598 carrier-grade NAT range (100.64.0.0/10). The
|
|
// stdlib's IsPrivate covers RFC 1918 but not CGNAT, so check it explicitly.
|
|
var cgnatNet = &net.IPNet{IP: net.IPv4(100, 64, 0, 0), Mask: net.CIDRMask(10, 32)}
|
|
|
|
func checkBlockedIP(endpoint string, ip net.IP) error {
|
|
return checkBlockedIPPolicy(endpoint, ip, false)
|
|
}
|
|
|
|
// checkBlockedIPPolicy rejects addresses that must never be dialed from a
|
|
// server with cluster-internal reach. allowPrivate keeps RFC 1918 / CGNAT
|
|
// reachable for callers whose target legitimately sits on an internal network
|
|
// (peer volume servers), while still blocking loopback, link-local (IMDS) and
|
|
// unspecified.
|
|
func checkBlockedIPPolicy(endpoint string, ip net.IP, allowPrivate bool) error {
|
|
if ip == nil {
|
|
return nil
|
|
}
|
|
if ip.Equal(imdsIPv4) {
|
|
return fmt.Errorf("remote endpoint %q targets instance metadata service %s", endpoint, ip)
|
|
}
|
|
switch {
|
|
case ip.IsLoopback():
|
|
return fmt.Errorf("remote endpoint %q resolves to loopback address %s", endpoint, ip)
|
|
case ip.IsUnspecified():
|
|
return fmt.Errorf("remote endpoint %q resolves to unspecified address %s", endpoint, ip)
|
|
case ip.IsLinkLocalUnicast(), ip.IsLinkLocalMulticast():
|
|
return fmt.Errorf("remote endpoint %q resolves to link-local address %s", endpoint, ip)
|
|
case ip.IsInterfaceLocalMulticast():
|
|
return fmt.Errorf("remote endpoint %q resolves to interface-local address %s", endpoint, ip)
|
|
}
|
|
if !allowPrivate {
|
|
switch {
|
|
case ip.IsPrivate():
|
|
return fmt.Errorf("remote endpoint %q resolves to private address %s", endpoint, ip)
|
|
case cgnatNet.Contains(ip):
|
|
return fmt.Errorf("remote endpoint %q resolves to CGNAT address %s", endpoint, ip)
|
|
}
|
|
}
|
|
// IPv6 transition addresses embed an IPv4 destination that routes to the
|
|
// same host wherever the matching relay exists (common in IPv6-only cloud).
|
|
// net.IP only normalizes ::ffff: mapped addresses, so pull the embedded
|
|
// IPv4 out of the other forms and re-check it against the deny list.
|
|
if embedded := embeddedTransitionIPv4(ip); embedded != nil {
|
|
return checkBlockedIPPolicy(endpoint, embedded, allowPrivate)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateReplicaTarget rejects a replica upload target that could redirect the
|
|
// forwarded write away from a peer volume server. The target must be a bare
|
|
// host:port -- a scheme, userinfo, path, query or fragment can smuggle a
|
|
// different destination through fmt.Sprintf -- whose host is not loopback,
|
|
// link-local (IMDS) or unspecified. Cluster peers legitimately sit on private
|
|
// networks, so RFC 1918 / CGNAT are allowed.
|
|
func validateReplicaTarget(ctx context.Context, target string) error {
|
|
if strings.TrimSpace(target) == "" {
|
|
return fmt.Errorf("replica target is empty")
|
|
}
|
|
if strings.Contains(target, "://") || strings.ContainsAny(target, "/?#@\\") {
|
|
return fmt.Errorf("replica target %q must be a bare host:port", target)
|
|
}
|
|
host, _, splitErr := net.SplitHostPort(target)
|
|
if splitErr != nil {
|
|
return fmt.Errorf("replica target %q must be a bare host:port: %w", target, splitErr)
|
|
}
|
|
if host == "" {
|
|
return fmt.Errorf("replica target %q has no host", target)
|
|
}
|
|
if _, ok := blockedIMDSHosts[strings.ToLower(host)]; ok {
|
|
return fmt.Errorf("replica target %q targets instance metadata service", target)
|
|
}
|
|
if ip := net.ParseIP(host); ip != nil {
|
|
return checkBlockedIPPolicy(target, ip, true)
|
|
}
|
|
resolveCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
|
defer cancel()
|
|
addrs, lookupErr := lookupIPAddrFunc(resolveCtx, host)
|
|
if lookupErr != nil {
|
|
return fmt.Errorf("resolve replica target host %q: %w", host, lookupErr)
|
|
}
|
|
for _, addr := range addrs {
|
|
if err := checkBlockedIPPolicy(target, addr.IP, true); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// embeddedTransitionIPv4 returns the IPv4 address carried by an IPv6 transition
|
|
// address -- NAT64 64:ff9b::/96 (RFC 6052), 6to4 2002::/16 (RFC 3056), Teredo
|
|
// 2001:0000::/32 (RFC 4380), and the deprecated IPv4-compatible ::/96 (RFC
|
|
// 4291) -- or nil when ip is not one of those. IPv4-mapped ::ffff:0:0/96 is
|
|
// excluded because net.IP already normalizes it via To4.
|
|
func embeddedTransitionIPv4(ip net.IP) net.IP {
|
|
v6 := ip.To16()
|
|
if v6 == nil || ip.To4() != nil {
|
|
return nil
|
|
}
|
|
switch {
|
|
case v6[0] == 0x00 && v6[1] == 0x64 && v6[2] == 0xff && v6[3] == 0x9b && allZero(v6[4:12]):
|
|
return net.IPv4(v6[12], v6[13], v6[14], v6[15])
|
|
case v6[0] == 0x20 && v6[1] == 0x02:
|
|
return net.IPv4(v6[2], v6[3], v6[4], v6[5])
|
|
case v6[0] == 0x20 && v6[1] == 0x01 && v6[2] == 0x00 && v6[3] == 0x00:
|
|
// Teredo obfuscates the client IPv4 as its ones' complement.
|
|
return net.IPv4(v6[12]^0xff, v6[13]^0xff, v6[14]^0xff, v6[15]^0xff)
|
|
case allZero(v6[:12]):
|
|
// IPv4-compatible ::a.b.c.d; :: and ::1 are already handled above.
|
|
return net.IPv4(v6[12], v6[13], v6[14], v6[15])
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func allZero(b []byte) bool {
|
|
for _, c := range b {
|
|
if c != 0 {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// guardedDialer returns a DialContext that resolves the host itself and
|
|
// re-applies checkBlockedIP to every resolved address immediately before
|
|
// dialing. This closes the DNS-rebinding window between
|
|
// validateRemoteEndpoint and the actual TCP connect performed by the AWS S3
|
|
// client: even if the attacker's DNS flips to 127.0.0.1 (or any other
|
|
// blocked range) after the up-front check, the dial is refused.
|
|
func guardedDialer(endpoint string) func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
return guardedDialerPolicy(endpoint, false)
|
|
}
|
|
|
|
// guardedDialerPolicy is guardedDialer with the same allowPrivate knob as
|
|
// checkBlockedIPPolicy, so the replica upload path can keep dialing private
|
|
// peers while still refusing loopback / link-local / unspecified at connect
|
|
// time (closing the rebinding window for replica hostnames too).
|
|
func guardedDialerPolicy(endpoint string, allowPrivate bool) func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
dialer := &net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}
|
|
return func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
host, port, splitErr := net.SplitHostPort(addr)
|
|
if splitErr != nil {
|
|
return nil, splitErr
|
|
}
|
|
// If the host is already a literal IP just validate and dial it.
|
|
if ip := net.ParseIP(host); ip != nil {
|
|
if err := checkBlockedIPPolicy(endpoint, ip, allowPrivate); err != nil {
|
|
return nil, err
|
|
}
|
|
return dialer.DialContext(ctx, network, addr)
|
|
}
|
|
// Otherwise resolve, validate every answer, and dial the first IP
|
|
// that passes the deny list. Using a literal-IP target prevents the
|
|
// kernel resolver in net.Dialer from looking the name up a second
|
|
// time inside Dial and getting a different answer.
|
|
addrs, lookupErr := lookupIPAddrFunc(ctx, host)
|
|
if lookupErr != nil {
|
|
return nil, fmt.Errorf("resolve remote endpoint host %q: %w", host, lookupErr)
|
|
}
|
|
var firstBlockErr error
|
|
for _, a := range addrs {
|
|
if err := checkBlockedIPPolicy(endpoint, a.IP, allowPrivate); err != nil {
|
|
if firstBlockErr == nil {
|
|
firstBlockErr = err
|
|
}
|
|
continue
|
|
}
|
|
return dialer.DialContext(ctx, network, net.JoinHostPort(a.IP.String(), port))
|
|
}
|
|
if firstBlockErr != nil {
|
|
return nil, firstBlockErr
|
|
}
|
|
return nil, fmt.Errorf("resolve remote endpoint host %q: no addresses", host)
|
|
}
|
|
}
|
|
|
|
// newGuardedHTTPClient returns an *http.Client whose transport refuses to
|
|
// dial addresses that fail checkBlockedIP at connect time. It is meant for
|
|
// per-request use; do not share across remote configs.
|
|
func newGuardedHTTPClient(endpoint string) *http.Client {
|
|
return newGuardedHTTPClientPolicy(endpoint, false)
|
|
}
|
|
|
|
// newGuardedHTTPClientPolicy is newGuardedHTTPClient with the allowPrivate knob
|
|
// for the replica upload path, whose targets are cluster peers on private
|
|
// networks.
|
|
func newGuardedHTTPClientPolicy(endpoint string, allowPrivate bool) *http.Client {
|
|
return &http.Client{
|
|
Transport: &http.Transport{
|
|
// No proxy: guardedDialer must see the real target address. Through
|
|
// a proxy it would only validate the proxy's IP while the proxy
|
|
// re-resolves the endpoint host, reopening the rebinding window the
|
|
// dialer exists to close. Operators that need a proxy can opt out
|
|
// with -volume.allowUntrustedRemoteEndpoints.
|
|
Proxy: nil,
|
|
DialContext: guardedDialerPolicy(endpoint, allowPrivate),
|
|
ForceAttemptHTTP2: true,
|
|
MaxIdleConns: 16,
|
|
IdleConnTimeout: 60 * time.Second,
|
|
TLSHandshakeTimeout: 10 * time.Second,
|
|
ExpectContinueTimeout: 1 * time.Second,
|
|
},
|
|
}
|
|
}
|
|
|
|
// guardedRemoteClient reports the caller-supplied endpoint a backend dials
|
|
// directly and a constructor that routes through the given HTTP client, or
|
|
// ok=false when nothing in the conf steers a destination. The S3-SDK family,
|
|
// azure (once AzureEndpoint is set) and the gcs token exchange all honor a
|
|
// caller-supplied endpoint, so each must pass the SSRF deny-list and the
|
|
// rebinding-safe dialer.
|
|
func guardedRemoteClient(remoteConf *remote_pb.RemoteConf) (endpoint string, makeClient func(*http.Client) (remote_storage.RemoteStorageClient, error), ok bool) {
|
|
if remoteConf == nil {
|
|
return "", nil, false
|
|
}
|
|
if ep, isS3 := s3remote.S3CompatibleEndpoint(remoteConf); isS3 {
|
|
return ep, func(httpClient *http.Client) (remote_storage.RemoteStorageClient, error) {
|
|
return s3remote.MakeWithHTTPClient(remoteConf, httpClient)
|
|
}, true
|
|
}
|
|
if remoteConf.Type == "azure" && remoteConf.AzureEndpoint != "" {
|
|
return remoteConf.AzureEndpoint, func(httpClient *http.Client) (remote_storage.RemoteStorageClient, error) {
|
|
return azureremote.MakeWithHTTPClient(remoteConf, httpClient)
|
|
}, true
|
|
}
|
|
// gcs reaches a fixed object host, but the token exchange goes wherever the
|
|
// supplied credentials say, so guard that endpoint instead.
|
|
if remoteConf.Type == "gcs" && remoteConf.GcsGoogleApplicationCredentials != "" {
|
|
if _, tokenURL, err := gcsremote.ParseInlineCredentials(remoteConf.GcsGoogleApplicationCredentials); err == nil {
|
|
return tokenURL, func(httpClient *http.Client) (remote_storage.RemoteStorageClient, error) {
|
|
return gcsremote.MakeWithHTTPClient(remoteConf, httpClient, gcsremote.StaticKeyCredentialTypes...)
|
|
}, true
|
|
}
|
|
}
|
|
return "", nil, false
|
|
}
|
|
|
|
// gcsCredentialsArePath reports whether a gcs credentials value is a filesystem
|
|
// path rather than inline JSON, matching the gcs client's own inline detection.
|
|
func gcsCredentialsArePath(creds string) bool {
|
|
return creds != "" && !strings.HasPrefix(creds, "{")
|
|
}
|
|
|
|
// checkGcsCredentials rejects a caller-supplied gcs credentials value that
|
|
// would make the SDK read from somewhere other than the credentials themselves,
|
|
// so the request fails before any client is built.
|
|
func checkGcsCredentials(creds string) error {
|
|
if creds == "" {
|
|
return nil
|
|
}
|
|
// A filesystem path is read from disk by the SDK. Accept only inline JSON
|
|
// on the request; the server env var still supplies a path.
|
|
if gcsCredentialsArePath(creds) {
|
|
return fmt.Errorf("gcs credentials must be inline JSON")
|
|
}
|
|
credType, _, parseErr := gcsremote.ParseInlineCredentials(creds)
|
|
if parseErr != nil {
|
|
return parseErr
|
|
}
|
|
if !slices.Contains(gcsremote.StaticKeyCredentialTypes, credType) {
|
|
return fmt.Errorf("gcs credential type %q is not accepted here", credType)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// BuildGuardedRemoteStorageClient builds a remote storage client whose dial
|
|
// path is validated against the SSRF deny-list and pinned against DNS
|
|
// rebinding, unless allowUntrusted is set. It is the single builder for every
|
|
// caller that dials a caller-influenced RemoteConf endpoint: the volume
|
|
// FetchAndWriteNeedle write path and the filer and S3 gateway remote-mount read
|
|
// paths, which otherwise reach the same s3manager sink unguarded.
|
|
func BuildGuardedRemoteStorageClient(ctx context.Context, remoteConf *remote_pb.RemoteConf, allowUntrusted bool) (remote_storage.RemoteStorageClient, error) {
|
|
if !allowUntrusted {
|
|
if remoteConf.GetType() == "gcs" {
|
|
if credsErr := checkGcsCredentials(remoteConf.GetGcsGoogleApplicationCredentials()); credsErr != nil {
|
|
return nil, fmt.Errorf("reject remote credentials: %w", credsErr)
|
|
}
|
|
}
|
|
if endpoint, makeClient, ok := guardedRemoteClient(remoteConf); ok {
|
|
if validateErr := validateRemoteEndpoint(ctx, endpoint); validateErr != nil {
|
|
return nil, fmt.Errorf("reject remote endpoint: %w", validateErr)
|
|
}
|
|
// Build a one-shot client whose dial path re-validates the resolved
|
|
// IP every time. This pins the validated endpoint against DNS
|
|
// rebinding (a hostname that resolves to a public IP for
|
|
// validateRemoteEndpoint and then flips to 127.0.0.1 / 169.254.x.x
|
|
// when the SDK dials).
|
|
client, err := makeClient(newGuardedHTTPClient(endpoint))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get remote client: %w", err)
|
|
}
|
|
return client, nil
|
|
}
|
|
}
|
|
client, err := remote_storage.GetRemoteStorage(remoteConf)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get remote client: %w", err)
|
|
}
|
|
return client, nil
|
|
}
|
|
|
|
func (vs *VolumeServer) FetchAndWriteNeedle(ctx context.Context, req *volume_server_pb.FetchAndWriteNeedleRequest) (resp *volume_server_pb.FetchAndWriteNeedleResponse, err error) {
|
|
if err := vs.checkGrpcAdminAuth(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := vs.CheckMaintenanceMode(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp = &volume_server_pb.FetchAndWriteNeedleResponse{}
|
|
v := vs.store.GetVolume(needle.VolumeId(req.VolumeId))
|
|
if v == nil {
|
|
return nil, fmt.Errorf("not found volume id %d", req.VolumeId)
|
|
}
|
|
|
|
remoteConf := req.RemoteConf
|
|
|
|
client, getClientErr := BuildGuardedRemoteStorageClient(ctx, remoteConf, vs.AllowUntrustedRemoteEndpoints)
|
|
if getClientErr != nil {
|
|
return nil, getClientErr
|
|
}
|
|
|
|
remoteStorageLocation := req.RemoteLocation
|
|
|
|
var data []byte
|
|
var readRemoteErr error
|
|
if cr, ok := client.(remote_storage.RemoteStorageConcurrentReader); ok {
|
|
concurrency := int(req.DownloadConcurrency)
|
|
if concurrency <= 0 {
|
|
concurrency = 0 // let the implementation choose its default
|
|
} else if concurrency > 64 {
|
|
concurrency = 64
|
|
}
|
|
data, readRemoteErr = cr.ReadFileWithConcurrency(remoteStorageLocation, req.Offset, req.Size, concurrency)
|
|
} else {
|
|
data, readRemoteErr = client.ReadFile(remoteStorageLocation, req.Offset, req.Size)
|
|
}
|
|
if readRemoteErr != nil {
|
|
return nil, fmt.Errorf("read from remote %+v: %w", remoteStorageLocation, readRemoteErr)
|
|
}
|
|
// The chunk is recorded with the requested size, so a short read would be
|
|
// cached as a full-size chunk with a zero-padded or truncated tail. Fail
|
|
// loudly instead of persisting silently corrupt content.
|
|
if int64(len(data)) != req.Size {
|
|
return nil, fmt.Errorf("read from remote %+v: got %d bytes, want %d", remoteStorageLocation, len(data), req.Size)
|
|
}
|
|
|
|
// Validate every replica target before writing anything, so a malformed or
|
|
// internal target fails the request instead of leaving a local write behind.
|
|
if !vs.AllowUntrustedRemoteEndpoints {
|
|
for _, replica := range req.Replicas {
|
|
if validateErr := validateReplicaTarget(ctx, replica.Url); validateErr != nil {
|
|
return nil, fmt.Errorf("reject replica target: %w", validateErr)
|
|
}
|
|
}
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
var localErr error
|
|
replicaErrs := make([]error, len(req.Replicas))
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
n := new(needle.Needle)
|
|
n.Id = types.NeedleId(req.NeedleId)
|
|
n.Cookie = types.Cookie(req.Cookie)
|
|
n.Data, n.DataSize = data, uint32(len(data))
|
|
// copied from *Needle.prepareWriteBuffer()
|
|
n.Size = 4 + types.Size(n.DataSize) + 1
|
|
n.Checksum = needle.NewCRC(n.Data)
|
|
n.LastModified = uint64(time.Now().Unix())
|
|
n.SetHasLastModifiedDate()
|
|
if _, localWriteErr := vs.store.WriteVolumeNeedle(v.Id, n, true, false); localWriteErr != nil {
|
|
localErr = fmt.Errorf("local write needle %d size %d: %v", req.NeedleId, req.Size, localWriteErr)
|
|
} else {
|
|
resp.ETag = n.Etag()
|
|
}
|
|
}()
|
|
if len(req.Replicas) > 0 {
|
|
fileId := needle.NewFileId(v.Id, req.NeedleId, req.Cookie)
|
|
for i, replica := range req.Replicas {
|
|
wg.Add(1)
|
|
go func(idx int, targetVolumeServer string) {
|
|
defer wg.Done()
|
|
uploadOption := &operation.UploadOption{
|
|
UploadUrl: fmt.Sprintf("http://%s/%s?type=replicate", targetVolumeServer, fileId.String()),
|
|
Filename: "",
|
|
Cipher: false,
|
|
IsInputCompressed: false,
|
|
IsReplication: true,
|
|
MimeType: "",
|
|
PairMap: nil,
|
|
Jwt: security.EncodedJwt(req.Auth),
|
|
}
|
|
|
|
// Upload through a client that re-checks the target at connect
|
|
// time, so a replica hostname cannot rebind to a blocked address
|
|
// after validateReplicaTarget. Peers may be private, so allow
|
|
// private here; the opt-out uses the shared global client.
|
|
var uploader *operation.Uploader
|
|
if vs.AllowUntrustedRemoteEndpoints {
|
|
var uploaderErr error
|
|
uploader, uploaderErr = operation.NewUploader()
|
|
if uploaderErr != nil {
|
|
replicaErrs[idx] = fmt.Errorf("remote write needle %d size %d: %v", req.NeedleId, req.Size, uploaderErr)
|
|
return
|
|
}
|
|
} else {
|
|
uploader = operation.NewUploaderWithHttpClient(newGuardedHTTPClientPolicy(targetVolumeServer, true))
|
|
}
|
|
|
|
if _, replicaWriteErr := uploader.UploadData(ctx, data, uploadOption); replicaWriteErr != nil {
|
|
replicaErrs[idx] = fmt.Errorf("remote write needle %d size %d: %v", req.NeedleId, req.Size, replicaWriteErr)
|
|
}
|
|
}(i, replica.Url)
|
|
}
|
|
}
|
|
|
|
wg.Wait()
|
|
|
|
// local write error wins; otherwise surface the first replica failure
|
|
err = localErr
|
|
for _, replicaErr := range replicaErrs {
|
|
if err == nil {
|
|
err = replicaErr
|
|
}
|
|
}
|
|
|
|
return resp, err
|
|
}
|