master: bind heartbeat claims to the connecting peer (#9443)

SendHeartbeat used to accept whatever Ip/Port/Volumes the caller put on
the wire. Three changes tighten that:

- Reject heartbeats whose Ip does not match the gRPC peer's source
  address. Loopback peers are still trusted; operators behind a proxy
  can opt out with -master.allowUntrustedHeartbeat.
- Track which (ip, port) first claimed a volume id or an ec shard slot
  and drop foreign re-claims. Non-EC volume claims are bounded by the
  replica copy count so legitimate replicas still register. EC
  ownership is keyed by (vid, shard_id) so the same vid can legitimately
  be split across many peers as long as their EcIndexBits are disjoint;
  rejected bits are cleared from the bitmap and the parallel ShardSizes
  array is compacted in lock-step.
- Maintain reverse indexes owner -> volumes and owner -> ec shard slots
  so disconnect cleanup is O(M) in what that peer held rather than O(N)
  over the whole map.

Bindings are also released when a heartbeat reports that the peer no
longer holds an id, either via explicit Deleted{Volumes,EcShards}
entries or by omitting it from a full snapshot. Without this, a planned
rebalance that moved a vid or an ec shard from peer A to peer B would
leave B's heartbeats permanently filtered out until A disconnected,
breaking ec encode/decode flows that delete shards on the source as
soon as the move completes.

The (vid -> owners) binding still does not track which replica slot
each peer occupies, so the first N claims under the copy count win;
strict per-slot mapping is a follow-up.
This commit is contained in:
Chris Lu 2026-05-12 15:38:52 -07:00 committed by GitHub
parent 10cc06333b
commit f28c7ce6df
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 1091 additions and 0 deletions

View File

@ -74,6 +74,10 @@ type MasterOptions struct {
telemetryEnabled *bool
debug *bool
debugPort *int
// allowUntrustedHeartbeat disables the source-ip binding check on
// SendHeartbeat. Required for deployments where a NAT or proxy sits
// between volume servers and the master.
allowUntrustedHeartbeat *bool
// shutdownCtx, when non-nil, tells startMaster to shut down once the ctx
// is cancelled. Used by integration tests and by weed mini; nil for
// standalone weed master.
@ -109,6 +113,7 @@ func init() {
m.telemetryEnabled = cmdMaster.Flag.Bool("telemetry", false, "enable telemetry reporting")
m.debug = cmdMaster.Flag.Bool("debug", false, "serves runtime profiling data via pprof on the port specified by -debug.port")
m.debugPort = cmdMaster.Flag.Int("debug.port", 6060, "http port for debugging")
m.allowUntrustedHeartbeat = cmdMaster.Flag.Bool("master.allowUntrustedHeartbeat", false, "accept volume server heartbeats whose advertised ip does not match the gRPC peer address (required behind NAT/proxy)")
}
var cmdMaster = &Command{
@ -463,5 +468,6 @@ func (m *MasterOptions) toMasterOption(whiteList []string) *weed_server.MasterOp
MetricsIntervalSec: *m.metricsIntervalSec,
TelemetryUrl: *m.telemetryUrl,
TelemetryEnabled: *m.telemetryEnabled,
AllowUntrustedHeartbeat: m.allowUntrustedHeartbeat != nil && *m.allowUntrustedHeartbeat,
}
}

View File

@ -90,6 +90,9 @@ func (ms *MasterServer) SendHeartbeat(stream master_pb.Seaweed_SendHeartbeatServ
ms.Topo.UnRegisterDataNode(dn)
glog.V(0).Infof("unregister disconnected volume server %s:%d", dn.Ip, dn.Port)
ms.UnRegisterUuids(dn.Ip, dn.Port)
if ms.heartbeatOwnership != nil {
ms.heartbeatOwnership.release(dn.Ip, uint32(dn.Port))
}
if ms.Topo.IsLeader() && (len(message.DeletedVids) > 0 || len(message.DeletedEcVids) > 0) {
ms.broadcastToClients(&master_pb.KeepConnectedResponse{VolumeLocation: message})
@ -135,6 +138,11 @@ func (ms *MasterServer) SendHeartbeat(stream master_pb.Seaweed_SendHeartbeatServ
if heartbeat.Ip == "" {
continue
} // ToDo must be removed after update major version
if err := ms.authorizeHeartbeatPeer(stream.Context(), heartbeat); err != nil {
glog.Errorf("SendHeartbeat refused: %v", err)
stats.MasterReceivedHeartbeatCounter.WithLabelValues("rejected").Inc()
return err
}
dcName, rackName := ms.Topo.Configuration.Locate(heartbeat.Ip, heartbeat.DataCenter, heartbeat.Rack)
dc := ms.Topo.GetOrCreateDataCenter(dcName)
rack := dc.GetOrCreateRack(rackName)
@ -165,6 +173,13 @@ func (ms *MasterServer) SendHeartbeat(stream master_pb.Seaweed_SendHeartbeatServ
dn.AdjustMaxVolumeCounts(heartbeat.MaxVolumeCounts)
dn.UpdateDiskTags(heartbeat.DiskTags)
// Reject claims on volume / ec shard ids that another peer already owns.
// Replica-slot semantics (one owner per replica) are approximated by the
// declared copy count; the strict per-slot mapping is a follow-up.
if ms.heartbeatOwnership != nil {
ms.filterVolumeOwnership(dn, heartbeat)
}
glog.V(4).Infof("master received heartbeat %s", heartbeat.String())
stats.MasterReceivedHeartbeatCounter.WithLabelValues("total").Inc()

View File

@ -0,0 +1,447 @@
package weed_server
import (
"context"
"fmt"
"math/bits"
"net"
"sync"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/peer"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/storage/super_block"
"github.com/seaweedfs/seaweedfs/weed/topology"
)
// heartbeatOwner identifies the data node that first claimed a volume or ec
// shard id. Two heartbeats are considered to come from the same owner when
// both ip and port match.
type heartbeatOwner struct {
ip string
port uint32
}
// ecShardKey identifies a single erasure-coded shard slot, scoped by the
// volume id it belongs to. EC distributes shards of the same volume id across
// multiple data nodes (one node per shard), so ownership must be tracked per
// (vid, shard_id) pair rather than per vid.
type ecShardKey struct {
vid uint32
shardId uint32
}
// heartbeatOwnership tracks which (ip, port) currently owns each volume id
// and each ec shard slot. For replicated non-EC volumes, multiple owners are
// allowed up to the declared copy count; for EC, each (vid, shard_id) pair
// has exactly one owner, but the same vid may be split across many peers
// when each peer claims a disjoint subset of shard ids.
//
// All mutating operations also update reverse indexes keyed by owner so that
// stream disconnects can release a peer's bindings in O(M) where M is the
// number of slots that specific peer held, rather than walking the full map.
type heartbeatOwnership struct {
mu sync.Mutex
volumes map[uint32][]heartbeatOwner // volume id -> owners
ecShards map[ecShardKey]heartbeatOwner // (vid, shard) -> owner
ownerToVolumes map[heartbeatOwner]map[uint32]struct{}
ownerToEcShards map[heartbeatOwner]map[ecShardKey]struct{}
}
func newHeartbeatOwnership() *heartbeatOwnership {
return &heartbeatOwnership{
volumes: make(map[uint32][]heartbeatOwner),
ecShards: make(map[ecShardKey]heartbeatOwner),
ownerToVolumes: make(map[heartbeatOwner]map[uint32]struct{}),
ownerToEcShards: make(map[heartbeatOwner]map[ecShardKey]struct{}),
}
}
// claimVolume returns true if (ip, port) is allowed to claim ownership of vid.
// maxOwners caps how many distinct owners the same vid may have (one per
// replica slot). Pass 1 if the replica count is unknown.
func (o *heartbeatOwnership) claimVolume(vid uint32, ip string, port uint32, maxOwners int) bool {
if maxOwners < 1 {
maxOwners = 1
}
owner := heartbeatOwner{ip: ip, port: port}
o.mu.Lock()
defer o.mu.Unlock()
owners := o.volumes[vid]
for _, existing := range owners {
if existing == owner {
return true
}
}
if len(owners) >= maxOwners {
return false
}
o.volumes[vid] = append(owners, owner)
o.indexVolumeLocked(owner, vid)
return true
}
// claimEcShards intersects the requested EcIndexBits with the bits this peer
// is allowed to own, claiming each (vid, shard_id) pair independently. The
// returned bitmap contains only the shards bound to (ip, port); rejected bits
// are dropped so the caller can adjust ShardSizes alignment accordingly.
func (o *heartbeatOwnership) claimEcShards(vid uint32, requested uint32, ip string, port uint32) uint32 {
if requested == 0 {
return 0
}
owner := heartbeatOwner{ip: ip, port: port}
o.mu.Lock()
defer o.mu.Unlock()
var granted uint32
for bitmap := requested; bitmap != 0; {
shardId := uint32(bits.TrailingZeros32(bitmap))
bitmap &^= 1 << shardId
key := ecShardKey{vid: vid, shardId: shardId}
existing, taken := o.ecShards[key]
if taken && existing != owner {
continue
}
if !taken {
o.ecShards[key] = owner
o.indexEcShardLocked(owner, key)
}
granted |= 1 << shardId
}
return granted
}
// release drops every entry owned by (ip, port). Called when a heartbeat
// stream ends so that a legitimate restart of the same peer (or a planned
// migration) can re-register the same ids. Uses the reverse indexes so the
// cost is proportional to what this peer held, not to the size of the cluster.
func (o *heartbeatOwnership) release(ip string, port uint32) {
owner := heartbeatOwner{ip: ip, port: port}
o.mu.Lock()
defer o.mu.Unlock()
for vid := range o.ownerToVolumes[owner] {
o.removeVolumeOwnerLocked(vid, owner)
}
delete(o.ownerToVolumes, owner)
for key := range o.ownerToEcShards[owner] {
if existing, ok := o.ecShards[key]; ok && existing == owner {
delete(o.ecShards, key)
}
}
delete(o.ownerToEcShards, owner)
}
// releaseVolume drops the binding for a single volume id held by (ip, port).
// Used when a heartbeat reports the volume as deleted from this peer or when
// a full snapshot no longer lists it, so a subsequent peer migration can
// claim the same id without waiting for the stream to disconnect.
func (o *heartbeatOwnership) releaseVolume(vid uint32, ip string, port uint32) {
owner := heartbeatOwner{ip: ip, port: port}
o.mu.Lock()
defer o.mu.Unlock()
o.releaseVolumeLocked(vid, owner)
}
// releaseEcShards drops the bindings for every shard set in the bitmap that
// is currently owned by (ip, port). Bits owned by other peers are ignored so
// a malicious or stale heartbeat cannot evict a legitimate owner.
func (o *heartbeatOwnership) releaseEcShards(vid uint32, bitmap uint32, ip string, port uint32) {
if bitmap == 0 {
return
}
owner := heartbeatOwner{ip: ip, port: port}
o.mu.Lock()
defer o.mu.Unlock()
for b := bitmap; b != 0; {
shardId := uint32(bits.TrailingZeros32(b))
b &^= 1 << shardId
o.releaseEcShardLocked(ecShardKey{vid: vid, shardId: shardId}, owner)
}
}
// peerVolumes returns the set of volume ids currently owned by (ip, port).
// The caller may mutate the returned slice; the snapshot is taken under the
// owner-tracker lock so it is safe to walk concurrently with other heartbeats.
func (o *heartbeatOwnership) peerVolumes(ip string, port uint32) []uint32 {
owner := heartbeatOwner{ip: ip, port: port}
o.mu.Lock()
defer o.mu.Unlock()
held := o.ownerToVolumes[owner]
if len(held) == 0 {
return nil
}
out := make([]uint32, 0, len(held))
for vid := range held {
out = append(out, vid)
}
return out
}
// peerEcShardBitmaps returns, for each EC volume id this peer currently owns,
// the bitmap of shard ids it holds. Used to diff a full snapshot against the
// existing bindings so dropped shards can be released.
func (o *heartbeatOwnership) peerEcShardBitmaps(ip string, port uint32) map[uint32]uint32 {
owner := heartbeatOwner{ip: ip, port: port}
o.mu.Lock()
defer o.mu.Unlock()
held := o.ownerToEcShards[owner]
if len(held) == 0 {
return nil
}
out := make(map[uint32]uint32, len(held))
for key := range held {
out[key.vid] |= 1 << key.shardId
}
return out
}
// removeVolumeOwnerLocked drops owner from o.volumes[vid] and trims the
// underlying slice. Caller must hold o.mu.
func (o *heartbeatOwnership) removeVolumeOwnerLocked(vid uint32, owner heartbeatOwner) {
owners, ok := o.volumes[vid]
if !ok {
return
}
kept := owners[:0]
for _, existing := range owners {
if existing != owner {
kept = append(kept, existing)
}
}
if len(kept) == 0 {
delete(o.volumes, vid)
} else {
o.volumes[vid] = kept
}
}
// releaseVolumeLocked drops a single (vid, owner) binding and tidies the
// reverse index. Caller must hold o.mu.
func (o *heartbeatOwnership) releaseVolumeLocked(vid uint32, owner heartbeatOwner) {
o.removeVolumeOwnerLocked(vid, owner)
if set, ok := o.ownerToVolumes[owner]; ok {
delete(set, vid)
if len(set) == 0 {
delete(o.ownerToVolumes, owner)
}
}
}
// releaseEcShardLocked drops a single (vid, shard, owner) binding and tidies
// the reverse index. Caller must hold o.mu.
func (o *heartbeatOwnership) releaseEcShardLocked(key ecShardKey, owner heartbeatOwner) {
if existing, ok := o.ecShards[key]; !ok || existing != owner {
return
}
delete(o.ecShards, key)
if set, ok := o.ownerToEcShards[owner]; ok {
delete(set, key)
if len(set) == 0 {
delete(o.ownerToEcShards, owner)
}
}
}
// indexVolumeLocked records the reverse-mapping owner -> vid. Caller must
// hold o.mu.
func (o *heartbeatOwnership) indexVolumeLocked(owner heartbeatOwner, vid uint32) {
set, ok := o.ownerToVolumes[owner]
if !ok {
set = make(map[uint32]struct{})
o.ownerToVolumes[owner] = set
}
set[vid] = struct{}{}
}
// indexEcShardLocked records the reverse-mapping owner -> (vid, shard).
// Caller must hold o.mu.
func (o *heartbeatOwnership) indexEcShardLocked(owner heartbeatOwner, key ecShardKey) {
set, ok := o.ownerToEcShards[owner]
if !ok {
set = make(map[ecShardKey]struct{})
o.ownerToEcShards[owner] = set
}
set[key] = struct{}{}
}
// authorizeHeartbeatPeer checks that heartbeat.Ip matches the connection's
// source address. Loopback peers are trusted because dev clusters routinely
// run master and volume server on the same host with mismatched advertised
// ips. In-process / bufconn / unix-socket peers (used by `weed server` where
// master and volume run in the same OS process) surface with a non-TCP
// net.Addr and are also trusted. Operators with a proxy in front of the
// master can disable this check via MasterOption.AllowUntrustedHeartbeat.
func (ms *MasterServer) authorizeHeartbeatPeer(ctx context.Context, heartbeat *master_pb.Heartbeat) error {
if ms.option != nil && ms.option.AllowUntrustedHeartbeat {
return nil
}
pr, ok := peer.FromContext(ctx)
if !ok || pr.Addr == nil {
return fmt.Errorf("heartbeat peer info missing")
}
if tlsInfo, ok := pr.AuthInfo.(credentials.TLSInfo); ok && len(tlsInfo.State.PeerCertificates) > 0 {
cn := tlsInfo.State.PeerCertificates[0].Subject.CommonName
glog.V(2).Infof("heartbeat from CN=%q claiming ip=%s:%d", cn, heartbeat.Ip, heartbeat.Port)
}
tcpAddr, isTCP := pr.Addr.(*net.TCPAddr)
if !isTCP {
// In-process / bufconn / unix-socket peer; same OS process as the
// master, so the source IP cannot be spoofed by a remote attacker.
return nil
}
host := tcpAddr.IP.String()
if host == heartbeat.Ip {
return nil
}
if tcpAddr.IP.IsLoopback() {
return nil
}
return fmt.Errorf("heartbeat ip %q does not match peer %q", heartbeat.Ip, host)
}
// volumeMaxOwners returns how many distinct peers may legitimately own the
// same volume id, derived from its replica placement byte. Falls back to one
// owner if the placement byte is malformed.
func volumeMaxOwners(replicaPlacement uint32) int {
rp, err := super_block.NewReplicaPlacementFromByte(byte(replicaPlacement))
if err != nil || rp == nil {
return 1
}
return rp.GetCopyCount()
}
// filterVolumeOwnership drops volume / ec shard claims from the heartbeat
// whenever another peer already owns that id. The peer's own heartbeat keeps
// its existing claims; only foreign claims are stripped. EC shards are
// filtered per (vid, shard_id), so multiple peers can legitimately heartbeat
// the same EC vid as long as their shard bitmaps are disjoint.
//
// Bindings are also released when the heartbeat reports that this peer no
// longer owns an id, either explicitly via Deleted{Volumes,EcShards} or
// implicitly by omitting it from a full snapshot. Without this, a planned
// migration that moves a vid (or an EC shard) from peer A to peer B would
// leave B's heartbeats permanently filtered out because the first-claim
// binding to A never expires until A disconnects.
func (ms *MasterServer) filterVolumeOwnership(dn *topology.DataNode, heartbeat *master_pb.Heartbeat) {
ip := dn.Ip
port := uint32(dn.Port)
// Explicit drops: release before we claim survivors so a same-heartbeat
// rebalance (delete shard X, claim shard Y) does not race itself.
for _, v := range heartbeat.DeletedVolumes {
ms.heartbeatOwnership.releaseVolume(v.Id, ip, port)
}
for _, s := range heartbeat.DeletedEcShards {
if s.EcIndexBits != 0 {
ms.heartbeatOwnership.releaseEcShards(s.Id, s.EcIndexBits, ip, port)
}
}
// Full snapshot of regular volumes: diff against this peer's existing
// bindings and release every vid the peer no longer reports. Triggered by
// a non-empty Volumes list or by HasNoVolumes=true (the latter means the
// peer holds zero volumes after the snapshot).
if len(heartbeat.Volumes) > 0 || heartbeat.HasNoVolumes {
present := make(map[uint32]struct{}, len(heartbeat.Volumes))
for _, v := range heartbeat.Volumes {
present[v.Id] = struct{}{}
}
for _, vid := range ms.heartbeatOwnership.peerVolumes(ip, port) {
if _, still := present[vid]; !still {
ms.heartbeatOwnership.releaseVolume(vid, ip, port)
}
}
}
// Full snapshot of EC shards: same diff, but bit-wise per vid. Triggered
// by EcShards being non-empty or HasNoEcShards=true.
if len(heartbeat.EcShards) > 0 || heartbeat.HasNoEcShards {
present := make(map[uint32]uint32, len(heartbeat.EcShards))
for _, s := range heartbeat.EcShards {
present[s.Id] |= s.EcIndexBits
}
for vid, held := range ms.heartbeatOwnership.peerEcShardBitmaps(ip, port) {
dropped := held &^ present[vid]
if dropped != 0 {
ms.heartbeatOwnership.releaseEcShards(vid, dropped, ip, port)
}
}
}
if n := len(heartbeat.Volumes); n > 0 {
kept := heartbeat.Volumes[:0]
for _, v := range heartbeat.Volumes {
if ms.heartbeatOwnership.claimVolume(v.Id, ip, port, volumeMaxOwners(v.ReplicaPlacement)) {
kept = append(kept, v)
} else {
glog.V(0).Infof("rejecting volume %d claim from %s:%d (owned by another peer)", v.Id, ip, port)
}
}
heartbeat.Volumes = kept
}
if n := len(heartbeat.NewVolumes); n > 0 {
kept := heartbeat.NewVolumes[:0]
for _, v := range heartbeat.NewVolumes {
if ms.heartbeatOwnership.claimVolume(v.Id, ip, port, volumeMaxOwners(v.ReplicaPlacement)) {
kept = append(kept, v)
} else {
glog.V(0).Infof("rejecting new volume %d claim from %s:%d (owned by another peer)", v.Id, ip, port)
}
}
heartbeat.NewVolumes = kept
}
if n := len(heartbeat.EcShards); n > 0 {
heartbeat.EcShards = ms.applyEcShardOwnership(heartbeat.EcShards, ip, port, "ec shard")
}
if n := len(heartbeat.NewEcShards); n > 0 {
heartbeat.NewEcShards = ms.applyEcShardOwnership(heartbeat.NewEcShards, ip, port, "new ec shard")
}
}
// applyEcShardOwnership claims (vid, shard_id) ownership for each shard set
// in the message bitmap. Bits owned by a different peer are cleared from
// EcIndexBits and the parallel ShardSizes array is compacted in lock-step;
// messages that end up with no remaining shards are dropped entirely.
func (ms *MasterServer) applyEcShardOwnership(messages []*master_pb.VolumeEcShardInformationMessage, ip string, port uint32, label string) []*master_pb.VolumeEcShardInformationMessage {
kept := messages[:0]
for _, s := range messages {
granted := ms.heartbeatOwnership.claimEcShards(s.Id, s.EcIndexBits, ip, port)
if granted == s.EcIndexBits {
kept = append(kept, s)
continue
}
rejected := s.EcIndexBits &^ granted
glog.V(0).Infof("rejecting %s bits 0x%x for volume %d from %s:%d (owned by another peer)", label, rejected, s.Id, ip, port)
if granted == 0 {
continue
}
s.ShardSizes = compactShardSizes(s.EcIndexBits, granted, s.ShardSizes)
s.EcIndexBits = granted
kept = append(kept, s)
}
return kept
}
// compactShardSizes returns the subset of sizes that corresponds to the bits
// in granted. The input sizes slice is parallel to the set bits in original
// (in increasing shard-id order); the same ordering applies to the output.
func compactShardSizes(original, granted uint32, sizes []int64) []int64 {
if len(sizes) == 0 || granted == original {
return sizes
}
out := sizes[:0]
var i int
for bitmap := original; bitmap != 0; {
shardId := uint32(bits.TrailingZeros32(bitmap))
bitmap &^= 1 << shardId
if i < len(sizes) && granted&(1<<shardId) != 0 {
out = append(out, sizes[i])
}
i++
}
return out
}

View File

@ -0,0 +1,613 @@
package weed_server
import (
"context"
"net"
"testing"
"google.golang.org/grpc/peer"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/sequence"
"github.com/seaweedfs/seaweedfs/weed/topology"
)
func TestHeartbeatOwnership_FirstClaimWins(t *testing.T) {
o := newHeartbeatOwnership()
if !o.claimVolume(1, "10.0.0.1", 8080, 1) {
t.Fatalf("first claim must succeed")
}
if o.claimVolume(1, "10.6.6.6", 8080, 1) {
t.Fatalf("attacker claim from a different peer must be rejected")
}
if !o.claimVolume(1, "10.0.0.1", 8080, 1) {
t.Fatalf("re-claim from the original peer must succeed")
}
}
func TestHeartbeatOwnership_RespectsReplicaCount(t *testing.T) {
o := newHeartbeatOwnership()
// 3 legitimate replicas
if !o.claimVolume(2, "10.0.0.1", 8080, 3) {
t.Fatal()
}
if !o.claimVolume(2, "10.0.0.2", 8080, 3) {
t.Fatal()
}
if !o.claimVolume(2, "10.0.0.3", 8080, 3) {
t.Fatal()
}
// 4th distinct peer is over count
if o.claimVolume(2, "10.6.6.6", 8080, 3) {
t.Fatalf("over-count claim must be rejected")
}
}
func TestHeartbeatOwnership_Release(t *testing.T) {
o := newHeartbeatOwnership()
if !o.claimVolume(3, "10.0.0.1", 8080, 1) {
t.Fatal()
}
o.release("10.0.0.1", 8080)
if !o.claimVolume(3, "10.0.0.2", 8080, 1) {
t.Fatalf("after release, another peer should be able to take the slot")
}
}
// TestHeartbeatOwnership_EcShardsPerSlot verifies the core EC correctness
// property: each (vid, shard_id) pair has exactly one owner, but the same
// vid may legitimately be split across multiple peers when they claim
// disjoint shard bitmaps. The previous vid-only model rejected those.
func TestHeartbeatOwnership_EcShardsPerSlot(t *testing.T) {
o := newHeartbeatOwnership()
// Peer A claims shards 0,1,2 of vid 7.
if got := o.claimEcShards(7, 0b0111, "10.0.0.1", 8080); got != 0b0111 {
t.Fatalf("peer A should own shards 0-2, got 0x%x", got)
}
// Re-claim from the owning peer is fine.
if got := o.claimEcShards(7, 0b0111, "10.0.0.1", 8080); got != 0b0111 {
t.Fatalf("re-claim from owner should succeed, got 0x%x", got)
}
// Peer B claims shards 3,4 of the same vid: must succeed because the
// slots are disjoint.
if got := o.claimEcShards(7, 0b11000, "10.0.0.2", 8080); got != 0b11000 {
t.Fatalf("peer B should own shards 3-4, got 0x%x", got)
}
// Peer C tries to hijack shard 0 (owned by A) and claim shard 5: only
// the unowned bit comes through.
if got := o.claimEcShards(7, 0b100001, "10.6.6.6", 8080); got != 0b100000 {
t.Fatalf("hijack of shard 0 must be rejected; shard 5 must be granted; got 0x%x", got)
}
}
// TestHeartbeatOwnership_EcShardReleaseIsolatesPeers asserts that
// disconnecting peer A frees only A's bindings, leaving B's intact.
func TestHeartbeatOwnership_EcShardReleaseIsolatesPeers(t *testing.T) {
o := newHeartbeatOwnership()
o.claimEcShards(9, 0b0111, "10.0.0.1", 8080)
o.claimEcShards(9, 0b11000, "10.0.0.2", 8080)
o.release("10.0.0.1", 8080)
// B's shards still locked.
if got := o.claimEcShards(9, 0b11000, "10.6.6.6", 8080); got != 0 {
t.Fatalf("B's bindings must survive A's release, got 0x%x", got)
}
// A's freed shards may be reclaimed by anyone.
if got := o.claimEcShards(9, 0b0111, "10.6.6.6", 8080); got != 0b0111 {
t.Fatalf("A's freed shards should be reclaimable, got 0x%x", got)
}
}
// TestHeartbeatOwnership_ReleaseScopedToOwner checks that volume bindings
// of peer B are not collateral damage when peer A disconnects.
func TestHeartbeatOwnership_ReleaseScopedToOwner(t *testing.T) {
o := newHeartbeatOwnership()
o.claimVolume(10, "10.0.0.1", 8080, 2)
o.claimVolume(10, "10.0.0.2", 8080, 2)
o.release("10.0.0.1", 8080)
// Slot vacated by A is reclaimable by a third peer.
if !o.claimVolume(10, "10.0.0.3", 8080, 2) {
t.Fatalf("A's slot should be reclaimable after release")
}
// B still owns its slot; a fourth peer must not push past the cap.
if o.claimVolume(10, "10.6.6.6", 8080, 2) {
t.Fatalf("over-count claim must be rejected; B's slot was not freed")
}
}
type fakeAddr struct {
network string
s string
}
func (f fakeAddr) Network() string { return f.network }
func (f fakeAddr) String() string { return f.s }
func tcpPeerContext(ip string, port int) context.Context {
return peer.NewContext(context.Background(), &peer.Peer{
Addr: &net.TCPAddr{IP: net.ParseIP(ip), Port: port},
})
}
func TestAuthorizeHeartbeatPeer_AcceptsMatchingIp(t *testing.T) {
ms := &MasterServer{option: &MasterOption{}}
ctx := tcpPeerContext("10.0.0.1", 55555)
if err := ms.authorizeHeartbeatPeer(ctx, &master_pb.Heartbeat{Ip: "10.0.0.1", Port: 8080}); err != nil {
t.Fatalf("unexpected rejection: %v", err)
}
}
func TestAuthorizeHeartbeatPeer_RejectsMismatch(t *testing.T) {
ms := &MasterServer{option: &MasterOption{}}
ctx := tcpPeerContext("10.0.0.1", 55555)
if err := ms.authorizeHeartbeatPeer(ctx, &master_pb.Heartbeat{Ip: "10.6.6.6", Port: 8080}); err == nil {
t.Fatalf("attacker should be rejected when source ip does not match claim")
}
}
func TestAuthorizeHeartbeatPeer_LoopbackAccepted(t *testing.T) {
ms := &MasterServer{option: &MasterOption{}}
ctx := tcpPeerContext("127.0.0.1", 55555)
// A loopback peer is allowed to advertise its external hostname.
if err := ms.authorizeHeartbeatPeer(ctx, &master_pb.Heartbeat{Ip: "host.example", Port: 8080}); err != nil {
t.Fatalf("loopback peer should be trusted: %v", err)
}
}
// TestAuthorizeHeartbeatPeer_InProcessAccepted covers the `weed server` case
// where master + volume run in the same OS process and the volume server
// reaches the master through an in-process / bufconn gRPC connection. Those
// peers surface with a non-TCP net.Addr (e.g. "@") and cannot be spoofed by
// a remote attacker, so they must be trusted regardless of heartbeat.Ip.
func TestAuthorizeHeartbeatPeer_InProcessAccepted(t *testing.T) {
ms := &MasterServer{option: &MasterOption{}}
ctx := peer.NewContext(context.Background(), &peer.Peer{
Addr: fakeAddr{network: "bufconn", s: "@"},
})
if err := ms.authorizeHeartbeatPeer(ctx, &master_pb.Heartbeat{Ip: "host.example", Port: 8080}); err != nil {
t.Fatalf("in-process peer should be trusted: %v", err)
}
}
// TestFilterVolumeOwnership_RejectsSecondPeerOnSameVolumeId verifies the
// end-to-end behaviour for non-EC: with replica count 1 there is exactly one
// legal owner of a vid, and a second peer's claim is stripped before the
// topology sees it.
func TestFilterVolumeOwnership_RejectsSecondPeerOnSameVolumeId(t *testing.T) {
topo := topology.NewTopology("t", sequence.NewMemorySequencer(), 32*1024, 5, false)
dc := topo.GetOrCreateDataCenter("dc")
rack := dc.GetOrCreateRack("rack")
ms := &MasterServer{
option: &MasterOption{},
Topo: topo,
heartbeatOwnership: newHeartbeatOwnership(),
}
dnA := rack.GetOrCreateDataNode("10.0.0.1", 8080, 8081, "10.0.0.1", "A", map[string]uint32{"": 10})
dnB := rack.GetOrCreateDataNode("10.6.6.6", 8080, 8081, "10.6.6.6", "B", map[string]uint32{"": 10})
hbA := &master_pb.Heartbeat{
Ip: "10.0.0.1",
Port: 8080,
Volumes: []*master_pb.VolumeInformationMessage{
{Id: 1, ReplicaPlacement: 0},
},
}
ms.filterVolumeOwnership(dnA, hbA)
if len(hbA.Volumes) != 1 {
t.Fatalf("peer A should retain its claim, got %d volumes", len(hbA.Volumes))
}
hbB := &master_pb.Heartbeat{
Ip: "10.6.6.6",
Port: 8080,
Volumes: []*master_pb.VolumeInformationMessage{
{Id: 1, ReplicaPlacement: 0},
},
}
ms.filterVolumeOwnership(dnB, hbB)
if len(hbB.Volumes) != 0 {
t.Fatalf("peer B's hijack claim should be stripped, got %d volumes", len(hbB.Volumes))
}
}
// TestFilterVolumeOwnership_ReleaseLetsNextPeerTakeOver covers the
// disconnect-and-replace flow: when peer A's stream ends, ownership is freed,
// and a different peer may then claim the same vid (e.g. after planned
// migration).
func TestFilterVolumeOwnership_ReleaseLetsNextPeerTakeOver(t *testing.T) {
ms := &MasterServer{
option: &MasterOption{},
heartbeatOwnership: newHeartbeatOwnership(),
}
topo := topology.NewTopology("t", sequence.NewMemorySequencer(), 32*1024, 5, false)
dc := topo.GetOrCreateDataCenter("dc")
rack := dc.GetOrCreateRack("rack")
dnA := rack.GetOrCreateDataNode("10.0.0.1", 8080, 8081, "10.0.0.1", "A", map[string]uint32{"": 10})
dnB := rack.GetOrCreateDataNode("10.0.0.2", 8080, 8081, "10.0.0.2", "B", map[string]uint32{"": 10})
hb := func(ip string) *master_pb.Heartbeat {
return &master_pb.Heartbeat{Ip: ip, Port: 8080, Volumes: []*master_pb.VolumeInformationMessage{{Id: 5}}}
}
a := hb("10.0.0.1")
ms.filterVolumeOwnership(dnA, a)
if len(a.Volumes) != 1 {
t.Fatalf("peer A should own the volume")
}
b := hb("10.0.0.2")
ms.filterVolumeOwnership(dnB, b)
if len(b.Volumes) != 0 {
t.Fatalf("peer B must not hijack while peer A still owns the id")
}
ms.heartbeatOwnership.release(dnA.Ip, uint32(dnA.Port))
c := hb("10.0.0.2")
ms.filterVolumeOwnership(dnB, c)
if len(c.Volumes) != 1 {
t.Fatalf("after release, peer B should be able to take over")
}
}
// TestFilterVolumeOwnership_EcShardsSplitAcrossPeers exercises the realistic
// EC topology where shards of the same vid live on different volume servers.
// Both peers must keep their disjoint shard bitmaps after filtering.
func TestFilterVolumeOwnership_EcShardsSplitAcrossPeers(t *testing.T) {
ms := &MasterServer{
option: &MasterOption{},
heartbeatOwnership: newHeartbeatOwnership(),
}
topo := topology.NewTopology("t", sequence.NewMemorySequencer(), 32*1024, 5, false)
dc := topo.GetOrCreateDataCenter("dc")
rack := dc.GetOrCreateRack("rack")
dnA := rack.GetOrCreateDataNode("10.0.0.1", 8080, 8081, "10.0.0.1", "A", map[string]uint32{"": 10})
dnB := rack.GetOrCreateDataNode("10.0.0.2", 8080, 8081, "10.0.0.2", "B", map[string]uint32{"": 10})
hbA := &master_pb.Heartbeat{
Ip: "10.0.0.1",
Port: 8080,
EcShards: []*master_pb.VolumeEcShardInformationMessage{
{Id: 100, EcIndexBits: 0x7F, ShardSizes: []int64{1, 2, 3, 4, 5, 6, 7}}, // shards 0..6
},
}
ms.filterVolumeOwnership(dnA, hbA)
if len(hbA.EcShards) != 1 || hbA.EcShards[0].EcIndexBits != 0x7F {
t.Fatalf("peer A should keep shards 0-6, got %+v", hbA.EcShards)
}
if len(hbA.EcShards[0].ShardSizes) != 7 {
t.Fatalf("shard sizes for peer A should be preserved, got %v", hbA.EcShards[0].ShardSizes)
}
hbB := &master_pb.Heartbeat{
Ip: "10.0.0.2",
Port: 8080,
EcShards: []*master_pb.VolumeEcShardInformationMessage{
{Id: 100, EcIndexBits: 0x3F80, ShardSizes: []int64{8, 9, 10, 11, 12, 13, 14}}, // shards 7..13
},
}
ms.filterVolumeOwnership(dnB, hbB)
if len(hbB.EcShards) != 1 || hbB.EcShards[0].EcIndexBits != 0x3F80 {
t.Fatalf("peer B should keep shards 7-13, got %+v", hbB.EcShards)
}
if len(hbB.EcShards[0].ShardSizes) != 7 {
t.Fatalf("shard sizes for peer B should be preserved, got %v", hbB.EcShards[0].ShardSizes)
}
}
// TestFilterVolumeOwnership_EcShardHijackStripped checks the partial-overlap
// case: the granted bits are kept (with their sizes), the rejected bits are
// stripped, and ShardSizes is compacted in lock-step with EcIndexBits.
func TestFilterVolumeOwnership_EcShardHijackStripped(t *testing.T) {
ms := &MasterServer{
option: &MasterOption{},
heartbeatOwnership: newHeartbeatOwnership(),
}
topo := topology.NewTopology("t", sequence.NewMemorySequencer(), 32*1024, 5, false)
dc := topo.GetOrCreateDataCenter("dc")
rack := dc.GetOrCreateRack("rack")
dnA := rack.GetOrCreateDataNode("10.0.0.1", 8080, 8081, "10.0.0.1", "A", map[string]uint32{"": 10})
dnAttacker := rack.GetOrCreateDataNode("10.6.6.6", 8080, 8081, "10.6.6.6", "X", map[string]uint32{"": 10})
hbA := &master_pb.Heartbeat{
Ip: "10.0.0.1",
Port: 8080,
EcShards: []*master_pb.VolumeEcShardInformationMessage{
{Id: 200, EcIndexBits: 0b0111, ShardSizes: []int64{10, 20, 30}}, // shards 0,1,2
},
}
ms.filterVolumeOwnership(dnA, hbA)
hbAttacker := &master_pb.Heartbeat{
Ip: "10.6.6.6",
Port: 8080,
EcShards: []*master_pb.VolumeEcShardInformationMessage{
// claim shards 1 (taken), 3 (free), 5 (free), with sizes in
// shard-id order.
{Id: 200, EcIndexBits: 0b101010, ShardSizes: []int64{77, 88, 99}},
},
}
ms.filterVolumeOwnership(dnAttacker, hbAttacker)
if len(hbAttacker.EcShards) != 1 {
t.Fatalf("attacker should keep the message with the granted bits only, got %d", len(hbAttacker.EcShards))
}
got := hbAttacker.EcShards[0]
if got.EcIndexBits != 0b101000 {
t.Fatalf("expected only shards 3 and 5 to survive, got 0x%x", got.EcIndexBits)
}
if len(got.ShardSizes) != 2 || got.ShardSizes[0] != 88 || got.ShardSizes[1] != 99 {
t.Fatalf("ShardSizes must compact alongside EcIndexBits, got %v", got.ShardSizes)
}
}
// TestHeartbeatOwnership_InvariantsAfterRelease covers the reverse-index
// bookkeeping: after release, the owner-keyed maps are empty for that peer.
func TestHeartbeatOwnership_InvariantsAfterRelease(t *testing.T) {
o := newHeartbeatOwnership()
owner := heartbeatOwner{ip: "10.0.0.1", port: 8080}
o.claimVolume(1, owner.ip, owner.port, 1)
o.claimVolume(2, owner.ip, owner.port, 1)
o.claimEcShards(3, 0b101, owner.ip, owner.port)
o.release(owner.ip, owner.port)
o.mu.Lock()
defer o.mu.Unlock()
if len(o.volumes) != 0 || len(o.ecShards) != 0 {
t.Fatalf("forward maps must be empty after sole owner releases: volumes=%v ecShards=%v", o.volumes, o.ecShards)
}
if _, ok := o.ownerToVolumes[owner]; ok {
t.Fatalf("owner-volume reverse index leaked")
}
if _, ok := o.ownerToEcShards[owner]; ok {
t.Fatalf("owner-ecshard reverse index leaked")
}
}
func TestAuthorizeHeartbeatPeer_OptOut(t *testing.T) {
ms := &MasterServer{option: &MasterOption{AllowUntrustedHeartbeat: true}}
ctx := tcpPeerContext("10.0.0.1", 55555)
if err := ms.authorizeHeartbeatPeer(ctx, &master_pb.Heartbeat{Ip: "10.6.6.6", Port: 8080}); err != nil {
t.Fatalf("opt-out must skip the check: %v", err)
}
}
// TestFilterVolumeOwnership_DeletedEcShardsReleaseBinding covers the
// rebalance flow: peer A heartbeats shard 0 of vid 5, then a follow-up
// heartbeat from A reports the shard as deleted. The binding must release so
// peer B can claim shard 0 on its next heartbeat.
func TestFilterVolumeOwnership_DeletedEcShardsReleaseBinding(t *testing.T) {
ms := &MasterServer{
option: &MasterOption{},
heartbeatOwnership: newHeartbeatOwnership(),
}
topo := topology.NewTopology("t", sequence.NewMemorySequencer(), 32*1024, 5, false)
dc := topo.GetOrCreateDataCenter("dc")
rack := dc.GetOrCreateRack("rack")
dnA := rack.GetOrCreateDataNode("10.0.0.1", 8080, 8081, "10.0.0.1", "A", map[string]uint32{"": 10})
dnB := rack.GetOrCreateDataNode("10.0.0.2", 8080, 8081, "10.0.0.2", "B", map[string]uint32{"": 10})
// Peer A claims shard 0 of vid 5.
hbAClaim := &master_pb.Heartbeat{
Ip: "10.0.0.1",
Port: 8080,
NewEcShards: []*master_pb.VolumeEcShardInformationMessage{
{Id: 5, EcIndexBits: 0b1, ShardSizes: []int64{100}},
},
}
ms.filterVolumeOwnership(dnA, hbAClaim)
if len(hbAClaim.NewEcShards) != 1 || hbAClaim.NewEcShards[0].EcIndexBits != 0b1 {
t.Fatalf("peer A initial claim must succeed, got %+v", hbAClaim.NewEcShards)
}
// Peer B tries to claim shard 0 first; the binding still belongs to A so
// the claim is filtered out.
hbBHijack := &master_pb.Heartbeat{
Ip: "10.0.0.2",
Port: 8080,
NewEcShards: []*master_pb.VolumeEcShardInformationMessage{
{Id: 5, EcIndexBits: 0b1, ShardSizes: []int64{100}},
},
}
ms.filterVolumeOwnership(dnB, hbBHijack)
if len(hbBHijack.NewEcShards) != 0 {
t.Fatalf("peer B must not hijack shard 0 while A owns it, got %+v", hbBHijack.NewEcShards)
}
// Peer A heartbeat reports shard 0 deleted. This must release the
// binding so a different peer can take ownership next.
hbADelete := &master_pb.Heartbeat{
Ip: "10.0.0.1",
Port: 8080,
DeletedEcShards: []*master_pb.VolumeEcShardInformationMessage{
{Id: 5, EcIndexBits: 0b1},
},
}
ms.filterVolumeOwnership(dnA, hbADelete)
// Peer B re-tries the claim; this time the slot is free.
hbBTakeover := &master_pb.Heartbeat{
Ip: "10.0.0.2",
Port: 8080,
NewEcShards: []*master_pb.VolumeEcShardInformationMessage{
{Id: 5, EcIndexBits: 0b1, ShardSizes: []int64{100}},
},
}
ms.filterVolumeOwnership(dnB, hbBTakeover)
if len(hbBTakeover.NewEcShards) != 1 || hbBTakeover.NewEcShards[0].EcIndexBits != 0b1 {
t.Fatalf("peer B should own shard 0 after A's release, got %+v", hbBTakeover.NewEcShards)
}
}
// TestFilterVolumeOwnership_FullSnapshotDropsStaleEcBits covers the case
// where a peer drops a shard between heartbeats without sending an explicit
// DeletedEcShards entry (e.g. after a restart that reloaded fewer shards).
// The full EcShards snapshot must release the shard so another peer can
// claim it.
func TestFilterVolumeOwnership_FullSnapshotDropsStaleEcBits(t *testing.T) {
ms := &MasterServer{
option: &MasterOption{},
heartbeatOwnership: newHeartbeatOwnership(),
}
topo := topology.NewTopology("t", sequence.NewMemorySequencer(), 32*1024, 5, false)
dc := topo.GetOrCreateDataCenter("dc")
rack := dc.GetOrCreateRack("rack")
dnA := rack.GetOrCreateDataNode("10.0.0.1", 8080, 8081, "10.0.0.1", "A", map[string]uint32{"": 10})
dnB := rack.GetOrCreateDataNode("10.0.0.2", 8080, 8081, "10.0.0.2", "B", map[string]uint32{"": 10})
// Peer A snapshots shards {0, 1, 3} = 0b1011 for vid 5.
hbAFull := &master_pb.Heartbeat{
Ip: "10.0.0.1",
Port: 8080,
EcShards: []*master_pb.VolumeEcShardInformationMessage{
{Id: 5, EcIndexBits: 0b1011, ShardSizes: []int64{10, 20, 30}},
},
}
ms.filterVolumeOwnership(dnA, hbAFull)
if hbAFull.EcShards[0].EcIndexBits != 0b1011 {
t.Fatalf("peer A initial snapshot should keep all three bits, got 0x%x", hbAFull.EcShards[0].EcIndexBits)
}
// Peer A re-snapshots with shard 1 dropped: 0b1001.
hbAShrink := &master_pb.Heartbeat{
Ip: "10.0.0.1",
Port: 8080,
EcShards: []*master_pb.VolumeEcShardInformationMessage{
{Id: 5, EcIndexBits: 0b1001, ShardSizes: []int64{10, 30}},
},
}
ms.filterVolumeOwnership(dnA, hbAShrink)
if hbAShrink.EcShards[0].EcIndexBits != 0b1001 {
t.Fatalf("peer A shrink snapshot should keep the two surviving bits, got 0x%x", hbAShrink.EcShards[0].EcIndexBits)
}
// Peer B can now claim shard 1 because the diff released that bit.
hbBTakeover := &master_pb.Heartbeat{
Ip: "10.0.0.2",
Port: 8080,
NewEcShards: []*master_pb.VolumeEcShardInformationMessage{
{Id: 5, EcIndexBits: 0b0010, ShardSizes: []int64{20}},
},
}
ms.filterVolumeOwnership(dnB, hbBTakeover)
if len(hbBTakeover.NewEcShards) != 1 || hbBTakeover.NewEcShards[0].EcIndexBits != 0b0010 {
t.Fatalf("peer B should pick up shard 1 after A's snapshot diff, got %+v", hbBTakeover.NewEcShards)
}
// Bits 0 and 3 must still be locked to A; peer B cannot hijack them.
hbBHijack := &master_pb.Heartbeat{
Ip: "10.0.0.2",
Port: 8080,
NewEcShards: []*master_pb.VolumeEcShardInformationMessage{
{Id: 5, EcIndexBits: 0b1001, ShardSizes: []int64{10, 30}},
},
}
ms.filterVolumeOwnership(dnB, hbBHijack)
if len(hbBHijack.NewEcShards) != 0 {
t.Fatalf("peer B must not steal bits A still owns, got %+v", hbBHijack.NewEcShards)
}
}
// TestFilterVolumeOwnership_FullSnapshotDropsStaleVolumes covers the same
// pattern for non-EC volumes: when peer A no longer lists a vid in a full
// snapshot, the binding must release so another peer can take over.
func TestFilterVolumeOwnership_FullSnapshotDropsStaleVolumes(t *testing.T) {
ms := &MasterServer{
option: &MasterOption{},
heartbeatOwnership: newHeartbeatOwnership(),
}
topo := topology.NewTopology("t", sequence.NewMemorySequencer(), 32*1024, 5, false)
dc := topo.GetOrCreateDataCenter("dc")
rack := dc.GetOrCreateRack("rack")
dnA := rack.GetOrCreateDataNode("10.0.0.1", 8080, 8081, "10.0.0.1", "A", map[string]uint32{"": 10})
dnB := rack.GetOrCreateDataNode("10.0.0.2", 8080, 8081, "10.0.0.2", "B", map[string]uint32{"": 10})
hbAFull := &master_pb.Heartbeat{
Ip: "10.0.0.1",
Port: 8080,
Volumes: []*master_pb.VolumeInformationMessage{
{Id: 7, ReplicaPlacement: 0},
{Id: 8, ReplicaPlacement: 0},
},
}
ms.filterVolumeOwnership(dnA, hbAFull)
// Snapshot drops vid 7.
hbAShrink := &master_pb.Heartbeat{
Ip: "10.0.0.1",
Port: 8080,
Volumes: []*master_pb.VolumeInformationMessage{
{Id: 8, ReplicaPlacement: 0},
},
}
ms.filterVolumeOwnership(dnA, hbAShrink)
// Peer B claims vid 7; the binding has been released by the diff.
hbB := &master_pb.Heartbeat{
Ip: "10.0.0.2",
Port: 8080,
Volumes: []*master_pb.VolumeInformationMessage{
{Id: 7, ReplicaPlacement: 0},
},
}
ms.filterVolumeOwnership(dnB, hbB)
if len(hbB.Volumes) != 1 {
t.Fatalf("peer B should pick up vid 7 after A's snapshot diff, got %+v", hbB.Volumes)
}
// Peer B cannot hijack vid 8 — A still owns it.
hbBHijack := &master_pb.Heartbeat{
Ip: "10.0.0.2",
Port: 8080,
Volumes: []*master_pb.VolumeInformationMessage{
{Id: 8, ReplicaPlacement: 0},
},
}
ms.filterVolumeOwnership(dnB, hbBHijack)
if len(hbBHijack.Volumes) != 0 {
t.Fatalf("peer B must not hijack vid 8 while A owns it, got %+v", hbBHijack.Volumes)
}
}
// TestHeartbeatOwnership_ReleaseEcShardsIgnoresForeign confirms that a
// malicious peer cannot use a forged DeletedEcShards entry to evict the
// legitimate owner of a shard slot.
func TestHeartbeatOwnership_ReleaseEcShardsIgnoresForeign(t *testing.T) {
o := newHeartbeatOwnership()
if got := o.claimEcShards(11, 0b1, "10.0.0.1", 8080); got != 0b1 {
t.Fatalf("peer A must own shard 0")
}
// Attacker tries to release A's binding.
o.releaseEcShards(11, 0b1, "10.6.6.6", 8080)
// Peer C still cannot claim it.
if got := o.claimEcShards(11, 0b1, "10.0.0.3", 8080); got != 0 {
t.Fatalf("foreign release must be a no-op, got 0x%x", got)
}
// Owner can still release its own binding.
o.releaseEcShards(11, 0b1, "10.0.0.1", 8080)
if got := o.claimEcShards(11, 0b1, "10.0.0.3", 8080); got != 0b1 {
t.Fatalf("after owner release, slot must be reclaimable, got 0x%x", got)
}
}

View File

@ -61,6 +61,10 @@ type MasterOption struct {
TelemetryUrl string
TelemetryEnabled bool
VolumeGrowthDisabled bool
// AllowUntrustedHeartbeat skips the source-ip check that binds a
// heartbeat's claimed Ip to the gRPC peer address. Operators with a
// NAT/proxy between volume servers and the master must enable this.
AllowUntrustedHeartbeat bool
}
type MasterServer struct {
@ -90,6 +94,11 @@ type MasterServer struct {
LockRingManager *cluster.LockRingManager
// heartbeatOwnership binds volume / ec shard ids to the peer that first
// claimed them in a heartbeat, so a later attacker cannot hijack lookups
// by re-announcing the same ids from a different host.
heartbeatOwnership *heartbeatOwnership
// telemetry
telemetryCollector *telemetry.Collector
}
@ -138,6 +147,7 @@ func NewMasterServer(r *mux.Router, option *MasterOption, peers map[string]pb.Se
MasterClient: wdclient.NewMasterClient(grpcDialOption, "", cluster.MasterType, option.Master, "", "", *pb.NewServiceDiscoveryFromMap(peers)),
adminLocks: NewAdminLocks(),
Cluster: cluster.NewCluster(),
heartbeatOwnership: newHeartbeatOwnership(),
}
ms.LockRingManager = cluster.NewLockRingManager(ms.broadcastToClients)