packages feed

wai-middleware-delegate-0.2.0.0: src/Network/Wai/Middleware/Delegate.hs

{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RankNTypes #-}

{- |
Copyright   : (c) 2018-2021 Tim Emiola
SPDX-License-Identifier: BSD3
Maintainer  : Tim Emiola <tim.emiola@gmail.com>

Provides a [WAI](https://hackage.haskell.com/packages/wai) middleware that
delegates handling of requests.

Provides 3 combinators that create middleware along with supporting data types.

* 'delegateTo': delegates handling of requests matching a predicate to a
  delegate Application

* 'delegateToProxy': delegates handling of requests matching a predicate to
  different host

* 'simpleProxy': is a simple reverse proxy, based on proxyApp of http-proxy by
  Erik de Castro Lopo/Michael Snoyman
-}
module Network.Wai.Middleware.Delegate
  ( -- * Middleware
    delegateTo
  , delegateToProxy
  , simpleProxy

    -- * Configuration
  , ProxySettings (..)
  , defaultSettings

    -- * Aliases
  , RequestPredicate
  )
where

import Blaze.ByteString.Builder (fromByteString)
import Control.Concurrent.Async (race_)
import Control.Exception
  ( SomeException
  , handle
  , toException
  )
import Control.Monad (unless)
import Control.Monad.IO.Class (MonadIO, liftIO)
import Data.ByteString (ByteString)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Char8 as C8
import qualified Data.ByteString.Lazy.Char8 as LC8
import Data.CaseInsensitive (mk)
import Data.Conduit
  ( ConduitT
  , Flush (..)
  , Void
  , await
  , mapOutput
  , runConduit
  , yield
  , ($$+)
  , ($$++)
  , (.|)
  )
import Data.Conduit.Network (appSink, appSource)
import Data.IORef (newIORef, readIORef, writeIORef)
import Data.Int (Int64)
import Data.Streaming.Network
  ( ClientSettings
  , clientSettingsTCP
  , runTCPClient
  )
import Network.HTTP.Client
  ( BodyReader
  , GivesPopper
  , Manager
  , Request (..)
  , RequestBody (..)
  , Response (..)
  , brRead
  , parseRequest
  , withResponse
  )
import Network.HTTP.Types
  ( HeaderName
  , hContentType
  , internalServerError500
  , status500
  )
import Network.HTTP.Types.Header (hHost)
import qualified Network.Wai as Wai
import Network.Wai.Conduit
  ( responseRawSource
  , responseSource
  , sourceRequestBody
  )


{- | Type alias for a function that determines if a request should be handled by
 a delegate.
-}
type RequestPredicate = Wai.Request -> Bool


{- | Create a middleware that handles all requests matching a predicate by
 delegating to an alternate Application.
-}
delegateTo :: Wai.Application -> RequestPredicate -> Wai.Middleware
delegateTo alt f actual req
  | f req = alt req
  | otherwise = actual req


{- | Creates a middleware that handles all requests matching a predicate by
 proxing them to a host specified by ProxySettings.
-}
delegateToProxy :: ProxySettings -> Manager -> RequestPredicate -> Wai.Middleware
delegateToProxy settings mgr = delegateTo (simpleProxy settings mgr)


-- | Settings that configure the proxy endpoint.
data ProxySettings = ProxySettings
  { proxyOnException :: SomeException -> Wai.Response
  -- ^ What to do with exceptions thrown by either the application or server.
  , proxyTimeout :: Int
  -- ^ Timeout value in seconds. Default value: 30
  , proxyHost :: BS.ByteString
  -- ^ The host being proxied
  , proxyRedirectCount :: Int
  -- ^ The number of redirects to follow. 0 means none, which is the default.
  }


{- | A default 'ProxySettings' that makes simplistic assumptions, e.g, that
target host is @localhost@
-}
defaultSettings :: ProxySettings
defaultSettings =
  ProxySettings
    { -- defaults to returning internal server error showing the error in the body
      proxyOnException = onException
    , -- default to 15 seconds
      proxyTimeout = 15
    , proxyHost = "localhost"
    , proxyRedirectCount = 0
    }
  where
    onException :: SomeException -> Wai.Response
    onException e =
      Wai.responseLBS
        internalServerError500
        [(hContentType, "text/plain; charset=utf-8")]
        $ LC8.fromChunks [C8.pack $ show e]


-- | A Wai Application that acts as a http/https proxy.
simpleProxy ::
  ProxySettings ->
  Manager ->
  Wai.Application
simpleProxy settings manager req respond
  -- we may connect requests to secure sites, when we do, we will not have
  -- seen their URI properly
  | Wai.requestMethod req == "CONNECT" =
      respond $
        responseRawSource
          (handleConnect req)
          (Wai.responseLBS status500 [("Content-Type", "text/plain")] "method CONNECT is not supported")
  | otherwise = do
      let scheme
            | Wai.isSecure req = "https"
            | otherwise = "http"
          rawUrl = Wai.rawPathInfo req <> Wai.rawQueryString req
          effectiveUrl = scheme ++ "://" ++ C8.unpack (proxyHost settings) ++ C8.unpack rawUrl
          newHost = proxyHost settings
          addHostHeader = (:) (hHost, newHost)

      proxyReq' <- parseRequest effectiveUrl
      let onException :: SomeException -> Wai.Response
          onException = proxyOnException settings . toException

          proxyReq =
            proxyReq'
              { method = Wai.requestMethod req
              , requestHeaders = addHostHeader $ filter dropUpstreamHeaders $ Wai.requestHeaders req
              , -- always pass redirects back to the client.
                redirectCount = proxyRedirectCount settings
              , requestBody =
                  case Wai.requestBodyLength req of
                    Wai.ChunkedBody ->
                      requestBodySourceChunked (sourceRequestBody req)
                    Wai.KnownLength l ->
                      requestBodySource (fromIntegral l) (sourceRequestBody req)
              , -- don't modify the response to ensure consistency with the response headers
                decompress = const False
              , host = newHost
              }

          respondUpstream = withResponse proxyReq manager $ \res -> do
            let body = mapOutput (Chunk . fromByteString) . bodyReaderSource $ responseBody res
                headers = (mk "X-Via-Proxy", "yes") : responseHeaders res
            respond $ responseSource (responseStatus res) headers body

      handle (respond . onException) respondUpstream


handleConnect ::
  Wai.Request ->
  ConduitT () C8.ByteString IO () ->
  ConduitT C8.ByteString Void IO () ->
  IO ()
handleConnect req fromClient toClient =
  runTCPClient (toClientSettings req) $ \ad -> do
    runConduit $ yield "HTTP/1.1 200 OK\r\n\r\n" .| toClient
    race_
      (runConduit $ fromClient .| appSink ad)
      (runConduit $ appSource ad .| toClient)


defaultClientPort :: Wai.Request -> Int
defaultClientPort req
  | Wai.isSecure req = 443
  | otherwise = 90


toClientSettings :: Wai.Request -> ClientSettings
toClientSettings req =
  case C8.break (== ':') $ Wai.rawPathInfo req of
    (h, "") -> clientSettingsTCP (defaultClientPort req) h
    (h, p') -> case C8.readInt $ C8.drop 1 p' of
      Just (p, _) -> clientSettingsTCP p h
      Nothing -> clientSettingsTCP (defaultClientPort req) h


dropUpstreamHeaders :: (HeaderName, b) -> Bool
dropUpstreamHeaders (k, _) = k `notElem` preservedHeaders


preservedHeaders :: [HeaderName]
preservedHeaders = ["content-encoding", "content-length", "host"]


type Source' = ConduitT () ByteString IO ()


srcToPopperIO :: Source' -> GivesPopper ()
srcToPopperIO src f = do
  (rsrc0, ()) <- src $$+ return ()
  irsrc <- newIORef rsrc0
  let popper :: IO ByteString
      popper = do
        rsrc <- readIORef irsrc
        (rsrc', mres) <- rsrc $$++ await
        writeIORef irsrc rsrc'
        case mres of
          Nothing -> return BS.empty
          Just bs
            | BS.null bs -> popper
            | otherwise -> return bs
  f popper


requestBodySource :: Int64 -> Source' -> RequestBody
requestBodySource size = RequestBodyStream size . srcToPopperIO


requestBodySourceChunked :: Source' -> RequestBody
requestBodySourceChunked = RequestBodyStreamChunked . srcToPopperIO


bodyReaderSource ::
  (MonadIO m) =>
  BodyReader ->
  ConduitT i ByteString m ()
bodyReaderSource br =
  loop
  where
    loop = do
      bs <- liftIO $ brRead br
      unless (BS.null bs) $ do
        yield bs
        loop