diff --git a/internal/staging/banmanager/banmanager.go b/internal/staging/banmanager/banmanager.go new file mode 100644 index 0000000000..96ceb519d4 --- /dev/null +++ b/internal/staging/banmanager/banmanager.go @@ -0,0 +1,258 @@ +// Copyright (c) 2021 The Decred developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package banmanager + +import ( + "fmt" + "net" + "runtime/debug" + "sync" + "time" + + "github.com/decred/dcrd/connmgr/v3" + "github.com/decred/dcrd/peer/v3" +) + +// Config is the configuration struct for the ban manager. +type Config struct { + // DisableBanning represents the status of disabling banning of + // misbehaving peers. + DisableBanning bool + + // BanThreshold represents the maximum allowed ban score before + // misbehaving peers are disconnecting and banned. + BanThreshold uint32 + + // BanDuration is the duration for which misbehaving peers stay banned for. + BanDuration time.Duration + + // MaxPeers indicates the maximum number of inbound and outbound + // peers allowed. + MaxPeers int + + // Whitelist represents the whitelisted IPs of the server. + WhiteList []net.IPNet +} + +// banMgrPeer extends a peer to maintain additional state maintained by the +// ban manager. +type banMgrPeer struct { + *peer.Peer + + isWhitelisted bool + banScore connmgr.DynamicBanScore +} + +// BanManager represents a peer ban score tracking manager. +type BanManager struct { + cfg Config + peers map[*peer.Peer]*banMgrPeer + banned map[string]time.Time + mtx sync.Mutex +} + +// NewBanManager initializes a new peer banning manager. +func NewBanManager(cfg *Config) *BanManager { + return &BanManager{ + cfg: *cfg, + peers: make(map[*peer.Peer]*banMgrPeer, cfg.MaxPeers), + banned: make(map[string]time.Time, cfg.MaxPeers), + } +} + +// lookupPeer returns the ban manager peer that maintains additional state for +// a given base peer. In the event the mapping does not exist, a warning is +// logged and nil is returned. +// +// This function MUST be called with the ban manager mutex locked (for reads). +func (bm *BanManager) lookupPeer(p *peer.Peer) *banMgrPeer { + bmp, ok := bm.peers[p] + if !ok { + log.Warnf("Attempt to lookup unknown peer %s\nStack: %v", p, + string(debug.Stack())) + return nil + } + + return bmp +} + +// isPeerWhitelisted checks if the provided peer is whitelisted per the +// provided whitelist. +func (bm *BanManager) isPeerWhitelisted(p *peer.Peer, whitelist []net.IPNet) bool { + host, _, err := net.SplitHostPort(p.Addr()) + if err != nil { + log.Errorf("Unable to split peer '%s' IP: %v", p.Addr(), err) + return false + } + + ip := net.ParseIP(host) + if ip == nil { + log.Errorf("Unable to parse IP '%s'", p.Addr()) + return false + } + + for _, ipnet := range whitelist { + if ipnet.Contains(ip) { + return true + } + } + + return false +} + +// IsPeerWhitelisted checks if the provided peer is whitelisted. +func (bm *BanManager) IsPeerWhitelisted(p *peer.Peer) bool { + bm.mtx.Lock() + bmp := bm.lookupPeer(p) + bm.mtx.Unlock() + if bmp == nil { + return false + } + + return bmp.isWhitelisted +} + +// AddPeer adds the provided peer to the ban manager. +func (bm *BanManager) AddPeer(p *peer.Peer) error { + host, _, err := net.SplitHostPort(p.Addr()) + if err != nil { + p.Disconnect() + return fmt.Errorf("cannot split hostport %v", err) + } + + bm.mtx.Lock() + banEnd, ok := bm.banned[host] + bm.mtx.Unlock() + + if ok { + if time.Now().Before(banEnd) { + p.Disconnect() + return fmt.Errorf("peer %s is banned for another %v - disconnecting", + host, time.Until(banEnd)) + } + + log.Infof("Peer %s is no longer banned", host) + + bm.mtx.Lock() + delete(bm.banned, host) + bm.mtx.Unlock() + } + + bmp := &banMgrPeer{ + Peer: p, + isWhitelisted: bm.isPeerWhitelisted(p, bm.cfg.WhiteList), + } + + bm.mtx.Lock() + bm.peers[p] = bmp + bm.mtx.Unlock() + + return nil +} + +// RemovePeer discards the provided peer from the ban manager. +func (bm *BanManager) RemovePeer(p *peer.Peer) { + bm.mtx.Lock() + delete(bm.peers, p) + bm.mtx.Unlock() +} + +// BanPeer bans the provided peer. +func (bm *BanManager) BanPeer(p *peer.Peer) { + // Return immediately if banning is disabled. + if bm.cfg.DisableBanning { + return + } + + bm.mtx.Lock() + bmp := bm.lookupPeer(p) + bm.mtx.Unlock() + if bmp == nil { + return + } + + // Return if the peer is whitelisted. + if bmp.isWhitelisted { + return + } + + // Ban and remove the peer. + host, _, err := net.SplitHostPort(p.Addr()) + if err != nil { + log.Debugf("can't split ban peer %s %v", p.Addr(), err) + return + } + + direction := directionString(p.Inbound()) + log.Infof("Banned peer %s (%s) for %v", host, direction, + bm.cfg.BanDuration) + + bm.mtx.Lock() + bm.banned[host] = time.Now().Add(bm.cfg.BanDuration) + bm.mtx.Unlock() + + p.Disconnect() + bm.RemovePeer(p) +} + +// AddBanScore increases the persistent and decaying ban scores of the +// provided peer by the values passed as parameters. If the resulting score +// exceeds half of the ban threshold, a warning is logged including the reason +// provided. Further, if the score is above the ban threshold, the peer will +// be banned. +func (bm *BanManager) AddBanScore(p *peer.Peer, persistent, transient uint32, reason string) bool { + // No warning is logged and no score is calculated if banning is disabled. + if bm.cfg.DisableBanning { + return false + } + + bm.mtx.Lock() + bmp := bm.lookupPeer(p) + bm.mtx.Unlock() + if bmp == nil { + return false + } + + if bmp.isWhitelisted { + log.Debugf("Misbehaving whitelisted peer %s: %s", p, reason) + return false + } + + banScore := bmp.banScore.Int() + warnThreshold := bm.cfg.BanThreshold >> 1 + if transient == 0 && persistent == 0 { + // The score is not being increased, but a warning message is still + // logged if the score is above the warn threshold. + if banScore > warnThreshold { + log.Warnf("Misbehaving peer %s: %s -- ban score is %d, "+ + "it was not increased this time", p, reason, banScore) + } + return false + } + + banScore = bmp.banScore.Increase(persistent, transient) + if banScore > warnThreshold { + log.Warnf("Misbehaving peer %s: %s -- ban score increased to %d", + p, reason, banScore) + if banScore > bm.cfg.BanThreshold { + log.Warnf("Misbehaving peer %s -- banning and disconnecting", p) + bm.BanPeer(p) + return true + } + } + + return false +} + +// BanScore returns the ban score of the provided peer. +func (bm *BanManager) BanScore(p *peer.Peer) uint32 { + bm.mtx.Lock() + bmp := bm.lookupPeer(p) + bm.mtx.Unlock() + if bmp == nil { + return 0 + } + return bmp.banScore.Int() +} diff --git a/internal/staging/banmanager/banmanager_test.go b/internal/staging/banmanager/banmanager_test.go new file mode 100644 index 0000000000..b4a7bd563b --- /dev/null +++ b/internal/staging/banmanager/banmanager_test.go @@ -0,0 +1,286 @@ +// Copyright (c) 2021 The Decred developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package banmanager + +import ( + "io" + "net" + "strconv" + "testing" + "time" + + "github.com/decred/dcrd/peer/v3" + "github.com/decred/dcrd/wire" + "github.com/decred/go-socks/socks" +) + +// TestBanPeer tests ban manager peer banning functionality. +func TestBanPeer(t *testing.T) { + bcfg := &Config{ + DisableBanning: false, + BanThreshold: 100, + BanDuration: time.Millisecond * 500, + MaxPeers: 10, + WhiteList: []net.IPNet{}, + } + + bmgr := NewBanManager(bcfg) + + peerCfg := &peer.Config{ + UserAgentName: "peer", + UserAgentVersion: "1.0", + Net: wire.MainNet, + Services: 0, + } + + // Add peer A, B and C. + pA, err := peer.NewOutboundPeer(peerCfg, "10.0.0.1:8333") + if err != nil { + t.Fatalf("NewOutboundPeer: unexpected err - %v\n", err) + } + + err = bmgr.AddPeer(pA) + if err != nil { + t.Fatalf("unexpected err -%v\n", err) + } + + pB, err := peer.NewOutboundPeer(peerCfg, "10.0.0.2:8333") + if err != nil { + t.Fatalf("NewOutboundPeer: unexpected err - %v\n", err) + } + + err = bmgr.AddPeer(pB) + if err != nil { + t.Fatalf("unexpected err -%v\n", err) + } + + pC, err := peer.NewOutboundPeer(peerCfg, "10.0.0.3:8333") + if err != nil { + t.Errorf("NewOutboundPeer: unexpected err - %v\n", err) + return + } + + err = bmgr.AddPeer(pC) + if err != nil { + t.Fatalf("unexpected err -%v\n", err) + } + + if len(bmgr.peers) != 3 { + t.Fatalf("expected 3 tracked peers, got %d", len(bmgr.peers)) + } + + // Remove disconnected peer C. + bmgr.RemovePeer(pC) + + bmgr.mtx.Lock() + if len(bmgr.peers) != 2 { + bmgr.mtx.Unlock() + t.Fatalf("expected 2 tracked peers, got %d", len(bmgr.peers)) + } + bmgr.mtx.Unlock() + + // Ensure the ban manager updates the correct peer's ban score. + expectedBBanScore := uint32(0) + peerB := bmgr.lookupPeer(pB) + if bmgr.BanScore(pB) != expectedBBanScore { + t.Fatalf("expected an unchanged ban score for peer B, got %d", + peerB.banScore.Int()) + } + + expectedABanScore := uint32(50) + bmgr.AddBanScore(pA, expectedABanScore, 0, "testing") + peerA := bmgr.lookupPeer(pA) + if bmgr.BanScore(pA) != expectedABanScore { + t.Fatalf("expected a ban score of %d for peer A, got %d", + expectedABanScore, peerA.banScore.Int()) + } + + // Ban peer A by exceeding the ban threshold. + bmgr.AddBanScore(pA, 120, 0, "testing") + + peerA = bmgr.lookupPeer(pA) + if peerA != nil { + t.Fatal("peer A still exists in the manager") + } + + // Outrightly ban peer B. + bmgr.BanPeer(pB) + + peerB = bmgr.lookupPeer(pB) + if peerB != nil { + t.Fatal("peer B still exists in the manager") + } + + bmgr.mtx.Lock() + if len(bmgr.peers) != 0 { + bmgr.mtx.Unlock() + t.Fatalf("expected no tracked peers, got %d", len(bmgr.peers)) + } + bmgr.mtx.Unlock() + + // Ensure there are two banned peers being tracked by the manager. + bmgr.mtx.Lock() + if len(bmgr.banned) != 2 { + bmgr.mtx.Unlock() + t.Fatalf("expected two tracked banned peers, got %d", len(bmgr.banned)) + } + bmgr.mtx.Unlock() + + // Ensure re-adding a banned peer fails if it is before the ban period ends. + err = bmgr.AddPeer(pA) + if err == nil { + t.Fatalf("expected a ban error \n") + } + + bmgr.mtx.Lock() + if len(bmgr.peers) != 0 { + bmgr.mtx.Unlock() + t.Fatalf("expected no tracked peers, got %d", len(bmgr.peers)) + } + bmgr.mtx.Unlock() + + // Wait for the ban period to end. + time.Sleep(time.Millisecond * 500) + + // Ensure re-adding a banned peer succeeds if it is after the ban period. + err = bmgr.AddPeer(pA) + if err != nil { + t.Fatalf("unexpected err -%v\n", err) + } + + bmgr.mtx.Lock() + if len(bmgr.peers) != 1 { + bmgr.mtx.Unlock() + t.Fatalf("expected a tracked peer, got %d", len(bmgr.peers)) + } + bmgr.mtx.Unlock() +} + +// conn mocks a network connection by implementing the net.Conn interface. It +// is used to test peer connection without actually opening a network +// connection. +type conn struct { + io.Reader + io.Writer + io.Closer + + // local network, address for the connection. + lnet, laddr string + + // remote network, address for the connection. + rnet, raddr string + + // mocks socks proxy if true. + proxy bool +} + +// LocalAddr returns the local address for the connection. +func (c conn) LocalAddr() net.Addr { + return &addr{c.lnet, c.laddr} +} + +// Remote returns the remote address for the connection. +func (c conn) RemoteAddr() net.Addr { + if !c.proxy { + return &addr{c.rnet, c.raddr} + } + host, strPort, _ := net.SplitHostPort(c.raddr) + port, _ := strconv.Atoi(strPort) + return &socks.ProxiedAddr{ + Net: c.rnet, + Host: host, + Port: port, + } +} + +// Close handles closing the connection. +func (c conn) Close() error { + return nil +} + +func (c conn) SetDeadline(t time.Time) error { return nil } +func (c conn) SetReadDeadline(t time.Time) error { return nil } +func (c conn) SetWriteDeadline(t time.Time) error { return nil } + +// addr mocks a network address. +type addr struct { + net, address string +} + +func (m addr) Network() string { return m.net } +func (m addr) String() string { return m.address } + +func TestPeerWhitelist(t *testing.T) { + ip := net.ParseIP("10.0.0.1") + ipnet := net.IPNet{ + IP: ip, + Mask: net.CIDRMask(32, 32), + } + whitelist := []net.IPNet{ipnet} + + bcfg := &Config{ + DisableBanning: false, + BanThreshold: 100, + MaxPeers: 10, + WhiteList: whitelist, + } + + bmgr := NewBanManager(bcfg) + + peerCfg := &peer.Config{ + UserAgentName: "peer", + UserAgentVersion: "1.0", + Net: wire.MainNet, + Services: 0, + } + + newPeer := func(cfg *peer.Config, addr string) (*peer.Peer, *conn, error) { + r, w := io.Pipe() + c := &conn{raddr: addr, Writer: w, Reader: r} + p, err := peer.NewOutboundPeer(cfg, addr) + if err != nil { + return nil, nil, err + } + p.AssociateConnection(c) + return p, c, nil + } + + // Add two connected peers and one unconnected peer. + pA, cA, err := newPeer(peerCfg, "10.0.0.1:8333") + if err != nil { + t.Errorf("unexpected peer error: %v", err) + } + defer cA.Close() + bmgr.AddPeer(pA) + + pB, cB, err := newPeer(peerCfg, "10.0.0.2:8333") + if err != nil { + t.Errorf("unexpected peer error: %v", err) + } + defer cB.Close() + bmgr.AddPeer(pB) + + pC, err := peer.NewOutboundPeer(peerCfg, "10.0.0.3:8333") + if err != nil { + t.Errorf("unexpected peer error: %v", err) + } + bmgr.AddPeer(pC) + + // Ensure a peer not associated with their connection cannot be + // whitelisted. + if bmgr.IsPeerWhitelisted(pC) { + t.Errorf("Expected an unconnected peer to not be whitelisted") + } + + // Ensure a peer not whitelisted is not marked as so. + if bmgr.IsPeerWhitelisted(pB) { + t.Errorf("Expected peer B not to be whitelisted") + } + + // Ensure a peer whitelisted to be marked as so. + if !bmgr.IsPeerWhitelisted(pA) { + t.Errorf("Expected peer A to be whitelisted") + } +} diff --git a/internal/staging/banmanager/log.go b/internal/staging/banmanager/log.go new file mode 100644 index 0000000000..b5b64b9710 --- /dev/null +++ b/internal/staging/banmanager/log.go @@ -0,0 +1,29 @@ +// Copyright (c) 2021 The Decred developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package banmanager + +import "github.com/decred/slog" + +// log is a logger that is initialized with no output filters. This +// means the package will not perform any logging by default until the caller +// requests it. +// The default amount of logging is none. +var log = slog.Disabled + +// UseLogger uses a specified Logger to output package logging info. +// This should be used in preference to SetLogWriter if the caller is also +// using slog. +func UseLogger(logger slog.Logger) { + log = logger +} + +// directionString is a helper function that returns a string that represents +// the direction of a connection (inbound or outbound). +func directionString(inbound bool) string { + if inbound { + return "inbound" + } + return "outbound" +}