haskoin-node-2.5.0: src/Haskoin/Node/PeerMgr/Addresses.hs
-- |
-- Module : Haskoin.Node.PeerMgr.Addresses
-- Copyright : (c) Haskoin Developers
-- License : MIT
-- Maintainer : jprupp@protonmail.ch
-- Stability : experimental
-- Portability : POSIX
--
-- The addresses that the peer manager connects to, as an explicit state
-- machine.
--
-- Every known address is 'Ready' to be tried, 'InUse' by a connection, or
-- 'Waiting' until a given time. How long an address waits after a connection
-- ends depends on how it ended ('Outcome').
--
-- = Why it works this way
--
-- * __Failing addresses back off.__ The node used to try a static peer that
-- refused connections again within seconds, forever, and an address it had
-- just disconnected for misbehaving could be connected again at once. Here a
-- failed address waits twice as long after each failure, up to
-- 'maxBackoff', and a misbehaving one waits 'banTime'.
--
-- * __Configured peers are trusted more.__ Addresses from the configuration
-- are what the operator asked for, so they are never banned and never
-- forgotten: they back off at most 'maxBackoff', even after misbehaving.
-- They are also tried first. Addresses from DNS seeds and gossip are
-- banned for misbehaving, and at most 'maxDiscovered' of each are kept, so
-- gossip cannot exhaust memory.
--
-- * __Seeds and gossip are kept apart.__ Addresses from DNS seeds count
-- against their own limit. A peer that gossips thousands of addresses can
-- fill the room for gossip, but not crowd out the seeds. Before, both
-- shared one limit, and once gossip had filled it, no new seed address was
-- learned.
--
-- * __A full book still learns.__ When the limit of a source is reached, a
-- new address takes the place of one from the same source that has failed
-- to connect, if there is one. Addresses that work, or have not been tried,
-- stay.
--
-- * __Random choice.__ 'takeAddress' picks among the addresses that can be
-- tried at random, as Bitcoin Core's address manager does. It used to take
-- the first one in the order of the map, which for socket addresses is the
-- order of the port, so an attacker that gossiped addresses with low ports
-- was always tried first. The randomness is an input, so that the book
-- stays pure.
--
-- * __Pure, with time as an input.__ Every transition takes the current time,
-- so the timing rules can be tested without waiting. "Haskoin.Node.PeerMgr"
-- keeps the book in a 'Control.Concurrent.STM.TVar'.
module Haskoin.Node.PeerMgr.Addresses
( AddressBook,
Source (..),
AddressState (..),
Outcome (..),
emptyBook,
addAddress,
takeAddress,
returnAddress,
markConnected,
finishAddress,
addressState,
addressSource,
needsReload,
markLoaded,
knownAddresses,
retryDelay,
maxBackoff,
banTime,
reloadInterval,
maxDiscovered,
)
where
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Maybe (isNothing)
import Data.Set (Set)
import Data.Set qualified as Set
import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, diffUTCTime)
import Data.Word (Word64)
-- | Where an address came from.
data Source
= -- | the static peers of the configuration
Configured
| -- | the DNS seeds of the network
Seeded
| -- | relayed by peers
Discovered
deriving stock (Eq, Ord, Show)
-- | Whether an address can be tried.
data AddressState
= -- | can be tried now
Ready
| -- | a connection to it is open or being opened
InUse
| -- | can be tried again at the given time; holds the number of failures in
-- a row
Waiting !UTCTime !Int
deriving stock (Eq, Show)
-- | How a connection ended.
data Outcome
= -- | the peer was online and the connection ended normally, such as by
-- rotation or because the peer closed it
Disconnected
| -- | the connection could not be made, or the peer stopped responding
Failed
| -- | the peer broke the protocol
Misbehaved
deriving stock (Eq, Show)
data Entry = Entry
{ source :: !Source,
state :: !AddressState,
-- | failures in a row
failures :: !Int
}
deriving stock (Eq, Show)
-- | Known addresses, how many of them came from seeds and from gossip, the
-- ones that may make room for a new address, and when the configured peers
-- and seeds were last loaded.
--
-- The counts and the index are kept in step with the entries by 'putEntry',
-- through which every change goes, so that learning an address never scans
-- the book.
data AddressBook a = AddressBook
{ entries :: !(Map a Entry),
loaded :: !(Maybe UTCTime),
-- | how many entries are 'Seeded'
seeded :: !Int,
-- | how many entries are 'Discovered'
discovered :: !Int,
-- | seed and gossiped addresses that failed and are not in use, by
-- source, with their failures in a row (see 'replaceable')
failing :: !(Map Source (Set (Int, a)))
}
deriving stock (Eq, Show)
-- | Wait after a normal disconnection before trying the address again.
retryDelay :: NominalDiffTime
retryDelay = 1
-- | Longest wait after failures.
maxBackoff :: NominalDiffTime
maxBackoff = 600
-- | Wait after a discovered address misbehaved.
banTime :: NominalDiffTime
banTime = 24 * 3600
-- | Shortest time between two loads of the configured peers and seeds.
reloadInterval :: NominalDiffTime
reloadInterval = 300
-- | Most addresses kept from each of the DNS seeds and gossip.
maxDiscovered :: Int
maxDiscovered = 4096
-- | No addresses, never loaded.
emptyBook :: AddressBook a
emptyBook =
AddressBook
{ entries = Map.empty,
loaded = Nothing,
seeded = 0,
discovered = 0,
failing = Map.empty
}
-- | Learn an address. A known address keeps its state; one from the
-- configuration is marked 'Configured'. When 'maxDiscovered' addresses from
-- the same source are known already, a new seed or gossiped address takes
-- the place of one from that source that has failed to connect and is not in
-- use, the one with the most failures in a row. If there is none, the new
-- address is dropped.
addAddress :: (Ord a) => Source -> a -> AddressBook a -> AddressBook a
addAddress src a book =
case Map.lookup a book.entries of
Just e
| src == Configured -> setEntry a e {source = Configured} book
| otherwise -> book
Nothing
| src == Configured -> fresh book
| sourceCount src book < maxDiscovered -> fresh book
| otherwise -> case evictable of
Just old -> fresh (forget old book)
Nothing -> book
where
fresh = setEntry a Entry {source = src, state = Ready, failures = 0}
-- the one with the most failures, and the greatest address among those
evictable = snd <$> (Set.lookupMax =<< Map.lookup src book.failing)
-- | Take an address to connect to: a 'Ready' one, or a 'Waiting' one whose
-- time has come. It becomes 'InUse'.
--
-- Configured addresses come first, in order. Otherwise the address is picked
-- at random, using the given number, among the seed and gossiped addresses
-- that the predicate allows. "Haskoin.Node.PeerMgr" uses the predicate to
-- skip addresses in a network group that it is connected to already.
takeAddress ::
(Ord a) => UTCTime -> Word64 -> (a -> Bool) -> AddressBook a -> Maybe (a, AddressBook a)
takeAddress now r allowed book =
case filter ((== Configured) . (.source) . snd) candidates of
(a, e) : _ -> Just (take_ a e)
[] -> case filter (allowed . fst) candidates of
[] -> Nothing
others ->
let (a, e) = others !! fromIntegral (r `mod` fromIntegral (length others))
in Just (take_ a e)
where
candidates = filter (ready . snd) (Map.toList book.entries)
take_ a e = (a, setEntry a e {state = InUse} book)
ready e = case e.state of
Ready -> True
InUse -> False
Waiting t _ -> t <= now
-- | Give back an address taken with 'takeAddress' without connecting to it.
returnAddress :: (Ord a) => a -> AddressBook a -> AddressBook a
returnAddress = adjust $ \e -> case e.state of
InUse -> e {state = Ready}
_ -> e
-- | A connection to the address completed the handshake. Its failures are
-- forgotten.
markConnected :: (Ord a) => a -> AddressBook a -> AddressBook a
markConnected = adjust $ \e -> e {failures = 0}
-- | A connection to the address ended. Decide when to try it again.
finishAddress ::
(Ord a) => UTCTime -> Outcome -> a -> AddressBook a -> AddressBook a
finishAddress now outcome = adjust $ \e ->
let wait d f = e {state = Waiting (d `addUTCTime` now) f, failures = f}
backoff f = min maxBackoff (retryDelay * 2 ^ min f 30)
in case outcome of
Disconnected -> wait retryDelay 0
Failed -> let f = e.failures + 1 in wait (backoff f) f
Misbehaved
| e.source == Configured -> wait maxBackoff (e.failures + 1)
| otherwise -> wait banTime (e.failures + 1)
-- | State of an address, or 'Nothing' if it is not known.
addressState :: (Ord a) => a -> AddressBook a -> Maybe AddressState
addressState a book = (.state) <$> Map.lookup a book.entries
-- | Where an address came from, or 'Nothing' if it is not known.
addressSource :: (Ord a) => a -> AddressBook a -> Maybe Source
addressSource a book = (.source) <$> Map.lookup a book.entries
-- | Whether to load the configured peers and seeds: when no address can be
-- tried now, and they were never loaded or not loaded for 'reloadInterval'.
needsReload :: (Ord a) => UTCTime -> AddressBook a -> Bool
needsReload now book =
isNothing (takeAddress now 0 (const True) book)
&& maybe True (\t -> now `diffUTCTime` t >= reloadInterval) book.loaded
-- | The configured peers and seeds were loaded at the given time.
markLoaded :: UTCTime -> AddressBook a -> AddressBook a
markLoaded now book = book {loaded = Just now}
-- | Every known address.
knownAddresses :: AddressBook a -> [a]
knownAddresses book = Map.keys book.entries
setEntry :: (Ord a) => a -> Entry -> AddressBook a -> AddressBook a
setEntry a e = putEntry a (Just e)
-- | Remove an address.
forget :: (Ord a) => a -> AddressBook a -> AddressBook a
forget a = putEntry a Nothing
adjust :: (Ord a) => (Entry -> Entry) -> a -> AddressBook a -> AddressBook a
adjust f a book = case Map.lookup a book.entries of
Nothing -> book
Just e -> putEntry a (Just (f e)) book
-- | Whether an entry may make room for a new address from its source: it is
-- a seed or gossiped address that failed and is not in use.
replaceable :: Entry -> Bool
replaceable e = e.source /= Configured && e.failures > 0 && e.state /= InUse
-- | Set the entry of an address, or remove it with 'Nothing'. The counts of
-- seed and gossiped addresses and the index of 'replaceable' ones follow.
putEntry :: (Ord a) => a -> Maybe Entry -> AddressBook a -> AddressBook a
putEntry a new book =
maybe id include new . maybe id exclude old $
book {entries = Map.alter (const new) a book.entries}
where
old = Map.lookup a book.entries
exclude e b =
count e.source (-1) $
if replaceable e
then b {failing = Map.adjust (Set.delete (e.failures, a)) e.source b.failing}
else b
include e b =
count e.source 1 $
if replaceable e
then b {failing = Map.insertWith Set.union e.source (Set.singleton (e.failures, a)) b.failing}
else b
-- | Change the count of addresses from a source. Configured addresses are
-- not counted.
count :: Source -> Int -> AddressBook a -> AddressBook a
count src n book = case src of
Configured -> book
Seeded -> book {seeded = book.seeded + n}
Discovered -> book {discovered = book.discovered + n}
-- | How many addresses from a source are known.
sourceCount :: Source -> AddressBook a -> Int
sourceCount src book = case src of
Configured -> 0
Seeded -> book.seeded
Discovered -> book.discovered