packages feed

hs-hath-1.2.0: src/Middleware.hs

module Middleware
  ( rateLimitMiddleware
  , tracingConnections
  , normalizeAcceptMiddleware
  , tracingTimeUsage
  , withHentaiHeaders
  , IPMap
  , KeystampMap
  , IP(..)
  , IPRecord(..)
  , timeWindow
  , checkRateLimit
  , isLocalIP
  , parseIPv4
  ) where

import           Control.Exception   ( finally )

import           Data.Bits           ( shiftR, (.&.) )

import qualified Data.ByteString     as BS
import qualified Data.HashMap.Strict as HashMap

import           GHC.Clock           ( getMonotonicTime )

import qualified Metrics.Gauge       as Gauge

import           Network.HTTP.Types  ( hAccept )
import           Network.Socket      ( HostAddress
                                     , HostAddress6
                                     , SockAddr(..)
                                     , hostAddress6ToTuple
                                     , hostAddressToTuple
                                     , tupleToHostAddress
                                     )
import           Network.Wai         ( Middleware
                                     , mapResponseHeaders
                                     , rawPathInfo
                                     , remoteHost
                                     , requestHeaders
                                     )

import           Relude              hiding ( Reader, ask, get, runReader )

import           Stats               ( StatsEnv(..) )

import           System.IO.Unsafe    ( unsafePerformIO )

import           Types               ( hentaiHeader )

import           Utils               ( tooManyRequestsResponse )

-- | Mirrors the official client's flood control (HTTPServer.FloodControlEntry):
-- every request adds one hit, hits drain at one per second, and more than
-- 'floodLimit' hits blocks the IP for 'blockSeconds'.
floodLimit :: Int
floodLimit = 10

blockSeconds :: Double
blockSeconds = 60

-- | Entries idle for this long carry no state worth keeping (any block has expired).
timeWindow :: Double
timeWindow = 60

data IPRecord
  = IPRecord { hitCount    :: !Int     -- ^ Hits left after draining
             , lastHit     :: !Double  -- ^ Monotonic time of the last counted hit
             , bannedUntil :: !Double  -- ^ When the block expires
             }
  deriving ( Show )

