packages feed

batchd-0.1.1.0: src/Batchd/Client/Http.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE DeriveDataTypeable #-}
{-# LANGUAGE DeriveGeneric #-}
-- | This module contains definitions for HTTP\/HTTPS protocol handling.
module Batchd.Client.Http where

import Control.Exception
import Control.Monad.State
import qualified Data.ByteString.Lazy as L
import qualified Data.Text.Lazy.Encoding as TLE
import Data.Text.Format.Heavy
import Data.Default
import Data.List
import Data.Aeson as Aeson
import Data.X509.CertificateStore
import Network.HTTP.Client
import Network.HTTP.Types.Status
import Network.HTTP.Client.TLS
import Network.TLS as TLS
import Network.TLS.Extra.Cipher
import Network.Connection

import Batchd.Core.Common.Types
import Batchd.Core.Common.Localize
import Batchd.Common.Types
import Batchd.Common.Config (getPassword)
import Batchd.Client.Types as C
import Batchd.Client.Config
import Batchd.Client.CmdLine
import Batchd.Client.Monad
import Batchd.Client.Logging

-- | Create a Manager - a structure which contains all required information to
-- maintain HTTP connection, over TLS if required.
makeClientManager :: CmdLine -> Client Manager
makeClientManager opts = do
    cfg <- liftIO loadClientConfig
    url <- liftIO $ getManagerUrl opts cfg
    if "https" `isPrefixOf` url
      then mkMngr (ccDisableServerCertificateCheck cfg) (ccCertificate cfg) (ccKey cfg) (ccCertificate cfg)
      else liftIO $ newManager defaultManagerSettings -- for HTTP everything is easy
  where
    mkMngr :: Bool -> Maybe FilePath -> Maybe FilePath -> Maybe FilePath -> Client Manager
    mkMngr disableServerCertCheck mbCertFile mbKeyFile mbCaFile = do
     when disableServerCertCheck $ do
         verbose (__ "Server certificate check disabled") ()
     -- load Credentials from certificate and private key file (.pem and .key usually)
     creds <- case (mbCertFile, mbKeyFile) of
                (Just certFile, Just keyFile) -> do
                    verbose (__ "Loading HTTPS certificate: {}, key: {}") (certFile, keyFile)
                    r <- liftIO $ credentialLoadX509 certFile keyFile
                    case r of
                      Right cr -> return $ Just cr
                      Left err -> fail =<< (__sf "Can't load certificate/key: {}" (Single $ show err))
                _ -> do
                     message (__ "Certificate or key file is not specified, try to access HTTPS without them") ()
                     return Nothing
     -- load trusted CA store if specified
     mbStore <- case mbCaFile of
                       Nothing -> return Nothing
                       Just caFile -> do
                          r <- liftIO $ readCertificateStore caFile
                          case r of
                            Just store -> return $ Just store
                            Nothing -> fail =<< (__s "cannot read specified CA store")
     let shared = case mbStore of
                    Nothing -> def
                    Just store -> def { sharedCAStore = store }
                
     let clientCertHook _ = return creds
         skip _ _ _ _ = do
                return []

         -- handlers for TLS protocol events on client side
         hooks =
            if disableServerCertCheck
              then def {
                         onCertificateRequest = clientCertHook, -- return loaded client certificate
                         onServerCertificate = skip
                       }
              else def { onCertificateRequest = clientCertHook }

         clientParams = (defaultParamsClient "" "")
                        { clientHooks = hooks,
                          clientUseServerNameIndication = True, -- SNI
                          clientShared = shared,
                          -- This is probably to be configurable
                          clientSupported = def { supportedCiphers = ciphersuite_all, supportedVersions = [TLS12] }
                        }
         tlsSettings = TLSSettings clientParams

     liftIO $ newManager $ mkManagerSettings tlsSettings Nothing

-- | Obtain list of authentication methods supported by server.
-- This is done by requesting OPTIONS /.
getAuthMethods :: Client [AuthMethod]
getAuthMethods = do
  mbMethods <- gets csAuthMethods
  case mbMethods of
    Just methods -> return methods
    Nothing -> do
      baseUrl <- getBaseUrl
      methods <- doOptions baseUrl
      modify $ \st -> st {csAuthMethods = Just methods}
      return methods

-- | Obtain user name and password if required
obtainCredentials :: Client C.Credentials
obtainCredentials = do
  cfg <- gets csConfig
  opts@(CmdLine o _) <- gets csCmdline
  methods <- getAuthMethods
  verbose (__ "Supported authentication methods: {}") (Single $ show methods)
  let needPassword = BasicAuth `elem` methods -- only basic auth of currently supported methods requires a password.
  name <- liftIO $ getUserName opts cfg
  pass <- if ccDisableAuth cfg || not needPassword
            then do
                 debug (__ "Password is not required") ()
                 return ""
            else do
                 mbPassword <- liftIO $ getConfigParam' (password o) "BATCH_PASSWORD" (ccPassword cfg)
                 case mbPassword of
                   Just p -> return p
                   Nothing -> liftIO $ getPassword =<< (__sf "{} password: " (Single name))
  let creds = (name, pass)
  -- remember credentials in client state
  modify $ \st -> st {csCredentials = Just creds}
  return creds

-- | Obain and cache user name and password, if required
getCredentials :: Client C.Credentials
getCredentials = do
  mbCreds <- gets csCredentials
  case mbCreds of
    Just creds -> return creds
    Nothing -> obtainCredentials

-- | Add authentication headers to HTTP request
applyAuth :: Request -> Client Request
applyAuth rq = do
  methods <- getAuthMethods
  -- liftIO $ print methods
  if BasicAuth `elem` methods
    then do
         (name, password) <- getCredentials
         return $ applyBasicAuth (stringToBstr name) (stringToBstr password) rq
    else if HeaderAuth `elem` methods
           then do
                (name, _) <- getCredentials
                return $ rq {requestHeaders = ("X-Auth-User", stringToBstr name) : requestHeaders rq}
           else return rq

handleStatus :: Response L.ByteString -> Client L.ByteString
handleStatus rs = do
  debug (__ "Received response: {}") (Single $ Shown rs)
  verbose (__ "Server response code is {} {}") (statusCode $ responseStatus rs, statusMessage $ responseStatus rs)
  if responseStatus rs == ok200
    then return $ responseBody rs
    else do
         liftIO $ throw $ ClientException $ TLE.decodeUtf8 $ responseBody rs

allowAny :: Request -> Response BodyReader -> IO ()
allowAny _ _ = return ()

getManager :: Client Manager
getManager = do
  mbManager <- gets csManager
  case mbManager of
    Just manager -> return manager
    Nothing -> do
               cmdline <- gets csCmdline
               manager <- makeClientManager cmdline
               modify $ \st -> st {csManager = Just manager}
               return manager

-- Generic HTTP request wrapper, useful mostly for debugging
doHttp :: Request -> Manager -> Client L.ByteString
doHttp request manager = do
  debug (__ "Sending request: {}") (Single $ Shown request)
  handleStatus =<< (liftIO $ httpLbs request manager)

-- PUT request
doPut :: ToJSON a => String -> a -> Client ()
doPut urlStr object = do
  manager <- getManager
  url <- liftIO $ parseUrlThrow urlStr
  let json = Aeson.encode object
  debug (__ "Request JSON: {}") (Single json)
  request <- applyAuth $ url {
                  method="PUT",
                  checkResponse = allowAny,
                  requestBody = RequestBodyLBS json
                }
  doHttp request manager
  return ()

-- POST request
doPost :: ToJSON a => String -> a -> Client ()
doPost urlStr object = do
  manager <- getManager
  url <- liftIO $ parseUrlThrow urlStr
  let json = Aeson.encode object
  debug (__ "Request JSON: {}") (Single json)
  request <- applyAuth $ url {
                  method="POST",
                  checkResponse = allowAny,
                  requestBody = RequestBodyLBS json
                }
  doHttp request manager
  return ()

-- DELETE request
doDelete :: String -> Client ()
doDelete urlStr = do
  manager <- getManager
  url <- liftIO $ parseUrlThrow urlStr
  request <- applyAuth $ url { method="DELETE",
                      checkResponse = allowAny
                    }
  doHttp request manager
  return ()

-- GET request
doGet :: FromJSON a => String -> Client a
doGet urlStr = do
  manager <- getManager
  url <- liftIO $  parseUrlThrow urlStr
  request <- applyAuth $ url {checkResponse = allowAny}
  responseLbs <- doHttp request manager
  case Aeson.eitherDecode responseLbs of
    Left err -> throwC =<< (__f "Can't parse server response for GET request: {}" (Single err))
    Right res -> return res

-- OPTIONS request
doOptions :: FromJSON a => String -> Client a
doOptions urlStr = do
  manager <- getManager
  url <- liftIO $ parseUrlThrow urlStr
  let request = url {checkResponse = allowAny, method = "OPTIONS"}
  responseLbs <- doHttp request manager
  case Aeson.eitherDecode responseLbs of
    Left err -> throwC =<< (__f "Can't parse server response for OPTIONS request: {}" (Single err))
    Right res -> return res