module Aws.Core
(
Loggable(..)
, Response(..)
, readResponse
, readResponseIO
, tellMetadata
, tellMetadataRef
, mapMetadata
, HTTPResponseConsumer
, ResponseConsumer(..)
, AsMemoryResponse(..)
, ListResponse(..)
, XmlException(..)
, HeaderException(..)
, FormException(..)
, NoCredentialsException(..)
, throwStatusCodeException
, readHex2
, elContent
, elCont
, force
, forceM
, textReadBool
, textReadInt
, readInt
, xmlCursorConsumer
, SignedQuery(..)
, NormalQuery
, UriOnlyQuery
, queryToHttpRequest
, queryToUri
, TimeInfo(..)
, AbsoluteTimeInfo(..)
, fromAbsoluteTimeInfo
, makeAbsoluteTimeInfo
, SignatureData(..)
, signatureData
, SignQuery(..)
, AuthorizationHash(..)
, amzHash
, signature
, authorizationV4
, queryList
, awsBool
, awsTrue
, awsFalse
, fmtTime
, fmtRfc822Time
, rfc822Time
, fmtAmzTime
, fmtTimeEpochSeconds
, parseHttpDate
, httpDate1
, textHttpDate
, iso8601UtcDate
, Transaction
, IteratedTransaction(..)
, Credentials(..)
, makeCredentials
, credentialsDefaultFile
, credentialsDefaultKey
, loadCredentialsFromFile
, loadCredentialsFromEnv
, loadCredentialsFromInstanceMetadata
, loadCredentialsFromEnvOrFile
, loadCredentialsFromEnvOrFileOrInstanceMetadata
, loadCredentialsDefault
, DefaultServiceConfiguration(..)
, Protocol(..)
, defaultPort
, Method(..)
, httpMethod
)
where
import Aws.Ec2.InstanceMetadata
import Aws.Network
import qualified Blaze.ByteString.Builder as Blaze
import Control.Applicative
import Control.Arrow
import qualified Control.Exception as E
import Control.Monad
import Control.Monad.IO.Class
import Control.Monad.Trans.Resource (ResourceT, MonadThrow (throwM))
import Crypto.Hash
import qualified Data.Aeson as A
import Data.Byteable
import Data.ByteString (ByteString)
import qualified Data.ByteString as B
import qualified Data.ByteString.Base16 as Base16
import qualified Data.ByteString.Base64 as Base64
import Data.ByteString.Char8 ()
import qualified Data.ByteString.Lazy as L
import qualified Data.ByteString.UTF8 as BU
import Data.Char
import Data.Conduit (($$+-))
import qualified Data.Conduit as C
#if MIN_VERSION_http_conduit(2,2,0)
import qualified Data.Conduit.Binary as CB
#endif
import qualified Data.Conduit.List as CL
import Data.IORef
import Data.List
import qualified Data.Map as M
import Data.Maybe
import Data.Monoid
import qualified Data.Text as T
import qualified Data.Text.Encoding as T
import qualified Data.Text.IO as T
import Data.Time
import qualified Data.Traversable as Traversable
import Data.Typeable
import Data.Word
import qualified Network.HTTP.Conduit as HTTP
import qualified Network.HTTP.Types as HTTP
import System.Directory
import System.Environment
import System.FilePath ((</>))
#if !MIN_VERSION_time(1,5,0)
import System.Locale
#endif
import qualified Text.XML as XML
import qualified Text.XML.Cursor as Cu
import Text.XML.Cursor hiding (force, forceM)
import Prelude
class Loggable a where
toLogText :: a -> T.Text
data Response m a = Response { responseMetadata :: m
, responseResult :: Either E.SomeException a }
deriving (Show, Functor)
readResponse :: MonadThrow n => Response m a -> n a
readResponse = either throwM return . responseResult
readResponseIO :: MonadIO io => Response m a -> io a
readResponseIO = liftIO . readResponse
tellMetadata :: m -> Response m ()
tellMetadata m = Response m (return ())
mapMetadata :: (m -> n) -> Response m a -> Response n a
mapMetadata f (Response m a) = Response (f m) a
instance Monoid m => Applicative (Response m) where
pure x = Response mempty (Right x)
(<*>) = ap
instance Monoid m => Monad (Response m) where
return x = Response mempty (Right x)
Response m1 (Left e) >>= _ = Response m1 (Left e)
Response m1 (Right x) >>= f = let Response m2 y = f x
in Response (m1 `mappend` m2) y
instance Monoid m => MonadThrow (Response m) where
throwM e = Response mempty (throwM e)
tellMetadataRef :: Monoid m => IORef m -> m -> IO ()
tellMetadataRef r m = modifyIORef r (`mappend` m)
type HTTPResponseConsumer a = HTTP.Response (C.ResumableSource (ResourceT IO) ByteString)
-> ResourceT IO a
class Monoid (ResponseMetadata resp) => ResponseConsumer req resp where
type ResponseMetadata resp
responseConsumer :: HTTP.Request -> req -> IORef (ResponseMetadata resp) -> HTTPResponseConsumer resp
instance ResponseConsumer r (HTTP.Response L.ByteString) where
type ResponseMetadata (HTTP.Response L.ByteString) = ()
responseConsumer _ _ _ resp = do
bss <- HTTP.responseBody resp $$+- CL.consume
return resp
{ HTTP.responseBody = L.fromChunks bss
}
class AsMemoryResponse resp where
type MemoryResponse resp :: *
loadToMemory :: resp -> ResourceT IO (MemoryResponse resp)
class ListResponse resp item | resp -> item where
listResponse :: resp -> [item]
class (SignQuery r, ResponseConsumer r a, Loggable (ResponseMetadata a))
=> Transaction r a
| r -> a
class Transaction r a => IteratedTransaction r a | r -> a where
nextIteratedRequest :: r -> a -> Maybe r
type V4Key = ((B.ByteString,B.ByteString),(B.ByteString,B.ByteString))
data Credentials
= Credentials {
accessKeyID :: B.ByteString
, secretAccessKey :: B.ByteString
, v4SigningKeys :: IORef [V4Key]
, iamToken :: Maybe B.ByteString
}
instance Show Credentials where
show c = "Credentials{accessKeyID=" ++ show (accessKeyID c) ++ ",secretAccessKey=" ++ show (secretAccessKey c) ++ ",iamToken=" ++ show (iamToken c) ++ "}"
makeCredentials :: MonadIO io
=> B.ByteString
-> B.ByteString
-> io Credentials
makeCredentials accessKeyID secretAccessKey = liftIO $ do
v4SigningKeys <- newIORef []
let iamToken = Nothing
return Credentials { .. }
credentialsDefaultFile :: MonadIO io => io (Maybe FilePath)
credentialsDefaultFile = liftIO $ tryMaybe ((</> ".aws-keys") <$> getHomeDirectory)
tryMaybe :: IO a -> IO (Maybe a)
tryMaybe action = E.catch (Just <$> action) f
where
f :: E.SomeException -> IO (Maybe a)
f _ = return Nothing
credentialsDefaultKey :: T.Text
credentialsDefaultKey = "default"
loadCredentialsFromFile :: MonadIO io => FilePath -> T.Text -> io (Maybe Credentials)
loadCredentialsFromFile file key = liftIO $ do
exists <- doesFileExist file
if exists
then do
contents <- map T.words . T.lines <$> T.readFile file
Traversable.sequence $ do
[_key, keyID, secret] <- find (hasKey key) contents
return (makeCredentials (T.encodeUtf8 keyID) (T.encodeUtf8 secret))
else return Nothing
where
hasKey _ [] = False
hasKey k (k2 : _) = k == k2
loadCredentialsFromEnv :: MonadIO io => io (Maybe Credentials)
loadCredentialsFromEnv = liftIO $ do
env <- getEnvironment
let lk = fmap (T.encodeUtf8 . T.pack) . flip lookup env
keyID = lk "AWS_ACCESS_KEY_ID"
secret = lk "AWS_ACCESS_KEY_SECRET" `mplus` lk "AWS_SECRET_ACCESS_KEY"
setSession creds = creds { iamToken = lk "AWS_SESSION_TOKEN" }
makeCredentials' k s = setSession <$> makeCredentials k s
Traversable.sequence $ makeCredentials' <$> keyID <*> secret
loadCredentialsFromInstanceMetadata :: MonadIO io => io (Maybe Credentials)
loadCredentialsFromInstanceMetadata = do
mgr <- liftIO $ HTTP.newManager HTTP.tlsManagerSettings
avail <- liftIO $ hostAvailable "169.254.169.254"
if not avail
then return Nothing
else do
info <- liftIO $ E.catch (getInstanceMetadata mgr "latest/meta-data/iam" "info" >>= return . Just) (\(_ :: HTTP.HttpException) -> return Nothing)
let infodict = info >>= A.decode :: Maybe (M.Map String String)
info' = infodict >>= M.lookup "InstanceProfileArn"
case info' of
Just name ->
do
let name' = drop 1 $ dropWhile (/= '/') $ name
creds <- liftIO $ E.catch (getInstanceMetadata mgr "latest/meta-data/iam/security-credentials" name' >>= return . Just) (\(_ :: HTTP.HttpException) -> return Nothing)
let dict = creds >>= A.decode :: Maybe (M.Map String String)
keyID = dict >>= M.lookup "AccessKeyId"
secret = dict >>= M.lookup "SecretAccessKey"
token = dict >>= M.lookup "Token"
ref <- liftIO $ newIORef []
return (Credentials <$> (T.encodeUtf8 . T.pack <$> keyID)
<*> (T.encodeUtf8 . T.pack <$> secret)
<*> return ref
<*> (Just . T.encodeUtf8 . T.pack <$> token))
Nothing -> return Nothing
loadCredentialsFromEnvOrFile :: MonadIO io => FilePath -> T.Text -> io (Maybe Credentials)
loadCredentialsFromEnvOrFile file key =
do
envcr <- loadCredentialsFromEnv
case envcr of
Just cr -> return (Just cr)
Nothing -> loadCredentialsFromFile file key
loadCredentialsFromEnvOrFileOrInstanceMetadata :: MonadIO io => FilePath -> T.Text -> io (Maybe Credentials)
loadCredentialsFromEnvOrFileOrInstanceMetadata file key =
do
envcr <- loadCredentialsFromEnv
case envcr of
Just cr -> return (Just cr)
Nothing ->
do
filecr <- loadCredentialsFromFile file key
case filecr of
Just cr -> return (Just cr)
Nothing -> loadCredentialsFromInstanceMetadata
loadCredentialsDefault :: MonadIO io => io (Maybe Credentials)
loadCredentialsDefault = do
mfile <- credentialsDefaultFile
case mfile of
Just file -> loadCredentialsFromEnvOrFileOrInstanceMetadata file credentialsDefaultKey
Nothing -> loadCredentialsFromEnv
data Protocol
= HTTP
| HTTPS
deriving (Eq,Read,Show,Ord,Typeable)
defaultPort :: Protocol -> Int
defaultPort HTTP = 80
defaultPort HTTPS = 443
data Method
= Head
| Get
| PostQuery
| Post
| Put
| Delete
deriving (Show, Eq, Ord)
httpMethod :: Method -> HTTP.Method
httpMethod Head = "HEAD"
httpMethod Get = "GET"
httpMethod PostQuery = "POST"
httpMethod Post = "POST"
httpMethod Put = "PUT"
httpMethod Delete = "DELETE"
data SignedQuery
= SignedQuery {
sqMethod :: !Method
, sqProtocol :: !Protocol
, sqHost :: !B.ByteString
, sqPort :: !Int
, sqPath :: !B.ByteString
, sqQuery :: !HTTP.Query
, sqDate :: !(Maybe UTCTime)
, sqAuthorization :: !(Maybe (IO B.ByteString))
, sqContentType :: !(Maybe B.ByteString)
, sqContentMd5 :: !(Maybe (Digest MD5))
, sqAmzHeaders :: !HTTP.RequestHeaders
, sqOtherHeaders :: !HTTP.RequestHeaders
#if MIN_VERSION_http_conduit(2, 0, 0)
, sqBody :: !(Maybe HTTP.RequestBody)
#else
, sqBody :: !(Maybe (HTTP.RequestBody (C.ResourceT IO)))
#endif
, sqStringToSign :: !B.ByteString
}
#if MIN_VERSION_http_conduit(2, 0, 0)
queryToHttpRequest :: SignedQuery -> IO HTTP.Request
#else
queryToHttpRequest :: SignedQuery -> IO (HTTP.Request (C.ResourceT IO))
#endif
queryToHttpRequest SignedQuery{..} = do
mauth <- maybe (return Nothing) (Just<$>) sqAuthorization
return $ HTTP.defaultRequest {
HTTP.method = httpMethod sqMethod
, HTTP.secure = case sqProtocol of
HTTP -> False
HTTPS -> True
, HTTP.host = sqHost
, HTTP.port = sqPort
, HTTP.path = sqPath
, HTTP.queryString =
if sqMethod == PostQuery
then ""
else HTTP.renderQuery False sqQuery
, HTTP.requestHeaders = catMaybes [ checkDate (\d -> ("Date", fmtRfc822Time d)) sqDate
, fmap (\c -> ("Content-Type", c)) contentType
, fmap (\md5 -> ("Content-MD5", Base64.encode $ toBytes md5)) sqContentMd5
, fmap (\auth -> ("Authorization", auth)) mauth]
++ sqAmzHeaders
++ sqOtherHeaders
, HTTP.requestBody =
case sqBody of
Just x -> x
Nothing ->
case sqMethod of
PostQuery -> HTTP.RequestBodyLBS . Blaze.toLazyByteString $
HTTP.renderQueryBuilder False sqQuery
_ -> HTTP.RequestBodyBuilder 0 mempty
, HTTP.decompress = HTTP.alwaysDecompress
#if MIN_VERSION_http_conduit(2,2,0)
, HTTP.checkResponse = \_ _ -> return ()
#else
, HTTP.checkStatus = \_ _ _-> Nothing
#endif
, HTTP.redirectCount = 10
}
where
checkDate f mb = maybe (f <$> mb) (const Nothing) $ lookup "date" sqOtherHeaders
contentType = sqContentType `mplus` defContentType
defContentType = case sqMethod of
PostQuery -> Just "application/x-www-form-urlencoded; charset=utf-8"
_ -> Nothing
queryToUri :: SignedQuery -> B.ByteString
queryToUri SignedQuery{..}
= B.concat [
case sqProtocol of
HTTP -> "http://"
HTTPS -> "https://"
, sqHost
, if sqPort == defaultPort sqProtocol then "" else T.encodeUtf8 . T.pack $ ':' : show sqPort
, sqPath
, HTTP.renderQuery True sqQuery
]
data TimeInfo
= Timestamp
| ExpiresAt { fromExpiresAt :: UTCTime }
| ExpiresIn { fromExpiresIn :: NominalDiffTime }
deriving (Show)
data AbsoluteTimeInfo
= AbsoluteTimestamp { fromAbsoluteTimestamp :: UTCTime }
| AbsoluteExpires { fromAbsoluteExpires :: UTCTime }
deriving (Show)
fromAbsoluteTimeInfo :: AbsoluteTimeInfo -> UTCTime
fromAbsoluteTimeInfo (AbsoluteTimestamp time) = time
fromAbsoluteTimeInfo (AbsoluteExpires time) = time
makeAbsoluteTimeInfo :: TimeInfo -> UTCTime -> AbsoluteTimeInfo
makeAbsoluteTimeInfo Timestamp now = AbsoluteTimestamp now
makeAbsoluteTimeInfo (ExpiresAt t) _ = AbsoluteExpires t
makeAbsoluteTimeInfo (ExpiresIn s) now = AbsoluteExpires $ addUTCTime s now
data SignatureData
= SignatureData {
signatureTimeInfo :: AbsoluteTimeInfo
, signatureTime :: UTCTime
, signatureCredentials :: Credentials
}
signatureData :: TimeInfo -> Credentials -> IO SignatureData
signatureData rti cr = do
now <- getCurrentTime
let ti = makeAbsoluteTimeInfo rti now
return SignatureData { signatureTimeInfo = ti, signatureTime = now, signatureCredentials = cr }
data NormalQuery
data UriOnlyQuery
class SignQuery request where
type ServiceConfiguration request :: * -> *
signQuery :: request -> ServiceConfiguration request queryType -> SignatureData -> SignedQuery
data AuthorizationHash
= HmacSHA1
| HmacSHA256
deriving (Show)
amzHash :: AuthorizationHash -> B.ByteString
amzHash HmacSHA1 = "HmacSHA1"
amzHash HmacSHA256 = "HmacSHA256"
signature :: Credentials -> AuthorizationHash -> B.ByteString -> B.ByteString
signature cr ah input = Base64.encode sig
where
sig = case ah of
HmacSHA1 -> computeSig SHA1
HmacSHA256 -> computeSig SHA256
computeSig :: HashAlgorithm a => a -> ByteString
computeSig t = toBytes (hmacAlg t (secretAccessKey cr) input)
authorizationV4 :: SignatureData
-> AuthorizationHash
-> B.ByteString
-> B.ByteString
-> B.ByteString
-> B.ByteString
-> IO B.ByteString
authorizationV4 sd ah region service headers canonicalRequest = do
let ref = v4SigningKeys $ signatureCredentials sd
date = fmtTime "%Y%m%d" $ signatureTime sd
mkHmac k i = case ah of
HmacSHA1 -> toBytes (hmac k i :: HMAC SHA1)
HmacSHA256 -> toBytes (hmac k i :: HMAC SHA256)
mkHash i = case ah of
HmacSHA1 -> toBytes (hash i :: Digest SHA1)
HmacSHA256 -> toBytes (hash i :: Digest SHA256)
alg = case ah of
HmacSHA1 -> "AWS4-HMAC-SHA1"
HmacSHA256 -> "AWS4-HMAC-SHA256"
allkeys <- readIORef ref
let mkey = case lookup (region,service) allkeys of
Just (d,k) | d /= date -> Nothing
| otherwise -> Just k
Nothing -> Nothing
key <- case mkey of
Just k -> return k
Nothing -> atomicModifyIORef ref $ \keylist ->
let secretKey = secretAccessKey $ signatureCredentials sd
kDate = mkHmac ("AWS4" <> secretKey) date
kRegion = mkHmac kDate region
kService = mkHmac kRegion service
kSigning = mkHmac kService "aws4_request"
lstK = (region,service)
keylist' = (lstK,(date,kSigning)) : filter ((lstK/=).fst) keylist
in (keylist', kSigning)
let canonicalRequestHash = Base16.encode $ mkHash canonicalRequest
stringToSign = B.concat [ alg
, "\n"
, fmtTime "%Y%m%dT%H%M%SZ" $ signatureTime sd
, "\n"
, date
, "/"
, region
, "/"
, service
, "/aws4_request\n"
, canonicalRequestHash
]
sig = Base16.encode $ mkHmac key stringToSign
return $ B.concat [ alg
, " Credential="
, accessKeyID (signatureCredentials sd)
, "/"
, date
, "/"
, region
, "/"
, service
, "/aws4_request,"
, "SignedHeaders="
, headers
, ",Signature="
, sig
]
class DefaultServiceConfiguration config where
defServiceConfig :: config
debugServiceConfig :: config
debugServiceConfig = defServiceConfig
queryList :: (a -> [(B.ByteString, B.ByteString)]) -> B.ByteString -> [a] -> [(B.ByteString, B.ByteString)]
queryList f prefix xs = concat $ zipWith combine prefixList (map f xs)
where prefixList = map (dot prefix . BU.fromString . show) [(1 :: Int) ..]
combine pf = map $ first (pf `dot`)
dot x y = B.concat [x, BU.fromString ".", y]
awsBool :: Bool -> B.ByteString
awsBool True = "true"
awsBool False = "false"
awsTrue :: B.ByteString
awsTrue = awsBool True
awsFalse :: B.ByteString
awsFalse = awsBool False
fmtTime :: String -> UTCTime -> B.ByteString
fmtTime s t = BU.fromString $ formatTime defaultTimeLocale s t
rfc822Time :: String
rfc822Time = "%a, %0d %b %Y %H:%M:%S GMT"
fmtRfc822Time :: UTCTime -> B.ByteString
fmtRfc822Time = fmtTime rfc822Time
fmtAmzTime :: UTCTime -> B.ByteString
fmtAmzTime = fmtTime "%Y-%m-%dT%H:%M:%S"
fmtTimeEpochSeconds :: UTCTime -> B.ByteString
fmtTimeEpochSeconds = fmtTime "%s"
parseHttpDate :: String -> Maybe UTCTime
parseHttpDate s = p "%a, %d %b %Y %H:%M:%S GMT" s
<|> p "%A, %d-%b-%y %H:%M:%S GMT" s
<|> p "%a %b %_d %H:%M:%S %Y" s
<|> p "%Y-%m-%dT%H:%M:%S%QZ" s
<|> p "%Y-%m-%dT%H:%M:%S%Q%Z" s
where p = parseTime defaultTimeLocale
httpDate1 :: String
httpDate1 = "%a, %d %b %Y %H:%M:%S GMT"
textHttpDate :: UTCTime -> T.Text
textHttpDate = T.pack . formatTime defaultTimeLocale httpDate1
iso8601UtcDate :: String
iso8601UtcDate = "%Y-%m-%dT%H:%M:%S%QZ"
readHex2 :: [Char] -> Maybe Word8
readHex2 [c1,c2] = do n1 <- readHex1 c1
n2 <- readHex1 c2
return . fromIntegral $ n1 * 16 + n2
where
readHex1 c | c >= '0' && c <= '9' = Just $ ord c ord '0'
| c >= 'A' && c <= 'F' = Just $ ord c ord 'A' + 10
| c >= 'a' && c <= 'f' = Just $ ord c ord 'a' + 10
readHex1 _ = Nothing
readHex2 _ = Nothing
newtype XmlException = XmlException { xmlErrorMessage :: String }
deriving (Show, Typeable)
instance E.Exception XmlException
newtype HeaderException = HeaderException { headerErrorMessage :: String }
deriving (Show, Typeable)
instance E.Exception HeaderException
newtype FormException = FormException { formErrorMesage :: String }
deriving (Show, Typeable)
instance E.Exception FormException
newtype NoCredentialsException = NoCredentialsException { noCredentialsErrorMessage :: String }
deriving (Show, Typeable)
instance E.Exception NoCredentialsException
throwStatusCodeException :: HTTP.Request
-> HTTP.Response (C.ResumableSource (ResourceT IO) ByteString)
-> ResourceT IO a
#if MIN_VERSION_http_conduit(2,2,0)
throwStatusCodeException req resp = do
let resp' = fmap (const ()) resp
body <- HTTP.responseBody resp C.$$+- CB.take (10*1024)
let sce = HTTP.StatusCodeException resp' (L.toStrict body)
throwM $ HTTP.HttpExceptionRequest req sce
#else
throwStatusCodeException _req resp = do
let cookies = HTTP.responseCookieJar resp
headers = HTTP.responseHeaders resp
status = HTTP.responseStatus resp
throwM $ HTTP.StatusCodeException status headers cookies
#endif
elContent :: T.Text -> Cursor -> [T.Text]
elContent name = laxElement name &/ content
elCont :: T.Text -> Cursor -> [String]
elCont name = laxElement name &/ content &| T.unpack
force :: MonadThrow m => String -> [a] -> m a
force = Cu.force . XmlException
forceM :: MonadThrow m => String -> [m a] -> m a
forceM = Cu.forceM . XmlException
textReadBool :: MonadThrow m => T.Text -> m Bool
textReadBool s = case T.unpack s of
"true" -> return True
"false" -> return False
_ -> throwM $ XmlException "Invalid Bool"
textReadInt :: (MonadThrow m, Num a) => T.Text -> m a
textReadInt s = case reads $ T.unpack s of
[(n,"")] -> return $ fromInteger n
_ -> throwM $ XmlException "Invalid Integer"
readInt :: (MonadThrow m, Num a) => String -> m a
readInt s = case reads s of
[(n,"")] -> return $ fromInteger n
_ -> throwM $ XmlException "Invalid Integer"
xmlCursorConsumer ::
(Monoid m)
=> (Cu.Cursor -> Response m a)
-> IORef m
-> HTTPResponseConsumer a
xmlCursorConsumer parse metadataRef res
= do doc <- HTTP.responseBody res $$+- XML.sinkDoc XML.def
let cursor = Cu.fromDocument doc
let Response metadata x = parse cursor
liftIO $ tellMetadataRef metadataRef metadata
case x of
Left err -> liftIO $ throwM err
Right v -> return v