{-# OPTIONS_HADDOCK not-home #-} {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE NamedFieldPuns #-} -- | Interactions with a Magic Wormhole Rendezvous server. -- -- Intended to be imported qualified, e.g. -- ``` -- import qualified MagicWormhole.Internal.Rendezvous as Rendezvous -- ``` module MagicWormhole.Internal.Rendezvous ( -- * Specific RPCs ping , list , allocate , claim , release , open , close -- * Running a Rendezvous client , runClient , Session -- * Errors , ServerError(..) , ClientError(..) ) where import Protolude hiding (list, phase) import Control.Concurrent.STM ( TVar , newTVar , modifyTVar' , readTVar , writeTVar , TMVar , newEmptyTMVar , putTMVar , takeTMVar , tryPutTMVar , TQueue , newTQueue , readTQueue , writeTQueue ) import Data.Aeson (eitherDecode, encode) import Data.Hashable (Hashable) import Data.HashMap.Lazy (HashMap) import qualified Data.HashMap.Lazy as HashMap import Data.String (String) import qualified Network.Socket as Socket import qualified Network.WebSockets as WS import qualified MagicWormhole.Internal.ClientProtocol as ClientProtocol import qualified MagicWormhole.Internal.Messages as Messages import MagicWormhole.Internal.WebSockets (WebSocketEndpoint(..)) -- | Abstract type representing a Magic Wormhole session. -- -- Use 'runClient' to get a 'Session' on the Magic Wormhole Rendezvous server. -- Once you have a 'Session', -- use 'ping', 'list', 'allocate', 'claim', 'release', 'open', and 'close' -- to communicate with the Rendezvous server. data Session = Session { connection :: WS.Connection , sessionAppID :: Messages.AppID -- ^ The 'AppID' of this running session , sessionSide :: Messages.Side -- ^ The 'Side' of this running session , pendingVar :: TVar (HashMap ResponseType (TMVar Messages.ServerMessage)) , messageChan :: TQueue Messages.MailboxMessage , motd :: TMVar (Maybe Text) } -- | Create a new 'Session'. new :: WS.Connection -- ^ Active WebSocket connection to a Rendezvous Server. -> Messages.AppID -> Messages.Side -> STM Session -- ^ Opaque 'Session' object. new connection appID side = Session connection appID side <$> newTVar mempty <*> newTQueue <*> newEmptyTMVar -- | Send a message to a Magic Wormhole Rendezvous server. send :: Session -- ^ An active session. Get this using 'runClient'. -> Messages.ClientMessage -- ^ Message to send to the server. -> IO () send session msg = WS.sendBinaryData (connection session) (encode msg) -- | Receive a message from a Magic Wormhole Rendezvous server. -- Blocks until such a message exists. receive :: Session -- ^ An active session. Get this using 'runClient'. -> IO Messages.ServerMessage -- ^ Next message from the server. receive session = do msg <- WS.receiveData (connection session) either (throwIO . ParseError) pure (eitherDecode msg) -- | Run a Magic Wormhole Rendezvous client. Use this to interact with a Magic Wormhole server. -- -- Will throw a 'ServerError' if the server declares we are unwelcome. runClient :: HasCallStack => WebSocketEndpoint -- ^ The websocket to connect to -> Messages.AppID -- ^ ID for your application (e.g. example.com/your-application) -> Messages.Side -- ^ Identifier for your side -> Maybe Socket.Socket -- ^ Just an existing socket to use or Nothing to create and use a new one -> (Session -> IO a) -- ^ Action to perform inside the Magic Wormhole session -> IO a -- ^ The result of the action runClient (WebSocketEndpoint host port path) appID side maybeSock app = case maybeSock of Nothing -> Socket.withSocketsDo . WS.runClient host port path $ runAction Just sock -> Socket.withSocketsDo . WS.runClientWithSocket sock host path WS.defaultConnectionOptions [] $ runAction where action ws session = do bind session appID side app session `finally` WS.sendClose ws ("Connection closed connection" :: Text) runAction ws = do session <- atomically $ new ws appID side (_, result) <- concurrently (readMessages session) (action ws session) pure result -- | Read messages from the websocket forever, or until we fail to handle one. readMessages session = do -- We read the message from the channel and handle it (either by setting -- the RPC response or forwarding to the mailbox message queue) all in -- one transaction. This means that if an exception occurs, the message -- will remain in the channel. msg <- try $ receive session case msg of Left (WS.CloseRequest _ _) -> pass Left err -> throwIO err Right msg' -> do result <- atomically $ gotMessage session msg' case result of Just err -> throwIO err Nothing -> readMessages session -- | Make a request to the rendezvous server. -- -- Will throw a 'ClientError' if there's a problem. rpc :: HasCallStack => Session -- ^ A Magic Wormhole session. Get one using 'runClient'. -> Messages.ClientMessage -- ^ The RPC to send. Will fail with an 'Error' if this is not a valid RPC. -> IO Messages.ServerMessage -- ^ The result of an RPC rpc session req = case expectedResponse req of Nothing -> -- XXX: Pretty sure we can juggle things around at the type level -- to remove this check. (i.e. make a type for RPC requests). throwIO (NotAnRPC req) Just responseType -> do box <- atomically $ expectResponse responseType send session req response <- atomically $ waitForResponse session responseType box case response of Messages.Error reason original -> throwIO (BadRequest reason original) response' -> pure response' where -- | Tell the connection that we expect a response of the given type. -- -- Will throw a 'ClientError' if we are already expecting a response of this type. expectResponse :: ResponseType -> STM (TMVar Messages.ServerMessage) expectResponse responseType = do pending <- readTVar (pendingVar session) case HashMap.lookup responseType pending of Nothing -> do box <- newEmptyTMVar writeTVar (pendingVar session) (HashMap.insert responseType box pending) pure box Just _ -> throwSTM (AlreadySent req) -- | Set the application ID and side for the rest of this connection. -- -- The Rendezvous protocol doesn't have a response to @bind@, so there's no -- way to tell if it has had its effect. -- -- See https://github.com/warner/magic-wormhole/issues/261 bind :: HasCallStack => Session -> Messages.AppID -> Messages.Side -> IO () bind session appID side' = send session (Messages.Bind appID side') -- | Ping the server. -- -- This is an in-band ping, used mostly for testing. It is not necessary to -- keep the connection alive. -- -- Throws a 'ClientError' if the server rejects the message for any reason. ping :: HasCallStack => Session -> Int -> IO Int ping session n = do response <- rpc session (Messages.Ping n) case response of Messages.Pong n' -> pure n' unexpected -> unexpectedMessage (Messages.Ping n) unexpected -- | List the nameplates on the server. -- -- Throws a 'ClientError' if the server rejects the message for any reason. list :: HasCallStack => Session -> IO [Messages.Nameplate] list session = do response <- rpc session Messages.List case response of Messages.Nameplates nameplates -> pure nameplates unexpected -> unexpectedMessage Messages.List unexpected -- | Allocate a nameplate on the server. -- -- Throws a 'ClientError' if the server rejects the message for any reason. allocate :: HasCallStack => Session -> IO Messages.Nameplate allocate session = do response <- rpc session Messages.Allocate case response of Messages.Allocated nameplate -> pure nameplate unexpected -> unexpectedMessage Messages.Allocate unexpected -- | Claim a nameplate on the server. -- -- Throws a 'ClientError' if the server rejects the message for any reason. claim :: HasCallStack => Session -> Messages.Nameplate -> IO Messages.Mailbox claim session nameplate = do response <- rpc session (Messages.Claim nameplate) case response of Messages.Claimed mailbox -> pure mailbox unexpected -> unexpectedMessage (Messages.Claim nameplate) unexpected -- | Release a nameplate on the server. -- -- TODO: Document semantics around "optional" nameplate. -- -- TODO: Make this impossible to call unless we have already claimed a -- namespace. -- -- Throws a 'ClientError' if the server rejects the message for any reason. release :: HasCallStack => Session -> Maybe Messages.Nameplate -> IO () release session nameplate' = do response <- rpc session (Messages.Release nameplate') case response of Messages.Released -> pure () unexpected -> unexpectedMessage (Messages.Release nameplate') unexpected -- | Open a mailbox on the server. -- -- If there's already a mailbox open, the server will send an error message. -- In the current implementation, that error will arise in a strange and -- unexpected place. -- -- See https://github.com/warner/magic-wormhole/issues/261#issuecomment-343192449 open :: HasCallStack => Session -> Messages.Mailbox -> IO ClientProtocol.Connection open session mailbox = do send session (Messages.Open mailbox) pure ClientProtocol.Connection { ClientProtocol.appID = sessionAppID session , ClientProtocol.ourSide = sessionSide session , ClientProtocol.send = add session , ClientProtocol.receive = readFromMailbox session } -- | Close a mailbox on the server. -- -- Throws a 'ClientError' if the server rejects the message for any reason. close :: HasCallStack => Session -> Maybe Messages.Mailbox -> Maybe Messages.Mood -> IO () close session mailbox' mood' = do response <- rpc session (Messages.Close mailbox' mood') case response of Messages.Closed -> pure () unexpected -> unexpectedMessage (Messages.Close mailbox' mood') unexpected -- | Send a message to the open mailbox. add :: HasCallStack => Session -> Messages.Phase -> Messages.Body -> IO () add session phase body = send session (Messages.Add phase body) -- | Read a message from someone else from an open mailbox -- -- Will block until we receive a message from someone who isn't us. -- Specifically, a message with a different 'side' to us. -- -- Will discard all messages that are from us. readFromMailbox :: HasCallStack => Session -> STM Messages.MailboxMessage readFromMailbox session = do msg <- readFromMailbox' session -- In the unlikely event that the other actual side has chosen the same side -- ID as us, this will loop forever. if Messages.side msg == sessionSide session then readFromMailbox session else pure msg -- | Read a message from an open mailbox. -- -- Will block if there's no message, or if we're in no state to receive -- messages (e.g. no mailbox open). readFromMailbox' :: HasCallStack => Session -> STM Messages.MailboxMessage readFromMailbox' session = readTQueue (messageChan session) -- | Called when an RPC receives a message as a response that does not match -- the request. -- -- As things are written, this should never happen, because @gotResponse@ -- makes sure we only ever populate the response placeholder with something -- that matches. -- -- TODO: Try to make this unnecessary. unexpectedMessage :: HasCallStack => Messages.ClientMessage -> Messages.ServerMessage -> a unexpectedMessage request response = panic $ "Unexpected message: " <> show response <> ", in response to: " <> show request waitForResponse :: Session -> ResponseType -> TMVar Messages.ServerMessage -> STM Messages.ServerMessage waitForResponse session responseType box = do response <- takeTMVar box modifyTVar' (pendingVar session) (HashMap.delete responseType) pure response -- | Called when we have received a response from the server. -- -- Tells anything waiting for the response that they can stop waiting now. gotResponse :: Session -> ResponseType -> Messages.ServerMessage -> STM (Maybe ServerError) gotResponse session responseType message = do pending <- readTVar (pendingVar session) case HashMap.lookup responseType pending of Nothing -> pure (Just (ResponseWithoutRequest message)) Just box -> do -- TODO: This will block processing messages from the server (by -- retrying the transaction) if the box is already populated (i.e. if we -- are in the middle of processing another response of the same type--a -- rare circumstance). I don't think we want that, but I'm not sure what -- behavior we do want. putTMVar box message pure Nothing -- | Called when we receive a message (possibly a response) from the server. gotMessage :: Session -> Messages.ServerMessage -> STM (Maybe ServerError) gotMessage session msg = case msg of Messages.Ack -> pure Nothing -- Skip Ack, because there's no point in handling it. Messages.Welcome welcome -> handleWelcome welcome Messages.Error{Messages.errorMessage, Messages.original} -> case expectedResponse original of Nothing -> pure (Just (ErrorForNonRequest errorMessage original)) Just responseType -> -- TODO: This not quite right, as messages that aren't requests -- (e.g. 'open') can generate errors. gotResponse session responseType msg Messages.Message mailboxMsg -> do writeTQueue (messageChan session) mailboxMsg pure Nothing Messages.Nameplates{} -> gotResponse session NameplatesResponse msg Messages.Allocated{} -> gotResponse session AllocatedResponse msg Messages.Claimed{} -> gotResponse session ClaimedResponse msg Messages.Released -> gotResponse session ReleasedResponse msg Messages.Closed -> gotResponse session ClosedResponse msg Messages.Pong{} -> gotResponse session PongResponse msg where handleWelcome welcome = case Messages.welcomeErrorMessage welcome of Just err -> pure (Just (Unwelcome err)) Nothing -> do notYet <- tryPutTMVar (motd session) (Messages.motd welcome) if notYet then pure Nothing else pure (Just (UnexpectedMessage (Messages.Welcome welcome))) -- | The type of a response. -- -- This is used pretty much only to match responses with requests. -- -- XXX: Duplication with ServerMessage? data ResponseType = NameplatesResponse | AllocatedResponse | ClaimedResponse | ReleasedResponse | ClosedResponse | PongResponse deriving (Eq, Show, Generic, Hashable) -- XXX: This expectResponse stuff feels off to me. I think we could do something better. -- -- e.g. -- - embed the response type in the ServerMessage value so we don't need to "specify" it twice -- (once in the parser, once here) -- - change the structure to have stricter types? -- rather than a map of a type value to generic response box, have one box for each -- request/response pair? -- | Map 'ClientMessage' to a response. 'Nothing' means that we do not need a response. expectedResponse :: Messages.ClientMessage -> Maybe ResponseType expectedResponse Messages.Bind{} = Nothing expectedResponse Messages.List = Just NameplatesResponse expectedResponse Messages.Allocate = Just AllocatedResponse expectedResponse Messages.Claim{} = Just ClaimedResponse expectedResponse Messages.Release{} = Just ReleasedResponse expectedResponse Messages.Open{} = Nothing expectedResponse Messages.Add{} = Nothing expectedResponse Messages.Close{} = Just ClosedResponse expectedResponse Messages.Ping{} = Just PongResponse -- | Error due to weirdness from the server. data ServerError = -- | Server sent us a response for something that we hadn't requested. ResponseWithoutRequest Messages.ServerMessage -- | We were sent a message other than "Welcome" on connect, or a -- "Welcome" message at any other time. | UnexpectedMessage Messages.ServerMessage -- | We received an 'error' message for a message that's not expected to -- have a response. | ErrorForNonRequest Text Messages.ClientMessage -- | Clients are not welcome on the server right now. | Unwelcome Text -- | We couldn't understand the message from the server. | ParseError String deriving (Eq, Show, Typeable) instance Exception ServerError -- | Error caused by misusing the client. data ClientError = -- | We tried to do an RPC while another RPC with the same response type -- was in flight. See warner/magic-wormhole#260 for details. AlreadySent Messages.ClientMessage -- | Tried to send a non-RPC as if it were an RPC (i.e. expecting a response). | NotAnRPC Messages.ClientMessage -- | We sent a message that the server could not understand. | BadRequest Text Messages.ClientMessage deriving (Eq, Show, Typeable) instance Exception ClientError