Files
seaweedfs/weed/server/volume_grpc_copy_verify_test.go
T
Chris Lu 37bf1cd91d volume: validate copy/tail source addresses before dialing (#11390)
* pb: stop exiting the process on malformed server addresses

ServerToGrpcAddress and GrpcAddressToServerAddress called glog.Fatalf
when hostAndPort could not parse the port, which os.Exit(255)ed the whole
process. A caller-supplied copy or tail source address reached this path
synchronously in the serving goroutine, so one anonymous VolumeCopy with
a non-numeric port terminated the volume server.

Log the parse error and return the input unchanged instead: the dial or
request that consumes the address then fails as an ordinary error.

* volume: validate copy and tail source addresses before dialing

VolumeCopy, VolumeEcShardsCopy and VolumeTailReceiver dial a
caller-supplied source address (SourceDataNode / SourceVolumeServer)
with no endpoint validation, so an anonymous caller could aim the volume
server at loopback, link-local (cloud metadata) or other unintended
destinations and read dial behavior back as a connectivity oracle.

Apply the same peer-target deny list FetchAndWriteNeedle uses for
replica targets: the source must be a bare host:port whose host is not
loopback, link-local or unspecified; cluster peers stay reachable on
private networks, and -volume.allowUntrustedRemoteEndpoints opts out.
The loopback-using copy tests set the flag to keep exercising the copy
path in process.

* rust volume: validate copy and tail source addresses before dialing

Mirror the Go guard on the Rust volume server: volume_copy,
volume_ec_shards_copy and volume_tail_receiver dial a caller-supplied
source address, so run it through validate_replica_target first (bare
host:port; no loopback, link-local or unspecified hosts; private peers
stay allowed). --volume.allowUntrustedRemoteEndpoints opts out; the test
fixture and the Rust test-cluster launcher set it so loopback sources in
tests keep working.

* volume: pin validated copy/tail source addresses at dial time

validateReplicaTarget resolves the source hostname once, but the gRPC
client resolved it again at connect, leaving a DNS-rebinding window for
hostname sources. The copy and tail source dials now run through the
same guardedDialerPolicy the remote-storage path uses, so every resolved
address is re-checked against the replica deny list (private peers
allowed) immediately before the TCP connect. guardedDialerPolicy also
moves to util.OutboundDialContext so the guarded path keeps the -ip.bind
source binding the default gRPC dialer had.

The Rust volume server mirrors this with connect_guarded, a tonic
connector that resolves, re-checks each address, and connects to the
first passing IP; handlers use it whenever the untrusted-endpoint
opt-out is off. A handler-level test now exercises the enabled
validation branches for all three source-taking RPCs.

* pb: return empty server address for malformed grpc addresses

GrpcAddressToServerAddress used to return the unparseable input on a
hostAndPort failure, so a malformed raft address (e.g. "host:abc")
flowed into admin dashboard master maps unchanged. Return an empty
string instead, skip empty conversions at the two raft-cluster merge
sites, and drop the now-stale comment about the fatal exit the earlier
commit removed.

* test: opt erasure-coding loopback clusters out of the remote endpoint guard

The erasure-coding suites drive VolumeEcShardsCopy / VolumeCopy between
volume servers bound to 127.0.0.1, which the copy/tail source guard now
rejects by default. Pass -volume.allowUntrustedRemoteEndpoints to the
test volume launches, matching what the volume_server framework
harnesses already do.

* admin: only claim fallback master leadership on an empty raft response

A nonempty RaftListClusterServers response whose entries were all
rejected left masterMap empty, so the fallback marked the reachable
current master as leader the same way a genuinely empty (non-raft)
response does. Track whether the successful response returned zero
servers and only promote the fallback master then.
2026-09-18 12:55:47 -07:00

291 lines
11 KiB
Go

package weed_server
import (
"context"
"errors"
"fmt"
"os"
"strings"
"sync/atomic"
"testing"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
"github.com/seaweedfs/seaweedfs/weed/stats"
"github.com/seaweedfs/seaweedfs/weed/storage"
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
"github.com/seaweedfs/seaweedfs/weed/storage/types"
"github.com/seaweedfs/seaweedfs/weed/util"
)
type volumeCopyStatusServer struct {
volume_server_pb.UnimplementedVolumeServerServer
delegate *VolumeServer
failStatusCall int32
statusErr error
statusCalls atomic.Int32
}
func (s *volumeCopyStatusServer) VolumeStatus(ctx context.Context, req *volume_server_pb.VolumeStatusRequest) (*volume_server_pb.VolumeStatusResponse, error) {
if s.statusCalls.Add(1) == s.failStatusCall {
return nil, s.statusErr
}
return s.delegate.VolumeStatus(ctx, req)
}
func (s *volumeCopyStatusServer) ReadVolumeFileStatus(ctx context.Context, req *volume_server_pb.ReadVolumeFileStatusRequest) (*volume_server_pb.ReadVolumeFileStatusResponse, error) {
return s.delegate.ReadVolumeFileStatus(ctx, req)
}
func (s *volumeCopyStatusServer) CopyFile(req *volume_server_pb.CopyFileRequest, stream volume_server_pb.VolumeServer_CopyFileServer) error {
return s.delegate.CopyFile(req, stream)
}
func newVolumeCopyTestStore(t *testing.T, dir string) *storage.Store {
t.Helper()
store := storage.NewStore(
grpc.WithTransportCredentials(insecure.NewCredentials()),
"127.0.0.1", 0, 0, "", "test-store",
[]string{dir}, []int32{10}, []util.MinFreeSpace{{}},
dir, storage.NeedleMapInMemory,
[]types.DiskType{types.HardDriveType}, [][]string{nil},
0, stats.DefaultDiskIOProbeConfig(),
)
store.Locations[0].AvailableSpace.Store(^uint64(0))
t.Cleanup(store.Close)
return store
}
func runVolumeCopyWithStatusFailure(t *testing.T, failStatusCall int32) (error, *storage.Store) {
t.Helper()
const vid = needle.VolumeId(43)
sourceStore := newVolumeCopyTestStore(t, t.TempDir())
if err := sourceStore.AddVolume(vid, "", storage.NeedleMapInMemory, "000", "", 0,
needle.GetCurrentVersion(), 0, types.HardDriveType, 0); err != nil {
t.Fatalf("add source volume: %v", err)
}
source := &volumeCopyStatusServer{
delegate: &VolumeServer{store: sourceStore},
failStatusCall: failStatusCall,
statusErr: errors.New("source volume status unavailable"),
}
port := serveGrpc(t, func(server *grpc.Server) {
volume_server_pb.RegisterVolumeServerServer(server, source)
})
targetStore := newVolumeCopyTestStore(t, t.TempDir())
target := &VolumeServer{
store: targetStore,
grpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
AllowUntrustedRemoteEndpoints: true,
}
err := target.VolumeCopy(&volume_server_pb.VolumeCopyRequest{
VolumeId: uint32(vid),
SourceDataNode: fmt.Sprintf("127.0.0.1:%d.%d", port-10000, port),
}, &fakeVolumeCopyStream{})
return err, targetStore
}
func TestVolumeCopyContinuesWhenInitialStatusUnavailable(t *testing.T) {
err, targetStore := runVolumeCopyWithStatusFailure(t, 1)
if err != nil {
t.Fatalf("VolumeCopy should continue when the initial status is unavailable: %v", err)
}
if targetStore.GetVolume(43) == nil {
t.Fatal("copied volume was not mounted")
}
}
func TestVolumeCopyFailsWhenFinalStatusUnavailable(t *testing.T) {
err, targetStore := runVolumeCopyWithStatusFailure(t, 2)
if err == nil || !strings.Contains(err.Error(), "status after copy") {
t.Fatalf("VolumeCopy error = %v, want final status error", err)
}
if targetStore.GetVolume(43) != nil {
t.Fatal("volume was mounted after final status validation failed")
}
select {
case message := <-targetStore.NewVolumesChan:
t.Fatalf("volume was announced after final status validation failed: %+v", message)
default:
}
dataBaseFileName := storage.VolumeFileName(targetStore.Locations[0].Directory, "", 43)
indexBaseFileName := storage.VolumeFileName(targetStore.Locations[0].IdxDirectory, "", 43)
for _, fileName := range []string{
dataBaseFileName + ".dat",
indexBaseFileName + ".idx",
dataBaseFileName + ".vif",
dataBaseFileName + ".note",
} {
if _, statErr := os.Stat(fileName); !os.IsNotExist(statErr) {
t.Fatalf("copy artifact %s remains after final status validation failed: %v", fileName, statErr)
}
}
}
func TestVolumeCopyKeepsExistingReplicaWhenDestinationFull(t *testing.T) {
const vid = needle.VolumeId(44)
sourceStore := newVolumeCopyTestStore(t, t.TempDir())
if err := sourceStore.AddVolume(vid, "", storage.NeedleMapInMemory, "000", "", 0,
needle.GetCurrentVersion(), 0, types.HardDriveType, 0); err != nil {
t.Fatalf("add source volume: %v", err)
}
source := &volumeCopyStatusServer{
delegate: &VolumeServer{store: sourceStore},
failStatusCall: 1,
statusErr: errors.New("source volume status unavailable"),
}
port := serveGrpc(t, func(server *grpc.Server) {
volume_server_pb.RegisterVolumeServerServer(server, source)
})
targetStore := newVolumeCopyTestStore(t, t.TempDir())
if err := targetStore.AddVolume(vid, "", storage.NeedleMapInMemory, "000", "", 0,
needle.GetCurrentVersion(), 0, types.HardDriveType, 0); err != nil {
t.Fatalf("add target volume: %v", err)
}
targetStore.Locations[0].AvailableSpace.Store(0)
target := &VolumeServer{
store: targetStore,
grpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
AllowUntrustedRemoteEndpoints: true,
}
err := target.VolumeCopy(&volume_server_pb.VolumeCopyRequest{
VolumeId: uint32(vid),
SourceDataNode: fmt.Sprintf("127.0.0.1:%d.%d", port-10000, port),
}, &fakeVolumeCopyStream{})
if err == nil {
t.Fatal("VolumeCopy should fail when no destination location is available")
}
if targetStore.GetVolume(vid) == nil {
t.Fatal("existing replica was destroyed before a destination was reserved")
}
}
func TestVolumeCopyReplacesReplicaAtSlotLimit(t *testing.T) {
const vid = needle.VolumeId(45)
sourceStore := newVolumeCopyTestStore(t, t.TempDir())
if err := sourceStore.AddVolume(vid, "", storage.NeedleMapInMemory, "000", "", 0,
needle.GetCurrentVersion(), 0, types.HardDriveType, 0); err != nil {
t.Fatalf("add source volume: %v", err)
}
source := &volumeCopyStatusServer{
delegate: &VolumeServer{store: sourceStore},
failStatusCall: 1,
statusErr: errors.New("source volume status unavailable"),
}
port := serveGrpc(t, func(server *grpc.Server) {
volume_server_pb.RegisterVolumeServerServer(server, source)
})
targetStore := newVolumeCopyTestStore(t, t.TempDir())
if err := targetStore.AddVolume(vid, "", storage.NeedleMapInMemory, "000", "", 0,
needle.GetCurrentVersion(), 0, types.HardDriveType, 0); err != nil {
t.Fatalf("add target volume: %v", err)
}
targetStore.Locations[0].MaxVolumeCount = 1
target := &VolumeServer{
store: targetStore,
grpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
AllowUntrustedRemoteEndpoints: true,
}
err := target.VolumeCopy(&volume_server_pb.VolumeCopyRequest{
VolumeId: uint32(vid),
SourceDataNode: fmt.Sprintf("127.0.0.1:%d.%d", port-10000, port),
}, &fakeVolumeCopyStream{})
if err != nil {
t.Fatalf("VolumeCopy should replace the replica at the slot limit: %v", err)
}
if targetStore.GetVolume(vid) == nil {
t.Fatal("replaced volume was not mounted")
}
}
// fakeVolumeCopyStream is a no-op VolumeServer_VolumeCopyServer; VolumeCopy
// errors out before sending anything in this test.
type fakeVolumeCopyStream struct {
grpc.ServerStream
}
func (s *fakeVolumeCopyStream) Send(*volume_server_pb.VolumeCopyResponse) error { return nil }
func (s *fakeVolumeCopyStream) Context() context.Context { return context.Background() }
func (s *fakeVolumeCopyStream) SetHeader(metadata.MD) error { return nil }
func (s *fakeVolumeCopyStream) SendHeader(metadata.MD) error { return nil }
func (s *fakeVolumeCopyStream) SetTrailer(metadata.MD) {}
func (s *fakeVolumeCopyStream) SendMsg(any) error { return nil }
func (s *fakeVolumeCopyStream) RecvMsg(any) error { return nil }
// TestVolumeCopy_KeepsExistingReplicaWhenSourceUnreachable verifies the
// verify-before-destroy invariant: a pre-existing healthy local replica must
// NOT be deleted when the source cannot be reached. The pre-fix code deleted
// the destination up front (and, on retry, could lose the volume entirely);
// the fix defers the delete until the source ReadVolumeFileStatus succeeds.
func TestVolumeCopy_KeepsExistingReplicaWhenSourceUnreachable(t *testing.T) {
dir := t.TempDir()
store := storage.NewStore(
grpc.WithTransportCredentials(insecure.NewCredentials()),
"127.0.0.1", 0, 0, "", "test-store",
[]string{dir}, []int32{10}, []util.MinFreeSpace{{}},
dir, storage.NeedleMapInMemory,
[]types.DiskType{types.HardDriveType}, [][]string{nil},
0, stats.DiskIOProbeConfig{},
)
const vid = needle.VolumeId(42)
if err := store.AddVolume(vid, "", storage.NeedleMapInMemory, "000", "", 0, needle.GetCurrentVersion(), 0, types.HardDriveType, 0); err != nil {
t.Fatalf("AddVolume: %v", err)
}
if store.GetVolume(vid) == nil {
t.Fatalf("setup: volume %d should exist", vid)
}
vs := &VolumeServer{
store: store,
grpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
AllowUntrustedRemoteEndpoints: true,
}
// 127.0.0.1:1 is unreachable, so ReadVolumeFileStatus on the source fails.
req := &volume_server_pb.VolumeCopyRequest{
VolumeId: uint32(vid),
SourceDataNode: "127.0.0.1:1",
}
err := vs.VolumeCopy(req, &fakeVolumeCopyStream{})
if err == nil {
t.Fatalf("VolumeCopy should fail when the source is unreachable")
}
if store.GetVolume(vid) == nil {
t.Fatalf("existing replica %d was destroyed before the source was verified", vid)
}
}
// The copy and tail handlers dial a caller-supplied source address. With the
// default posture (AllowUntrustedRemoteEndpoints unset) a source on a blocked
// address must be rejected before any dial; the opt-out flag restores the old
// behavior for operators whose sources legitimately sit on those ranges.
func TestCopyTailHandlersRejectUntrustedSources(t *testing.T) {
for _, source := range []string{"127.0.0.1:1.10001", "169.254.169.254:0.80"} {
vs := &VolumeServer{
grpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
}
if err := vs.VolumeCopy(&volume_server_pb.VolumeCopyRequest{VolumeId: 1, SourceDataNode: source}, &fakeVolumeCopyStream{}); err == nil {
t.Errorf("VolumeCopy accepted source %q", source)
}
if _, err := vs.VolumeEcShardsCopy(context.Background(), &volume_server_pb.VolumeEcShardsCopyRequest{VolumeId: 1, SourceDataNode: source}); err == nil {
t.Errorf("VolumeEcShardsCopy accepted source %q", source)
}
if _, err := vs.VolumeTailReceiver(context.Background(), &volume_server_pb.VolumeTailReceiverRequest{VolumeId: 1, SourceVolumeServer: source}); err == nil {
t.Errorf("VolumeTailReceiver accepted source %q", source)
}
}
}