-- Change the Map key from SockAddr to a custom IP type
data IP = IPv4 {-# UNPACK #-} !HostAddress | IPv6 {-# UNPACK #-} !HostAddress6
  deriving ( Eq, Ord, Show, Generic )

instance Hashable IP

type IPMap = HashMap IP IPRecord

{-# INLINE getIP #-}
-- Helper function to extract just the IP from a SockAddr
getIP :: SockAddr -> Maybe IP
getIP (SockAddrInet _ ha) = Just $ IPv4 ha
getIP (SockAddrInet6 _ _ ha _) = Just $ IPv6 ha
getIP _ = Nothing

{-# NOINLINE globalIPMap #-}
globalIPMap :: IORef IPMap
globalIPMap = unsafePerformIO $ newIORef HashMap.empty

-- | Flood control for /h/. Local addresses and those the predicate exempts
-- (the RPC servers, as in the official client) are never limited.
rateLimitMiddleware :: (IP -> IO Bool) -> Middleware
rateLimitMiddleware exempt app req k
  | "/h/" `BS.isPrefixOf` rawPathInfo req = do
    now <- getMonotonicTime
    let sockAddr = remoteHost req
    case getIP sockAddr of
      Nothing -> app req k  -- If we can't get IP, just allow the request
      Just ip
        | isLocalIP ip -> app req k
        | otherwise -> do
          skip <- exempt ip
          allowed <- if skip then pure True else checkRateLimit ip now
          if allowed
            then app req k
            else k tooManyRequestsResponse
  | otherwise = app req k  -- Skip rate limiting for non-resource paths

-- | "a.b.c.d" or "::ffff:a.b.c.d", the form rpc_server_ip uses.
-- ponytail: IPv4 only; the RPC servers have no IPv6 addresses.
parseIPv4 :: ByteString -> Maybe IP
parseIPv4 s = case traverse (readMaybe @Int . decodeUtf8) (BS.split 46 v4) of
  Just [ a, b, c, d ]
    | all (\x -> x >= 0 && x <= 255) [ a, b, c, d ] -> Just
      (IPv4 (tupleToHostAddress ( fromIntegral a, fromIntegral b, fromIntegral c, fromIntegral d )))
  _ -> Nothing
  where
    v4 = fromMaybe s (BS.stripPrefix "::ffff:" s)

-- | Loopback, private and link-local addresses, which the official client
-- exempts from flood control (HTTPServer.localNetworkPattern).
isLocalIP :: IP -> Bool
isLocalIP (IPv4 ha) = localV4 (hostAddressToTuple ha)
isLocalIP (IPv6 ha) = case hostAddress6ToTuple ha of
  ( 0, 0, 0, 0, 0, 0, 0, 1 ) -> True
  -- IPv4-mapped (::ffff:a.b.c.d), as seen on dual-stack sockets
  ( 0, 0, 0, 0, 0, 0xffff, hi, lo ) -> localV4 ( byte hi 8, byte hi 0, byte lo 8, byte lo 0 )
  ( w, _, _, _, _, _, _, _ ) -> (w .&. 0xfe00) == 0xfc00 -- fc00::/7
  where
    byte w n = fromIntegral (w `shiftR` n)

localV4 :: ( Word8, Word8, Word8, Word8 ) -> Bool
localV4 = \case
  ( 127, _, _, _ ) -> True
  ( 10, _, _, _ ) -> True
  ( 192, 168, _, _ ) -> True
  ( 172, b, _, _ ) -> b >= 16 && b <= 31
  ( 169, 254, _, _ ) -> True
  _ -> False

-- | Count a hit for the IP; False if the request should be blocked.
checkRateLimit :: IP -> Double -> IO Bool
checkRateLimit ip now = atomicModifyIORef' globalIPMap $ \m0 ->
  let
    -- ponytail: O(n) prune once the map is large; fine for the few hundred IPs a client sees per minute
    m = if HashMap.size m0 > 1000
          then HashMap.filter ((> now - timeWindow) . lastHit) m0
          else m0
  in 
    case HashMap.lookup ip m of
      Just r
        | bannedUntil r > now -> ( m, False ) -- blocked requests are not counted, as in the official client
      old -> let
          r0      = fromMaybe (IPRecord 0 now 0) old
          hits    = max 0 (hitCount r0 - floor (now - lastHit r0)) + 1
          blocked = hits > floodLimit
          r       = IPRecord { hitCount    = hits
                             , lastHit     = now
                             , bannedUntil = if blocked then now + blockSeconds else bannedUntil r0
                             }
        in 
          ( HashMap.insert ip r m, not blocked )

type KeystampMap = HashMap ByteString ( Int, Double )

-- | Track active connections
tracingConnections :: StatsEnv -> Middleware
tracingConnections statsEnv app req k = do
  Gauge.inc (statsActiveConnectionsGauge statsEnv)
  finally (app req k) (Gauge.dec (statsActiveConnectionsGauge statsEnv))

normalizeAcceptMiddleware :: Middleware
normalizeAcceptMiddleware app req = app req { requestHeaders = normalizedHeaders }
  where
    normalizedHeaders = map normalizeHeader (requestHeaders req)

    normalizeHeader ( name, value )
      | name == hAccept = ( name, "*/*" )
      | otherwise = ( name, value )

tracingTimeUsage :: StatsEnv -> Middleware
tracingTimeUsage statsEnv app req k = do
  startTime <- getMonotonicTime
  finally (app req k) (do
                         endTime <- getMonotonicTime
                         let latency = endTime - startTime
                         Gauge.set (round (latency * 1000000)) (statsLatency statsEnv))

withHentaiHeaders :: Middleware
withHentaiHeaders app req k = app req (k . mapResponseHeaders addHeaders)
  where
    addHeaders headers@(map fst -> names)
      = filter ((`notElem` names) . fst) hentaiHeader ++ headers