packages feed

haskoin-node-2.4.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.
--   Discovered addresses, from DNS seeds and gossip, are banned for
--   misbehaving, and at most 'maxDiscovered' of them are kept, so gossip
--   cannot exhaust memory.
--
-- * __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,
    needsReload,
    markLoaded,
    knownAddresses,
    retryDelay,
    maxBackoff,
    banTime,
    reloadInterval,
    maxDiscovered,
  )
where

import Data.List (find)
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Maybe (isNothing)
import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, diffUTCTime)

-- | Where an address came from.
data Source
  = -- | the static peers of the configuration
    Configured
  | -- | DNS seeds, or addresses relayed by peers
    Discovered
  deriving stock (Eq, 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, and when the configured peers and seeds were last
-- loaded.
data AddressBook a = AddressBook
  { entries :: !(Map a Entry),
    loaded :: !(Maybe UTCTime)
  }
  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 discovered addresses kept.
maxDiscovered :: Int
maxDiscovered = 4096

-- | No addresses, never loaded.
emptyBook :: AddressBook a
emptyBook = AddressBook {entries = Map.empty, loaded = Nothing}

-- | Learn an address. A known address keeps its state; one from the
-- configuration is marked 'Configured'. A discovered address is dropped if
-- 'maxDiscovered' are known already.
addAddress :: (Ord a) => Source -> a -> AddressBook a -> AddressBook a
addAddress src a book =
  case Map.lookup a book.entries of
    Just e
      | src == Configured -> put e {source = Configured}
      | otherwise -> book
    Nothing
      | src == Discovered && discovered >= maxDiscovered -> book
      | otherwise -> put Entry {source = src, state = Ready, failures = 0}
  where
    put e = book {entries = Map.insert a e book.entries}
    discovered = Map.size (Map.filter ((== Discovered) . (.source)) book.entries)

-- | Take an address to connect to: a 'Ready' one, or a 'Waiting' one whose
-- time has come. It becomes 'InUse'.
takeAddress :: (Ord a) => UTCTime -> AddressBook a -> Maybe (a, AddressBook a)
takeAddress now book = do
  (a, e) <- find (ready . snd) (Map.toList book.entries)
  return (a, setEntry a e {state = InUse} book)
  where
    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

-- | 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 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 book = book {entries = Map.insert a e book.entries}

adjust :: (Ord a) => (Entry -> Entry) -> a -> AddressBook a -> AddressBook a
adjust f a book = book {entries = Map.adjust f a book.entries}