postgres-websockets 0.7.0.0 → 0.8.0.0
raw patch · 12 files changed
+546/−353 lines, 12 filesdep +asyncdep +lens-aesondep +networkdep −transformersdep ~hasqldep ~hasql-pooldep ~lens
Dependencies added: async, lens-aeson, network
Dependencies removed: transformers
Dependency ranges changed: hasql, hasql-pool, lens, websockets
Files
- app/Config.hs +0/−65
- app/Main.hs +5/−103
- postgres-websockets.cabal +22/−18
- src/PostgresWebsockets.hs +12/−113
- src/PostgresWebsockets/Broadcast.hs +9/−5
- src/PostgresWebsockets/Claims.hs +53/−37
- src/PostgresWebsockets/Config.hs +102/−0
- src/PostgresWebsockets/HasqlBroadcast.hs +7/−4
- src/PostgresWebsockets/Middleware.hs +127/−0
- src/PostgresWebsockets/Server.hs +53/−0
- test/ClaimsSpec.hs +39/−8
- test/ServerSpec.hs +117/−0
− app/Config.hs
@@ -1,65 +0,0 @@-{-|-Module : Config-Description : Manages PostgresWebsockets configuration options.--This module provides a helper function to read the command line-arguments using the optparse-applicative and the AppConfig type to store-them. It also can be used to define other middleware configuration that-may be delegated to some sort of external configuration.--It currently includes a hardcoded CORS policy but this could easly be-turned in configurable behaviour if needed.--Other hardcoded options such as the minimum version number also belong here.--}-module Config ( prettyVersion- , readOptions- , minimumPgVersion- , PgVersion (..)- , AppConfig (..)- )- where--import Env-import Data.Text (intercalate)-import Data.Version (versionBranch)-import Paths_postgres_websockets (version)-import Protolude hiding (intercalate, (<>), optional)---- | Config file settings for the server-data AppConfig = AppConfig {- configDatabase :: Text- , configPath :: Maybe Text- , configHost :: Text- , configPort :: Int- , configListenChannel :: Text- , configJwtSecret :: ByteString- , configJwtSecretIsBase64 :: Bool- , configPool :: Int- }---- | User friendly version number-prettyVersion :: Text-prettyVersion = intercalate "." $ map show $ versionBranch version---- | Function to read and parse options from the environment-readOptions :: IO AppConfig-readOptions =- Env.parse (header "You need to configure some environment variables to start the service.") $- AppConfig <$> var (str <=< nonempty) "PGWS_DB_URI" (help "String to connect to PostgreSQL")- <*> optional (var str "PGWS_ROOT_PATH" (help "Root path to serve static files, unset to disable."))- <*> var str "PGWS_HOST" (def "*4" <> helpDef show <> help "Address the server will listen for websocket connections")- <*> var auto "PGWS_PORT" (def 3000 <> helpDef show <> help "Port the server will listen for websocket connections")- <*> var str "PGWS_LISTEN_CHANNEL" (def "postgres-websockets-listener" <> helpDef show <> help "Master channel used in the database to send or read messages in any notification channel")- <*> var str "PGWS_JWT_SECRET" (help "Secret used to sign JWT tokens used to open communications channels")- <*> var auto "PGWS_JWT_SECRET_BASE64" (def False <> helpDef show <> help "Indicate whether the JWT secret should be decoded from a base64 encoded string")- <*> var auto "PGWS_POOL_SIZE" (def 10 <> helpDef show <> help "How many connection to the database should be used by the connection pool")--data PgVersion = PgVersion {- pgvNum :: Int32-, pgvName :: Text-}---- | Tells the minimum PostgreSQL version required by this version of PostgresWebsockets-minimumPgVersion :: PgVersion-minimumPgVersion = PgVersion 90300 "9.3"
app/Main.hs view
@@ -1,45 +1,9 @@ module Main where -import Protolude hiding (replace)-import PostgresWebsockets-import Config (AppConfig (..),- PgVersion (..),- minimumPgVersion,- prettyVersion,- readOptions)--import qualified Data.ByteString as BS-import qualified Data.ByteString.Base64 as B64-import Data.String (IsString (..))-import Data.Text (pack, replace, strip, stripPrefix)-import Data.Text.Encoding (encodeUtf8, decodeUtf8)-import qualified Hasql.Statement as H-import qualified Hasql.Session as H-import qualified Hasql.Decoders as HD-import qualified Hasql.Encoders as HE-import qualified Hasql.Pool as P-import Network.Wai.Application.Static-import Data.Time.Clock (UTCTime, getCurrentTime)-import Control.AutoUpdate ( defaultUpdateSettings- , mkAutoUpdate- , updateAction- )--import Network.Wai (Application, responseLBS)-import Network.HTTP.Types (status200)-import Network.Wai.Handler.Warp-import Network.Wai.Middleware.RequestLogger (logStdout)-import System.IO (BufferMode (..),- hSetBuffering)+import Protolude+import PostgresWebsockets -isServerVersionSupported :: H.Session Bool-isServerVersionSupported = do- ver <- H.statement () pgVersion- return $ ver >= pgvNum minimumPgVersion- where- pgVersion =- H.Statement "SELECT current_setting('server_version_num')::integer"- HE.noParams (HD.singleRow $ HD.column $ HD.nonNullable HD.int4) False+import System.IO (BufferMode (..), hSetBuffering) main :: IO () main = do@@ -51,67 +15,5 @@ <> prettyVersion <> " / Connects websockets to PostgreSQL asynchronous notifications." - conf <- loadSecretFile =<< readOptions- shutdownSignal <- newEmptyMVar- let host = configHost conf- port = configPort conf- listenChannel = toS $ configListenChannel conf- pgSettings = toS (configDatabase conf)- waitForShutdown cl = void $ forkIO (takeMVar shutdownSignal >> cl >> die "Shutting server down...")-- appSettings = setHost ((fromString . toS) host)- . setPort port- . setServerName (toS $ "postgres-websockets/" <> prettyVersion)- . setTimeout 3600- . setInstallShutdownHandler waitForShutdown- . setGracefulShutdownTimeout (Just 5)- $ defaultSettings-- putStrLn $ ("Listening on port " :: Text) <> show (configPort conf)-- let shutdown = putErrLn ("Broadcaster connection is dead" :: Text) >> putMVar shutdownSignal ()- pool <- P.acquire (configPool conf, 10, pgSettings)- multi <- newHasqlBroadcaster shutdown listenChannel pgSettings- getTime <- mkGetTime-- runSettings appSettings $- postgresWsMiddleware getTime listenChannel (configJwtSecret conf) pool multi $- logStdout $ maybe dummyApp staticApp' (configPath conf)-- where- mkGetTime :: IO (IO UTCTime)- mkGetTime = mkAutoUpdate defaultUpdateSettings {updateAction = getCurrentTime}- staticApp' :: Text -> Application- staticApp' = staticApp . defaultFileServerSettings . toS- dummyApp :: Application- dummyApp _ respond =- respond $ responseLBS status200 [("Content-Type", "text/plain")] "Hello, Web!"--loadSecretFile :: AppConfig -> IO AppConfig-loadSecretFile conf = extractAndTransform secret- where- secret = decodeUtf8 $ configJwtSecret conf- isB64 = configJwtSecretIsBase64 conf-- extractAndTransform :: Text -> IO AppConfig- extractAndTransform s =- fmap setSecret $ transformString isB64 =<<- case stripPrefix "@" s of- Nothing -> return . encodeUtf8 $ s- Just filename -> chomp <$> BS.readFile (toS filename)- where- chomp bs = fromMaybe bs (BS.stripSuffix "\n" bs)-- -- Turns the Base64url encoded JWT into Base64- transformString :: Bool -> ByteString -> IO ByteString- transformString False t = return t- transformString True t =- case B64.decode $ encodeUtf8 $ strip $ replaceUrlChars $ decodeUtf8 t of- Left errMsg -> panic $ pack errMsg- Right bs -> return bs-- setSecret bs = conf {configJwtSecret = bs}-- -- replace: Replace every occurrence of one substring with another- replaceUrlChars =- replace "_" "/" . replace "-" "+" . replace "." "="+ conf <- loadConfig+ void $ serve conf
postgres-websockets.cabal view
@@ -1,5 +1,5 @@ name: postgres-websockets-version: 0.7.0.0+version: 0.8.0.0 synopsis: Middleware to map LISTEN/NOTIFY messages to Websockets description: Please see README.md homepage: https://github.com/diogob/postgres-websockets#readme@@ -20,6 +20,11 @@ , PostgresWebsockets.Broadcast , PostgresWebsockets.HasqlBroadcast , PostgresWebsockets.Claims+ , PostgresWebsockets.Config++ other-modules: Paths_postgres_websockets+ , PostgresWebsockets.Server+ , PostgresWebsockets.Middleware build-depends: base >= 4.7 && < 5 , hasql-pool >= 0.5 && < 0.6 , text >= 1.2 && < 1.3@@ -45,32 +50,26 @@ , time >= 1.8.0.2 && < 1.9 , contravariant >= 1.5.2 && < 1.6 , alarmclock >= 0.7.0.2 && < 0.8+ , async >= 2.2.0 && < 2.3+ , envparse >= 0.4.1+ , base64-bytestring >= 1.0.0.3 && < 1.1+ , bytestring >= 0.10+ , warp >= 3.2 && < 4+ , wai-extra >= 3.0.29 && < 3.1+ , wai-app-static >= 3.1.7.1 && < 3.2+ , auto-update >= 0.1.6 && < 0.2+ default-language: Haskell2010- default-extensions: OverloadedStrings, NoImplicitPrelude, LambdaCase+ default-extensions: OverloadedStrings, NoImplicitPrelude, LambdaCase, RecordWildCards executable postgres-websockets hs-source-dirs: app main-is: Main.hs- other-modules: Config- , Paths_postgres_websockets+ other-modules: ghc-options: -Wall -threaded -rtsopts -with-rtsopts=-N build-depends: base >= 4.7 && < 5- , transformers >= 0.4 && < 0.6- , hasql >= 0.19- , hasql-pool >= 0.4- , warp >= 3.2 && < 4 , postgres-websockets , protolude >= 0.2.3- , base64-bytestring >= 1.0.0.3 && < 1.1- , bytestring >= 0.10- , text >= 1.2 && < 1.3- , time >= 1.8.0.2 && < 1.9- , wai >= 3.2 && < 4- , wai-extra >= 3.0.29 && < 3.1- , wai-app-static >= 3.1.7.1 && < 3.2- , http-types >= 0.9- , envparse >= 0.4.1- , auto-update >= 0.1.6 && < 0.2 default-language: Haskell2010 default-extensions: OverloadedStrings, NoImplicitPrelude, QuasiQuotes @@ -81,6 +80,7 @@ other-modules: BroadcastSpec , ClaimsSpec , HasqlBroadcastSpec+ , ServerSpec build-depends: base , protolude >= 0.2.3 , postgres-websockets@@ -96,6 +96,10 @@ , unordered-containers >= 0.2 , wai-extra >= 3.0.29 && < 3.1 , stm >= 2.5.0.0 && < 2.6+ , websockets >= 0.12.7.0 && < 0.13+ , network >= 2.8.0.1 && < 2.9+ , lens >= 4.17.1 && < 4.18+ , lens-aeson ghc-options: -Wall -threaded -rtsopts -with-rtsopts=-N default-language: Haskell2010 default-extensions: OverloadedStrings, NoImplicitPrelude
src/PostgresWebsockets.hs view
@@ -1,117 +1,16 @@-{-| PostgresWebsockets Middleware, composing this allows postgrest to create- websockets connections that will communicate with the database through LISTEN/NOTIFY channels.--}-{-# LANGUAGE DeriveGeneric #-}+{-|+Module : PostgresWebsockets+Description : PostgresWebsockets main library interface. +These are all function necessary to start a fully functionaing service.+-} module PostgresWebsockets- ( postgresWsMiddleware- -- * Re-exports- , newHasqlBroadcaster- , newHasqlBroadcasterOrError+ ( prettyVersion+ , loadConfig+ , serve+ , postgresWsMiddleware ) where -import qualified Hasql.Pool as H-import qualified Hasql.Notifications as H-import qualified Network.Wai as Wai-import qualified Network.Wai.Handler.WebSockets as WS-import qualified Network.WebSockets as WS-import Protolude--import qualified Data.Aeson as A-import qualified Data.ByteString.Char8 as BS-import qualified Data.ByteString.Lazy as BL-import qualified Data.HashMap.Strict as M-import qualified Data.Text.Encoding.Error as T-import Data.Time.Clock (UTCTime)-import Data.Time.Clock.POSIX (utcTimeToPOSIXSeconds, posixSecondsToUTCTime)-import Control.Concurrent.AlarmClock (newAlarmClock, setAlarm)-import PostgresWebsockets.Broadcast (Multiplexer, onMessage)-import qualified PostgresWebsockets.Broadcast as B-import PostgresWebsockets.Claims-import PostgresWebsockets.HasqlBroadcast (newHasqlBroadcaster,- newHasqlBroadcasterOrError)--data Message = Message- { claims :: A.Object- , channel :: Text- , payload :: Text- } deriving (Show, Eq, Generic)--instance A.ToJSON Message---- | Given a secret, a function to fetch the system time, a Hasql Pool and a Multiplexer this will give you a WAI middleware.-postgresWsMiddleware :: IO UTCTime -> Text -> ByteString -> H.Pool -> Multiplexer -> Wai.Application -> Wai.Application-postgresWsMiddleware =- WS.websocketsOr WS.defaultConnectionOptions `compose` wsApp- where- compose = (.) . (.) . (.) . (.) . (.)---- private functions-jwtExpirationStatusCode :: Word16-jwtExpirationStatusCode = 3001---- when the websocket is closed a ConnectionClosed Exception is triggered--- this kills all children and frees resources for us-wsApp :: IO UTCTime -> Text -> ByteString -> H.Pool -> Multiplexer -> WS.ServerApp-wsApp getTime dbChannel secret pool multi pendingConn =- getTime >>= validateClaims requestChannel secret (toS jwtToken) >>= either rejectRequest forkSessions- where- hasRead m = m == ("r" :: ByteString) || m == ("rw" :: ByteString)- hasWrite m = m == ("w" :: ByteString) || m == ("rw" :: ByteString)- rejectRequest = WS.rejectRequest pendingConn . encodeUtf8- -- the URI has one of the two formats - /:jwt or /:channel/:jwt- pathElements = BS.split '/' $ BS.drop 1 $ WS.requestPath $ WS.pendingRequest pendingConn- jwtToken- | length pathElements > 1 = headDef "" $ tailSafe pathElements- | length pathElements <= 1 = headDef "" pathElements- requestChannel- | length pathElements > 1 = Just $ headDef "" pathElements- | length pathElements <= 1 = Nothing- forkSessions (ch, mode, validClaims) = do- -- role claim defaults to anon if not specified in jwt- -- We should accept only after verifying JWT- conn <- WS.acceptRequest pendingConn- -- Fork a pinging thread to ensure browser connections stay alive- WS.withPingThread conn 30 (pure ()) $ do- case M.lookup "exp" validClaims of- Just (A.Number expClaim) -> do- connectionExpirer <- newAlarmClock $ const (WS.sendCloseCode conn jwtExpirationStatusCode ("JWT expired" :: ByteString))- setAlarm connectionExpirer (posixSecondsToUTCTime $ realToFrac expClaim)- Just _ -> pure ()- Nothing -> pure ()-- when (hasRead mode) $- onMessage multi ch $ WS.sendTextData conn . B.payload-- when (hasWrite mode) $- let sendNotifications = void . H.notifyPool pool dbChannel . toS- in notifySession validClaims (toS ch) conn getTime sendNotifications-- waitForever <- newEmptyMVar- void $ takeMVar waitForever---- Having both channel and claims as parameters seem redundant--- But it allows the function to ignore the claims structure and the source--- of the channel, so all claims decoding can be coded in the caller-notifySession :: A.Object- -> Text- -> WS.Connection- -> IO UTCTime- -> (ByteString -> IO ())- -> IO ()-notifySession claimsToSend ch wsCon getTime send =- withAsync (forever relayData) wait- where- relayData = jsonMsgWithTime >>= send-- jsonMsgWithTime = liftA2 jsonMsg claimsWithTime (WS.receiveData wsCon)-- -- we need to decode the bytestring to re-encode valid JSON for the notification- jsonMsg :: M.HashMap Text A.Value -> ByteString -> ByteString- jsonMsg cl = BL.toStrict . A.encode . Message cl ch . decodeUtf8With T.lenientDecode-- claimsWithChannel = M.insert "channel" (A.String ch) claimsToSend- claimsWithTime :: IO (M.HashMap Text A.Value)- claimsWithTime = do- time <- utcTimeToPOSIXSeconds <$> getTime- return $ M.insert "message_delivered_at" (A.Number $ realToFrac time) claimsWithChannel+import PostgresWebsockets.Middleware+import PostgresWebsockets.Server+import PostgresWebsockets.Config
src/PostgresWebsockets/Broadcast.hs view
@@ -1,9 +1,13 @@-{-| PostgresWebsockets functions to broadcast messages to several listening clients- This module provides a type called Multiplexer.- The multiplexer contains a map of channels and a producer thread.+{-| +Module : PostgresWebsockets.Broadcast+Description : Distribute messages from one producer to several consumers. - This module avoids any database implementation details, it is used by HasqlBroadcast where- the database logic is combined.+PostgresWebsockets functions to broadcast messages to several listening clients+This module provides a type called Multiplexer.+The multiplexer contains a map of channels and a producer thread.++This module avoids any database implementation details, it is used by HasqlBroadcast where+the database logic is combined. -} module PostgresWebsockets.Broadcast ( Multiplexer (src) , Message (..)
src/PostgresWebsockets/Claims.hs view
@@ -1,10 +1,14 @@-{-| This module provides the JWT claims validation. Since websockets and- listening connections in the database tend to be resource intensive- (not to mention stateful) we need claims authorizing a specific channel and- mode of operation.+{-|+Module : PostgresWebsockets.Claims+Description : Parse and validate JWT to open postgres-websockets channels.++This module provides the JWT claims validation. Since websockets and+listening connections in the database tend to be resource intensive+(not to mention stateful) we need claims authorizing a specific channel and+mode of operation. -} module PostgresWebsockets.Claims- ( validateClaims+ ( ConnectionInfo,validateClaims ) where import Control.Lens@@ -12,51 +16,63 @@ import Crypto.JWT import qualified Data.HashMap.Strict as M import Protolude+import Data.List import Data.Time.Clock (UTCTime)-import Data.String (String, fromString) import qualified Data.Aeson as JSON-import qualified Data.Aeson.Types as JSON - type Claims = M.HashMap Text JSON.Value-type ConnectionInfo = (ByteString, ByteString, Claims)+type ConnectionInfo = ([ByteString], ByteString, Claims) {-| Given a secret, a token and a timestamp it validates the claims and returns either an error message or a triple containing channel, mode and claims hashmap. -}-validateClaims :: Maybe ByteString -> ByteString -> LByteString -> UTCTime -> IO (Either Text ConnectionInfo)-validateClaims requestChannel secret jwtToken time =- runExceptT $ do- cl <- liftIO $ jwtClaims time (parseJWK secret) jwtToken- cl' <- case cl of- JWTClaims c -> pure c- JWTInvalid JWTExpired -> throwError "Token expired"- _ -> throwError "Error"- channel <- claimAsJSON requestChannel "channel" cl'- mode <- claimAsJSON Nothing "mode" cl'- pure (channel, mode, cl')-- where- claimAsJSON :: Maybe ByteString -> Text -> Claims -> ExceptT Text IO ByteString- claimAsJSON defaultVal name cl = case M.lookup name cl of- Just (JSON.String s) -> pure $ encodeUtf8 s- Just _ -> throwError "claim is not string value"- Nothing -> nonExistingClaim defaultVal name+validateClaims+ :: Maybe ByteString+ -> ByteString+ -> LByteString+ -> UTCTime+ -> IO (Either Text ConnectionInfo)+validateClaims requestChannel secret jwtToken time = runExceptT $ do+ cl <- liftIO $ jwtClaims time (parseJWK secret) jwtToken+ cl' <- case cl of+ JWTClaims c -> pure c+ JWTInvalid JWTExpired -> throwError "Token expired"+ JWTInvalid err -> throwError $ "Error: " <> show err+ channels <- let chs = claimAsJSONList "channels" cl' in pure $ case claimAsJSON "channel" cl' of+ Just c -> case chs of+ Just cs -> nub (c : cs)+ Nothing -> [c]+ Nothing -> fromMaybe [] chs+ mode <-+ let md = claimAsJSON "mode" cl'+ in case md of+ Just m -> pure m+ Nothing -> throwError "Missing mode"+ requestedAllowedChannels <- case (requestChannel, length channels) of+ (Just rc, 0) -> pure [rc]+ (Just rc, _) -> pure $ filter (== rc) channels+ (Nothing, _) -> pure channels+ validChannels <- if null requestedAllowedChannels then throwError "No allowed channels" else pure requestedAllowedChannels+ pure (validChannels, mode, cl') - nonExistingClaim :: Maybe ByteString -> Text -> ExceptT Text IO ByteString- nonExistingClaim Nothing name = throwError (name <> " not in claims")- nonExistingClaim (Just defaultVal) _ = pure defaultVal+ where+ claimAsJSON :: Text -> Claims -> Maybe ByteString+ claimAsJSON name cl = case M.lookup name cl of+ Just (JSON.String s) -> Just $ encodeUtf8 s+ _ -> Nothing -{- Private functions and types copied from postgrest+ claimAsJSONList :: Text -> Claims -> Maybe [ByteString]+ claimAsJSONList name cl = case M.lookup name cl of+ Just channelsJson ->+ case JSON.fromJSON channelsJson :: JSON.Result [Text] of+ JSON.Success channelsList -> Just $ encodeUtf8 <$> channelsList+ _ -> Nothing+ Nothing -> Nothing - This code duplication will be short lived since postgrest will migrate towards jose- Then this library will use jose's verifyClaims and error types.--} {-| Possible situations encountered with client JWTs -} data JWTAttempt = JWTInvalid JWTError- | JWTMissingSecret | JWTClaims (M.HashMap Text JSON.Value) deriving Eq @@ -66,11 +82,11 @@ -} jwtClaims :: UTCTime -> JWK -> LByteString -> IO JWTAttempt jwtClaims _ _ "" = return $ JWTClaims M.empty-jwtClaims time jwk payload = do+jwtClaims time jwk' payload = do let config = defaultJWTValidationSettings (const True) eJwt <- runExceptT $ do jwt <- decodeCompact payload- verifyClaimsAt config jwk time jwt+ verifyClaimsAt config jwk' time jwt return $ case eJwt of Left e -> JWTInvalid e Right jwt -> JWTClaims . claims2map $ jwt
+ src/PostgresWebsockets/Config.hs view
@@ -0,0 +1,102 @@+{-|+Module : PostgresWebsockets.Config+Description : Manages PostgresWebsockets configuration options.++This module provides a helper function to read the command line+arguments using the AppConfig type to store+them. It also can be used to define other middleware configuration that+may be delegated to some sort of external configuration.+-}+module PostgresWebsockets.Config + ( prettyVersion+ , loadConfig+ , warpSettings+ , AppConfig (..)+ ) where++import Env+import Data.Text (intercalate, pack, replace, strip, stripPrefix)+import Data.Version (versionBranch)+import Paths_postgres_websockets (version)+import Protolude hiding (intercalate, (<>), optional, replace)+import Data.String (IsString(..))+import Network.Wai.Handler.Warp+import qualified Data.ByteString as BS+import qualified Data.ByteString.Base64 as B64++-- | Config file settings for the server+data AppConfig = AppConfig {+ configDatabase :: Text+ , configPath :: Maybe Text+ , configHost :: Text+ , configPort :: Int+ , configListenChannel :: Text+ , configJwtSecret :: ByteString+ , configJwtSecretIsBase64 :: Bool+ , configPool :: Int+ }++-- | User friendly version number+prettyVersion :: Text+prettyVersion = intercalate "." $ map show $ versionBranch version++-- | Load all postgres-websockets config from Environment variables. This can be used to use just the middleware or to feed into warpSettings+loadConfig :: IO AppConfig+loadConfig = readOptions >>= loadSecretFile++-- | Given a shutdown handler and an AppConfig builds a Warp Settings to start a stand-alone server+warpSettings :: (IO () -> IO ()) -> AppConfig -> Settings+warpSettings waitForShutdown AppConfig{..} =+ setHost (fromString $ toS configHost)+ . setPort configPort+ . setServerName (toS $ "postgres-websockets/" <> prettyVersion)+ . setTimeout 3600+ . setInstallShutdownHandler waitForShutdown+ . setGracefulShutdownTimeout (Just 5)+ $ defaultSettings+++-- private++-- | Function to read and parse options from the environment+readOptions :: IO AppConfig+readOptions =+ Env.parse (header "You need to configure some environment variables to start the service.") $+ AppConfig <$> var (str <=< nonempty) "PGWS_DB_URI" (help "String to connect to PostgreSQL")+ <*> optional (var str "PGWS_ROOT_PATH" (help "Root path to serve static files, unset to disable."))+ <*> var str "PGWS_HOST" (def "*4" <> helpDef show <> help "Address the server will listen for websocket connections")+ <*> var auto "PGWS_PORT" (def 3000 <> helpDef show <> help "Port the server will listen for websocket connections")+ <*> var str "PGWS_LISTEN_CHANNEL" (def "postgres-websockets-listener" <> helpDef show <> help "Master channel used in the database to send or read messages in any notification channel")+ <*> var str "PGWS_JWT_SECRET" (help "Secret used to sign JWT tokens used to open communications channels")+ <*> var auto "PGWS_JWT_SECRET_BASE64" (def False <> helpDef show <> help "Indicate whether the JWT secret should be decoded from a base64 encoded string")+ <*> var auto "PGWS_POOL_SIZE" (def 10 <> helpDef show <> help "How many connection to the database should be used by the connection pool")++loadSecretFile :: AppConfig -> IO AppConfig+loadSecretFile conf = extractAndTransform secret+ where+ secret = decodeUtf8 $ configJwtSecret conf+ isB64 = configJwtSecretIsBase64 conf++ extractAndTransform :: Text -> IO AppConfig+ extractAndTransform s =+ fmap setSecret $ transformString isB64 =<<+ case stripPrefix "@" s of+ Nothing -> return . encodeUtf8 $ s+ Just filename -> chomp <$> BS.readFile (toS filename)+ where+ chomp bs = fromMaybe bs (BS.stripSuffix "\n" bs)++ -- Turns the Base64url encoded JWT into Base64+ transformString :: Bool -> ByteString -> IO ByteString+ transformString False t = return t+ transformString True t =+ case B64.decode $ encodeUtf8 $ strip $ replaceUrlChars $ decodeUtf8 t of+ Left errMsg -> panic $ pack errMsg+ Right bs -> return bs++ setSecret bs = conf {configJwtSecret = bs}++ -- replace: Replace every occurrence of one substring with another+ replaceUrlChars =+ replace "_" "/" . replace "-" "+" . replace "." "="+
src/PostgresWebsockets/HasqlBroadcast.hs view
@@ -1,6 +1,10 @@-{-| Uses Broadcast module adding database as a source producer.- This module provides a function to produce a 'Multiplexer' from a Hasql 'Connection'.- The producer issues a LISTEN command upon Open commands and UNLISTEN upon Close.+{-| +Module : PostgresWebsockets.Broadcast+Description : Build a Hasql.Notifications based producer 'Multiplexer'.++Uses Broadcast module adding database as a source producer.+This module provides a function to produce a 'Multiplexer' from a Hasql 'Connection'.+The producer issues a LISTEN command upon Open commands and UNLISTEN upon Close. -} module PostgresWebsockets.HasqlBroadcast ( newHasqlBroadcaster@@ -76,7 +80,6 @@ onMessage multi "chat" (\ch -> forever $ fmap print (atomically $ readTChan ch) @- -} newHasqlBroadcasterForChannel :: IO () -> Text -> IO Connection -> IO Multiplexer newHasqlBroadcasterForChannel onConnectionFailure ch getCon = do
+ src/PostgresWebsockets/Middleware.hs view
@@ -0,0 +1,127 @@+{-|+Module : PostgresWebsockets.Middleware+Description : PostgresWebsockets WAI middleware, add functionality to any WAI application.++Allow websockets connections that will communicate with the database through LISTEN/NOTIFY channels.+-}+{-# LANGUAGE DeriveGeneric #-}++module PostgresWebsockets.Middleware+ ( postgresWsMiddleware+ ) where++import qualified Hasql.Pool as H+import qualified Hasql.Notifications as H+import qualified Network.Wai as Wai+import qualified Network.Wai.Handler.WebSockets as WS+import qualified Network.WebSockets as WS+import Protolude++import qualified Data.Aeson as A+import qualified Data.ByteString.Char8 as BS+import qualified Data.ByteString.Lazy as BL+import qualified Data.HashMap.Strict as M+import qualified Data.Text.Encoding.Error as T+import Data.Time.Clock (UTCTime)+import Data.Time.Clock.POSIX (utcTimeToPOSIXSeconds, posixSecondsToUTCTime)+import Control.Concurrent.AlarmClock (newAlarmClock, setAlarm)+import PostgresWebsockets.Broadcast (Multiplexer, onMessage)+import qualified PostgresWebsockets.Broadcast as B+import PostgresWebsockets.Claims++data Message = Message+ { claims :: A.Object+ , channel :: Text+ , payload :: Text+ } deriving (Show, Eq, Generic)++instance A.ToJSON Message++-- | Given a secret, a function to fetch the system time, a Hasql Pool and a Multiplexer this will give you a WAI middleware.+postgresWsMiddleware :: IO UTCTime -> Text -> ByteString -> H.Pool -> Multiplexer -> Wai.Application -> Wai.Application+postgresWsMiddleware =+ WS.websocketsOr WS.defaultConnectionOptions `compose` wsApp+ where+ compose = (.) . (.) . (.) . (.) . (.)++-- private functions+jwtExpirationStatusCode :: Word16+jwtExpirationStatusCode = 3001++-- when the websocket is closed a ConnectionClosed Exception is triggered+-- this kills all children and frees resources for us+wsApp :: IO UTCTime -> Text -> ByteString -> H.Pool -> Multiplexer -> WS.ServerApp+wsApp getTime dbChannel secret pool multi pendingConn =+ getTime >>= validateClaims requestChannel secret (toS jwtToken) >>= either rejectRequest forkSessions+ where+ hasRead m = m == ("r" :: ByteString) || m == ("rw" :: ByteString)+ hasWrite m = m == ("w" :: ByteString) || m == ("rw" :: ByteString)++ rejectRequest :: Text -> IO ()+ rejectRequest msg = do+ putErrLn $ "Rejecting Request: " <> msg+ WS.rejectRequest pendingConn (toS msg)++ -- the URI has one of the two formats - /:jwt or /:channel/:jwt+ pathElements = BS.split '/' $ BS.drop 1 $ WS.requestPath $ WS.pendingRequest pendingConn+ jwtToken = + case length pathElements `compare` 1 of+ GT -> headDef "" $ tailSafe pathElements+ _ -> headDef "" pathElements+ requestChannel =+ case length pathElements `compare` 1 of+ GT -> Just $ headDef "" pathElements+ _ -> Nothing+ forkSessions :: ConnectionInfo -> IO ()+ forkSessions (chs, mode, validClaims) = do+ -- We should accept only after verifying JWT+ conn <- WS.acceptRequest pendingConn+ -- Fork a pinging thread to ensure browser connections stay alive+ WS.withPingThread conn 30 (pure ()) $ do+ case M.lookup "exp" validClaims of+ Just (A.Number expClaim) -> do+ connectionExpirer <- newAlarmClock $ const (WS.sendCloseCode conn jwtExpirationStatusCode ("JWT expired" :: ByteString))+ setAlarm connectionExpirer (posixSecondsToUTCTime $ realToFrac expClaim)+ Just _ -> pure ()+ Nothing -> pure ()++ when (hasRead mode) $+ forM_ chs $ flip (onMessage multi) $ WS.sendTextData conn . B.payload++ when (hasWrite mode) $+ let sendNotifications = void . H.notifyPool pool dbChannel . toS+ in notifySession validClaims conn getTime sendNotifications chs++ waitForever <- newEmptyMVar+ void $ takeMVar waitForever++-- Having both channel and claims as parameters seem redundant+-- But it allows the function to ignore the claims structure and the source+-- of the channel, so all claims decoding can be coded in the caller+notifySession :: A.Object+ -> WS.Connection+ -> IO UTCTime+ -> (ByteString -> IO ())+ -> [ByteString]+ -> IO ()+notifySession claimsToSend wsCon getTime send chs =+ withAsync (forever relayData) wait+ where+ relayData = do + msg <- WS.receiveData wsCon+ forM_ chs (relayChannelData msg . toS)++ relayChannelData msg ch = do+ claims' <- claimsWithTime ch+ send $ jsonMsg ch claims' msg++ -- we need to decode the bytestring to re-encode valid JSON for the notification+ jsonMsg :: Text -> M.HashMap Text A.Value -> ByteString -> ByteString+ jsonMsg ch cl = BL.toStrict . A.encode . Message cl ch . decodeUtf8With T.lenientDecode++ claimsWithTime :: Text -> IO (M.HashMap Text A.Value)+ claimsWithTime ch = do+ time <- utcTimeToPOSIXSeconds <$> getTime+ return $ M.insert "message_delivered_at" (A.Number $ realToFrac time) (claimsWithChannel ch)++ claimsWithChannel ch = M.insert "channel" (A.String ch) claimsToSend
+ src/PostgresWebsockets/Server.hs view
@@ -0,0 +1,53 @@+{-|+Module : PostgresWebsockets.Server+Description : Functions to start a full stand-alone PostgresWebsockets server.+-}+module PostgresWebsockets.Server+ ( serve + ) where++import Protolude+import PostgresWebsockets.Middleware+import PostgresWebsockets.Config+import PostgresWebsockets.HasqlBroadcast (newHasqlBroadcaster)++import qualified Hasql.Pool as P+import Network.Wai.Application.Static+import Data.Time.Clock (UTCTime, getCurrentTime)+import Control.AutoUpdate ( defaultUpdateSettings+ , mkAutoUpdate+ , updateAction+ )+import Network.Wai (Application, responseLBS)+import Network.HTTP.Types (status200)+import Network.Wai.Handler.Warp+import Network.Wai.Middleware.RequestLogger (logStdout)++-- | Start a stand-alone warp server using the parameters from AppConfig and a opening a database connection pool.+serve :: AppConfig -> IO ()+serve conf = do+ shutdownSignal <- newEmptyMVar+ let listenChannel = toS $ configListenChannel conf+ pgSettings = toS (configDatabase conf)+ waitForShutdown cl = void $ forkIO (takeMVar shutdownSignal >> cl >> die "Shutting server down...")+ appSettings = warpSettings waitForShutdown conf++ putStrLn $ ("Listening on port " :: Text) <> show (configPort conf)++ let shutdown = putErrLn ("Broadcaster connection is dead" :: Text) >> putMVar shutdownSignal ()+ pool <- P.acquire (configPool conf, 10, pgSettings)+ multi <- newHasqlBroadcaster shutdown listenChannel pgSettings+ getTime <- mkGetTime++ runSettings appSettings $+ postgresWsMiddleware getTime listenChannel (configJwtSecret conf) pool multi $+ logStdout $ maybe dummyApp staticApp' (configPath conf)++ where+ mkGetTime :: IO (IO UTCTime)+ mkGetTime = mkAutoUpdate defaultUpdateSettings {updateAction = getCurrentTime}+ staticApp' :: Text -> Application+ staticApp' = staticApp . defaultFileServerSettings . toS+ dummyApp :: Application+ dummyApp _ respond =+ respond $ responseLBS status200 [("Content-Type", "text/plain")] "Hello, Web!"
test/ClaimsSpec.hs view
@@ -4,20 +4,51 @@ import qualified Data.HashMap.Strict as M import Test.Hspec-import Data.Aeson (Value (..) )+import Data.Aeson (Value (..), toJSON) import Data.Time.Clock import PostgresWebsockets.Claims +secret :: ByteString+secret = "reallyreallyreallyreallyverysafe"+ spec :: Spec spec = describe "validate claims" $ do it "should invalidate an expired token" $ do time <- getCurrentTime- validateClaims Nothing "reallyreallyreallyreallyverysafe"- "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtb2RlIjoiciIsImNoYW5uZWwiOiJ0ZXN0IiwiZXhwIjoxfQ.4rDYiMZFR2WHB7Eq4HMdvDP_BQZVtHIfyJgy0NshbHY" time- `shouldReturn` Left "Token expired"- it "should succeed using a matching token" $ do+ validateClaims Nothing secret+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtb2RlIjoiciIsImNoYW5uZWwiOiJ0ZXN0IiwiZXhwIjoxfQ.4rDYiMZFR2WHB7Eq4HMdvDP_BQZVtHIfyJgy0NshbHY" time+ `shouldReturn` Left "Token expired"+ it "request any channel from a token that does not have channels or channel claims should succeed" $ do time <- getCurrentTime- validateClaims Nothing "reallyreallyreallyreallyverysafe"- "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtb2RlIjoiciIsImNoYW5uZWwiOiJ0ZXN0In0.1d4s-at2kWj8OSabHZHTbNh1dENF7NWy_r0ED3Rwf58" time- `shouldReturn` Right ("test", "r", M.fromList[("mode",String "r"),("channel",String "test")])+ validateClaims (Just (encodeUtf8 "test")) secret+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtb2RlIjoiciJ9.jL5SsRFegNUlbBm8_okhHSujqLcKKZdDglfdqNl1_rY" time+ `shouldReturn` Right (["test"], "r", M.fromList[("mode",String "r")])+ it "requesting a channel that is set by and old style channel claim should work" $ do+ time <- getCurrentTime+ validateClaims (Just (encodeUtf8 "test")) secret+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtb2RlIjoiciIsImNoYW5uZWwiOiJ0ZXN0In0.1d4s-at2kWj8OSabHZHTbNh1dENF7NWy_r0ED3Rwf58" time+ `shouldReturn` Right (["test"], "r", M.fromList[("mode",String "r"),("channel",String "test")])+ it "no requesting channel should return all channels in the token" $ do+ time <- getCurrentTime+ validateClaims Nothing secret+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtb2RlIjoiciIsImNoYW5uZWxzIjpbInRlc3QiLCJhbm90aGVyIHRlc3QiXX0.b9N8J8tPOPIxxFj5WJ7sWrmcL8ib63i8eirsRZTM9N0" time+ `shouldReturn` Right (["test", "another test"], "r", M.fromList[("mode",String "r"),("channels", toJSON["test"::Text, "another test"::Text] ) ])++ it "requesting a channel from the channels claim shoud return only the requested channel" $ do+ time <- getCurrentTime+ validateClaims (Just (encodeUtf8 "test")) secret+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtb2RlIjoiciIsImNoYW5uZWxzIjpbInRlc3QiLCJ0ZXN0MiJdfQ.MumdJ5FpFX4Z6SJD3qsygVF0r9vqxfqhj5J30O32N0k" time+ `shouldReturn` Right (["test"], "r", M.fromList[("mode",String "r"),("channels", toJSON ["test"::Text, "test2"] )])++ it "requesting a channel not from the channels claim shoud error" $ do+ time <- getCurrentTime+ validateClaims (Just (encodeUtf8 "notAllowed")) secret+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtb2RlIjoiciIsImNoYW5uZWxzIjpbInRlc3QiLCJ0ZXN0MiJdfQ.MumdJ5FpFX4Z6SJD3qsygVF0r9vqxfqhj5J30O32N0k" time+ `shouldReturn` Left "No allowed channels"++ it "requesting a channel with no mode fails" $ do+ time <- getCurrentTime+ validateClaims (Just (encodeUtf8 "test")) secret+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjaGFubmVscyI6WyJ0ZXN0IiwidGVzdDIiXX0.akC1PEYk2DEZtLP2XjC6qXOGZJejmPx49qv-VeEtQYQ" time+ `shouldReturn` Left "Missing mode"
+ test/ServerSpec.hs view
@@ -0,0 +1,117 @@+module ServerSpec (spec) where++import Protolude++import Test.Hspec+import PostgresWebsockets+import PostgresWebsockets.Config++import Control.Lens+import Data.Aeson.Lens++import qualified Network.WebSockets as WS+import Network.Socket (withSocketsDo)++testServerConfig :: AppConfig+testServerConfig = AppConfig + { configDatabase = "postgres://localhost/postgres"+ , configPath = Nothing+ , configHost = "*"+ , configPort = 8080+ , configListenChannel = "postgres-websockets-test-channel"+ , configJwtSecret = "reallyreallyreallyreallyverysafe"+ , configJwtSecretIsBase64 = False+ , configPool = 10+ }++startTestServer :: IO ThreadId+startTestServer = do+ threadId <- forkIO $ serve testServerConfig+ threadDelay 1000+ pure threadId++withServer :: IO () -> IO ()+withServer action =+ bracket startTestServer+ killThread+ (const action)++sendWsData :: Text -> Text -> IO ()+sendWsData uri msg =+ withSocketsDo $ + WS.runClient + "localhost" + (configPort testServerConfig) + (toS uri) + (`WS.sendTextData` msg)++testChannel :: Text+testChannel = "/test/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtb2RlIjoicncifQ.auy9z4-pqoVEAay9oMi1FuG7ux_C_9RQCH8-wZgej18"++secondaryChannel :: Text+secondaryChannel = "/secondary/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtb2RlIjoicncifQ.auy9z4-pqoVEAay9oMi1FuG7ux_C_9RQCH8-wZgej18"++testAndSecondaryChannel :: Text+testAndSecondaryChannel = "/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtb2RlIjoicnciLCJjaGFubmVscyI6WyJ0ZXN0Iiwic2Vjb25kYXJ5Il19.7tB2A9MhpY4tyqhfnHNy5FUYw4gwpKtL4UAHBXbNEz4"++waitForWsData :: Text -> IO (MVar ByteString)+waitForWsData uri = do+ msg <- newEmptyMVar+ void $ forkIO $+ withSocketsDo $ + WS.runClient + "localhost" + (configPort testServerConfig) + (toS uri) + (\c -> do+ m <- WS.receiveData c+ putMVar msg m+ )+ threadDelay 1000+ pure msg++waitForMultipleWsData :: Int -> Text -> IO (MVar [ByteString])+waitForMultipleWsData messageCount uri = do+ msg <- newEmptyMVar+ void $ forkIO $+ withSocketsDo $ + WS.runClient + "localhost" + (configPort testServerConfig) + (toS uri) + (\c -> do+ m <- replicateM messageCount (WS.receiveData c)+ putMVar msg m+ )+ threadDelay 1000+ pure msg++spec :: Spec+spec = around_ withServer $+ describe "serve" $ do+ it "should be able to send messages to test server" $+ sendWsData testChannel "test data"+ it "should be able to receive messages from test server" $ do+ msg <- waitForWsData testChannel+ sendWsData testChannel "test data"+ msgJson <- takeMVar msg+ (msgJson ^? key "payload" . _String) `shouldBe` Just "test data"+ it "should be able to send messages to multiple channels in one shot" $ do+ msg <- waitForWsData testChannel+ secondaryMsg <- waitForWsData secondaryChannel+ sendWsData testAndSecondaryChannel "test data"+ msgJson <- takeMVar msg+ secondaryMsgJson <- takeMVar secondaryMsg++ (msgJson ^? key "payload" . _String) `shouldBe` Just "test data"+ (msgJson ^? key "channel" . _String) `shouldBe` Just "test"+ (secondaryMsgJson ^? key "payload" . _String) `shouldBe` Just "test data"+ (secondaryMsgJson ^? key "channel" . _String) `shouldBe` Just "secondary"+ it "should be able to receive from multiple channels in one shot" $ do+ msgs <- waitForMultipleWsData 2 testAndSecondaryChannel+ sendWsData testAndSecondaryChannel "test data"+ msgsJson <- takeMVar msgs++ forM_ + msgsJson + (\msgJson -> (msgJson ^? key "payload" . _String) `shouldBe` Just "test data")